mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-16 08:11:35 +02:00
Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 10314bcdcf | |||
| d573878a2f | |||
| a66efe207d | |||
| 40977337b1 | |||
| 1598f34954 | |||
| 3c3e2c54c5 | |||
| 00c72c441b | |||
| 4046b28a8c | |||
| a86d57ac44 | |||
| c9d67582ae | |||
| 1cfd91a503 | |||
| c0ad6d0328 | |||
| 84de8c9cb1 |
@@ -262,7 +262,7 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v7
|
- uses: actions/checkout@v7
|
||||||
- name: Setup .NET 8
|
- name: Setup .NET 8
|
||||||
uses: actions/setup-dotnet@v6
|
uses: actions/setup-dotnet@v4
|
||||||
with:
|
with:
|
||||||
dotnet-version: "8.0.x"
|
dotnet-version: "8.0.x"
|
||||||
- name: Publish gen1tls (win-x64 Native AOT)
|
- name: Publish gen1tls (win-x64 Native AOT)
|
||||||
|
|||||||
@@ -46,11 +46,7 @@ when the runtime schema is absent. The supported row types are `toggle`,
|
|||||||
`choice`, `number`, and `text`. Their optional fields retain the meanings
|
`choice`, `number`, and `text`. Their optional fields retain the meanings
|
||||||
established by the existing in-game option UI: choices are `[label, value]`
|
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
|
pairs, numeric rows may provide `min`, `max`, and `step`, and text rows may
|
||||||
provide `maxLen`. A row may also use
|
provide `maxLen`.
|
||||||
`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
|
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
|
included. A disabled or failed mod must not contribute rows. If an older
|
||||||
@@ -73,10 +69,9 @@ it may ignore an unknown row type or optional field.
|
|||||||
For compatibility with files produced by the original unversioned prototype,
|
For compatibility with files produced by the original unversioned prototype,
|
||||||
a missing `schema_version` means version 1. Consumers must ignore documents
|
a missing `schema_version` means version 1. Consumers must ignore documents
|
||||||
with a newer version rather than guessing at their shape. Producers must bump
|
with a newer version rather than guessing at their shape. Producers must bump
|
||||||
the version whenever they change the document envelope or the meaning of an
|
the version whenever they change the document shape or the meaning of an
|
||||||
existing field. New optional row fields that older consumers can safely ignore
|
existing field. Version 1 is therefore the legacy unversioned format as well
|
||||||
do not require a bump. Version 1 is therefore the legacy unversioned format as
|
as the explicitly versioned format shown above.
|
||||||
well as the explicitly versioned format shown above.
|
|
||||||
|
|
||||||
## Migration note
|
## Migration note
|
||||||
|
|
||||||
|
|||||||
@@ -90,7 +90,6 @@ Every mod contains a root `manifest.json` defining its metadata, supported games
|
|||||||
| `optional_imports` | `array` | User-supplied files that unlock optional mod functionality. They use the same validation and private-copy flow but never block the mod from loading. |
|
| `optional_imports` | `array` | User-supplied files that unlock optional mod functionality. They use the same validation and private-copy flow but never block the mod from loading. |
|
||||||
| `conflicts` / `incompatible` | `array` | List of mod IDs that cannot run concurrently with this mod. |
|
| `conflicts` / `incompatible` | `array` | List of mod IDs that cannot run concurrently with this mod. |
|
||||||
| `permissions` | `array` | Requested privileges (e.g. `["engine_internals"]`, `["network"]`, `["filesystem"]`). |
|
| `permissions` | `array` | Requested privileges (e.g. `["engine_internals"]`, `["network"]`, `["filesystem"]`). |
|
||||||
| `log_url` | `string` | Optional https URL for `mod.postLog` log reporting (api 2; requires the `network` permission). |
|
|
||||||
| `github` | `string` | GitHub repository (`"owner/repo"`) used for update checks and dependency download links. |
|
| `github` | `string` | GitHub repository (`"owner/repo"`) used for update checks and dependency download links. |
|
||||||
|
|
||||||
### Declaring Dependencies & Scoping
|
### Declaring Dependencies & Scoping
|
||||||
@@ -226,8 +225,6 @@ exposes `headbutt`, `whirlpool`, `waterfall`, `sweet_scent`, and the
|
|||||||
contextual `squirtbottle` key item. Fishing rows include the owned rods that
|
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
|
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.
|
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
|
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" }`
|
the active game's own field-item path. Fishing accepts `{ rod = "OLD_ROD" }`
|
||||||
@@ -237,52 +234,6 @@ not need generation-specific badge, terrain, bike, fishing, or field-move
|
|||||||
logic. Action lists are extensible; callers should render the records they
|
logic. Action lists are extensible; callers should render the records they
|
||||||
understand and ignore unknown ids rather than assuming a fixed list length.
|
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
|
## Rendering pipelines
|
||||||
|
|
||||||
Most registries hand the engine *content*. `render_pipelines` hands it
|
Most registries hand the engine *content*. `render_pipelines` hands it
|
||||||
@@ -913,39 +864,6 @@ with a full standard library that the sandbox cannot reach, so handing one
|
|||||||
to a mod would undo every other rule; `mod.fetch`'s workers run engine
|
to a mod would undo every other rule; `mod.fetch`'s workers run engine
|
||||||
code, so a mod gets asynchrony without gaining any new reach.
|
code, so a mod gets asynchrony without gaining any new reach.
|
||||||
|
|
||||||
## Log reporting
|
|
||||||
|
|
||||||
`mod.postLog(body, opts)` is the one-way exception to the rule that a mod
|
|
||||||
decides where it talks. It reports a debug/crash log to the https URL the
|
|
||||||
manifest declares in `log_url`, and it is the only API that may not be
|
|
||||||
pointed at a caller-chosen address:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"permissions": ["network"],
|
|
||||||
"log_url": "https://logs.example.com/receive"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
The URL is validated at load: it must be `https://`, and declaring it
|
|
||||||
without the `network` permission is a load violation for api 2 mods. The
|
|
||||||
destination is reviewed when the mod ships, not chosen per call, so a mod
|
|
||||||
cannot aim this at arbitrary hosts or read back anything a server replies.
|
|
||||||
|
|
||||||
```lua
|
|
||||||
-- fire and forget; poll() never blocks, same shape as mod.fetch
|
|
||||||
local job = mod:postLog("session crashed at 0x1f3a\n" .. logText)
|
|
||||||
```
|
|
||||||
|
|
||||||
`postLog(body, opts)` returns the same opaque handle as `mod.fetch:get`,
|
|
||||||
polled and released through `mod.fetch:poll` / `mod.fetch:release`. `opts`
|
|
||||||
is a closed list with one switch: `format`, either `"text"` (the default)
|
|
||||||
or `"json"`. `json` wraps the body in an envelope of `{ ts, mod, format,
|
|
||||||
body }` so a server can attribute and sort reports; any other key or value
|
|
||||||
is refused before a job is submitted. The body is capped at 64 KB, the
|
|
||||||
transfer is bounded by the same worker ceilings as `mod.fetch`, and the
|
|
||||||
response body is never returned to the mod.
|
|
||||||
|
|
||||||
## Background jobs
|
## Background jobs
|
||||||
|
|
||||||
`mod.fetch` covers work waiting on a server. `mod.job` covers work waiting on
|
`mod.fetch` covers work waiting on a server. `mod.job` covers work waiting on
|
||||||
|
|||||||
@@ -154,16 +154,6 @@ love::image::ImageData *Canvas::newImageData(love::image::Image *module, int sli
|
|||||||
return module->newImageData(r.w, r.h, dataformat);
|
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)
|
void Canvas::draw(Graphics *gfx, Quad *q, const Matrix4 &t)
|
||||||
{
|
{
|
||||||
if (gfx->isCanvasActive(this))
|
if (gfx->isCanvasActive(this))
|
||||||
@@ -242,3 +232,4 @@ StringMap<Canvas::SettingType, Canvas::SETTING_MAX_ENUM> Canvas::settingTypes(Ca
|
|||||||
|
|
||||||
} // graphics
|
} // graphics
|
||||||
} // love
|
} // love
|
||||||
|
|
||||||
|
|||||||
@@ -81,8 +81,6 @@ public:
|
|||||||
int getRequestedMSAA() const;
|
int getRequestedMSAA() const;
|
||||||
|
|
||||||
virtual love::image::ImageData *newImageData(love::image::Image *module, int slice, int mipmap, const Rect &rect);
|
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 void generateMipmaps() = 0;
|
||||||
|
|
||||||
virtual int getMSAA() const = 0;
|
virtual int getMSAA() const = 0;
|
||||||
|
|||||||
@@ -23,7 +23,6 @@
|
|||||||
#include "Graphics.h"
|
#include "Graphics.h"
|
||||||
|
|
||||||
#include <algorithm> // For min/max
|
#include <algorithm> // For min/max
|
||||||
#include <cstring>
|
|
||||||
|
|
||||||
namespace love
|
namespace love
|
||||||
{
|
{
|
||||||
@@ -198,9 +197,6 @@ Canvas::Canvas(const Settings &settings)
|
|||||||
, texture(0)
|
, texture(0)
|
||||||
, renderbuffer(0)
|
, renderbuffer(0)
|
||||||
, actualSamples(0)
|
, actualSamples(0)
|
||||||
, readbackBuffer(0)
|
|
||||||
, readbackFence(nullptr)
|
|
||||||
, readbackSize(0)
|
|
||||||
{
|
{
|
||||||
format = getSizedFormat(format);
|
format = getSizedFormat(format);
|
||||||
|
|
||||||
@@ -318,14 +314,6 @@ bool Canvas::loadVolatile()
|
|||||||
|
|
||||||
void Canvas::unloadVolatile()
|
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)
|
if (fbo != 0 || renderbuffer != 0 || texture != 0)
|
||||||
{
|
{
|
||||||
// This is a bit ugly, but we need some way to destroy the cached FBO
|
// This is a bit ugly, but we need some way to destroy the cached FBO
|
||||||
@@ -492,68 +480,6 @@ love::image::ImageData *Canvas::newImageData(love::image::Image *module, int sli
|
|||||||
return data;
|
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()
|
void Canvas::generateMipmaps()
|
||||||
{
|
{
|
||||||
if (getMipmapCount() == 1 || getMipmapMode() == MIPMAPS_NONE)
|
if (getMipmapCount() == 1 || getMipmapMode() == MIPMAPS_NONE)
|
||||||
|
|||||||
@@ -54,8 +54,6 @@ public:
|
|||||||
ptrdiff_t getHandle() const override;
|
ptrdiff_t getHandle() const override;
|
||||||
|
|
||||||
love::image::ImageData *newImageData(love::image::Image *module, int slice, int mipmap, const Rect &rect) 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;
|
void generateMipmaps() override;
|
||||||
|
|
||||||
int getMSAA() const override
|
int getMSAA() const override
|
||||||
@@ -109,9 +107,6 @@ private:
|
|||||||
GLenum status;
|
GLenum status;
|
||||||
|
|
||||||
int actualSamples;
|
int actualSamples;
|
||||||
GLuint readbackBuffer;
|
|
||||||
GLsync readbackFence;
|
|
||||||
size_t readbackSize;
|
|
||||||
|
|
||||||
static SupportedFormat supportedFormats[PIXELFORMAT_MAX_ENUM];
|
static SupportedFormat supportedFormats[PIXELFORMAT_MAX_ENUM];
|
||||||
static SupportedFormat checkedFormats[PIXELFORMAT_MAX_ENUM];
|
static SupportedFormat checkedFormats[PIXELFORMAT_MAX_ENUM];
|
||||||
|
|||||||
@@ -116,31 +116,6 @@ int w_Canvas_newImageData(lua_State *L)
|
|||||||
return 1;
|
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)
|
int w_Canvas_generateMipmaps(lua_State *L)
|
||||||
{
|
{
|
||||||
Canvas *c = luax_checkcanvas(L, 1);
|
Canvas *c = luax_checkcanvas(L, 1);
|
||||||
@@ -164,8 +139,6 @@ static const luaL_Reg w_Canvas_functions[] =
|
|||||||
{ "getMSAA", w_Canvas_getMSAA },
|
{ "getMSAA", w_Canvas_getMSAA },
|
||||||
{ "renderTo", w_Canvas_renderTo },
|
{ "renderTo", w_Canvas_renderTo },
|
||||||
{ "newImageData", w_Canvas_newImageData },
|
{ "newImageData", w_Canvas_newImageData },
|
||||||
{ "requestImageData", w_Canvas_requestImageData },
|
|
||||||
{ "pollImageData", w_Canvas_pollImageData },
|
|
||||||
{ "generateMipmaps", w_Canvas_generateMipmaps },
|
{ "generateMipmaps", w_Canvas_generateMipmaps },
|
||||||
{ "getMipmapMode", w_Canvas_getMipmapMode },
|
{ "getMipmapMode", w_Canvas_getMipmapMode },
|
||||||
{ 0, 0 }
|
{ 0, 0 }
|
||||||
|
|||||||
@@ -366,7 +366,6 @@ public class GameActivity extends SDLActivity {
|
|||||||
Log.d("GameActivity", "Cancelling vibration");
|
Log.d("GameActivity", "Cancelling vibration");
|
||||||
vibrator.cancel();
|
vibrator.cancel();
|
||||||
}
|
}
|
||||||
unregisterSecondaryDisplayListener();
|
|
||||||
onHostDestroy();
|
onHostDestroy();
|
||||||
super.onDestroy();
|
super.onDestroy();
|
||||||
}
|
}
|
||||||
@@ -377,7 +376,6 @@ public class GameActivity extends SDLActivity {
|
|||||||
Log.d("GameActivity", "Cancelling vibration");
|
Log.d("GameActivity", "Cancelling vibration");
|
||||||
vibrator.cancel();
|
vibrator.cancel();
|
||||||
}
|
}
|
||||||
unregisterSecondaryDisplayListener();
|
|
||||||
teardownSecondaryDisplay();
|
teardownSecondaryDisplay();
|
||||||
onHostPause();
|
onHostPause();
|
||||||
super.onPause();
|
super.onPause();
|
||||||
@@ -387,7 +385,6 @@ public class GameActivity extends SDLActivity {
|
|||||||
public void onResume() {
|
public void onResume() {
|
||||||
super.onResume();
|
super.onResume();
|
||||||
onHostResume();
|
onHostResume();
|
||||||
if (secondaryEnabled) registerSecondaryDisplayListener();
|
|
||||||
setupSecondaryDisplay();
|
setupSecondaryDisplay();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1408,7 +1405,6 @@ public class GameActivity extends SDLActivity {
|
|||||||
// in src/jni/love/src/common/android.cpp.
|
// in src/jni/love/src/common/android.cpp.
|
||||||
private static volatile SecondaryPresentation secondaryPresentation;
|
private static volatile SecondaryPresentation secondaryPresentation;
|
||||||
private static volatile boolean secondaryEnabled = false;
|
private static volatile boolean secondaryEnabled = false;
|
||||||
private SecondaryDisplayMonitor secondaryDisplayMonitor;
|
|
||||||
private static final int MAX_SECONDARY_TOUCHES = 32;
|
private static final int MAX_SECONDARY_TOUCHES = 32;
|
||||||
private static final java.util.ArrayDeque<String> secondaryTouches =
|
private static final java.util.ArrayDeque<String> secondaryTouches =
|
||||||
new java.util.ArrayDeque<>();
|
new java.util.ArrayDeque<>();
|
||||||
@@ -1420,44 +1416,11 @@ public class GameActivity extends SDLActivity {
|
|||||||
if (self == null) return;
|
if (self == null) return;
|
||||||
self.runOnUiThread(new Runnable() {
|
self.runOnUiThread(new Runnable() {
|
||||||
@Override public void run() {
|
@Override public void run() {
|
||||||
if (on) {
|
if (on) setupSecondaryDisplay(); else teardownSecondaryDisplay();
|
||||||
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() {
|
private static void setupSecondaryDisplay() {
|
||||||
GameActivity self = (GameActivity) mSingleton;
|
GameActivity self = (GameActivity) mSingleton;
|
||||||
if (self == null || !secondaryEnabled || secondaryPresentation != null) return;
|
if (self == null || !secondaryEnabled || secondaryPresentation != null) return;
|
||||||
@@ -1503,35 +1466,6 @@ 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
|
@Keep
|
||||||
public static boolean hasSecondaryDisplay() {
|
public static boolean hasSecondaryDisplay() {
|
||||||
return secondaryPresentation != null;
|
return secondaryPresentation != null;
|
||||||
|
|||||||
@@ -12,6 +12,48 @@
|
|||||||
"tintColor": "3b5ca8",
|
"tintColor": "3b5ca8",
|
||||||
"category": "games",
|
"category": "games",
|
||||||
"versions": [
|
"versions": [
|
||||||
|
{
|
||||||
|
"version": "0.1.92",
|
||||||
|
"date": "2026-08-15",
|
||||||
|
"size": 11364274,
|
||||||
|
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.1.92/gen1recomp++-0.1.92-ios.ipa",
|
||||||
|
"localizedDescription": "Download the correct version for your computer below.\n\n## Contributors\n\n- @bryanthaboi"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "0.1.91",
|
||||||
|
"date": "2026-08-15",
|
||||||
|
"size": 11351477,
|
||||||
|
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.1.91/gen1recomp++-0.1.91-ios.ipa",
|
||||||
|
"localizedDescription": "Download the correct version for your computer below.\n\n## Contributors\n\n- @bryanthaboi"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "0.1.90",
|
||||||
|
"date": "2026-08-15",
|
||||||
|
"size": 11344338,
|
||||||
|
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.1.90/gen1recomp++-0.1.90-ios.ipa",
|
||||||
|
"localizedDescription": "Download the correct version for your computer below.\n\n## Contributors\n\n- @1Jamie\n- @bryanthaboi"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "0.1.89",
|
||||||
|
"date": "2026-08-15",
|
||||||
|
"size": 11343841,
|
||||||
|
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.1.89/gen1recomp++-0.1.89-ios.ipa",
|
||||||
|
"localizedDescription": "Download the correct version for your computer below.\n\n## Contributors\n\n- @1Jamie\n- @anxiousintrovert\n- @AverageConsumer\n- @bryanthaboi"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "0.1.88",
|
||||||
|
"date": "2026-08-14",
|
||||||
|
"size": 11311237,
|
||||||
|
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.1.88/gen1recomp++-0.1.88-ios.ipa",
|
||||||
|
"localizedDescription": "Download the correct version for your computer below.\n\n## Contributors\n\n- @bryanthaboi"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "0.1.87",
|
||||||
|
"date": "2026-08-14",
|
||||||
|
"size": 11310910,
|
||||||
|
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.1.87/gen1recomp++-0.1.87-ios.ipa",
|
||||||
|
"localizedDescription": "Download the correct version for your computer below.\n\n## Contributors\n\n- @AverageConsumer\n- @bryanthaboi\n- @mleo2003\n- @ShaneMcGovernIE\n- MaxTomahawk"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"version": "0.1.86",
|
"version": "0.1.86",
|
||||||
"date": "2026-08-14",
|
"date": "2026-08-14",
|
||||||
|
|||||||
Symlink
+1
@@ -0,0 +1 @@
|
|||||||
|
/Users/bryanbassett/Documents/development/pokemon-gen1-recomp-project/.bazinga/mods/timekeepers_hut
|
||||||
@@ -1,257 +0,0 @@
|
|||||||
-- 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
|
|
||||||
+52
-95
@@ -38,7 +38,6 @@ local romText = RomText
|
|||||||
local BattleState = {}
|
local BattleState = {}
|
||||||
BattleState.__index = BattleState
|
BattleState.__index = BattleState
|
||||||
BattleState.isOpaque = true
|
BattleState.isOpaque = true
|
||||||
BattleState.isBattleState = true
|
|
||||||
|
|
||||||
-- Category identity for per-category GAME SPEED (RFC 0007), the same
|
-- Category identity for per-category GAME SPEED (RFC 0007), the same
|
||||||
-- style OverworldController.isOverworld already uses. Every battle --
|
-- style OverworldController.isOverworld already uses. Every battle --
|
||||||
@@ -157,13 +156,6 @@ function BattleState:caughtMarkerVisible()
|
|||||||
function() return false end, self) == true
|
function() return false end, self) == true
|
||||||
end
|
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()
|
function BattleState:moveGridNavigation()
|
||||||
if self:wideLayout() then return true end
|
if self:wideLayout() then return true end
|
||||||
if not Runtime.wantsHook("battle.move_grid_navigation") then return false end
|
if not Runtime.wantsHook("battle.move_grid_navigation") then return false end
|
||||||
@@ -1174,7 +1166,7 @@ function BattleState:startMessage(item)
|
|||||||
local npos = text:find("[\n\v]", pos)
|
local npos = text:find("[\n\v]", pos)
|
||||||
local chunk = npos and text:sub(pos, npos - 1) or text:sub(pos)
|
local chunk = npos and text:sub(pos, npos - 1) or text:sub(pos)
|
||||||
local codes = Font.encode(chunk)
|
local codes = Font.encode(chunk)
|
||||||
self.lines[#self.lines + 1] = { codes = codes, cont = cont, text = chunk }
|
self.lines[#self.lines + 1] = { codes = codes, cont = cont }
|
||||||
self.total = self.total + #codes
|
self.total = self.total + #codes
|
||||||
if not npos then break end
|
if not npos then break end
|
||||||
cont = text:sub(npos, npos) == "\v"
|
cont = text:sub(npos, npos) == "\v"
|
||||||
@@ -1207,18 +1199,6 @@ function BattleState:beginMsgLine()
|
|||||||
self.shown[#self.shown + 1] = {}
|
self.shown[#self.shown + 1] = {}
|
||||||
end
|
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()
|
function BattleState:updateQueue()
|
||||||
if self.waitingUI then
|
if self.waitingUI then
|
||||||
if self.game.stack:top() ~= self then return true end
|
if self.game.stack:top() ~= self then return true end
|
||||||
@@ -1951,77 +1931,6 @@ function BattleState:playerHasPP()
|
|||||||
return false
|
return false
|
||||||
end
|
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)
|
function BattleState:swapMoves(i, j)
|
||||||
if i == j then return end
|
if i == j then return end
|
||||||
local moves = self.player.curMoves
|
local moves = self.player.curMoves
|
||||||
@@ -2191,7 +2100,42 @@ function BattleState:update(dt)
|
|||||||
self.menuIndex = row * 2 + col + 1
|
self.menuIndex = row * 2 + col + 1
|
||||||
if input:wasPressed("a") then
|
if input:wasPressed("a") then
|
||||||
require("src.core.Sound").play(self.data, "Press_AB")
|
require("src.core.Sound").play(self.data, "Press_AB")
|
||||||
self:chooseMenu(({ "fight", "party", "item", "run" })[self.menuIndex])
|
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
|
||||||
end
|
end
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
@@ -2220,7 +2164,8 @@ function BattleState:update(dt)
|
|||||||
end
|
end
|
||||||
elseif input:wasPressed("b") then
|
elseif input:wasPressed("b") then
|
||||||
require("src.core.Sound").play(self.data, "Press_AB")
|
require("src.core.Sound").play(self.data, "Press_AB")
|
||||||
self:cancelMove()
|
self.moveSwapIndex = nil
|
||||||
|
self.phase = "menu"
|
||||||
elseif input:wasPressed("a") then
|
elseif input:wasPressed("a") then
|
||||||
require("src.core.Sound").play(self.data, "Press_AB")
|
require("src.core.Sound").play(self.data, "Press_AB")
|
||||||
if self.moveSwapIndex then
|
if self.moveSwapIndex then
|
||||||
@@ -2228,7 +2173,19 @@ function BattleState:update(dt)
|
|||||||
self.moveSwapIndex = nil
|
self.moveSwapIndex = nil
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
self:chooseMove(self.moveIndex)
|
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
|
||||||
end
|
end
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|||||||
+14
-29
@@ -27,17 +27,6 @@ Catching.BALLS = BALLS
|
|||||||
-- divisor, which is what the old per-field `or` defaults resolved to
|
-- divisor, which is what the old per-field `or` defaults resolved to
|
||||||
local DEFAULT_BALL = { randMax = 255, hpFactor = 12, wobbleFactor = 150 }
|
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)
|
function Catching.registerInto(registry, _, owner)
|
||||||
for id, record in pairs(BALLS) do
|
for id, record in pairs(BALLS) do
|
||||||
registry:register(id, record, owner)
|
registry:register(id, record, owner)
|
||||||
@@ -52,9 +41,21 @@ end
|
|||||||
local function stockAttempt(def, targetMon, targetDef, rng, rateOverride, statuses)
|
local function stockAttempt(def, targetMon, targetDef, rng, rateOverride, statuses)
|
||||||
if def.autoCatch then return true, 3 end
|
if def.autoCatch then return true, 3 end
|
||||||
local randMax = def.randMax
|
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 s = targetMon.status
|
||||||
local rate, statusBonus, f, record = stockFactors(
|
local record = Status.recordFor(statuses, s)
|
||||||
def, targetMon, targetDef, rateOverride, statuses)
|
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 function shakes()
|
local function shakes()
|
||||||
local ballFactor2 = def.wobbleFactor or DEFAULT_BALL.wobbleFactor
|
local ballFactor2 = def.wobbleFactor or DEFAULT_BALL.wobbleFactor
|
||||||
@@ -79,22 +80,6 @@ local function stockAttempt(def, targetMon, targetDef, rng, rateOverride, status
|
|||||||
return false, shakes()
|
return false, shakes()
|
||||||
end
|
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
|
-- Returns caught, shakes (0-3). rateOverride replaces the species catch
|
||||||
-- rate (the Safari game's BAIT/ROCK-modified wEnemyMonActualCatchRate).
|
-- rate (the Safari game's BAIT/ROCK-modified wEnemyMonActualCatchRate).
|
||||||
-- opts (all optional): ballDef = the merged ball record, statuses = the
|
-- opts (all optional): ballDef = the merged ball record, statuses = the
|
||||||
|
|||||||
+17
-27
@@ -83,35 +83,25 @@ function Damage.critRoll(ruleset, attacker, moveId, rng, highCrit)
|
|||||||
return rng(0, 255) < b
|
return rng(0, 255) < b
|
||||||
end
|
end
|
||||||
|
|
||||||
-- Exact number of the 256 RNG outcomes that pass MoveHitTest. Keeping the
|
-- Accuracy test: rand(0..255) < floor(accuracy * 255 / 100) adjusted by
|
||||||
-- threshold public lets read-only UIs preview the same rules the roll uses.
|
-- accuracy/evasion stages. With oneIn256Miss a max-accuracy move still
|
||||||
function Damage.accuracyThreshold(ruleset, move, attacker, defender)
|
-- misses on 255.
|
||||||
-- 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 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, accuracyStage))
|
|
||||||
acc = math.min(255, Stats.applyStage(acc, -evasionStage))
|
|
||||||
if not ruleset.oneIn256Miss and move.accuracy >= 100
|
|
||||||
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)
|
function Damage.accuracyRoll(ruleset, move, attacker, defender, rng)
|
||||||
rng = rng or love.math.random
|
rng = rng or love.math.random
|
||||||
local acc = Damage.accuracyThreshold(ruleset, move, attacker, defender)
|
-- X ACCURACY sets USING_X_ACCURACY: the move simply never misses
|
||||||
if acc == 256 then return true end
|
-- (MoveHitTest returns before any accuracy math, 1/256 included)
|
||||||
|
if attacker.xAccuracy then return true end
|
||||||
|
local acc = math.floor(move.accuracy * 255 / 100)
|
||||||
|
-- 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)))
|
||||||
|
if not ruleset.oneIn256Miss and move.accuracy >= 100
|
||||||
|
and (attacker.stages.accuracy or 0) >= (defender.stages.evasion or 0) then
|
||||||
|
return true
|
||||||
|
end
|
||||||
return rng(0, 255) < acc
|
return rng(0, 255) < acc
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -1,172 +0,0 @@
|
|||||||
-- 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
|
|
||||||
@@ -417,88 +417,4 @@ function HostShell.httpGet(url, userAgent, accept, maxTime)
|
|||||||
return body
|
return body
|
||||||
end
|
end
|
||||||
|
|
||||||
-- POST returning success/failure. Strictly one-way: the response body is
|
|
||||||
-- discarded, only the HTTP status class is surfaced (postLog callers never
|
|
||||||
-- trust the reply). curl --data-binary reads the payload from a pipe, so a
|
|
||||||
-- large body never lands in the command line; the Android bridge has no POST
|
|
||||||
-- transport, and httpPost reports that instead of half-working through
|
|
||||||
-- httpDownload (a GET round-trip to a POST endpoint would be a lie).
|
|
||||||
function HostShell.httpPost(url, body, contentType, userAgent, maxTime)
|
|
||||||
if type(url) ~= "string" or url == "" then return nil, "missing url" end
|
|
||||||
if type(body) ~= "string" then return nil, "missing body" end
|
|
||||||
userAgent = userAgent or "gen1recomp"
|
|
||||||
if HostShell.haveCurl() then
|
|
||||||
-- 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. 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)
|
|
||||||
.. "-X POST "
|
|
||||||
.. "-H " .. HostShell.quote("User-Agent: " .. userAgent) .. " "
|
|
||||||
if contentType then
|
|
||||||
cmd = cmd .. "-H " .. HostShell.quote("Content-Type: " .. contentType) .. " "
|
|
||||||
end
|
|
||||||
cmd = cmd .. "-H " .. HostShell.quote("Content-Length: " .. tostring(#body)) .. " "
|
|
||||||
.. "--data-binary " .. HostShell.quote("@" .. bodyPath) .. " "
|
|
||||||
.. "-w " .. HostShell.quote(HTTP_MARK_FMT) .. " "
|
|
||||||
.. HostShell.quote(url) .. " 2>&1"
|
|
||||||
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
|
|
||||||
local _, status, noise = splitCurlOutput(out)
|
|
||||||
if not status then return nil, fetchError(url, nil, noise) end
|
|
||||||
if status < 200 or status >= 300 then
|
|
||||||
return nil, fetchError(url, status, "log post rejected")
|
|
||||||
end
|
|
||||||
return true
|
|
||||||
end
|
|
||||||
if not haveBridge() then
|
|
||||||
return nil, "no network transport on this platform"
|
|
||||||
end
|
|
||||||
return nil, "no POST transport on this platform"
|
|
||||||
end
|
|
||||||
|
|
||||||
return HostShell
|
return HostShell
|
||||||
|
|||||||
@@ -83,12 +83,6 @@ function ItemEffects.healsHP(id)
|
|||||||
or id == "REVIVE" or id == "MAX_REVIVE"
|
or id == "REVIVE" or id == "MAX_REVIVE"
|
||||||
end
|
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?
|
-- Does this item need a party-member target?
|
||||||
-- 'data' is optional for compat purposes; targeting falls back to itemDef/vanilla detection
|
-- 'data' is optional for compat purposes; targeting falls back to itemDef/vanilla detection
|
||||||
function ItemEffects.needsTarget(id, itemDef, data)
|
function ItemEffects.needsTarget(id, itemDef, data)
|
||||||
|
|||||||
@@ -215,19 +215,12 @@ function LauncherMods.checkDependencies(manifest, options, version, installedMan
|
|||||||
return m and not m.experimental
|
return m and not m.experimental
|
||||||
end
|
end
|
||||||
|
|
||||||
local function conflictApplies(spec, other)
|
|
||||||
return not spec.range or (other and other.version
|
|
||||||
and Semver.satisfies(other.version, spec.range))
|
|
||||||
end
|
|
||||||
|
|
||||||
-- (a) Conflicts declared by target manifest
|
-- (a) Conflicts declared by target manifest
|
||||||
if type(manifest.conflictSpecs) == "table" then
|
if type(manifest.conflictSpecs) == "table" then
|
||||||
for _, spec in ipairs(manifest.conflictSpecs) do
|
for _, spec in ipairs(manifest.conflictSpecs) do
|
||||||
local conflictId = spec.id
|
local conflictId = spec.id
|
||||||
local installedOther = installedMap[conflictId]
|
local installedOther = installedMap[conflictId]
|
||||||
if installedOther and isEnabled(conflictId)
|
if installedOther and isEnabled(conflictId) and not conflictIdsSeen[conflictId] then
|
||||||
and conflictApplies(spec, installedOther)
|
|
||||||
and not conflictIdsSeen[conflictId] then
|
|
||||||
conflictIdsSeen[conflictId] = true
|
conflictIdsSeen[conflictId] = true
|
||||||
hasIssues = true
|
hasIssues = true
|
||||||
depsResult[#depsResult + 1] = {
|
depsResult[#depsResult + 1] = {
|
||||||
@@ -245,12 +238,11 @@ function LauncherMods.checkDependencies(manifest, options, version, installedMan
|
|||||||
|
|
||||||
-- (b) Reverse conflicts declared by installed mods against target manifest
|
-- (b) Reverse conflicts declared by installed mods against target manifest
|
||||||
if manifest.id then
|
if manifest.id then
|
||||||
local installedTarget = installedMap[manifest.id] or manifest
|
|
||||||
for _, other in ipairs(manifests) do
|
for _, other in ipairs(manifests) do
|
||||||
if other.id ~= manifest.id and isEnabled(other.id) and not conflictIdsSeen[other.id] then
|
if other.id ~= manifest.id and isEnabled(other.id) and not conflictIdsSeen[other.id] then
|
||||||
local conflicts = other.conflictSpecs or {}
|
local conflicts = other.conflictSpecs or {}
|
||||||
for _, spec in ipairs(conflicts) do
|
for _, spec in ipairs(conflicts) do
|
||||||
if spec.id == manifest.id and conflictApplies(spec, installedTarget) then
|
if spec.id == manifest.id then
|
||||||
conflictIdsSeen[other.id] = true
|
conflictIdsSeen[other.id] = true
|
||||||
hasIssues = true
|
hasIssues = true
|
||||||
depsResult[#depsResult + 1] = {
|
depsResult[#depsResult + 1] = {
|
||||||
|
|||||||
+2
-27
@@ -1094,23 +1094,6 @@ function Loader:_api(mod)
|
|||||||
return { available = function() return false end,
|
return { available = function() return false end,
|
||||||
get = refuse, poll = refuse, release = refuse, cancel = refuse }
|
get = refuse, poll = refuse, release = refuse, cancel = refuse }
|
||||||
end)(),
|
end)(),
|
||||||
-- One-way crash-log reporting to the https URL the manifest declares in
|
|
||||||
-- log_url. The destination is reviewed at load, not chosen per call, so
|
|
||||||
-- a mod cannot aim this at arbitrary hosts; the response body is never
|
|
||||||
-- returned, and the worker pool bounds the transfer. Same handle/poll/
|
|
||||||
-- release shape as mod.fetch, so mod.job's sibling patterns carry over.
|
|
||||||
postLog = (function()
|
|
||||||
if mod.manifest.permissionSet.network and mod.manifest.log_url then
|
|
||||||
return function(_, body, opts)
|
|
||||||
return Net.postLog(loader, modId, mod.manifest.log_url, body, opts)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
local function refuse()
|
|
||||||
error(('[%s] mod.postLog needs the "network" permission and a '
|
|
||||||
.. "log_url in manifest.json"):format(modId), 2)
|
|
||||||
end
|
|
||||||
return refuse
|
|
||||||
end)(),
|
|
||||||
-- Background compute, behind the "background" permission. The worker
|
-- Background compute, behind the "background" permission. The worker
|
||||||
-- rebuilds this mod's sandbox before loading the script, so a job is the
|
-- rebuilds this mod's sandbox before loading the script, so a job is the
|
||||||
-- one thing love.thread is not: off the main thread without a Lua state
|
-- one thing love.thread is not: off the main thread without a Lua state
|
||||||
@@ -1308,7 +1291,7 @@ function Loader:_api(mod)
|
|||||||
-- mod.world materializes on first touch, like the image helper above: a
|
-- 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
|
-- 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
|
-- acts on is still being wired when the entry chunk runs
|
||||||
local world, battle
|
local world
|
||||||
setmetatable(api, { __index = function(_, key)
|
setmetatable(api, { __index = function(_, key)
|
||||||
-- mod.game is the live service owner, resolved per generation the way
|
-- 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
|
-- mod.world is: src/core/Game.lua's singleton under Gen 1, the Game2
|
||||||
@@ -1317,17 +1300,9 @@ function Loader:_api(mod)
|
|||||||
-- entry chunk runs. This is what a mod should hold instead of requiring
|
-- 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.
|
-- src.core.Game, which under Gold hands back a table nothing instantiated.
|
||||||
if key == "game" then return loader:_game() end
|
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 key ~= "world" then return nil end
|
||||||
if world then return world 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
|
-- 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
|
-- state and its flags are a bitfield, so the resolution differs even
|
||||||
-- where the method set does not (src/world/gen2/WorldAPI.lua)
|
-- where the method set does not (src/world/gen2/WorldAPI.lua)
|
||||||
|
|||||||
@@ -986,45 +986,12 @@ end
|
|||||||
function ManagerState:buildOptionRows(m, schema)
|
function ManagerState:buildOptionRows(m, schema)
|
||||||
local rows = {}
|
local rows = {}
|
||||||
local modId = m.id
|
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
|
for _, row in ipairs(schema) do
|
||||||
if type(row) ~= "table" or type(row.key) ~= "string" or row.key == ""
|
if type(row) ~= "table" or type(row.key) ~= "string" or row.key == ""
|
||||||
or not OPTION_TYPES[row.type] then
|
or not OPTION_TYPES[row.type] then
|
||||||
-- malformed rows are skipped, reported where the errors screen reads
|
-- malformed rows are skipped, reported where the errors screen reads
|
||||||
Runtime.reportError(modId, "options row skipped: "
|
Runtime.reportError(modId, "options row skipped: "
|
||||||
.. tostring(type(row) == "table" and (row.key or row.type) or row))
|
.. 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
|
elseif row.type == "toggle" then
|
||||||
rows[#rows + 1] = { id = row.key, label = row.label or row.key,
|
rows[#rows + 1] = { id = row.key, label = row.label or row.key,
|
||||||
value = function()
|
value = function()
|
||||||
@@ -1032,7 +999,6 @@ function ManagerState:buildOptionRows(m, schema)
|
|||||||
end,
|
end,
|
||||||
step = function()
|
step = function()
|
||||||
self:setOption(modId, row.key, not self:optionValue(modId, row))
|
self:setOption(modId, row.key, not self:optionValue(modId, row))
|
||||||
refresh(row.key)
|
|
||||||
return true
|
return true
|
||||||
end }
|
end }
|
||||||
elseif row.type == "choice" then
|
elseif row.type == "choice" then
|
||||||
@@ -1055,7 +1021,6 @@ function ManagerState:buildOptionRows(m, schema)
|
|||||||
end
|
end
|
||||||
index = clampIndex(index + dir, #choices)
|
index = clampIndex(index + dir, #choices)
|
||||||
self:setOption(modId, row.key, choices[index][2])
|
self:setOption(modId, row.key, choices[index][2])
|
||||||
refresh(row.key)
|
|
||||||
return true
|
return true
|
||||||
end }
|
end }
|
||||||
elseif row.type == "number" then
|
elseif row.type == "number" then
|
||||||
@@ -1071,7 +1036,6 @@ function ManagerState:buildOptionRows(m, schema)
|
|||||||
step = function(_, dir)
|
step = function(_, dir)
|
||||||
local cur = tonumber(self:optionValue(modId, row)) or 0
|
local cur = tonumber(self:optionValue(modId, row)) or 0
|
||||||
self:setOption(modId, row.key, clamp(cur + dir * (row.step or 1)))
|
self:setOption(modId, row.key, clamp(cur + dir * (row.step or 1)))
|
||||||
refresh(row.key)
|
|
||||||
return true
|
return true
|
||||||
end,
|
end,
|
||||||
activate = function()
|
activate = function()
|
||||||
@@ -1080,10 +1044,7 @@ function ManagerState:buildOptionRows(m, schema)
|
|||||||
max = row.max or 99,
|
max = row.max or 99,
|
||||||
start = math.max(1, tonumber(self:optionValue(modId, row)) or 1),
|
start = math.max(1, tonumber(self:optionValue(modId, row)) or 1),
|
||||||
onDone = function(qty)
|
onDone = function(qty)
|
||||||
if qty then
|
if qty then self:setOption(modId, row.key, clamp(qty)) end
|
||||||
self:setOption(modId, row.key, clamp(qty))
|
|
||||||
refresh(row.key)
|
|
||||||
end
|
|
||||||
end,
|
end,
|
||||||
}))
|
}))
|
||||||
end }
|
end }
|
||||||
@@ -1100,7 +1061,6 @@ function ManagerState:buildOptionRows(m, schema)
|
|||||||
default = self:optionValue(modId, row),
|
default = self:optionValue(modId, row),
|
||||||
onDone = function(name)
|
onDone = function(name)
|
||||||
self:setOption(modId, row.key, name)
|
self:setOption(modId, row.key, name)
|
||||||
refresh(row.key)
|
|
||||||
end,
|
end,
|
||||||
}))
|
}))
|
||||||
end }
|
end }
|
||||||
@@ -1115,8 +1075,6 @@ function ManagerState:buildOptionRows(m, schema)
|
|||||||
self:setOption(modId, row.key, row.default)
|
self:setOption(modId, row.key, row.default)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
self.optionRows = self:buildOptionRows(m, schema)
|
|
||||||
self.cursor = clampIndex(self.cursor, #self.optionRows)
|
|
||||||
self:notify("DEFAULTS RESTORED")
|
self:notify("DEFAULTS RESTORED")
|
||||||
end }
|
end }
|
||||||
return rows
|
return rows
|
||||||
|
|||||||
@@ -308,24 +308,6 @@ function Manifest.validate(raw, path)
|
|||||||
|
|
||||||
local github = Manifest.parseGithub(raw.github)
|
local github = Manifest.parseGithub(raw.github)
|
||||||
|
|
||||||
-- log_url: the mod's one-way crash-log reporting destination. https-only,
|
|
||||||
-- declared in the manifest so the engine reviews the target at load instead
|
|
||||||
-- of trusting per-call URLs from gameplay code, and gated on the `network`
|
|
||||||
-- permission the mod must also declare. api 1 mods never carry it: it is a
|
|
||||||
-- load violation, not a warning, because a postLog-capable mod that does not
|
|
||||||
-- opt in to networking is a bug in the manifest itself.
|
|
||||||
local logUrl = nil
|
|
||||||
if raw.log_url ~= nil then
|
|
||||||
if strict and not permissionSet.network then
|
|
||||||
violation(strict, raw.id, "log_url requires the network permission")
|
|
||||||
elseif strict and (type(raw.log_url) ~= "string"
|
|
||||||
or not raw.log_url:match("^https://")) then
|
|
||||||
violation(strict, raw.id, "log_url must be an https:// URL")
|
|
||||||
elseif strict then
|
|
||||||
logUrl = raw.log_url
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
assert(raw.experimental == nil or type(raw.experimental) == "boolean",
|
assert(raw.experimental == nil or type(raw.experimental) == "boolean",
|
||||||
"experimental must be a boolean")
|
"experimental must be a boolean")
|
||||||
local experimental = raw.experimental == true
|
local experimental = raw.experimental == true
|
||||||
@@ -432,7 +414,6 @@ function Manifest.validate(raw, path)
|
|||||||
affects_link = affectsLink,
|
affects_link = affectsLink,
|
||||||
permissions = permissions,
|
permissions = permissions,
|
||||||
permissionSet = permissionSet,
|
permissionSet = permissionSet,
|
||||||
log_url = logUrl,
|
|
||||||
options_schema = optionalFile(raw.options_schema, "options_schema"),
|
options_schema = optionalFile(raw.options_schema, "options_schema"),
|
||||||
assets_transforms = optionalFile(raw.assets_transforms, "assets_transforms"),
|
assets_transforms = optionalFile(raw.assets_transforms, "assets_transforms"),
|
||||||
required_imports = requiredImports,
|
required_imports = requiredImports,
|
||||||
|
|||||||
@@ -32,14 +32,6 @@ local Net = {}
|
|||||||
Net.MAX_INFLIGHT = 4
|
Net.MAX_INFLIGHT = 4
|
||||||
-- Clamp on the caller's timeout, so a mod cannot pin a worker indefinitely.
|
-- Clamp on the caller's timeout, so a mod cannot pin a worker indefinitely.
|
||||||
Net.MAX_SECONDS = 30
|
Net.MAX_SECONDS = 30
|
||||||
-- 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()
|
local function fetch()
|
||||||
return require("src.net.Fetch")
|
return require("src.net.Fetch")
|
||||||
@@ -107,62 +99,6 @@ function Net.get(loader, modId, url, opts)
|
|||||||
return handle
|
return handle
|
||||||
end
|
end
|
||||||
|
|
||||||
-- The closed list of postLog format switches. Anything outside it is a
|
|
||||||
-- caller bug, rejected before a job is submitted, so the surface stays
|
|
||||||
-- exactly two shapes on the wire.
|
|
||||||
local POST_FORMATS = { text = true, json = true }
|
|
||||||
|
|
||||||
-- A one-way log POST to the mod's manifest-declared log_url (https only,
|
|
||||||
-- validated in Manifest.lua). Same shape as get(): opaque handle, per-mod
|
|
||||||
-- in-flight ceiling, user agent naming the mod. The response body is never
|
|
||||||
-- returned -- a postLog is fire-and-forget reporting, and the engine has no
|
|
||||||
-- reason to hand a mod a server's reply.
|
|
||||||
function Net.postLog(loader, modId, logUrl, body, opts)
|
|
||||||
if type(body) ~= "string" or body == "" then
|
|
||||||
return nil, "log body must be a non-empty string"
|
|
||||||
end
|
|
||||||
if #body > Net.MAX_BODY then
|
|
||||||
return nil, ("log body too large (%d bytes, limit %d)"):format(#body, Net.MAX_BODY)
|
|
||||||
end
|
|
||||||
opts = type(opts) == "table" and opts or {}
|
|
||||||
for key in pairs(opts) do
|
|
||||||
if key ~= "format" then
|
|
||||||
return nil, ("unknown log option %q (format is the only switch)"):format(tostring(key))
|
|
||||||
end
|
|
||||||
end
|
|
||||||
local format = opts.format or "text"
|
|
||||||
if not POST_FORMATS[format] then
|
|
||||||
return nil, ("unknown log format %q (text and json only)"):format(tostring(format))
|
|
||||||
end
|
|
||||||
local denial = Net.urlDenial(logUrl)
|
|
||||||
if denial then return nil, denial end
|
|
||||||
local b = bucket(loader, modId)
|
|
||||||
if inflight(b) >= Net.MAX_INFLIGHT then
|
|
||||||
return nil, ("too many requests in flight (limit %d); poll and release "
|
|
||||||
.. "the ones you have"):format(Net.MAX_INFLIGHT)
|
|
||||||
end
|
|
||||||
local payload = body
|
|
||||||
local contentType = "text/plain"
|
|
||||||
if format == "json" then
|
|
||||||
local Json = require("src.link.Json")
|
|
||||||
payload = Json.encode({
|
|
||||||
ts = os.time(),
|
|
||||||
mod = modId,
|
|
||||||
format = "json",
|
|
||||||
body = body,
|
|
||||||
})
|
|
||||||
contentType = "application/json"
|
|
||||||
end
|
|
||||||
local id = fetch().post(logUrl, payload, {
|
|
||||||
userAgent = "gen1recomp-mod/" .. tostring(modId),
|
|
||||||
contentType = contentType,
|
|
||||||
maxSeconds = Net.MAX_SECONDS,
|
|
||||||
})
|
|
||||||
local handle = {}
|
|
||||||
b[handle] = id
|
|
||||||
return handle
|
|
||||||
end
|
|
||||||
|
|
||||||
-- A copy of the job's state, never the engine's own table. An unknown or
|
-- A copy of the job's state, never the engine's own table. An unknown or
|
||||||
-- forged handle reads as an error rather than nil, so a mod that lost track of
|
-- forged handle reads as an error rather than nil, so a mod that lost track of
|
||||||
-- one cannot spin waiting on it forever.
|
-- one cannot spin waiting on it forever.
|
||||||
|
|||||||
@@ -132,17 +132,6 @@ function Fetch.get(url, opts)
|
|||||||
accept = opts.accept, maxSeconds = opts.maxSeconds })
|
accept = opts.accept, maxSeconds = opts.maxSeconds })
|
||||||
end
|
end
|
||||||
|
|
||||||
-- POST a body to a URL, one-way. The result carries no body: postLog
|
|
||||||
-- reporting never trusts a server's reply, so the worker surfaces only
|
|
||||||
-- ok/error and the transport's complaint.
|
|
||||||
-- opts: { userAgent, contentType, maxSeconds }
|
|
||||||
function Fetch.post(url, body, opts)
|
|
||||||
opts = opts or {}
|
|
||||||
return submit({ kind = "post", url = url, body = body,
|
|
||||||
userAgent = opts.userAgent or "gen1recomp",
|
|
||||||
contentType = opts.contentType, maxSeconds = opts.maxSeconds })
|
|
||||||
end
|
|
||||||
|
|
||||||
-- Download a URL to `saveRel`, a path relative to the LOVE save directory.
|
-- Download a URL to `saveRel`, a path relative to the LOVE save directory.
|
||||||
-- Progress is reported as a 0..1 fraction when `size` is known.
|
-- Progress is reported as a 0..1 fraction when `size` is known.
|
||||||
function Fetch.download(url, saveRel, opts)
|
function Fetch.download(url, saveRel, opts)
|
||||||
|
|||||||
@@ -99,20 +99,6 @@ local function doDownload(job)
|
|||||||
post({ id = job.id, ok = true, path = rel, done = true })
|
post({ id = job.id, ok = true, path = rel, done = true })
|
||||||
end
|
end
|
||||||
|
|
||||||
local function doPost(job)
|
|
||||||
if not HostShell then
|
|
||||||
post({ id = job.id, ok = false, err = "no transport" })
|
|
||||||
return
|
|
||||||
end
|
|
||||||
local ok, err = HostShell.httpPost(job.url, job.body, job.contentType,
|
|
||||||
job.userAgent, tonumber(job.maxSeconds) or GET_MAX_SECONDS)
|
|
||||||
if not ok then
|
|
||||||
post({ id = job.id, ok = false, err = err or "post failed" })
|
|
||||||
return
|
|
||||||
end
|
|
||||||
post({ id = job.id, ok = true, done = true })
|
|
||||||
end
|
|
||||||
|
|
||||||
while true do
|
while true do
|
||||||
local job = cmdCh:demand()
|
local job = cmdCh:demand()
|
||||||
-- The flag is checked before the job's KIND, so a worker woken by a
|
-- The flag is checked before the job's KIND, so a worker woken by a
|
||||||
@@ -128,9 +114,6 @@ while true do
|
|||||||
elseif job.kind == "get" then
|
elseif job.kind == "get" then
|
||||||
local ok, err = pcall(doGet, job)
|
local ok, err = pcall(doGet, job)
|
||||||
if not ok then post({ id = job.id, ok = false, err = tostring(err) }) end
|
if not ok then post({ id = job.id, ok = false, err = tostring(err) }) end
|
||||||
elseif job.kind == "post" then
|
|
||||||
local ok, err = pcall(doPost, job)
|
|
||||||
if not ok then post({ id = job.id, ok = false, err = tostring(err) }) end
|
|
||||||
elseif job.kind == "download" then
|
elseif job.kind == "download" then
|
||||||
local ok, err = pcall(doDownload, job)
|
local ok, err = pcall(doDownload, job)
|
||||||
if not ok then post({ id = job.id, ok = false, err = tostring(err) }) end
|
if not ok then post({ id = job.id, ok = false, err = tostring(err) }) end
|
||||||
|
|||||||
@@ -253,16 +253,6 @@ function TextBox:beginLine()
|
|||||||
table.insert(self.shown, {})
|
table.insert(self.shown, {})
|
||||||
end
|
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)
|
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
|
||||||
|
|||||||
+43
-63
@@ -121,8 +121,6 @@ local TEXT_ASK_FORGET_MOVE = Strings.source(
|
|||||||
-- Gen 1 uses. The second label is the two-glyph <PK><MN> ligature (charmap
|
-- 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.
|
-- $e1/$e2), which is what makes it fit a six-tile column.
|
||||||
local MENU = { "FIGHT", "<PK><MN>", "PACK", "RUN" }
|
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_BOX_X = 8
|
||||||
local MENU_COL_SPACING = 6
|
local MENU_COL_SPACING = 6
|
||||||
|
|
||||||
@@ -1662,64 +1660,6 @@ function BattleState:playerMoves()
|
|||||||
return (self.battle and self.battle.player and self.battle.player.moves) or {}
|
return (self.battle and self.battle.player and self.battle.player.moves) or {}
|
||||||
end
|
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).
|
-- MoveSelectionScreen's `.pressed_select` (engine/battle/core.asm:5320-5374).
|
||||||
-- SELECT marks a slot, SELECT again swaps the marked slot with the one under
|
-- 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
|
-- the cursor, and A or B clears the mark without swapping (the A arm opens
|
||||||
@@ -1893,7 +1833,37 @@ function BattleState:update(_dt)
|
|||||||
or self.menuIndex - 2
|
or self.menuIndex - 2
|
||||||
elseif input:wasPressed("a") then
|
elseif input:wasPressed("a") then
|
||||||
self:playSfx("Sfx_ReadText2")
|
self:playSfx("Sfx_ReadText2")
|
||||||
self:chooseMenu(MENU_ACTION[MENU[self.menuIndex]])
|
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
|
||||||
end
|
end
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
@@ -1914,12 +1884,22 @@ function BattleState:update(_dt)
|
|||||||
elseif input:wasPressed("b") then
|
elseif input:wasPressed("b") then
|
||||||
-- B leaves the list, and a mark never survives it
|
-- B leaves the list, and a mark never survives it
|
||||||
self:playSfx("Sfx_ReadText2")
|
self:playSfx("Sfx_ReadText2")
|
||||||
self:cancelMove()
|
self.moveSwapIndex = nil
|
||||||
|
self.phase = "menu"
|
||||||
elseif input:wasPressed("a") then
|
elseif input:wasPressed("a") then
|
||||||
-- `xor a / ld [wSwappingMove], a` opens the A arm: choosing a move
|
-- `xor a / ld [wSwappingMove], a` opens the A arm: choosing a move
|
||||||
-- cancels a pending swap rather than performing it
|
-- cancels a pending swap rather than performing it
|
||||||
self:playSfx("Sfx_ReadText2")
|
self:playSfx("Sfx_ReadText2")
|
||||||
self:chooseMove(self.moveIndex)
|
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 })
|
||||||
end
|
end
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -95,10 +95,8 @@ end
|
|||||||
-- are listed; callers receive copied labels and never inspect world internals.
|
-- are listed; callers receive copied labels and never inspect world internals.
|
||||||
function WorldAPI:availableFieldActions()
|
function WorldAPI:availableFieldActions()
|
||||||
local game, ow, out = self.game, self:overworld(), {}
|
local game, ow, out = self.game, self:overworld(), {}
|
||||||
if not (game and game.save and ow and ow.map and ow.player) then
|
if not (game and game.save and ow and ow.map and ow.player)
|
||||||
return out, NO_OVERWORLD
|
or not acceptsMenuInput(game, ow) then return out end
|
||||||
end
|
|
||||||
if not acceptsMenuInput(game, ow) then return out, "world is busy" end
|
|
||||||
local save, inventory = game.save, game.save.inventory or {}
|
local save, inventory = game.save, game.save.inventory or {}
|
||||||
local items = game.data and game.data.items or {}
|
local items = game.data and game.data.items or {}
|
||||||
|
|
||||||
|
|||||||
@@ -78,10 +78,8 @@ end
|
|||||||
-- collision and fishing rules.
|
-- collision and fishing rules.
|
||||||
function WorldAPI:availableFieldActions()
|
function WorldAPI:availableFieldActions()
|
||||||
local world, game, out = self:overworld(), self.game, {}
|
local world, game, out = self:overworld(), self.game, {}
|
||||||
if not (world and game and game.save and world.map and world.player) then
|
if not (world and game and game.save and world.map and world.player)
|
||||||
return out, NO_OVERWORLD
|
or not world:acceptsMenuInput() then return out end
|
||||||
end
|
|
||||||
if not world:acceptsMenuInput() then return out, "world is busy" end
|
|
||||||
local inventory = game.save.inventory or {}
|
local inventory = game.save.inventory or {}
|
||||||
|
|
||||||
if (inventory.BICYCLE or 0) > 0 then
|
if (inventory.BICYCLE or 0) > 0 then
|
||||||
|
|||||||
@@ -49,16 +49,6 @@ check(position("onHostPause();") < position("super.onPause();"),
|
|||||||
check(position("onHostDestroy();") < position("super.onDestroy();"),
|
check(position("onHostDestroy();") < position("super.onDestroy();"),
|
||||||
"destroy hook runs before SDL destruction")
|
"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),
|
check(not source:lower():find("openxr", 1, true),
|
||||||
"generic Android activity must not require OpenXR")
|
"generic Android activity must not require OpenXR")
|
||||||
check(not source:find("QuestActivity", 1, true) and
|
check(not source:find("QuestActivity", 1, true) and
|
||||||
|
|||||||
@@ -1,81 +0,0 @@
|
|||||||
-- 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")
|
|
||||||
@@ -1,246 +0,0 @@
|
|||||||
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()
|
|
||||||
@@ -550,51 +550,6 @@ local installedColorlib = Manifest.validate({
|
|||||||
version = "1.0.0",
|
version = "1.0.0",
|
||||||
entry = "main.lua",
|
entry = "main.lua",
|
||||||
}, "mods/colorlib")
|
}, "mods/colorlib")
|
||||||
local unconditionalConflict = LauncherMods.checkDependencies(testTargetManifest,
|
|
||||||
nil, nil, { testTargetManifest, installedColorlib })
|
|
||||||
check(unconditionalConflict.hasIssues == true,
|
|
||||||
"dependency resolver reports an unversioned conflict")
|
|
||||||
|
|
||||||
local function rangeTarget(version, conflicts)
|
|
||||||
return Manifest.validate({
|
|
||||||
id = "range_target",
|
|
||||||
name = "Range Target",
|
|
||||||
version = version,
|
|
||||||
entry = "main.lua",
|
|
||||||
conflicts = conflicts or {},
|
|
||||||
}, "mods/range_target")
|
|
||||||
end
|
|
||||||
local function rangeSource(conflicts)
|
|
||||||
return Manifest.validate({
|
|
||||||
id = "range_source",
|
|
||||||
name = "Range Source",
|
|
||||||
version = "1.0.0",
|
|
||||||
entry = "main.lua",
|
|
||||||
conflicts = conflicts or {},
|
|
||||||
}, "mods/range_source")
|
|
||||||
end
|
|
||||||
|
|
||||||
local forwardSource = rangeSource({ "range_target@<2.0.0" })
|
|
||||||
local matchingTarget = rangeTarget("1.4.0")
|
|
||||||
local nonmatchingTarget = rangeTarget("2.0.0")
|
|
||||||
local forwardMatching = LauncherMods.checkDependencies(forwardSource,
|
|
||||||
nil, nil, { forwardSource, matchingTarget })
|
|
||||||
check(forwardMatching.hasIssues == true and #forwardMatching.deps == 1,
|
|
||||||
"dependency resolver applies a matching forward conflict range")
|
|
||||||
local forwardNonmatching = LauncherMods.checkDependencies(forwardSource,
|
|
||||||
nil, nil, { forwardSource, nonmatchingTarget })
|
|
||||||
check(forwardNonmatching.hasIssues == false and #forwardNonmatching.deps == 0,
|
|
||||||
"dependency resolver ignores a nonmatching forward conflict range")
|
|
||||||
|
|
||||||
local reverseSource = rangeSource({ "range_target@<2.0.0" })
|
|
||||||
local reverseMatching = LauncherMods.checkDependencies(matchingTarget,
|
|
||||||
nil, nil, { reverseSource, matchingTarget })
|
|
||||||
check(reverseMatching.hasIssues == true and #reverseMatching.deps == 1,
|
|
||||||
"dependency resolver applies a matching reverse conflict range")
|
|
||||||
local reverseNonmatching = LauncherMods.checkDependencies(nonmatchingTarget,
|
|
||||||
nil, nil, { reverseSource, nonmatchingTarget })
|
|
||||||
check(reverseNonmatching.hasIssues == false and #reverseNonmatching.deps == 0,
|
|
||||||
"dependency resolver ignores a nonmatching reverse conflict range")
|
|
||||||
-- ------- scoped dependency tests
|
-- ------- scoped dependency tests
|
||||||
local Json = require("src.link.Json")
|
local Json = require("src.link.Json")
|
||||||
local scopedDepManifest = Manifest.validate({
|
local scopedDepManifest = Manifest.validate({
|
||||||
|
|||||||
@@ -1106,27 +1106,6 @@ check(loader.modOptions.okmod.hardcore == false
|
|||||||
and loader.modOptions.okmod.startMoney == 3000
|
and loader.modOptions.okmod.startMoney == 3000
|
||||||
and loader.modOptions.okmod.tag == "BLUE",
|
and loader.modOptions.okmod.tag == "BLUE",
|
||||||
"RESET DEFAULTS restores every schema default")
|
"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")
|
press(ms, "b")
|
||||||
check(ms.screen == "list", "B leaves the options screen")
|
check(ms.screen == "list", "B leaves the options screen")
|
||||||
|
|
||||||
|
|||||||
@@ -1,169 +0,0 @@
|
|||||||
-- mod.postLog: one-way log reporting to the manifest-declared log_url.
|
|
||||||
-- The things this pins are the strict ones -- https-only destination that
|
|
||||||
-- lives in the manifest (not per-call), a closed list of format switches,
|
|
||||||
-- a body ceiling, opaque per-mod handles, and refusal without the network
|
|
||||||
-- permission.
|
|
||||||
|
|
||||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
|
||||||
|
|
||||||
local T = require("tests.modkit")
|
|
||||||
local Net = require("src.mods.Net")
|
|
||||||
local Fetch = require("src.net.Fetch")
|
|
||||||
|
|
||||||
-- Stand in for the worker pool: jobs resolve when the test says so, so no
|
|
||||||
-- test here touches a socket.
|
|
||||||
local submitted, nextId, states = {}, 0, {}
|
|
||||||
Fetch.post = function(url, body, opts)
|
|
||||||
nextId = nextId + 1
|
|
||||||
submitted[nextId] = { url = url, body = body, opts = opts }
|
|
||||||
states[nextId] = { status = "pending", progress = 0 }
|
|
||||||
return nextId
|
|
||||||
end
|
|
||||||
Fetch.poll = function(id) return states[id] or { status = "error", err = "unknown job" } end
|
|
||||||
Fetch.isPending = function(id) return (states[id] or {}).status == "pending" end
|
|
||||||
Fetch.release = function(id) states[id] = nil end
|
|
||||||
Fetch.cancel = function(id)
|
|
||||||
if states[id] and states[id].status == "pending" then states[id].status = "cancelled" end
|
|
||||||
end
|
|
||||||
Fetch.available = function() return true end
|
|
||||||
|
|
||||||
local LOGGER = {
|
|
||||||
["mods/log_sender/manifest.json"] = [[{
|
|
||||||
"id": "log_sender",
|
|
||||||
"name": "Log Sender",
|
|
||||||
"version": "1.0.0",
|
|
||||||
"entry": "main.lua",
|
|
||||||
"api": 2,
|
|
||||||
"permissions": ["network"],
|
|
||||||
"log_url": "https://logs.example.com/logs"
|
|
||||||
}]],
|
|
||||||
["mods/log_sender/main.lua"] = [[
|
|
||||||
local mod = ...
|
|
||||||
mod.exports.send = function(body, opts)
|
|
||||||
local handle, err = mod:postLog(body, opts)
|
|
||||||
if not handle then return nil, err end
|
|
||||||
return handle
|
|
||||||
end
|
|
||||||
mod.exports.poll = function(h) return mod.fetch:poll(h) end
|
|
||||||
mod.exports.release = function(h) return mod.fetch:release(h) end
|
|
||||||
]],
|
|
||||||
}
|
|
||||||
|
|
||||||
local function manifest(id, extra)
|
|
||||||
return ('{"id": "%s", "name": "T", "version": "1.0.0", "entry": "main.lua", '
|
|
||||||
.. '"api": 2%s}'):format(id, extra or "")
|
|
||||||
end
|
|
||||||
|
|
||||||
local NO_URL = {
|
|
||||||
["mods/log_no_url/manifest.json"] = manifest("log_no_url", ', "permissions": ["network"]'),
|
|
||||||
["mods/log_no_url/main.lua"] = [[
|
|
||||||
local mod = ...
|
|
||||||
mod.exports.try = function()
|
|
||||||
local ok, err = pcall(function() return mod:postLog("body") end)
|
|
||||||
return ok, err
|
|
||||||
end
|
|
||||||
]],
|
|
||||||
}
|
|
||||||
|
|
||||||
-- ------------------------------------------------ the closed opts list
|
|
||||||
local run = T.sdk.loadMods({ "mods/log_sender" }, { fs = T.sdk.memfs(LOGGER) })
|
|
||||||
T.eq(#run.errors, 0, "the logger mod loads clean (" .. tostring(run.errors[1]) .. ")")
|
|
||||||
local api = run.loader.exports.log_sender
|
|
||||||
|
|
||||||
local bad, badErr = api.send("body", { format = "xml" })
|
|
||||||
T.eq(bad, nil, "an unknown format is refused")
|
|
||||||
T.check(badErr and badErr:find("text and json only", 1, true), "and names the allowed ones")
|
|
||||||
|
|
||||||
local badKey, keyErr = api.send("body", { envelope = true })
|
|
||||||
T.eq(badKey, nil, "an unknown opt key is refused")
|
|
||||||
T.check(keyErr and keyErr:find("format is the only switch", 1, true), "and says so")
|
|
||||||
|
|
||||||
-- ------------------------------------------------ body validation
|
|
||||||
local empty, emptyErr = api.send("")
|
|
||||||
T.eq(empty, nil, "an empty body is refused")
|
|
||||||
local big = string.rep("x", Net.MAX_BODY + 1)
|
|
||||||
local bigH, bigErr = api.send(big)
|
|
||||||
T.eq(bigH, nil, "an oversized body is refused")
|
|
||||||
T.check(bigErr and bigErr:find("limit", 1, true), "and names the limit")
|
|
||||||
|
|
||||||
-- ------------------------------------------------- text format (default)
|
|
||||||
local handle, err = api.send("hello log")
|
|
||||||
T.check(handle ~= nil, "a plain text post returns a handle (" .. tostring(err) .. ")")
|
|
||||||
T.eq(type(handle), "table", "the handle is opaque, not the engine's job id")
|
|
||||||
T.eq(api.poll(handle).status, "pending", "a fresh post polls as pending")
|
|
||||||
|
|
||||||
local sent
|
|
||||||
for _, job in pairs(submitted) do
|
|
||||||
if job.url == "https://logs.example.com/logs" and job.body == "hello log" then sent = job end
|
|
||||||
end
|
|
||||||
T.check(sent ~= nil, "the post reached the pool with the manifest URL")
|
|
||||||
T.eq(sent.opts.contentType, "text/plain", "plain text posts as text/plain")
|
|
||||||
T.check(sent.opts.userAgent:find("log_sender", 1, true),
|
|
||||||
"the request identifies the calling mod")
|
|
||||||
|
|
||||||
-- -------------------------------------------------- json format
|
|
||||||
local jh, jerr = api.send("line one", { format = "json" })
|
|
||||||
T.check(jh ~= nil, "a json post returns a handle (" .. tostring(jerr) .. ")")
|
|
||||||
local jsent
|
|
||||||
for _, job in pairs(submitted) do
|
|
||||||
if job.opts.contentType == "application/json" then jsent = job end
|
|
||||||
end
|
|
||||||
T.check(jsent ~= nil, "json posts as application/json")
|
|
||||||
local decoded = require("src.link.Json").decode(jsent.body)
|
|
||||||
T.eq(type(decoded), "table", "the json body is a table")
|
|
||||||
T.eq(decoded.format, "json", "the envelope names its format")
|
|
||||||
T.eq(decoded.mod, "log_sender", "the envelope names the mod")
|
|
||||||
T.eq(decoded.body, "line one", "the payload survives the envelope")
|
|
||||||
|
|
||||||
-- completion flows through poll, like get
|
|
||||||
states[1] = { status = "ok", progress = 1 }
|
|
||||||
local got = api.poll(handle)
|
|
||||||
T.eq(got.status, "ok", "a completed post polls ok")
|
|
||||||
|
|
||||||
api.release(handle)
|
|
||||||
run.release()
|
|
||||||
|
|
||||||
-- --------------------------------------- manifest without log_url refuses
|
|
||||||
local nurl = T.sdk.loadMods({ "mods/log_no_url" }, { fs = T.sdk.memfs(NO_URL) })
|
|
||||||
T.eq(#nurl.errors, 0, "no log_url loads clean (" .. tostring(nurl.errors[1]) .. ")")
|
|
||||||
local okCall, callErr = nurl.loader.exports.log_no_url.try()
|
|
||||||
T.check(not okCall and callErr:find("log_url", 1, true),
|
|
||||||
"postLog without log_url names the missing manifest field")
|
|
||||||
nurl.release()
|
|
||||||
|
|
||||||
-- ------------------------------------------- manifest validation: the gate
|
|
||||||
-- log_url without the network permission is a load violation in a strict
|
|
||||||
-- manifest: the mod declares a network capability it did not opt in to. The
|
|
||||||
-- violation fires inside manifest validation, so the mod never enters
|
|
||||||
-- loader.mods at all.
|
|
||||||
local badManifest = T.sdk.loadMods({ "mods/log_bad" }, { fs = T.sdk.memfs({
|
|
||||||
["mods/log_bad/manifest.json"] = manifest("log_bad",
|
|
||||||
', "log_url": "https://logs.example.com/logs"'),
|
|
||||||
["mods/log_bad/main.lua"] = "local mod = ...",
|
|
||||||
}) })
|
|
||||||
T.eq(badManifest.mods.log_bad, nil,
|
|
||||||
"log_url without network: the mod is refused before load")
|
|
||||||
|
|
||||||
-- a non-https log_url is refused even with the permission
|
|
||||||
local httpManifest = T.sdk.loadMods({ "mods/log_http" }, { fs = T.sdk.memfs({
|
|
||||||
["mods/log_http/manifest.json"] = manifest("log_http",
|
|
||||||
', "permissions": ["network"], "log_url": "http://logs.example.com/logs"'),
|
|
||||||
["mods/log_http/main.lua"] = "local mod = ...",
|
|
||||||
}) })
|
|
||||||
T.eq(httpManifest.mods.log_http, nil,
|
|
||||||
"an http log_url: the mod is refused before load")
|
|
||||||
|
|
||||||
-- an api 1 manifest carries no strict surface: log_url is ignored, and the
|
|
||||||
-- mod loads (its postLog call still refuses -- there is no log_url to use)
|
|
||||||
local api1 = T.sdk.loadMods({ "mods/log_api1" }, { fs = T.sdk.memfs({
|
|
||||||
["mods/log_api1/manifest.json"] = [[{
|
|
||||||
"id": "log_api1", "name": "T", "version": "1.0.0", "entry": "main.lua",
|
|
||||||
"api": 1, "log_url": "https://logs.example.com/logs"
|
|
||||||
}]],
|
|
||||||
["mods/log_api1/main.lua"] = "local mod = ...",
|
|
||||||
}) })
|
|
||||||
T.eq(#api1.errors, 0, "an api 1 manifest ignores log_url ("
|
|
||||||
.. tostring(api1.errors[1]) .. ")")
|
|
||||||
T.check(api1.loader.mods.log_api1 ~= nil, "and the mod loads")
|
|
||||||
|
|
||||||
T.finish("mod_postlog")
|
|
||||||
@@ -34,9 +34,6 @@ function redGame.stack:top() return self.states[#self.states] end
|
|||||||
|
|
||||||
local RedAPI = require("src.world.WorldAPI")
|
local RedAPI = require("src.world.WorldAPI")
|
||||||
local red = RedAPI.new(redGame, "fixture")
|
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")
|
local RedWorld = require("src.world.OverworldController")
|
||||||
T.check(type(RedWorld.useBicycle) == "function"
|
T.check(type(RedWorld.useBicycle) == "function"
|
||||||
and type(RedWorld.useFishingRod) == "function"
|
and type(RedWorld.useFishingRod) == "function"
|
||||||
@@ -62,9 +59,7 @@ T.check(not ok and err == "fishing rod unavailable",
|
|||||||
T.eq(redWorld.rodUsed, used, "a rejected Red rod changes nothing")
|
T.eq(redWorld.rodUsed, used, "a rejected Red rod changes nothing")
|
||||||
|
|
||||||
redWorld.player.moving = true
|
redWorld.player.moving = true
|
||||||
actions, err = red:availableFieldActions()
|
T.eq(#red:availableFieldActions(), 0, "Red hides actions while moving")
|
||||||
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")
|
ok, err = red:useFieldAction("bicycle")
|
||||||
T.check(not ok and err == "world is busy",
|
T.check(not ok and err == "world is busy",
|
||||||
"Red refuses a stale action while busy")
|
"Red refuses a stale action while busy")
|
||||||
@@ -147,9 +142,6 @@ goldWorld.fieldContext = function(_, mon) return {
|
|||||||
|
|
||||||
local GoldAPI = require("src.world.gen2.WorldAPI")
|
local GoldAPI = require("src.world.gen2.WorldAPI")
|
||||||
local gold = GoldAPI.new(goldGame, "fixture")
|
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()
|
actions = gold:availableFieldActions()
|
||||||
byId = {}
|
byId = {}
|
||||||
for _, action in ipairs(actions) do byId[action.id] = action end
|
for _, action in ipairs(actions) do byId[action.id] = action end
|
||||||
@@ -178,9 +170,4 @@ T.check(gold:useFieldAction("squirtbottle"),
|
|||||||
T.eq(goldWorld.itemUsed, "SQUIRTBOTTLE",
|
T.eq(goldWorld.itemUsed, "SQUIRTBOTTLE",
|
||||||
"Gold delegates the SquirtBottle to its field-item path")
|
"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()
|
T.finish()
|
||||||
|
|||||||
Reference in New Issue
Block a user