mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-15 07:41:21 +02:00
Compare commits
40 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 00c72c441b | |||
| 7804ef9793 | |||
| 673d8b3ad8 | |||
| 62e1296ced | |||
| a3bbd78e7b | |||
| 8dfbd1daae | |||
| 4046b28a8c | |||
| a86d57ac44 | |||
| 72f126ae26 | |||
| 5d7a8c9291 | |||
| c48fc578ca | |||
| dfc216f974 | |||
| 542856c83d | |||
| 545a99d86d | |||
| 2b6473ae03 | |||
| df0be1cba6 | |||
| c9d67582ae | |||
| 1cfd91a503 | |||
| fb738fa1ce | |||
| c0ad6d0328 | |||
| 84de8c9cb1 | |||
| 6307bc92f6 | |||
| 052dd26b3e | |||
| c8f6c7241b | |||
| b3928388ef | |||
| a38fae5a97 | |||
| 9fab992d42 | |||
| dcc388a942 | |||
| 797a6bebfe | |||
| 97a9c0f58f | |||
| 407f649e9d | |||
| 84635cdfdf | |||
| 3de45b671c | |||
| 4395792226 | |||
| a77210799f | |||
| 6f67292b0e | |||
| 78e8a31ead | |||
| b6388013ec | |||
| ba7cd8fabf | |||
| 5192106730 |
@@ -79,3 +79,9 @@ mobile/ios/bundle_id.local
|
||||
|
||||
# Local options / preferences
|
||||
/options.lua*
|
||||
|
||||
# User-owned ROMs imported for individual mods. Manifests declare the
|
||||
# destinations, but source checkouts and packaged mods never ship the files.
|
||||
/mods/*/baseroms/
|
||||
/imports/baseroms/
|
||||
/imports/baseroms-recovery/
|
||||
|
||||
@@ -254,7 +254,7 @@ engine's globals. Every chunk you author gets it: `main.lua`, your
|
||||
| `os.getenv`, `os.execute`, `os.remove`, `os.rename`, `os.exit` | nothing; `os.time`/`os.date`/`os.clock` still work |
|
||||
| `package`, `dofile`, `loadfile`, `debug`, `getfenv`, `setfenv` | `require` for the supported engine modules |
|
||||
| `require("ffi")`, `require("love.*")` | the `love` table you are given |
|
||||
| `love.filesystem` | `mod.storage` (per-mod, per-playthrough) and `mod:read` |
|
||||
| `love.filesystem` | `mod.storage` (per-mod, per-playthrough), `mod:read` for a known file, `mod:list` / `mod:info` to iterate your own directory |
|
||||
| `love.thread`, `love.event` | `mod.events`, `mod.hooks` |
|
||||
| `love.system` | `mod.device:powerInfo()` for battery information; `mod.steps` (with the `steps` permission) for the step bridge |
|
||||
|
||||
@@ -269,9 +269,12 @@ Three consequences worth knowing before you write against it:
|
||||
`mod.find("your_id").exports` — the channel that was always the intended
|
||||
one. The same goes for the standard library: `string`, `table` and `math`
|
||||
are per-mod copies, so patching one is a local decision.
|
||||
- **Paths cannot climb.** `mod:read`, `mod.assets:path` and `mod.assets:image`
|
||||
join to your own directory, and `..`, absolute paths and drive letters are
|
||||
refused. So are `entry` and `options_schema` in your manifest.
|
||||
- **Paths cannot climb.** `mod:read`, `mod:list`, `mod:info`, `mod.assets:path`
|
||||
and `mod.assets:image` join to your own directory, and `..`, absolute paths
|
||||
and drive letters are refused. So are `entry` and `options_schema` in your
|
||||
manifest. `mod:list("assets")` is the sandboxed `getDirectoryItems` for a
|
||||
folder you shipped; `mod:info` tells file from directory so a walk can
|
||||
recurse.
|
||||
- **Ship source, not bytecode.** A precompiled entry file is refused.
|
||||
|
||||
`permissions` in the manifest is still a disclosure the manager shows the
|
||||
|
||||
@@ -178,7 +178,8 @@ M.PALLET_TOWN = {
|
||||
end
|
||||
end
|
||||
|
||||
local function enterLab()
|
||||
local function enterLab(oak)
|
||||
if oak then oak.stepFrames = nil end
|
||||
Commands.hide_object(ctx, "PALLET_TOWN", "PALLETTOWN_OAK")
|
||||
Commands.show_object(ctx, "OAKS_LAB", "OAKSLAB_OAK2")
|
||||
ow.doorWarp = true
|
||||
@@ -187,12 +188,17 @@ M.PALLET_TOWN = {
|
||||
end
|
||||
|
||||
local function walkToLab(oak)
|
||||
-- lockstep half runs Oak on the player's own frames per cell
|
||||
-- engine/overworld/movement.asm:737 (DoScriptedNPCMovement)
|
||||
local i = 0
|
||||
if oak then
|
||||
oak.stepFrames = ow.player.stepFramesCur or ow.player.stepFrames
|
||||
end
|
||||
local function tick()
|
||||
i = i + 1
|
||||
local playerStep = escort.playerSteps[i]
|
||||
if not playerStep then
|
||||
enterLab()
|
||||
enterLab(oak)
|
||||
return
|
||||
end
|
||||
if oak and escort.oakSteps[i] then
|
||||
|
||||
@@ -446,7 +446,7 @@ local function pewterGymEscort(game, ow)
|
||||
end
|
||||
|
||||
local function afterWalk()
|
||||
if guy then guy.facing = "left" end
|
||||
if guy then guy.stepFrames, guy.facing = nil, "left" end
|
||||
Music.playMap(game.data, "PEWTER_CITY")
|
||||
push(game, t._PewterCityYoungsterGoTakeOnBrockText
|
||||
or "Go take on BROCK\nat the GYM first!", walkHome)
|
||||
@@ -469,6 +469,11 @@ local function pewterGymEscort(game, ow)
|
||||
end
|
||||
|
||||
local function beginWalk()
|
||||
-- the escort runs the youngster on the player's own frames per cell
|
||||
-- engine/overworld/movement.asm:737 (DoScriptedNPCMovement)
|
||||
if guy then
|
||||
guy.stepFrames = ow.player.stepFramesCur or ow.player.stepFrames
|
||||
end
|
||||
Music.play(game.data, "Music_MuseumGuy")
|
||||
if guy and head > 0 then
|
||||
local h = 0
|
||||
|
||||
@@ -240,7 +240,7 @@ resolves to the weaker claim:
|
||||
| `warned` | present, answers nil or degrades, and names itself once with the mod attributed |
|
||||
| `absent` | deliberately not served; a nil read is the honest failure |
|
||||
|
||||
Today that is 288 backed, 32 warned and 161 absent across the fifteen modules.
|
||||
Today that is 291 backed, 32 warned and 161 absent across the fifteen modules.
|
||||
`notes` keys are documentation topics rather than a member list -- dotted paths
|
||||
(`save.money`), field names (`warpAt`), hook names (`hook ui.pc.items`) and
|
||||
bare topics (`identity`, `iteration`, `rawset`) all appear there. `members` is
|
||||
@@ -486,6 +486,11 @@ has its own entry points for (`start_battle "wild" species level`, `warp`,
|
||||
**by name, before the first row runs**, so a mod never gets a half-run queue.
|
||||
`marchInPlace` still has no Gen 2 equivalent (the Gen 2 movement stream has no
|
||||
byte for it) and returns `nil, reason` rather than approximating one.
|
||||
`availableFieldActions` and `useFieldAction` expose the same contextual field
|
||||
item and move records in both games. Gold extends the shared ids with its own
|
||||
`headbutt`, `whirlpool`, `waterfall`, `sweet_scent`, and `squirtbottle`
|
||||
actions. Each engine keeps ownership of its inventory, badges, terrain,
|
||||
surfing, bike, fishing, and field-move rules.
|
||||
|
||||
**Hooks and events that fire on Gold.** Every name below is the Gen 1 name
|
||||
carrying the Gen 1 payload keys, because Gold's call sites reuse them rather
|
||||
@@ -763,6 +768,11 @@ name and the existing payload, plus fields where Gen 2 genuinely carries more
|
||||
|
||||
The list is much shorter than it was. What is outstanding, in descending value:
|
||||
|
||||
- `trainer.before_battle`: Gold constructs and pushes its trainer battle in
|
||||
`src/world/gen2/World.lua:startBattle`, which does not yet expose a deferred
|
||||
preparation boundary or a battle-local player-party view. Gen 1 mods can use
|
||||
the hook documented in `docs/modding.md`; do not claim Gold compatibility
|
||||
when that selection is required.
|
||||
- `pokemon.before_give` / `pokemon.received`: Gold has no give-mon seam of its
|
||||
own yet.
|
||||
- `link.*` and `trade.completed`: a Gold boot offers no link menu at all. The
|
||||
|
||||
+145
-11
@@ -44,6 +44,25 @@ Every mod contains a root `manifest.json` defining its metadata, supported games
|
||||
"optional_dependencies": [
|
||||
"gen1_modern_ui"
|
||||
],
|
||||
"required_imports": [
|
||||
{
|
||||
"id": "stadium2",
|
||||
"name": "Pokemon Stadium 2 ROM",
|
||||
"description": "Pokemon Stadium 2 (USA), any supported N64 byte order",
|
||||
"file": "stadium2.z64",
|
||||
"format": "n64",
|
||||
"size": 67108864,
|
||||
"md5": ["00000000000000000000000000000000"]
|
||||
}
|
||||
],
|
||||
"optional_imports": [
|
||||
{
|
||||
"id": "bonus_source",
|
||||
"name": "Optional bonus source",
|
||||
"file": "bonus.bin",
|
||||
"md5": "00000000000000000000000000000000"
|
||||
}
|
||||
],
|
||||
"conflicts": [],
|
||||
"permissions": ["engine_internals"],
|
||||
"description": "A brief description of the mod.",
|
||||
@@ -67,6 +86,8 @@ Every mod contains a root `manifest.json` defining its metadata, supported games
|
||||
| `priority` | `integer` | Load priority order (lower numbers load earlier; dependencies always precede dependents regardless of priority). |
|
||||
| `dependencies` | `array` | Hard required dependencies. A mod will not load if a required dependency is missing or disabled for the active game. |
|
||||
| `optional_dependencies` | `array` | Soft dependencies. Guarantees that if the target mod is present and active, it loads *before* this mod without blocking load if absent. |
|
||||
| `required_imports` | `array` | User-supplied files required by this mod. The launcher validates and copies each file into this mod's `baseroms/` directory; the mod does not load while one is missing. |
|
||||
| `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. |
|
||||
| `permissions` | `array` | Requested privileges (e.g. `["engine_internals"]`, `["network"]`, `["filesystem"]`). |
|
||||
| `github` | `string` | GitHub repository (`"owner/repo"`) used for update checks and dependency download links. |
|
||||
@@ -91,6 +112,42 @@ Dependencies in `dependencies` and `optional_dependencies` can be declared in se
|
||||
#### Version-Scoped Dependencies
|
||||
When a mod supports multiple games (`"games": ["gen1", "gen2"]`), a dependency can specify `"games": ["gen2"]` to indicate it is only required when booting Gen 2. When booting Gen 1, the engine will ignore the dependency, preventing unnecessary boot blocks on games that do not need it.
|
||||
|
||||
### Required user-supplied files
|
||||
|
||||
`required_imports` and `optional_imports` keep copyrighted or otherwise user-owned source material
|
||||
out of mod archives while giving every platform the same installation flow.
|
||||
Each object requires a stable `id`, a display `name`, a destination `file`
|
||||
(a filename, never a path), and one MD5 digest or an array of accepted MD5
|
||||
digests. `format` is either `"raw"` (the default) or `"n64"`. An optional
|
||||
`description` gives players dump or region guidance in the import panel.
|
||||
`size` declares the exact canonical byte length; `max_size` declares a smaller
|
||||
per-import ceiling when an exact size is not appropriate. Every import also
|
||||
has an engine-enforced 128 MiB ceiling and is rejected before hashing when its
|
||||
filesystem reports an invalid size.
|
||||
|
||||
For `"n64"`, the launcher recognizes `.z64`, `.v64`, and `.n64` byte orders,
|
||||
strips a recognized 512-byte copier header, converts the bytes to canonical
|
||||
big-endian `.z64` order, and then checks MD5. The canonical bytes are written
|
||||
to `mods/<mod-id>/baseroms/<file>`. Each selection is a private grant to that
|
||||
mod: the launcher never scans or copies another mod's imported files merely
|
||||
because its manifest names the same digest. Mods read the result with their existing scoped `mod:read` API, for
|
||||
example `mod:read("baseroms/stadium2.z64")`; no host path or new filesystem
|
||||
permission is exposed. Missing `required_imports` block the mod before its
|
||||
entry chunk runs; missing `optional_imports` remain visible in the same
|
||||
launcher panel but do not block loading.
|
||||
|
||||
MD5 here identifies a known dump because ROM databases commonly publish it;
|
||||
it is not a security or authenticity guarantee. Do not paste the SHA-1 used by
|
||||
Gen1Recomp's own game-ROM importer into an import's `md5` field. Mod archives
|
||||
must not include anything beneath `baseroms/`. The engine records a validation
|
||||
receipt keyed by file size and modification time so launcher refreshes and
|
||||
later boots do not repeatedly hash an unchanged imported ROM.
|
||||
|
||||
New mobile code should call `love.system.pickFile("required_import")`. The
|
||||
older iOS-only `"stadium"` picker kind remains temporarily for compatibility.
|
||||
Android now returns `false` for unknown picker kinds instead of treating them
|
||||
as game-ROM picks.
|
||||
|
||||
## Mods and Gold (Gen 2)
|
||||
|
||||
The mod API is one API across both generations, but Gold runs its own battle
|
||||
@@ -147,6 +204,24 @@ Companion UIs and alternate party screens can call
|
||||
operation is accepted only during idle overworld play; menus, movement,
|
||||
scripts, battles, and transitions leave the party untouched.
|
||||
|
||||
## Contextual field actions
|
||||
|
||||
`mod.world:availableFieldActions()` returns the field items and moves that can
|
||||
start at the player's current position. Both games expose `bicycle`, `fish`,
|
||||
`cut`, `surf`, `strength`, `flash`, `dig`, and `teleport`; Gold additionally
|
||||
exposes `headbutt`, `whirlpool`, `waterfall`, `sweet_scent`, and the
|
||||
contextual `squirtbottle` key item. Fishing rows include the owned rods that
|
||||
are valid choices. The list is empty while the world is busy, and omits an
|
||||
action whenever its item, move, badge, terrain, or engine state forbids it.
|
||||
|
||||
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" }`
|
||||
and chooses automatically when only one rod is available. Invalid, stale, and
|
||||
busy requests return `nil` plus a reason without changing game state. Mods do
|
||||
not need generation-specific badge, terrain, bike, fishing, or field-move
|
||||
logic. Action lists are extensible; callers should render the records they
|
||||
understand and ignore unknown ids rather than assuming a fixed list length.
|
||||
|
||||
## Rendering pipelines
|
||||
|
||||
Most registries hand the engine *content*. `render_pipelines` hands it
|
||||
@@ -308,6 +383,25 @@ local keys, code, message = mod.storage:list(game, "history/quick")
|
||||
local deleted, code, message = mod.storage:delete(game, "history/quick/q0001")
|
||||
```
|
||||
|
||||
For independently generated binary data, use the opaque byte methods. They
|
||||
accept and return the exact Lua string of bytes, including NUL bytes and bytes
|
||||
that are not valid text:
|
||||
|
||||
```lua
|
||||
local ok, code, message = mod.storage:writeBytes(
|
||||
game, "cache/maps/pallet/terrain", encodedMesh)
|
||||
local encodedMesh, code, message = mod.storage:readBytes(
|
||||
game, "cache/maps/pallet/terrain")
|
||||
```
|
||||
|
||||
Opaque values are limited to 512 MiB per key. The engine stores them without
|
||||
decoding, compression, or an engine-defined file format, and never executes
|
||||
them. A consuming mod owns validation of its format, fingerprint, checksum,
|
||||
and compression metadata. Byte writes are staged and compared byte-for-byte
|
||||
before replacement, and reads can recover a valid backup after an interrupted
|
||||
write. Existing table values and opaque byte values use one shared logical key
|
||||
space; delete a key before changing its value from one type to the other.
|
||||
|
||||
`context` returns `{ engineVersion, gameVersion, playthroughId }`. The engine
|
||||
version is compatibility metadata; physical launcher-slot and path identity stays
|
||||
private. A title-selected context may additionally contain `normalSavedAt`, the
|
||||
@@ -316,19 +410,22 @@ progress or a slot/path handle.
|
||||
|
||||
At the title screen only, `mod.storage:selected(game)` returns a bound storage
|
||||
facade for the launcher-selected existing playthrough, or `nil, code, message`.
|
||||
Resolving this facade is read-only: it never allocates an identity, adopts a
|
||||
Resolving this facade is non-allocating: it never allocates an identity, adopts a
|
||||
fresh New Game, or exposes a slot id/path. Its `context()`, `read(key)`,
|
||||
`write(key, value)`, `list(prefix)`, and `delete(key)` methods have the same
|
||||
data-only and transaction contract as `mod.storage`, but remain restricted to
|
||||
the calling mod's selected existing namespace. It is intended for title tools
|
||||
that need to browse or manage durable history before the first normal SAVE.
|
||||
`write(key, value)`, `readBytes(key)`, `writeBytes(key, bytes)`,
|
||||
`list(prefix)`, and `delete(key)` methods have the same scoped and
|
||||
transactional contract as `mod.storage`, but remain restricted to the calling
|
||||
mod's selected existing namespace. It is intended for title tools that need to
|
||||
browse or manage durable history before the first normal SAVE.
|
||||
|
||||
Values must be tables containing serializable data only. Keys are conservative
|
||||
slash-separated segments (letters, digits, `_`, `-`); paths and filesystem
|
||||
handles are never exposed. Writes are staged and decode-verified, reads recover
|
||||
from a valid staged/backup generation, and methods return structured errors for
|
||||
normal data or I/O failures. The playthrough identity is allocated lazily on the
|
||||
first storage/checkpoint call, so an unused API changes no save bytes.
|
||||
Table values must contain serializable data only. Opaque values must be Lua
|
||||
strings. Keys are conservative slash-separated segments (letters, digits, `_`,
|
||||
`-`); paths and filesystem handles are never exposed. Table writes are staged
|
||||
and decode-verified; opaque writes are staged and byte-verified; reads recover
|
||||
from a valid staged/backup generation. Methods return structured errors for
|
||||
normal data, byte validation, and I/O failures. The playthrough identity is
|
||||
allocated lazily on the first storage/checkpoint call, so an unused API changes
|
||||
no save bytes.
|
||||
|
||||
`mod.checkpoints` captures and reconstructs engine-owned semantic runtime state:
|
||||
|
||||
@@ -423,6 +520,43 @@ animation/messages, forced choices, and every phase that cannot safely be
|
||||
checkpointed remain excluded. Exceptions are contained by normal hook isolation
|
||||
and fall through without advancing a turn.
|
||||
|
||||
Gen 1 trainer encounters also expose `trainer.before_battle` after the
|
||||
challenge text and immediately before battle construction. This lets a mod
|
||||
defer the encounter while it collects a player choice through a registered
|
||||
screen, then resume with a battle-local view of the save party:
|
||||
|
||||
```lua
|
||||
mod.hooks:wrap("trainer.before_battle", function(next, game, context, continue)
|
||||
-- context = { trainerClass, partyIndex, mapId, npcId }
|
||||
mod.ui.push(game, "party_registration", {
|
||||
onConfirm = function(indices)
|
||||
continue({ playerPartyIndices = indices })
|
||||
end,
|
||||
onCancel = function()
|
||||
continue({ cancel = true })
|
||||
end,
|
||||
})
|
||||
return true
|
||||
end)
|
||||
```
|
||||
|
||||
Return `true` only when retaining `continue` for a later callback. Calling
|
||||
`continue({ cancel = true })` ends the encounter without constructing a battle;
|
||||
the normal encounter completion callback returns control to the overworld and
|
||||
no trainer-defeated state is written. A cancelled sight encounter is suppressed
|
||||
at the current player cell so it cannot immediately reopen; moving one cell or
|
||||
talking to the trainer permits a new challenge. Calling `continue()` uses the
|
||||
full save party; passing
|
||||
`{ playerPartyIndices = { 2, 4, 5 } }` uses those ordered, one-based party
|
||||
members for initial send, switching and forced replacement, exhaustion,
|
||||
experience traversal, and battle party displays. The continuation is one-shot.
|
||||
An empty, duplicate, out-of-range, or otherwise malformed list safely falls
|
||||
back to the full party. The view references the original Pokemon records and
|
||||
never reorders or replaces `game.save.party`; trainer battle checkpoints retain
|
||||
the selected indices. Mods remain responsible for selection policy and should
|
||||
use only public `mod.ui`, hook, and save APIs. See RFC 0010 for the exact
|
||||
contract and compatibility guarantees.
|
||||
|
||||
## Developer console
|
||||
|
||||
Boot with developer mode on to unlock the in-game console and hot-reload
|
||||
|
||||
@@ -929,6 +929,10 @@ mod.content.sfx:register("SFX_MOD_CHIME", { file = "chime.ogg" })
|
||||
|
||||
| field | type | required |
|
||||
|---|---|---|
|
||||
| `anchorX` | number | no |
|
||||
| `anchorY` | number | no |
|
||||
| `frameHeight` | integer >= 1 | no |
|
||||
| `frameWidth` | integer >= 1 | no |
|
||||
| `frames` | integer >= 1 | yes |
|
||||
| `id` | string | no |
|
||||
| `image` | file path | yes |
|
||||
@@ -1100,6 +1104,7 @@ mod.content.tokens:register("CLOCK", function(game) return "12" end)
|
||||
| `paletteSource` | string | no |
|
||||
| `parties` | list of list of {level, species} | yes |
|
||||
| `pic` | file path | no |
|
||||
| `trueColor` | boolean | no |
|
||||
|
||||
```lua
|
||||
mod.content.trainers:patch("OPP_BROCK", { baseMoney = 99 })
|
||||
@@ -1122,7 +1127,9 @@ do not.
|
||||
| `index` | integer 0..255 | no |
|
||||
| `items` | list of items id | no |
|
||||
| `name` | string | yes |
|
||||
| `pic` | file path | no |
|
||||
| `trainers` | list of {id?, index?, name, party, trainerType?} | yes |
|
||||
| `trueColor` | boolean | no |
|
||||
|
||||
```lua
|
||||
mod.content.trainers:patch("BEAUTY", { baseMoney = 99 })
|
||||
|
||||
@@ -325,7 +325,7 @@ This is not a dev-mode feature; it installs on any Gold boot that has mods.
|
||||
| `src.pokemon.Boxes` | facade | over `src/core/gen2/Boxes.lua` | 22 / 0 / 0 |
|
||||
| `src.battle.BattleState` | facade | over `src/ui/gen2/BattleState.lua` | 16 / 2 / 39 |
|
||||
| `src.ui.PartyMenu` | facade | over `src/ui/gen2/PartyMenu.lua` | 15 / 2 / 16 |
|
||||
| `src.world.WorldAPI` | alias | `src/world/gen2/WorldAPI.lua` | 12 / 2 / 0 |
|
||||
| `src.world.WorldAPI` | alias | `src/world/gen2/WorldAPI.lua` | 15 / 2 / 0 |
|
||||
| `src.world.PikachuFollower` | alias | `src/world/gen2/Follower.lua` | 10 / 0 / 11 |
|
||||
| `src.script.ScriptRunner` | facade | over `src/script/gen2/Vm.lua` | 10 / 7 / 1 |
|
||||
| `src.ui.OptionsMenu` | facade | over `src/ui/gen2/OptionsMenu.lua` | 8 / 0 / 1 |
|
||||
@@ -774,7 +774,7 @@ profile to test in, and `POKEPORT_DEV=1` adds the console and `F5` hot reload.
|
||||
|
||||
- **Coverage is partial and will stay partial.** 15 Gen 1 modules are served
|
||||
out of a much larger engine, and within those 15 the coverage table records
|
||||
288 backed members against 32 warned and 161 absent. The absent ones are not
|
||||
291 backed members against 32 warned and 161 absent. The absent ones are not
|
||||
a backlog; most are absent because there is no honest Gen 2 answer, and each
|
||||
one carries its reason. The counts move as the adapter learns something: a
|
||||
member that turns out to answer nil is demoted from backed to warned or
|
||||
|
||||
@@ -26,8 +26,8 @@ wiki's Save Model. `mod.save` and `mod.options` keep their existing behavior.
|
||||
## The exact API delta
|
||||
|
||||
Backward-compatible, additive-only. `Loader:_api` binds a new `mod.storage`
|
||||
facade to the calling mod id. Mods receive logical keys and decoded values, never
|
||||
filesystem handles or physical paths.
|
||||
facade to the calling mod id. Mods receive logical keys and either decoded table
|
||||
values or exact opaque byte strings, never filesystem handles or physical paths.
|
||||
|
||||
### Lazy opaque playthrough identity
|
||||
|
||||
@@ -75,6 +75,25 @@ Returns a freshly decoded table, or `nil, code, message`. It tries main, staged,
|
||||
then backup data. A valid staged/backup value is returned and promoted
|
||||
best-effort; corrupt bytes are never executed.
|
||||
|
||||
### `mod.storage:writeBytes(game, key, bytes)`
|
||||
|
||||
Accepts a Lua string containing opaque bytes and returns `true`, or
|
||||
`false, code, message`. Empty strings are valid. Payloads are limited to 512
|
||||
MiB per key. The engine writes the supplied bytes exactly as received, without
|
||||
decoding, compression, checksums, or an engine-defined envelope. The consuming
|
||||
mod owns semantic validation of its format.
|
||||
|
||||
Byte records use private `.bin`, `.bin.tmp`, and `.bin.bak` witnesses. A staged
|
||||
and replacement write is read back and compared byte-for-byte before it is
|
||||
committed. A failed write leaves the previous verified generation readable.
|
||||
Byte storage never passes its payload to the Lua serializer, loader, or module
|
||||
resolver.
|
||||
|
||||
Table and byte records share one logical key namespace and a key has one type.
|
||||
Writing one type over the other returns `type_conflict`; callers must delete the
|
||||
key before changing its type. `mod.storage:selected(game)` exposes the same
|
||||
`readBytes` and `writeBytes` operations for the selected playthrough facade.
|
||||
|
||||
### `mod.storage:list(game[, prefix])`
|
||||
|
||||
Returns sorted logical keys beneath a valid prefix, an exact key when the prefix
|
||||
@@ -92,9 +111,9 @@ Physical records are scoped as:
|
||||
`persistence root / mod_storage / game version / playthrough id / mod id`
|
||||
|
||||
Stable error codes are `not_in_playthrough`, `storage_unavailable`,
|
||||
`invalid_key`, `encode_failed`, `write_failed`, `verify_failed`, and
|
||||
`not_found`. Ordinary data and I/O failures are return values, not callback-
|
||||
terminating errors.
|
||||
`invalid_key`, `encode_failed`, `invalid_bytes`, `size_limit`, `type_conflict`,
|
||||
`type_mismatch`, `write_failed`, `verify_failed`, and `not_found`. Ordinary
|
||||
data and I/O failures are return values, not callback-terminating errors.
|
||||
|
||||
The restricted serializer's recursive writer runs outside LuaJIT traces. A
|
||||
1,000-process GC stress regression found compiled recursion could intermittently
|
||||
@@ -107,6 +126,8 @@ boundary.
|
||||
**Nothing.** No API is removed, no manifest field changes, and no storage path or
|
||||
playthrough id is created unless a mod invokes `mod.storage` or
|
||||
`mod.checkpoints`. Existing save bytes remain unchanged on the no-caller path.
|
||||
Existing files outside the scoped storage contract are not imported; a caller
|
||||
must rebuild them through `writeBytes`.
|
||||
|
||||
## Parity tests
|
||||
|
||||
@@ -115,8 +136,10 @@ playthrough id is created unless a mod invokes `mod.storage` or
|
||||
- **Engine identity:** lazy allocation, save/load preservation, stable legacy
|
||||
mapping, fresh-playthrough replacement, and version/slot isolation.
|
||||
- **Public Mod API:** two real API-2 entry chunks prove data-only roundtrip,
|
||||
opaque byte roundtrip including NUL bytes, no execution, size/type rejection,
|
||||
deterministic listing, key rejection, mod/game/playthrough isolation,
|
||||
corrupt-main recovery, failure retention, exact delete, and no-mod no-write.
|
||||
corrupt-main recovery, failure retention, selected-playthrough access, exact
|
||||
delete, and no-mod no-write.
|
||||
|
||||
## Deprecation etiquette
|
||||
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
# RFC 0010: Deferred trainer preparation and battle-local party scope
|
||||
|
||||
## Status
|
||||
|
||||
Proposed.
|
||||
|
||||
## Motivation
|
||||
|
||||
Challenge and tournament mods sometimes need a player to choose an eligible
|
||||
subset of the save party before a trainer battle. The current public surface
|
||||
can replace the opponent through `trainer.party` and observe
|
||||
`world.trainer_engaged`, but it cannot pause the engagement before battle
|
||||
construction or keep unselected save-party members out of initial send,
|
||||
switch, replacement, exhaustion, experience, and party-menu traversal.
|
||||
|
||||
Temporarily rewriting `game.save.party` is not a safe substitute: it changes
|
||||
authoritative save state, composes poorly with checkpoints and other mods, and
|
||||
can strand excluded Pokémon if a callback or process fails.
|
||||
|
||||
## Decision and plan extended
|
||||
|
||||
This implements **D-AT-001: battle-local Gym registration without save-party
|
||||
mutation**, the consuming design decision tracked as capability `AT-SP-001` in
|
||||
the Adaptive Trainers implementation plan. The plan file is
|
||||
[`docs/superpowers/plans/2026-08-14-adaptive-trainers.md`](https://github.com/MaxTomahawk/gen1recomp-adaptive-trainers/blob/main/docs/superpowers/plans/2026-08-14-adaptive-trainers.md),
|
||||
Task 5. The engine delta also extends the additive, guarded public-hook
|
||||
decision used by RFC 0007 and the screen facade documented in
|
||||
`docs/modding.md`; it deliberately contains none of the consuming mod's Gym
|
||||
or party-size policy.
|
||||
|
||||
## Exact API delta
|
||||
|
||||
Add the guarded hook:
|
||||
|
||||
```lua
|
||||
mod.hooks:wrap("trainer.before_battle", function(next, game, context, continue)
|
||||
-- context = { trainerClass, partyIndex, mapId, npcId }
|
||||
-- Return true only when the battle has been deferred.
|
||||
-- continue({ cancel = true }) returns without constructing a battle.
|
||||
-- Call continue() for the full save party, or:
|
||||
-- continue({ playerPartyIndices = { 2, 4, 5 } })
|
||||
end)
|
||||
```
|
||||
|
||||
The hook runs after the trainer's challenge text and immediately before the
|
||||
trainer battle is constructed. A mod may push a registered screen with
|
||||
`mod.ui.push`, return `true`, and retain `continue` for its confirm/cancel
|
||||
callback. `continue` is one-shot and returns `false` after the first call.
|
||||
Returning anything other than `true` without calling it continues immediately
|
||||
with vanilla scope. With no subscriber, no context or continuation is built.
|
||||
|
||||
`playerPartyIndices` is an ordered, one-based list into `game.save.party`.
|
||||
Valid unique indices create `battle.playerParty` as a battle-local view of the
|
||||
same Pokémon records; the save party itself is never reordered or replaced.
|
||||
Malformed or empty scopes degrade to the full party. The view governs initial
|
||||
send, all battle party menus and targets, voluntary and forced replacement,
|
||||
exhaustion/blackout checks, participant and EXP.ALL traversal, party counts,
|
||||
and party-ball presentation. Checkpoints preserve the index list and rebuild
|
||||
the same view before restoring battlers.
|
||||
|
||||
`{ cancel = true }` ends a deferred encounter through its normal completion
|
||||
callback without constructing a battle or writing trainer-defeated state. A
|
||||
cancelled sight encounter is suppressed while the player remains on the same
|
||||
cell, preventing immediate reacquisition; moving or directly talking permits a
|
||||
new challenge. Cancellation is also one-shot; if supplied alongside a party
|
||||
index list, cancellation wins.
|
||||
|
||||
The API sets no maximum, chooses no members, identifies no boss, and contains
|
||||
no scaling or challenge policy.
|
||||
|
||||
## Migration and compatibility
|
||||
|
||||
Existing mods change nothing. `BattleState.newTrainer(game, class, index)`
|
||||
keeps its current behavior; the optional fourth argument is additive. Existing
|
||||
battle checkpoints without a party scope restore against the full save party.
|
||||
Wild, Safari, link, and no-mod battles are unchanged.
|
||||
|
||||
## Verification
|
||||
|
||||
- The catalog-driven hook gate proves empty-chain parity and the guarded hot
|
||||
path proves no-mod engagement starts exactly once without allocation.
|
||||
- A sandboxed fixture mod defers through its public hook facade, inspects the
|
||||
data-only context, and resumes once with ordered indices.
|
||||
- Engine tests cover initial send, party menus, replacement/exhaustion,
|
||||
EXP traversal, invalid-scope fallback, and save-party identity.
|
||||
- Battle-checkpoint tests prove scoped capture/restore and old-checkpoint
|
||||
compatibility.
|
||||
|
||||
## Deprecation etiquette
|
||||
|
||||
Nothing is deprecated. The hook and optional constructor argument are
|
||||
additive.
|
||||
@@ -76,15 +76,6 @@ end
|
||||
local editorHost, editorVersion, editorWindow
|
||||
local closeEditor -- forward declaration: openEditor hands it to the editor
|
||||
|
||||
-- tools/save-editor/ models the Gen 1 save and nothing else: a Gen 2 party row
|
||||
-- carries fields its MonOps and panels have no idea about (dvs, statExp,
|
||||
-- happiness, pokerus, caughtLevel), and SaveIO.save writes the WHOLE table
|
||||
-- back, so a Gold slot opened here comes out in a shape src/core/gen2/Save.lua
|
||||
-- then has to quarantine on the next boot. Refuse by name, the same way the
|
||||
-- .sav paths do (src/save_convert/SaveConvert.lua GEN2_SAV_UNSUPPORTED), so
|
||||
-- Red/Blue/Yellow slots are untouched.
|
||||
local GEN2_NO_EDITOR = { gold = "Pokemon Gold" }
|
||||
|
||||
-- The editor's modules use flat names (require("Kit"), require("Party")), so
|
||||
-- their directories have to be on the require path. It must be
|
||||
-- love.filesystem's path, not package.path: in a packaged build these files
|
||||
@@ -137,11 +128,6 @@ local function openEditor(version, slotId)
|
||||
Importer.saveNotice = Importer.saveNotice or {}
|
||||
Importer.saveNotice[version] = { ok = false, text = text }
|
||||
end
|
||||
local gen2Name = GEN2_NO_EDITOR[version]
|
||||
if gen2Name then
|
||||
refuse(gen2Name .. " uses a Gen 2 save; the save editor does not read one yet.")
|
||||
return
|
||||
end
|
||||
local SaveData = require("src.core.SaveData")
|
||||
local path = SaveData.slotDiskPath(version, slotId)
|
||||
if not path then
|
||||
@@ -320,14 +306,6 @@ function love.load(args)
|
||||
-- cache has to be mounted before the editor's Data:load.
|
||||
if editorMode then
|
||||
local version = os.getenv("POKEPORT_VERSION") or "red"
|
||||
local gen2Name = GEN2_NO_EDITOR[version]
|
||||
if gen2Name then
|
||||
-- No launcher behind this run to carry a notice, so say it and stop
|
||||
-- rather than open a Gen 2 slot on Gen 1 panels.
|
||||
print(gen2Name .. " uses a Gen 2 save; the save editor does not read one yet.")
|
||||
love.event.quit(1)
|
||||
return
|
||||
end
|
||||
require("src.core.GameVersion").set(version)
|
||||
require("src.import.CacheFs").mountVersion(version)
|
||||
addEditorRequirePath()
|
||||
|
||||
@@ -192,8 +192,14 @@ bool System::pickFile(const char *kind) const
|
||||
dest = "picked_mod.zip";
|
||||
else if (strcmp(kind, "sav") == 0 || strcmp(kind, "save") == 0)
|
||||
dest = "picked_save.sav";
|
||||
else if (strcmp(kind, "required_import") == 0)
|
||||
dest = "picked_required_import.bin";
|
||||
else if (strcmp(kind, "rom") == 0)
|
||||
dest = "picked_rom.gb";
|
||||
// Unknown kinds used to fall through to the ROM destination. Refuse them
|
||||
// so a newer Lua caller cannot silently route an unrelated file as a ROM.
|
||||
else
|
||||
return false;
|
||||
}
|
||||
return love::android::showFilePicker(dest);
|
||||
#else
|
||||
@@ -202,6 +208,15 @@ bool System::pickFile(const char *kind) const
|
||||
#endif
|
||||
}
|
||||
|
||||
const char *System::pickFileKinds() const
|
||||
{
|
||||
#ifdef LOVE_ANDROID
|
||||
return "rom,mod,sav,required_import";
|
||||
#else
|
||||
return "";
|
||||
#endif
|
||||
}
|
||||
|
||||
bool System::createFile(const char *suggestedName) const
|
||||
{
|
||||
#ifdef LOVE_ANDROID
|
||||
|
||||
@@ -112,10 +112,12 @@ public:
|
||||
* love::android::showFilePicker and src/import/RomImporter.lua.
|
||||
*
|
||||
* @param kind Optional pick kind: nullptr/"rom" -> picked_rom.gb,
|
||||
* "mod" -> picked_mod.zip, "sav"/"save" -> picked_save.sav.
|
||||
* "mod" -> picked_mod.zip, "sav"/"save" -> picked_save.sav,
|
||||
* "required_import" -> picked_required_import.bin.
|
||||
* @return Whether the picker was shown.
|
||||
**/
|
||||
virtual bool pickFile(const char *kind = nullptr) const;
|
||||
virtual const char *pickFileKinds() const;
|
||||
|
||||
/**
|
||||
* Shows the platform's native "create / save a file" UI (Android SAF
|
||||
|
||||
@@ -102,6 +102,12 @@ int w_pickFile(lua_State *L)
|
||||
return 1;
|
||||
}
|
||||
|
||||
int w_pickFileKinds(lua_State *L)
|
||||
{
|
||||
luax_pushstring(L, instance()->pickFileKinds());
|
||||
return 1;
|
||||
}
|
||||
|
||||
int w_createFile(lua_State *L)
|
||||
{
|
||||
const char *suggested = luaL_optstring(L, 1, nullptr);
|
||||
@@ -222,6 +228,7 @@ static const luaL_Reg functions[] =
|
||||
{ "openURL", w_openURL },
|
||||
{ "vibrate", w_vibrate },
|
||||
{ "pickFile", w_pickFile },
|
||||
{ "pickFileKinds", w_pickFileKinds },
|
||||
{ "createFile", w_createFile },
|
||||
{ "syncHealthSteps", w_syncHealthSteps },
|
||||
{ "restartApp", w_restartApp },
|
||||
|
||||
@@ -12,6 +12,48 @@
|
||||
"tintColor": "3b5ca8",
|
||||
"category": "games",
|
||||
"versions": [
|
||||
{
|
||||
"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",
|
||||
"date": "2026-08-14",
|
||||
"size": 11306689,
|
||||
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.1.86/gen1recomp++-0.1.86-ios.ipa",
|
||||
"localizedDescription": "Download the correct version for your computer below.\n\n## Issues closed\n\n- #1283 Intro broken and leading to softlock (R,B,Y currently unplayable without save file)\n\n## Contributors\n\n- @bryanthaboi"
|
||||
},
|
||||
{
|
||||
"version": "0.1.85",
|
||||
"date": "2026-08-14",
|
||||
"size": 11306462,
|
||||
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.1.85/gen1recomp++-0.1.85-ios.ipa",
|
||||
"localizedDescription": "Download the correct version for your computer below.\n\n## Contributors\n\n- @bryanthaboi"
|
||||
},
|
||||
{
|
||||
"version": "0.1.84",
|
||||
"date": "2026-08-14",
|
||||
"size": 11306462,
|
||||
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.1.84/gen1recomp++-0.1.84-ios.ipa",
|
||||
"localizedDescription": "Download the correct version for your computer below.\n\n## Issues closed\n\n- #919 Bills House game save moves strangely after reload\n- #982 Trading QOL aspect missing from OG.\n- #1003 Land Pokemon Encounters while Surfing in Cerulean Cave\n- #1012 Multiple issues with text speed, animations, and audio\n- #1022 Cannot interact with small trees\n- #1028 Trainers not resetting on route change.\n- #1033 Some issues with Link battles.\n- #1243 Can't sell TMs at Poké Marts\n\n## Contributors\n\n- @1Jamie\n- @AverageConsumer\n- @bryanthaboi\n- @TheRealSolidusSnake\n- Myles Resnick\n- ShaneMcGovernIE"
|
||||
},
|
||||
{
|
||||
"version": "0.1.83",
|
||||
"date": "2026-08-13",
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
// without updating that patch (see mobile/ios/patch_love_src.py).
|
||||
//
|
||||
// Contract (mirrors love-android's GameActivity.showFilePicker):
|
||||
// love.system.pickFile("rom"|"mod"|"sav") -> copies the user's pick into
|
||||
// love.system.pickFile("rom"|"mod"|"sav"|"required_import") -> copies the user's pick into
|
||||
// the LÖVE save directory as picked_rom.gb / picked_mod.zip /
|
||||
// picked_save.sav; RomImporter's pending-file scan consumes it.
|
||||
// love.system.createFile(name) -> exports save dir's pending_export.sav
|
||||
@@ -83,6 +83,8 @@ public final class GRPickerBridge: NSObject {
|
||||
types = [.zip]
|
||||
case "sav":
|
||||
destName = "picked_save.sav"
|
||||
case "required_import":
|
||||
destName = "picked_required_import.bin"
|
||||
// A Nintendo 64 cartridge, for mods that build assets out of one --
|
||||
// the voxel mod's Pokemon Stadium battle models are the caller this
|
||||
// was added for. Its own filename on purpose: an N64 ROM landing on
|
||||
@@ -135,7 +137,7 @@ public final class GRPickerBridge: NSObject {
|
||||
// Kept beside the switch it describes, because the two drifting apart is
|
||||
// the only way this can lie.
|
||||
@objc public static func supportedPickerKinds() -> NSString {
|
||||
return "rom,mod,sav,stadium" as NSString
|
||||
return "rom,mod,sav,stadium,required_import" as NSString
|
||||
}
|
||||
|
||||
@objc(presentExportWithName:saveDir:)
|
||||
|
||||
@@ -89,7 +89,8 @@ int w_pickFile(lua_State *L)
|
||||
return gr_callBridge(L, "GRPickerBridge", "presentPickerWithKind:saveDir:", kind);
|
||||
}
|
||||
|
||||
// love.system.pickFileKinds() -> "rom,mod,sav,stadium", or nil off iOS.
|
||||
// love.system.pickFileKinds() -> the comma-separated kinds supported by the
|
||||
// Swift bridge (including required_import), or nil off iOS.
|
||||
//
|
||||
// So a caller can ask what this build's picker understands BEFORE opening it.
|
||||
// An unknown kind is refused (GRPickerBridge), and a refusal looks exactly
|
||||
|
||||
@@ -93,7 +93,8 @@ build_autotools mpg123 "$MPG123_VERSION" "$MPG123_TARBALL" libmpg123.so.0 \
|
||||
# The symbol that was missing when this was bullseye's copy. Assert it, so a
|
||||
# version bump that quietly regresses below the host's expectations fails the
|
||||
# build instead of silently killing audio again.
|
||||
objdump -T "$PREFIX/lib/libmpg123.so.0" | grep -q 'mpg123_info2' \
|
||||
mpg123_syms="$(objdump -T "$PREFIX/lib/libmpg123.so.0")"
|
||||
grep -q 'mpg123_info2' <<<"$mpg123_syms" \
|
||||
|| fail "bundled libmpg123 lacks mpg123_info2; the host's libsndfile will fail to relocate"
|
||||
|
||||
# ------------------------------------------------------------ compile SDL2
|
||||
@@ -128,8 +129,9 @@ fi
|
||||
# the host having that exact stack -- which is the bug this replaced.
|
||||
sdl_lib="$PREFIX/lib/libSDL2-2.0.so.0"
|
||||
[ -f "$sdl_lib" ] || fail "SDL2 build produced no libSDL2-2.0.so.0"
|
||||
sdl_needed="$(objdump -p "$sdl_lib")"
|
||||
for forbidden in libpulse libasound libX11 libwayland libdrm libgbm libsndio; do
|
||||
if objdump -p "$sdl_lib" | grep -q "NEEDED.*$forbidden"; then
|
||||
if grep -q "NEEDED.*$forbidden" <<<"$sdl_needed"; then
|
||||
fail "SDL2 hard-links $forbidden; it must dlopen its backends (--enable-*-shared)"
|
||||
fi
|
||||
done
|
||||
@@ -164,8 +166,9 @@ fi
|
||||
|
||||
openal_lib="$PREFIX/lib/libopenal.so.1"
|
||||
[ -f "$openal_lib" ] || fail "openal-soft build produced no libopenal.so.1"
|
||||
openal_needed="$(objdump -p "$openal_lib")"
|
||||
for forbidden in libsndio libasound libpulse libjack; do
|
||||
if objdump -p "$openal_lib" | grep -q "NEEDED.*$forbidden"; then
|
||||
if grep -q "NEEDED.*$forbidden" <<<"$openal_needed"; then
|
||||
fail "openal hard-links $forbidden; backends must stay behind dlopen"
|
||||
fi
|
||||
done
|
||||
@@ -196,7 +199,8 @@ fi
|
||||
|
||||
theora_lib="$PREFIX/lib/libtheoradec.so.1"
|
||||
[ -f "$theora_lib" ] || fail "libtheora build produced no libtheoradec.so.1"
|
||||
if objdump -p "$theora_lib" | grep -q "NEEDED.*libcairo"; then
|
||||
theora_needed="$(objdump -p "$theora_lib")"
|
||||
if grep -q "NEEDED.*libcairo" <<<"$theora_needed"; then
|
||||
fail "libtheoradec still links libcairo (--disable-examples stopped working)"
|
||||
fi
|
||||
|
||||
@@ -231,16 +235,18 @@ love_bin="$PREFIX/bin/love"
|
||||
love_lib="$PREFIX/lib/liblove-$LOVE_VERSION.so"
|
||||
[ -x "$love_bin" ] || fail "LÖVE build produced no bin/love"
|
||||
[ -f "$love_lib" ] || fail "LÖVE build produced no lib/liblove-$LOVE_VERSION.so"
|
||||
file "$love_bin" | grep -q 'ARM aarch64' \
|
||||
love_file="$(file "$love_bin")"
|
||||
grep -q 'ARM aarch64' <<<"$love_file" \
|
||||
|| fail "built love is not an aarch64 ELF (got: $(file -b "$love_bin"))"
|
||||
|
||||
# A configure run that lost an optional dependency still exits 0 and still
|
||||
# builds -- the loss only shows up as a missing love module at runtime, i.e.
|
||||
# in a shipped artifact. Assert the decoder/font/video libs really linked.
|
||||
love_needed="$(objdump -p "$love_lib")"
|
||||
for soname in libSDL2-2.0.so.0 libopenal.so.1 libfreetype.so.6 \
|
||||
libmodplug.so.1 libmpg123.so.0 libvorbisfile.so.3 \
|
||||
libtheoradec.so.1 libluajit-5.1.so.2; do
|
||||
objdump -p "$love_lib" | grep -q "NEEDED.*$soname" \
|
||||
grep -q "NEEDED.*$soname" <<<"$love_needed" \
|
||||
|| fail "liblove is not linked against $soname (a -dev package went missing)"
|
||||
done
|
||||
|
||||
|
||||
@@ -51,6 +51,12 @@ rm -f "$OUTPUT"
|
||||
tools/rom_manifest.json tools/rom_manifest_blue.json \
|
||||
tools/rom_manifest_yellow.json tools/rom_manifest_gold.json \
|
||||
-x '*.DS_Store' 'data/generated/*' 'assets/generated/*')
|
||||
if [ -f "$ROOT/PATCH_NOTES.md" ]; then
|
||||
(cd "$ROOT" && zip -q "$OUTPUT" PATCH_NOTES.md)
|
||||
fi
|
||||
if [ -f "$ROOT/mobile/ios/app-repo.json" ]; then
|
||||
(cd "$ROOT" && zip -q "$OUTPUT" mobile/ios/app-repo.json)
|
||||
fi
|
||||
|
||||
if [ -n "$BUILD_INFO" ]; then
|
||||
[ -f "$BUILD_INFO" ] || fail "missing build-info: $BUILD_INFO"
|
||||
|
||||
@@ -135,6 +135,7 @@ if [ -f data/generated/maps.lua ]; then
|
||||
run_tier "T3 save editor: events + dex" "$LUA" tests/save_editor_task7_tests.lua
|
||||
run_tier "T3 save editor: map browser" "$LUA" tests/save_editor_task8_tests.lua
|
||||
run_tier "T3 save editor: mod awareness" "$LUA" tests/save_editor_mod_tests.lua
|
||||
run_tier "T3 save editor: gold / gen2" "$LUA" tests/save_editor_gen2_tests.lua
|
||||
run_tier "T3 save editor: wheel scrolling" "$LUA" tests/save_editor_wheel_bug595_test.lua
|
||||
run_tier "T3 save editor: pad / NX input" "$LUA" tests/save_editor_pad_input_test.lua
|
||||
run_tier "T5 link (loopback lockstep)" "$LUA" tests/run_link_tests.lua
|
||||
|
||||
+78
-14
@@ -316,6 +316,27 @@ function BattleState.trainerPicPath(data, trainer, oppClass, partyIndex)
|
||||
return base and base.pic or nil
|
||||
end
|
||||
|
||||
-- trueColor on the trainer record, or on the basePic it reuses when the
|
||||
-- subclass does not set the flag itself. Explicit false stays false.
|
||||
function BattleState.trainerTrueColor(data, trainer)
|
||||
if not trainer then return false end
|
||||
if trainer.trueColor ~= nil then
|
||||
return trainer.trueColor and true or false
|
||||
end
|
||||
local base = trainer.basePic and data and data.trainers
|
||||
and data.trainers[trainer.basePic]
|
||||
return (base and base.trueColor) and true or false
|
||||
end
|
||||
|
||||
-- Load a trainer frontpic through getImage so a trueColor portrait skips
|
||||
-- the 4-shade quantize the same way a species pic does.
|
||||
function BattleState.trainerSprite(data, trainer, oppClass, partyIndex)
|
||||
return getImage(
|
||||
BattleState.trainerPicPath(data, trainer, oppClass, partyIndex),
|
||||
BattleState.trainerPalette(data, trainer),
|
||||
BattleState.trainerTrueColor(data, trainer))
|
||||
end
|
||||
|
||||
-- The battle-BGP fade variant of a pic (AnimationFlashScreen and the
|
||||
-- SetAnimationBGPalette effects remap the four BG shades; on the SGB
|
||||
-- the colorizer then colors the REMAPPED shade, so a faded pic shows
|
||||
@@ -617,6 +638,44 @@ local function newBattle(game)
|
||||
return self
|
||||
end
|
||||
|
||||
local function scopedPlayerParty(game, indices)
|
||||
if indices == nil then return nil, nil end
|
||||
if type(indices) ~= "table" then
|
||||
Logger.warn("trainer battle party scope is not a table; using full party")
|
||||
return nil, nil
|
||||
end
|
||||
local count = #indices
|
||||
local keyCount = 0
|
||||
for key in pairs(indices) do
|
||||
keyCount = keyCount + 1
|
||||
if type(key) ~= "number" or key % 1 ~= 0 or key < 1 or key > count then
|
||||
Logger.warn("trainer battle party scope is malformed; using full party")
|
||||
return nil, nil
|
||||
end
|
||||
end
|
||||
if count == 0 or keyCount ~= count then
|
||||
Logger.warn("trainer battle party scope is empty or sparse; using full party")
|
||||
return nil, nil
|
||||
end
|
||||
local party, normalized, seen = {}, {}, {}
|
||||
for i = 1, count do
|
||||
local index = indices[i]
|
||||
if type(index) ~= "number" or index % 1 ~= 0
|
||||
or not game.save.party[index] or seen[index] then
|
||||
Logger.warn("trainer battle party scope contains an invalid index; using full party")
|
||||
return nil, nil
|
||||
end
|
||||
seen[index] = true
|
||||
normalized[i] = index
|
||||
party[i] = game.save.party[index]
|
||||
end
|
||||
return party, normalized
|
||||
end
|
||||
|
||||
function BattleState:playerPartyView()
|
||||
return self.playerParty or self.game.save.party
|
||||
end
|
||||
|
||||
-- opts.hooked: rod encounter, announced with _HookedMonAttackedText
|
||||
function BattleState.newWild(game, species, level, opts)
|
||||
local self = newBattle(game)
|
||||
@@ -692,13 +751,15 @@ local function applySpecialMoves(data, oppClass, partyIndex, party)
|
||||
end
|
||||
end
|
||||
|
||||
function BattleState.newTrainer(game, oppClass, partyIndex)
|
||||
function BattleState.newTrainer(game, oppClass, partyIndex, opts)
|
||||
local self = newBattle(game)
|
||||
self.kind = "trainer"
|
||||
self.oppClass = oppClass
|
||||
-- the object_event trainer arg (roster index). computeMusicKind keys
|
||||
-- data/scripts/victories.lua on class#party, so keep it on the battle (#782).
|
||||
self.partyIndex = partyIndex or 1
|
||||
self.playerParty, self.playerPartyIndices = scopedPlayerParty(game,
|
||||
type(opts) == "table" and opts.playerPartyIndices or nil)
|
||||
self.trainer = game.data.trainers[oppClass]
|
||||
assert(self.trainer, "unknown trainer class " .. tostring(oppClass))
|
||||
-- pret GetTrainerName_: RIVAL1/2/3 copy wRivalName into wTrainerName
|
||||
@@ -742,7 +803,7 @@ function BattleState.newTrainer(game, oppClass, partyIndex)
|
||||
end
|
||||
end
|
||||
self.enemyIndex = 1
|
||||
local playerMon = Party.firstHealthy(game.save.party)
|
||||
local playerMon = Party.firstHealthy(self:playerPartyView())
|
||||
if not playerMon then
|
||||
Logger.warn("trainer battle with no healthy party; skipping")
|
||||
self.dead = true
|
||||
@@ -756,9 +817,8 @@ function BattleState.newTrainer(game, oppClass, partyIndex)
|
||||
-- MonsterPalettes[0] = PAL_MEWMON -- InitBattleCommon zeroes
|
||||
-- wEnemyMonSpecies2 before the intro's SET_PAL_BATTLE
|
||||
-- (engine/battle/core.asm:6682, engine/gfx/palettes.asm SetPal_Battle)
|
||||
self.trainerPic = getImage(
|
||||
BattleState.trainerPicPath(game.data, self.trainer, oppClass, partyIndex),
|
||||
BattleState.trainerPalette(game.data, self.trainer))
|
||||
self.trainerPic = BattleState.trainerSprite(
|
||||
game.data, self.trainer, oppClass, partyIndex)
|
||||
self.introText = Strings("%s wants\nto fight!", self.trainer.name)
|
||||
return self
|
||||
end
|
||||
@@ -2002,7 +2062,7 @@ function BattleState:update(dt)
|
||||
-- loops the party menu until a healthy mon is picked, so B and
|
||||
-- fainted picks land back here and reopen it
|
||||
if self.player.mon.hp <= 0 then
|
||||
if Party.firstHealthy(self.game.save.party) then
|
||||
if Party.firstHealthy(self:playerPartyView()) then
|
||||
self:openReplacementMenu()
|
||||
end
|
||||
return
|
||||
@@ -3890,7 +3950,8 @@ function BattleState:awardExp()
|
||||
-- (RemoveFaintedPlayerMon), so it drops out of the divisor and only
|
||||
-- the surviving participants are counted and paid
|
||||
local participants, alive = 0, {}
|
||||
for _, mon in ipairs(self.game.save.party) do
|
||||
local playerParty = self:playerPartyView()
|
||||
for _, mon in ipairs(playerParty) do
|
||||
if self.participants and self.participants[mon] then
|
||||
participants = participants + 1
|
||||
if mon.hp > 0 then table.insert(alive, mon) end
|
||||
@@ -3984,9 +4045,9 @@ function BattleState:awardExp()
|
||||
-- experience.asm:9-13); each mon gets its own GainedText with the
|
||||
-- "with EXP.ALL," tail (wBoostExpByExpAll) -- pokered prints no
|
||||
-- summary line
|
||||
for _, mon in ipairs(self.game.save.party) do
|
||||
for _, mon in ipairs(playerParty) do
|
||||
if mon.hp > 0 then
|
||||
ctx.applyShare(mon, math.max(1, ctx.participants) * #self.game.save.party * 2, "expAll")
|
||||
ctx.applyShare(mon, math.max(1, ctx.participants) * #playerParty * 2, "expAll")
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -4027,7 +4088,7 @@ function BattleState:enemyMonFainted()
|
||||
local nextName = nextMon.nickname or self.data.pokemon[nextMon.species].name
|
||||
local style = tostring((self.game.save.options or {}).battleStyle or "shift")
|
||||
:lower()
|
||||
local partyCount = #self.game.save.party
|
||||
local partyCount = #self:playerPartyView()
|
||||
-- ReplaceFaintedEnemyMon (core.asm:892-896): DrawEnemyPokeballs puts the
|
||||
-- foe's party ball row -- and the HUD chrome PlaceEnemyHUDTiles lays
|
||||
-- down under it (draw_hud_pokeball_gfx.asm:9-11, 33-45, 134-141) -- into
|
||||
@@ -4054,6 +4115,7 @@ function BattleState:enemyMonFainted()
|
||||
local game = self.game
|
||||
Screens.push(game, "PartyMenu", {
|
||||
battle = self,
|
||||
party = self:playerPartyView(),
|
||||
forceSwitch = true,
|
||||
onSwitch = function(mon)
|
||||
if mon ~= self.player.mon and mon.hp > 0 then
|
||||
@@ -4226,7 +4288,7 @@ function BattleState.isOaksLabStarterRival(self)
|
||||
end
|
||||
|
||||
function BattleState:playerMonFainted()
|
||||
local nextMon = Party.firstHealthy(self.game.save.party)
|
||||
local nextMon = Party.firstHealthy(self:playerPartyView())
|
||||
-- Being out of useable POKéMON blacks you out even when the battle was
|
||||
-- already decided in our favour. A double faint -- our last mon dying
|
||||
-- to residual damage on the turn it lands the KO -- used to hit the
|
||||
@@ -4299,6 +4361,7 @@ function BattleState:openReplacementMenu()
|
||||
self:ui(function()
|
||||
return self:buildScreen("PartyMenu", {
|
||||
battle = self,
|
||||
party = self:playerPartyView(),
|
||||
-- ChooseNextMon: pick immediately (no SWITCH/STATS/CANCEL)
|
||||
forceSwitch = true,
|
||||
onSwitch = function(mon)
|
||||
@@ -4779,6 +4842,7 @@ function BattleState:openParty()
|
||||
self:ui(function()
|
||||
return self:buildScreen("PartyMenu", {
|
||||
battle = self,
|
||||
party = self:playerPartyView(),
|
||||
onSwitch = function(mon)
|
||||
if mon == self.player.mon then
|
||||
self:say(Strings("%s is\nalready out!", self.player.name))
|
||||
@@ -4825,8 +4889,8 @@ function BattleState:finish()
|
||||
-- here it did not, so say so rather than silently papering over it.
|
||||
-- The old-man / PROF.OAK demo also skips it: the party never fought
|
||||
-- (Yellow's Pallet intro runs before the player owns a mon at all).
|
||||
if self.result ~= "lose" and not self.demo
|
||||
and not Party.firstHealthy(self.game.save.party) then
|
||||
if self.kind ~= "link" and self.result ~= "lose" and not self.demo
|
||||
and not Party.firstHealthy(self:playerPartyView()) then
|
||||
Logger.warn("battle finished %s with no healthy party; forcing blackout",
|
||||
tostring(self.result))
|
||||
self.result = "lose"
|
||||
@@ -5707,7 +5771,7 @@ function BattleState:drawHUDs(slide)
|
||||
for i = 10, 17 do hudTile(0x76, i * 8, 88) end
|
||||
hudTile(0x6F, 72, 88)
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
self:drawBallRow(self.playerParty or self.game.save.party, 88, 80, 8)
|
||||
self:drawBallRow(self:playerPartyView(), 88, 80, 8)
|
||||
end
|
||||
local hidePlayer = self.safari or self.demo
|
||||
if showStatus and self.player and not hidePlayer and not self.showPlayerBack
|
||||
|
||||
@@ -98,10 +98,69 @@ function Mon.stats(baseStats, dvs, level, statExp)
|
||||
}
|
||||
end
|
||||
|
||||
-- The species string is the source of truth. `mon.name` is a copy of that
|
||||
-- species' display name (GetPokemonName), kept so menus can print without a
|
||||
-- Data lookup. It is NOT the nickname: an un-nicknamed mon has nickname nil
|
||||
-- and prints this copy. Changing species without rewriting it leaves the
|
||||
-- previous species' name on the party list and the SUMMARY's top line.
|
||||
function Mon.syncIdentity(mon, data)
|
||||
if type(mon) ~= "table" then return mon end
|
||||
local def = data and data.pokemon and data.pokemon[mon.species]
|
||||
if not def then return mon end
|
||||
mon.name = def.name or mon.species
|
||||
if def.types then mon.types = def.types end
|
||||
if mon.dvs then
|
||||
mon.gender = Mon.gender(def, mon.dvs,
|
||||
{ species = mon.species, level = mon.level })
|
||||
mon.shiny = Mon.isShiny(mon.dvs,
|
||||
{ species = mon.species, def = def, level = mon.level })
|
||||
if mon.species == Unown.SPECIES then
|
||||
mon.unownLetter = Unown.letterFromDVs(mon.dvs)
|
||||
else
|
||||
mon.unownLetter = nil
|
||||
end
|
||||
end
|
||||
return mon
|
||||
end
|
||||
|
||||
-- Every screen that prints a mon without a Data lookup should go through here:
|
||||
-- nickname if the player set one, otherwise the species display copy `name`.
|
||||
-- Skipping `name` and jumping to `species` is how a swapped mon can still
|
||||
-- read as ABRA on one menu and RAYQUAZA on another.
|
||||
function Mon.displayName(mon)
|
||||
if type(mon) ~= "table" then return "?" end
|
||||
return mon.nickname or mon.name or mon.species or "?"
|
||||
end
|
||||
|
||||
-- Party, boxes, both Day-Care sides, and a pending egg. Editor CONTINUE and
|
||||
-- hydrate have to walk the same set: leaving dayCare.man.mon on the old
|
||||
-- `name` is the ABRA bug in a second closet.
|
||||
function Mon.eachSaveMon(save, fn)
|
||||
if type(save) ~= "table" or type(fn) ~= "function" then return end
|
||||
for _, mon in ipairs(save.party or {}) do fn(mon) end
|
||||
for _, box in pairs(save.boxes or {}) do
|
||||
if type(box) == "table" then
|
||||
for _, mon in ipairs(box) do fn(mon) end
|
||||
end
|
||||
end
|
||||
local dc = save.dayCare
|
||||
if type(dc) == "table" then
|
||||
if dc.man and dc.man.mon then fn(dc.man.mon) end
|
||||
if dc.lady and dc.lady.mon then fn(dc.lady.mon) end
|
||||
if dc.egg then fn(dc.egg) end
|
||||
end
|
||||
if save.daycare and save.daycare.mon then fn(save.daycare.mon) end
|
||||
end
|
||||
|
||||
function Mon.syncSaveIdentity(save, data)
|
||||
Mon.eachSaveMon(save, function(mon) Mon.syncIdentity(mon, data) end)
|
||||
end
|
||||
|
||||
function Mon.refreshStats(mon, data)
|
||||
if type(mon) ~= "table" then return mon end
|
||||
local def = data and data.pokemon and data.pokemon[mon.species]
|
||||
if not (def and def.baseStats) then return mon end
|
||||
Mon.syncIdentity(mon, data)
|
||||
-- engine/pokemon/move_mon.asm:1402
|
||||
local stats = Mon.stats(def.baseStats, mon.dvs, mon.level or 1, mon.statExp)
|
||||
mon.stats = stats
|
||||
|
||||
@@ -44,6 +44,7 @@ local BATTLE_FIELDS = {
|
||||
"sideToxic", "isGymLeader", "musicKind", "lastBall", "lockedBall",
|
||||
"lowHealthAlarmDisabled", "lowHealthAlarmOn", "victoryMusicPlayed",
|
||||
"endBattleText",
|
||||
"playerPartyIndices",
|
||||
}
|
||||
|
||||
local function partyIndex(party, mon)
|
||||
@@ -91,6 +92,23 @@ local function integer(value, min, max)
|
||||
and value >= (min or -math.huge) and value <= (max or math.huge)
|
||||
end
|
||||
|
||||
local function exactIndexSet(indices, maxIndex, requireMember)
|
||||
if type(indices) ~= "table" then return nil end
|
||||
local count, keys = #indices, 0
|
||||
for key in pairs(indices) do
|
||||
keys = keys + 1
|
||||
if not integer(key, 1, count) then return nil end
|
||||
end
|
||||
if keys ~= count or (requireMember and count == 0) then return nil end
|
||||
local seen = {}
|
||||
for i = 1, count do
|
||||
local index = indices[i]
|
||||
if not integer(index, 1, maxIndex) or seen[index] then return nil end
|
||||
seen[index] = true
|
||||
end
|
||||
return seen
|
||||
end
|
||||
|
||||
local function validateMoveList(data, moves)
|
||||
if type(moves) ~= "table" then return false end
|
||||
for _, move in ipairs(moves) do
|
||||
@@ -200,6 +218,16 @@ function BattleCheckpoint.validate(game, checkpoint)
|
||||
if type(party) ~= "table" or not validateBattler(game.data, model.player, #party) then
|
||||
return nil, "invalid_content", "Player battle state is invalid."
|
||||
end
|
||||
local scopedIndices
|
||||
if model.playerPartyIndices ~= nil then
|
||||
if model.kind ~= "trainer" then
|
||||
return nil, "invalid_checkpoint", "Battle party scope is invalid."
|
||||
end
|
||||
scopedIndices = exactIndexSet(model.playerPartyIndices, #party, true)
|
||||
if not scopedIndices or not scopedIndices[model.player.index] then
|
||||
return nil, "invalid_checkpoint", "Battle party scope is invalid."
|
||||
end
|
||||
end
|
||||
if model.kind == "wild" then
|
||||
if not validateMon(game.data, model.enemyMon)
|
||||
or not validateBattler(game.data, model.enemy, 1) then
|
||||
@@ -220,12 +248,15 @@ function BattleCheckpoint.validate(game, checkpoint)
|
||||
end
|
||||
end
|
||||
for _, indices in ipairs({ model.participants, model.leveledUp }) do
|
||||
if type(indices) ~= "table" then
|
||||
local referenced = exactIndexSet(indices, #party, false)
|
||||
if not referenced then
|
||||
return nil, "invalid_checkpoint", "Battle party reference set is missing."
|
||||
end
|
||||
for _, index in ipairs(indices) do
|
||||
if not integer(index, 1, #party) then
|
||||
return nil, "invalid_checkpoint", "Battle party reference is invalid."
|
||||
if scopedIndices then
|
||||
for index in pairs(referenced) do
|
||||
if not scopedIndices[index] then
|
||||
return nil, "invalid_checkpoint", "Battle party reference is invalid."
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -276,7 +307,9 @@ function BattleCheckpoint.restore(game, checkpoint, copy)
|
||||
local model = checkpoint.runtime.battle
|
||||
local battle
|
||||
if model.kind == "trainer" then
|
||||
battle = BattleState.newTrainer(game, model.oppClass, model.partyIndex)
|
||||
battle = BattleState.newTrainer(game, model.oppClass, model.partyIndex, {
|
||||
playerPartyIndices = model.playerPartyIndices,
|
||||
})
|
||||
battle.enemyParty = assert(copy(model.enemyParty))
|
||||
battle.enemyIndex = model.enemyIndex
|
||||
else
|
||||
|
||||
+5
-2
@@ -283,6 +283,9 @@ function Game2:continueGame(save)
|
||||
local modsDiff = SaveData.modsDiff(save, activeMods)
|
||||
self.save = save
|
||||
self:adoptSave(save)
|
||||
-- Editor species swaps used to leave mon.name on the previous species.
|
||||
-- CONTINUE rewrites party, boxes, and Day-Care copies from the live record.
|
||||
require("src.battle.gen2.Mon").syncSaveIdentity(save, self.data)
|
||||
-- options.lua wins over anything a save file carries: options are a display
|
||||
-- preference that survives New Game and is edited from the launcher, so a
|
||||
-- save written before they moved out must not drag old values back in.
|
||||
@@ -588,13 +591,13 @@ function Game2:useFieldItem(itemId)
|
||||
end
|
||||
if not allowed then
|
||||
self:say(("%s can't learn %s!"):format(
|
||||
mon.nickname or mon.species or "?", moveName))
|
||||
require("src.battle.gen2.Mon").displayName(mon), moveName))
|
||||
return
|
||||
end
|
||||
for _, move in ipairs(mon.moves or {}) do
|
||||
if move.id == moveId then
|
||||
self:say(("%s already knows %s!"):format(
|
||||
mon.nickname or mon.species or "?", moveName))
|
||||
require("src.battle.gen2.Mon").displayName(mon), moveName))
|
||||
return
|
||||
end
|
||||
end
|
||||
|
||||
+49
-2
@@ -832,9 +832,41 @@ local function tryMigrateLegacy(version, fs)
|
||||
return id
|
||||
end
|
||||
|
||||
-- Scan the filesystem for orphaned slot files under saves/<version>/ when options.lua
|
||||
-- has no registered slots for this version (e.g. options.lua was reset or lost).
|
||||
local function scanDiskSlots(version, fs)
|
||||
if not fs then return nil end
|
||||
local dir = "saves/" .. version
|
||||
local slots = {}
|
||||
if fs.getDirectoryItems and fs.getInfo and fs.getInfo(dir) then
|
||||
local items = pcall(fs.getDirectoryItems, dir) and fs.getDirectoryItems(dir) or {}
|
||||
local numbers = {}
|
||||
for _, item in ipairs(items) do
|
||||
local slotId = item:match("^(slot%d+)%.lua$")
|
||||
if slotId then
|
||||
local n = tonumber(slotId:match("%d+"))
|
||||
table.insert(numbers, { id = slotId, num = n or 0 })
|
||||
end
|
||||
end
|
||||
table.sort(numbers, function(a, b) return a.num < b.num end)
|
||||
for _, item in ipairs(numbers) do
|
||||
table.insert(slots, item.id)
|
||||
end
|
||||
else
|
||||
for i = 1, 30 do
|
||||
local slotId = "slot" .. i
|
||||
local path = dir .. "/" .. slotId .. ".lua"
|
||||
if fs.getInfo and fs.getInfo(path) then
|
||||
table.insert(slots, slotId)
|
||||
end
|
||||
end
|
||||
end
|
||||
return #slots > 0 and slots or nil
|
||||
end
|
||||
|
||||
-- Resolve (once per version per process) which slot in-game saves use: an
|
||||
-- existing registry wins; otherwise a lazy legacy migration may create
|
||||
-- slot1; otherwise false, meaning the flat legacy path.
|
||||
-- slot1; otherwise auto-recover disk slots; otherwise false (flat legacy path).
|
||||
local function ensureVersionSlots(version, fs)
|
||||
if slotsChecked[version] then return end
|
||||
slotsChecked[version] = true
|
||||
@@ -848,7 +880,22 @@ local function ensureVersionSlots(version, fs)
|
||||
activeSlotCache[version] = reg.active or reg.list[1]
|
||||
return
|
||||
end
|
||||
activeSlotCache[version] = tryMigrateLegacy(version, fs) or false
|
||||
local migrated = tryMigrateLegacy(version, fs)
|
||||
if migrated then
|
||||
activeSlotCache[version] = migrated
|
||||
return
|
||||
end
|
||||
-- Auto-recovery: if options.lua lost its slot registry, scan disk for orphaned slot files
|
||||
local recovered = scanDiskSlots(version, fs)
|
||||
if recovered and #recovered > 0 then
|
||||
opts.saveSlots = opts.saveSlots or {}
|
||||
opts.saveSlots[version] = { list = recovered, active = recovered[1] }
|
||||
SaveData.saveOptions(opts, fs)
|
||||
activeSlotCache[version] = recovered[1]
|
||||
Logger.info("auto-recovered %d save slot(s) for %s from disk", #recovered, version)
|
||||
return
|
||||
end
|
||||
activeSlotCache[version] = false
|
||||
end
|
||||
|
||||
-- (body for the forward-declared saveNames.) Resolves the ACTIVE slot for
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+328
-68
@@ -299,6 +299,11 @@ end
|
||||
|
||||
local CART_DRAG_SLOP = 8
|
||||
local TAU = math.pi * 2
|
||||
-- The 3D mesh is inset inside the hit box so yaw/pitch and the 1.05 hover
|
||||
-- scale cannot climb into the title row (or the gear) on desktop, high-DPI,
|
||||
-- or a portrait phone. Fraction of the shorter side, with a pixel floor.
|
||||
local CART_MESH_PAD = 0.07
|
||||
local CART_MESH_PAD_MIN = 8
|
||||
|
||||
local function cartridgeState(imp, version)
|
||||
imp._cartridge = imp._cartridge or {}
|
||||
@@ -543,8 +548,11 @@ local function cartridgeButton(imp, x, y, w, h, key, version, gameName, action)
|
||||
Theme.A.focus, 2, Theme.cardRadius() + 2)
|
||||
end
|
||||
|
||||
local halfW, halfH = w / 2, h / 2
|
||||
local depth = math.max(8, w * 0.14)
|
||||
local meshPad = math.max(CART_MESH_PAD_MIN,
|
||||
math.floor(math.min(w, h) * CART_MESH_PAD))
|
||||
local halfW = math.max(1, w / 2 - meshPad)
|
||||
local halfH = math.max(1, h / 2 - meshPad)
|
||||
local depth = math.max(8, (halfW * 2) * 0.14)
|
||||
local project = function(px, py, pz)
|
||||
return cartProject(cx + pressX, cy + pressY, yaw, pitch,
|
||||
px * pressedScale, py * pressedScale, pz * pressedScale)
|
||||
@@ -630,6 +638,7 @@ end
|
||||
|
||||
local function modStatusColor(status)
|
||||
if status == "ok" then return Strings("Ready"), PAL.green end
|
||||
if status == "needs_import" then return Strings("Import required"), PAL.yellow end
|
||||
if status == "conflict" then return Strings("Conflict"), PAL.red end
|
||||
-- not a fault: the mod is intact, this is simply not a game it is for
|
||||
-- (src/mods/ModTargets.lua)
|
||||
@@ -638,14 +647,8 @@ local function modStatusColor(status)
|
||||
end
|
||||
|
||||
-- MODS panel scope row: which game the list is answering for, plus dedicated Profile control (cycle + gear).
|
||||
local function buildModScopeRow(imp, x, y, w, m)
|
||||
local function modScopeOptions(imp)
|
||||
local GameVersion = require("src.core.GameVersion")
|
||||
local LauncherMods = require("src.mods.LauncherMods")
|
||||
local h = math.max(Kit.tapMin(), math.floor(26 * m.s))
|
||||
local gap = math.floor(6 * m.s)
|
||||
local label = Strings("Show for:")
|
||||
Kit.text("small", label, x, y + (h - Kit.textHeight("small")) / 2, PAL.muted)
|
||||
local cx = x + Kit.textWidth("small", label) + math.floor(10 * m.s)
|
||||
local options = { { id = nil, label = Strings("All games") } }
|
||||
for _, version in ipairs(GameVersion.ORDER) do
|
||||
if imp.ready and imp.ready[version] then
|
||||
@@ -653,6 +656,33 @@ local function buildModScopeRow(imp, x, y, w, m)
|
||||
{ id = version, label = GameVersion.info(version).label }
|
||||
end
|
||||
end
|
||||
return options
|
||||
end
|
||||
|
||||
local function modScopeCurrentLabel(imp, options)
|
||||
for _, opt in ipairs(options) do
|
||||
if imp.modScope == opt.id then return opt.label end
|
||||
end
|
||||
return options[1] and options[1].label or Strings("All games")
|
||||
end
|
||||
|
||||
local function modScopeChipsWidth(options, gap, m)
|
||||
local need = 0
|
||||
for i, opt in ipairs(options) do
|
||||
need = need + Kit.textWidth("micro", opt.label) + math.floor(18 * m.s)
|
||||
if i < #options then need = need + gap end
|
||||
end
|
||||
return need
|
||||
end
|
||||
|
||||
local function buildModScopeRow(imp, x, y, w, m)
|
||||
local LauncherMods = require("src.mods.LauncherMods")
|
||||
local h = math.max(Kit.tapMin(), math.floor(26 * m.s))
|
||||
local gap = math.floor(6 * m.s)
|
||||
local label = Strings("Show for:")
|
||||
Kit.text("small", label, x, y + (h - Kit.textHeight("small")) / 2, PAL.muted)
|
||||
local cx = x + Kit.textWidth("small", label) + math.floor(10 * m.s)
|
||||
local options = modScopeOptions(imp)
|
||||
|
||||
-- Dedicated Profile control section (cycle button + gear icon button) on right side of Scope Bar
|
||||
local _, activeProf = LauncherMods.getProfiles()
|
||||
@@ -690,9 +720,12 @@ local function buildModScopeRow(imp, x, y, w, m)
|
||||
})
|
||||
|
||||
if #options >= 2 then
|
||||
for _, opt in ipairs(options) do
|
||||
local cw = Kit.textWidth("micro", opt.label) + math.floor(18 * m.s)
|
||||
if cx + cw <= profX - gap then
|
||||
local avail = profX - gap - cx
|
||||
-- Chips stay when they all fit; otherwise they used to be skipped and
|
||||
-- vanish off the portrait edge. Collapse to one menu in that case only.
|
||||
if modScopeChipsWidth(options, gap, m) <= avail then
|
||||
for _, opt in ipairs(options) do
|
||||
local cw = Kit.textWidth("micro", opt.label) + math.floor(18 * m.s)
|
||||
if Kit.chip(cx, y, cw, h, opt.label, imp.modScope == opt.id, PAL.lineStrong,
|
||||
"mod-scope-" .. tostring(opt.id or "all")) then
|
||||
local want = opt.id
|
||||
@@ -701,6 +734,15 @@ local function buildModScopeRow(imp, x, y, w, m)
|
||||
end
|
||||
cx = cx + cw + gap
|
||||
end
|
||||
elseif avail > 0 then
|
||||
local shown = Kit.ellipsize("micro", modScopeCurrentLabel(imp, options),
|
||||
math.max(0, avail - math.floor(18 * m.s)))
|
||||
local cw = math.min(avail,
|
||||
Kit.textWidth("micro", shown) + math.floor(18 * m.s))
|
||||
if Kit.chip(cx, y, cw, h, shown, true, PAL.lineStrong, "mod-scope-menu") then
|
||||
queueAction(imp, "mod-scope-menu",
|
||||
function() imp._modScopePopup = true end)
|
||||
end
|
||||
end
|
||||
end
|
||||
return h + math.floor(8 * m.s)
|
||||
@@ -740,8 +782,8 @@ local function setPage(imp, key, v)
|
||||
imp._pages[key] = v
|
||||
end
|
||||
|
||||
-- A hand-drawn X, for the same reason drawCheck exists below: the UI font has
|
||||
-- no guaranteed glyph, and the launcher ships no icon asset for it.
|
||||
-- A hand-drawn X / check: the UI font has no guaranteed glyph for either,
|
||||
-- and the launcher ships no icon asset for them.
|
||||
local function drawCross(x, y, size, color)
|
||||
love.graphics.push("all")
|
||||
love.graphics.setColor(color)
|
||||
@@ -752,6 +794,18 @@ local function drawCross(x, y, size, color)
|
||||
love.graphics.pop()
|
||||
end
|
||||
|
||||
local function drawCheck(x, y, size, color)
|
||||
love.graphics.push("all")
|
||||
love.graphics.setColor(color)
|
||||
love.graphics.setLineWidth(math.max(2.2, size * 0.17))
|
||||
love.graphics.setLineJoin("bevel")
|
||||
love.graphics.line(
|
||||
x + size * 0.02, y + size * 0.52,
|
||||
x + size * 0.38, y + size * 0.80,
|
||||
x + size * 1.015, y + size * 0.18)
|
||||
love.graphics.pop()
|
||||
end
|
||||
|
||||
-- ------------------------------------------------------------- header
|
||||
-- Rail, logo row (settings and quit on the right), tab bar.
|
||||
-- Returns the y at which content may start. Its vertical arithmetic is
|
||||
@@ -1316,20 +1370,32 @@ local function buildGamePanel(imp, x, y, w, availH, m, version)
|
||||
or tostring(version)
|
||||
local ready = (not locked) and imp.ready[version] or false
|
||||
|
||||
-- title + status tag
|
||||
-- title + status tag. Ready is a check chip (the font has no tick glyph);
|
||||
-- missing ROM stays a yellow "ROM REQUIRED" tag so it still reads as an action.
|
||||
local titleH = Kit.textHeight("title")
|
||||
Kit.text("title", Kit.ellipsize("title", gameName, w * 0.6), x, y, PAL.heading)
|
||||
local tagText, tagCol
|
||||
if ready then tagText, tagCol = Strings("GOOD TO GO"), PAL.green
|
||||
elseif imp.baseRoms and imp.baseRoms[version] then
|
||||
tagText, tagCol = Strings("ROM FOUND"), PAL.green
|
||||
elseif locked then tagText, tagCol = Strings("COMING SOON"), PAL.steel
|
||||
else tagText, tagCol = Strings("ROM REQUIRED"), PAL.yellow end
|
||||
local tagW = Kit.textWidth("micro", tagText) + math.floor(18 * m.s)
|
||||
local tagH = Kit.textHeight("micro") + math.floor(10 * m.s)
|
||||
local tagX = x + Kit.textWidth("title", Kit.ellipsize("title", gameName, w * 0.6))
|
||||
+ math.floor(12 * m.s)
|
||||
Kit.tag(tagX, y + (titleH - tagH) / 2, tagW, tagH, tagText, tagCol)
|
||||
local tagY = y + (titleH - tagH) / 2
|
||||
local tagW, tagCol
|
||||
if ready then
|
||||
tagCol = PAL.green
|
||||
tagW = tagH
|
||||
if love.graphics then
|
||||
Theme.strokeRounded(tagX, tagY, tagW, tagH, tagCol, 0.7, 1)
|
||||
local ck = math.floor(tagH * 0.55)
|
||||
drawCheck(tagX + (tagW - ck) / 2, tagY + (tagH - ck) / 2, ck, tagCol)
|
||||
end
|
||||
else
|
||||
local tagText
|
||||
if imp.baseRoms and imp.baseRoms[version] then
|
||||
tagText, tagCol = Strings("ROM FOUND"), PAL.green
|
||||
elseif locked then tagText, tagCol = Strings("COMING SOON"), PAL.steel
|
||||
else tagText, tagCol = Strings("ROM REQUIRED"), PAL.yellow end
|
||||
tagW = Kit.textWidth("micro", tagText) + math.floor(18 * m.s)
|
||||
Kit.tag(tagX, tagY, tagW, tagH, tagText, tagCol)
|
||||
end
|
||||
if ready then
|
||||
local hint = Strings("(PRESS THE CART TO PLAY)")
|
||||
local hintX = tagX + tagW + math.floor(10 * m.s)
|
||||
@@ -1337,8 +1403,11 @@ local function buildGamePanel(imp, x, y, w, availH, m, version)
|
||||
Kit.text("micro", Kit.ellipsize("micro", hint, hintW), hintX,
|
||||
y + (titleH - Kit.textHeight("micro")) / 2, PAL.heading)
|
||||
end
|
||||
local cy = y + titleH + math.floor(12 * m.s)
|
||||
local remaining = availH - (titleH + math.floor(12 * m.s))
|
||||
-- Extra gap under the title when the cart is showing: 12px left the 3D
|
||||
-- shell sitting on the hairline. Scaled, and still small on a phone.
|
||||
local afterTitle = math.floor((ready and 22 or 12) * m.s)
|
||||
local cy = y + titleH + afterTitle
|
||||
local remaining = availH - (titleH + afterTitle)
|
||||
|
||||
local gap = m.gap
|
||||
local lx, lw, rx2, rw
|
||||
@@ -1446,20 +1515,6 @@ local function currentSort(imp)
|
||||
return sortKey
|
||||
end
|
||||
|
||||
-- A hand-drawn check mark: the UI font has no guaranteed glyph for one, and
|
||||
-- a tofu box on the "you already have this" signal would be worse than none.
|
||||
local function drawCheck(x, y, size, color)
|
||||
love.graphics.push("all")
|
||||
love.graphics.setColor(color)
|
||||
love.graphics.setLineWidth(math.max(2.2, size * 0.17))
|
||||
love.graphics.setLineJoin("bevel")
|
||||
love.graphics.line(
|
||||
x + size * 0.02, y + size * 0.52,
|
||||
x + size * 0.38, y + size * 0.80,
|
||||
x + size * 1.015, y + size * 0.18)
|
||||
love.graphics.pop()
|
||||
end
|
||||
|
||||
-- One compact coloured checkbox for each game. The cartridge colour carries
|
||||
-- the game identity even when the row is narrow.
|
||||
local function modGameCheckbox(x, y, size, checked, game, id)
|
||||
@@ -1978,21 +2033,47 @@ local TRUST_WARNING = "if you did not get this from bryanthaboi's github "
|
||||
.. "it might have been tampered with. go to the discord to verify "
|
||||
.. COMMUNITY_URL .. " (or click the logo above)"
|
||||
|
||||
-- Mark + optional updater + Patch notes. Chips match the mark's 22px
|
||||
-- height so they do not read as bigger than the logo; the row can still
|
||||
-- be tapMin tall for spacing. On a phone the notes chip drops onto a
|
||||
-- second row rather than overflowing the mark.
|
||||
local function footerLayout(imp, m, markW)
|
||||
local markH = math.floor(22 * m.s)
|
||||
local rowH = math.max(markH, Kit.tapMin())
|
||||
local notesLabel = Strings("Patch notes")
|
||||
-- Tight chip, still enough for Kit.button's labelInset so the words survive.
|
||||
local chipPad = math.floor(24 * m.s)
|
||||
local nw = Kit.textWidth("micro", notesLabel) + chipPad
|
||||
local upStatus, upLabel, upAction, upGlow = LauncherView._updateControl(imp)
|
||||
local uw = upStatus
|
||||
and (Kit.textWidth("micro", upLabel) + chipPad) or 0
|
||||
local gap = math.floor(10 * m.s)
|
||||
local inner = m.w - 2 * m.pad
|
||||
local topW = (markW or 0) + (upStatus and (gap + uw) or 0) + gap + nw
|
||||
return {
|
||||
rowH = rowH, chipH = markH, gap = gap,
|
||||
notesLabel = notesLabel, nw = nw,
|
||||
upStatus = upStatus, upLabel = upLabel, upAction = upAction, upGlow = upGlow,
|
||||
uw = uw, wrap = topW > inner,
|
||||
}
|
||||
end
|
||||
|
||||
-- Pinned to the bottom of the window; returns the y it starts at, so the
|
||||
-- panels above know how much room they have.
|
||||
-- Deliberately compact: at a large UI scale the footer is pure overhead
|
||||
-- competing with the panel for a short window's height, so the mark and the
|
||||
-- link share one line and the trust warning is capped at a single line.
|
||||
local function footerHeight(imp, m)
|
||||
-- Top pad + mark/update row + gap + the FULL wrapped trust message +
|
||||
-- bottom pad. The message wraps to as many lines as it needs: truncating
|
||||
-- a trust warning defeats its purpose, and the bottom pad is not optional
|
||||
-- either (without it the last line sits flush on the window edge and its
|
||||
-- lower half clips off). The row is tapMin tall because the small update
|
||||
-- button rides beside the mark.
|
||||
local rowH = math.max(math.floor(22 * m.s), Kit.tapMin())
|
||||
return math.floor(8 * m.s) + rowH + math.floor(6 * m.s)
|
||||
+ Kit.wrapHeight("micro", TRUST_WARNING, m.contentW)
|
||||
-- Top pad + mark/update row + optional notes wrap row + gap + the FULL
|
||||
-- wrapped trust message + bottom pad. The message wraps to as many lines
|
||||
-- as it needs: truncating a trust warning defeats its purpose, and the
|
||||
-- bottom pad is not optional either (without it the last line sits flush
|
||||
-- on the window edge and its lower half clips off). The row is tapMin
|
||||
-- tall because the small update button rides beside the mark.
|
||||
local f = footerLayout(imp, m, math.floor(130 * m.s))
|
||||
local h = math.floor(8 * m.s) + f.rowH + math.floor(6 * m.s)
|
||||
if f.wrap then h = h + f.chipH + math.floor(6 * m.s) end
|
||||
return h + Kit.wrapHeight("micro", TRUST_WARNING, m.contentW)
|
||||
+ math.floor(8 * m.s)
|
||||
end
|
||||
|
||||
@@ -2009,19 +2090,17 @@ local function buildFooter(imp, m, y)
|
||||
local bw, bh = imp.bcg:getDimensions()
|
||||
local scale = math.min((130 * m.s) / bw, (22 * m.s) / bh)
|
||||
local dw, dh = bw * scale, bh * scale
|
||||
local rowH = math.max(math.floor(22 * m.s), Kit.tapMin())
|
||||
-- The mark and the small self-update control share the row, centred as a
|
||||
-- group. The updater moved down here from the header, where it overlapped
|
||||
-- the wordmark on a phone; small on purpose, its glow still carries the
|
||||
-- "act on me" signal.
|
||||
local upStatus, upLabel, upAction, upGlow = LauncherView._updateControl(imp)
|
||||
-- Kit.button insets its label 16*scale per side, so the width must budget
|
||||
-- more than that or the label ellipsizes ("Check for updat...").
|
||||
local uw = upStatus
|
||||
and (Kit.textWidth("micro", upLabel) + math.floor(36 * m.s)) or 0
|
||||
local groupW = dw + (upStatus and (math.floor(10 * m.s) + uw) or 0)
|
||||
local bx = m.x + math.floor((m.w - groupW) / 2)
|
||||
local f = footerLayout(imp, m, dw)
|
||||
local rowH, gap, chipH = f.rowH, f.gap, f.chipH
|
||||
-- The mark, the small self-update control, and Patch notes share the row,
|
||||
-- centred as a group. The updater moved down here from the header, where
|
||||
-- it overlapped the wordmark on a phone; small on purpose, its glow still
|
||||
-- carries the "act on me" signal. Notes wrap under the mark on a phone.
|
||||
local topW = dw + (f.upStatus and (gap + f.uw) or 0)
|
||||
if not f.wrap then topW = topW + gap + f.nw end
|
||||
local bx = m.x + math.floor((m.w - topW) / 2)
|
||||
local my = cy + math.floor((rowH - dh) / 2)
|
||||
local chipY = cy + math.floor((rowH - chipH) / 2)
|
||||
local hot = Kit.hover(bx, my, dw, dh)
|
||||
love.graphics.setShader(imp.invertShader)
|
||||
love.graphics.setColor(1, 1, 1, hot and 1 or 0.85)
|
||||
@@ -2031,14 +2110,30 @@ local function buildFooter(imp, m, y)
|
||||
if Kit.press(bx, my, dw, dh) then
|
||||
queueAction(imp, "bcg", function() love.system.openURL(COMMUNITY_URL) end)
|
||||
end
|
||||
if upStatus then
|
||||
btn(imp, bx + dw + math.floor(10 * m.s), cy, uw, rowH, "updater",
|
||||
upLabel, {
|
||||
kind = upGlow and "warn" or "ghost", font = "micro",
|
||||
glow = upGlow, action = upAction,
|
||||
local cx = bx + dw
|
||||
if f.upStatus then
|
||||
cx = cx + gap
|
||||
btn(imp, cx, chipY, f.uw, chipH, "updater",
|
||||
f.upLabel, {
|
||||
kind = f.upGlow and "warn" or "ghost", font = "micro",
|
||||
glow = f.upGlow, action = f.upAction,
|
||||
})
|
||||
cx = cx + f.uw
|
||||
end
|
||||
local function notesBtn(x, y)
|
||||
btn(imp, x, y, f.nw, chipH, "patch-notes", f.notesLabel, {
|
||||
kind = "ghost", font = "micro",
|
||||
action = function() imp._appPatchNotes = true end,
|
||||
})
|
||||
end
|
||||
if f.wrap then
|
||||
cy = cy + rowH + gap
|
||||
notesBtn(m.x + math.floor((m.w - f.nw) / 2), cy)
|
||||
cy = cy + chipH + math.floor(6 * m.s)
|
||||
else
|
||||
notesBtn(cx + gap, chipY)
|
||||
cy = cy + rowH + math.floor(6 * m.s)
|
||||
end
|
||||
cy = cy + rowH + math.floor(6 * m.s)
|
||||
-- The trust message wraps in full, each line centred under the mark, and
|
||||
-- the URL inside it IS the link -- no separate link floating elsewhere.
|
||||
-- font:getWrap never splits an unspaced word, so the URL stays whole on
|
||||
@@ -2543,6 +2638,34 @@ local function buildSortModal(imp, m)
|
||||
action = function() imp._sortPopup = nil end })
|
||||
end
|
||||
|
||||
-- Game-scope chooser used when the Show-for chips cannot all fit on the
|
||||
-- mods toolbar (portrait phones). Same options as the chip row.
|
||||
local function buildModScopeModal(imp, m)
|
||||
local options = modScopeOptions(imp)
|
||||
local pad = math.floor(18 * m.s)
|
||||
local w = math.floor(360 * m.s)
|
||||
local gap = math.floor(8 * m.s)
|
||||
local h = pad + Kit.textHeight("button") + math.floor(12 * m.s)
|
||||
+ #options * (m.btnH + gap) + m.btnH + pad
|
||||
local px, py, pw = modalPanel(m, w, h)
|
||||
local cy = py + pad
|
||||
Kit.text("button", Strings("Show for"), px + pad, cy, PAL.heading)
|
||||
cy = cy + Kit.textHeight("button") + math.floor(12 * m.s)
|
||||
for _, opt in ipairs(options) do
|
||||
local key = tostring(opt.id or "all")
|
||||
btn(imp, px + pad, cy, pw - 2 * pad, m.btnH, "scopepop-" .. key, opt.label, {
|
||||
kind = (imp.modScope == opt.id) and "primary" or "ghost", font = "small",
|
||||
action = function()
|
||||
imp:_setModScope(opt.id)
|
||||
imp._modScopePopup = nil
|
||||
end })
|
||||
cy = cy + m.btnH + gap
|
||||
end
|
||||
btn(imp, px + pad, cy, pw - 2 * pad, m.btnH, "scopepop-close",
|
||||
Strings("Close"), { font = "small",
|
||||
action = function() imp._modScopePopup = nil end })
|
||||
end
|
||||
|
||||
-- Category filter for FIND MODS. Two columns, because an index can list
|
||||
-- enough categories to overflow a single stacked column on a short window.
|
||||
local function buildFilterModal(imp, m)
|
||||
@@ -2645,11 +2768,14 @@ local function buildModActionsModal(imp, m)
|
||||
local hasGit = mod.github and mod.github ~= ""
|
||||
local depSpecs = mod.dependencySpecs or (mod.manifest and mod.manifest.dependencySpecs)
|
||||
local hasDeps = depSpecs and #depSpecs > 0
|
||||
local imports = mod.imports or mod.requiredImports
|
||||
local hasImports = imports and #imports > 0
|
||||
local info = hasGit and imp:_modUpdateInfo(mod.id)
|
||||
local pad = math.floor(18 * m.s)
|
||||
local w = math.floor(440 * m.s)
|
||||
local gap = math.floor(8 * m.s)
|
||||
local nBtns = (hasGit and 2 or 0) + (hasDeps and 1 or 0) + 2
|
||||
local nBtns = (hasGit and 2 or 0) + (hasDeps and 1 or 0)
|
||||
+ (hasImports and 1 or 0) + 2
|
||||
local h = pad + Kit.textHeight("button") + math.floor(4 * m.s)
|
||||
+ Kit.textHeight("small") + math.floor(12 * m.s)
|
||||
+ nBtns * (m.btnH + gap) - gap + pad
|
||||
@@ -2699,6 +2825,19 @@ local function buildModActionsModal(imp, m)
|
||||
end })
|
||||
cy = cy + m.btnH + gap
|
||||
end
|
||||
if hasImports then
|
||||
local missing = tonumber(mod.missingRequiredImports) or 0
|
||||
local label = missing > 0
|
||||
and Strings("Imported files (%d required)", missing)
|
||||
or Strings("Imported files")
|
||||
btn(imp, px + pad, cy, pw - 2 * pad, m.btnH, "modact-imports",
|
||||
label, { kind = missing > 0 and "warn" or "accent", font = "small",
|
||||
action = function()
|
||||
imp._modImports = id
|
||||
imp._modActions = nil
|
||||
end })
|
||||
cy = cy + m.btnH + gap
|
||||
end
|
||||
local armed = deleteArmed(imp, "mod", id, nil)
|
||||
btn(imp, px + pad, cy, pw - 2 * pad, m.btnH, "modact-del",
|
||||
DELETE_LABEL(armed), {
|
||||
@@ -2715,6 +2854,109 @@ local function buildModActionsModal(imp, m)
|
||||
action = function() imp._modActions = nil end })
|
||||
end
|
||||
|
||||
-- Imported files declared by one installed mod. The engine picks, validates,
|
||||
-- canonicalizes and copies; this surface never exposes a host path to mod code.
|
||||
local function buildRequiredImportsModal(imp, m)
|
||||
local mod
|
||||
for _, candidate in ipairs(imp.mods or {}) do
|
||||
if candidate.id == imp._modImports then mod = candidate break end
|
||||
end
|
||||
if not mod then imp._modImports = nil return end
|
||||
local imports = mod.imports or mod.requiredImports or {}
|
||||
local pad, gap = math.floor(18 * m.s), math.floor(8 * m.s)
|
||||
local w = math.floor(540 * m.s)
|
||||
local notice = imp.requiredImportNotice
|
||||
if not notice or notice.modId ~= mod.id then notice = nil end
|
||||
local noticeText
|
||||
if notice then
|
||||
local importName = notice.importId
|
||||
for _, row in ipairs(imports) do
|
||||
if row.id == notice.importId then importName = row.name break end
|
||||
end
|
||||
noticeText = Strings("%s rejected: %s", importName, notice.text)
|
||||
end
|
||||
local noticeW = w - 2 * pad
|
||||
local noticeH = noticeText and Kit.wrapHeight("small", noticeText, noticeW, 2) or 0
|
||||
local rowH = math.max(math.floor(70 * m.s), m.btnH)
|
||||
local perPage = math.min(4, math.max(1, #imports))
|
||||
local pagerH = #imports > perPage and math.max(Kit.tapMin(), math.floor(30 * m.s)) or 0
|
||||
local h = pad + Kit.textHeight("button") + math.floor(4 * m.s)
|
||||
+ Kit.textHeight("small") + math.floor(12 * m.s)
|
||||
+ noticeH + (noticeH > 0 and gap or 0)
|
||||
+ perPage * rowH + math.max(0, perPage - 1) * gap
|
||||
+ (pagerH > 0 and (gap + pagerH) or 0) + gap + m.btnH + pad
|
||||
local px, py, pw = modalPanel(m, w, h)
|
||||
local cy = py + pad
|
||||
Kit.text("button", Kit.ellipsize("button", mod.name, pw - 2 * pad),
|
||||
px + pad, cy, PAL.heading)
|
||||
cy = cy + Kit.textHeight("button") + math.floor(4 * m.s)
|
||||
Kit.text("small", Strings("User-supplied files are validated by MD5 and copied into this mod only."),
|
||||
px + pad, cy, PAL.muted)
|
||||
cy = cy + Kit.textHeight("small") + math.floor(12 * m.s)
|
||||
if noticeText then
|
||||
cy = cy + Kit.textWrapped("small", noticeText, px + pad, cy,
|
||||
pw - 2 * pad, PAL.red, 2) + gap
|
||||
end
|
||||
|
||||
local pageKey = "required-imports-" .. mod.id
|
||||
local cur = page(imp, pageKey)
|
||||
local first, last, bounded = Kit.pageBounds(cur, #imports, perPage)
|
||||
setPage(imp, pageKey, bounded)
|
||||
for i = first, last do
|
||||
local row = imports[i]
|
||||
local importId = row.id
|
||||
Kit.card(px + pad, cy, pw - 2 * pad, rowH, row.present and "muted" or false)
|
||||
local innerX = px + pad + math.floor(12 * m.s)
|
||||
local actionW = math.floor(108 * m.s)
|
||||
local removeW = row.present and math.floor(86 * m.s) or 0
|
||||
local actionX = px + pw - pad - math.floor(10 * m.s) - actionW
|
||||
if removeW > 0 then actionX = actionX - removeW - math.floor(6 * m.s) end
|
||||
local textW = actionX - innerX - math.floor(8 * m.s)
|
||||
Kit.text("small", Kit.ellipsize("small", row.name, textW), innerX,
|
||||
cy + math.floor(8 * m.s), PAL.heading)
|
||||
local stateY = cy + math.floor(8 * m.s) + Kit.textHeight("small")
|
||||
+ math.floor(3 * m.s)
|
||||
if row.description and row.description ~= "" then
|
||||
Kit.text("micro", Kit.ellipsize("micro", row.description, textW),
|
||||
innerX, stateY, PAL.muted)
|
||||
stateY = stateY + Kit.textHeight("micro") + math.floor(2 * m.s)
|
||||
end
|
||||
local state = row.present and Strings("Ready - %s", row.file)
|
||||
or (row.error and Strings("Invalid file - choose again")
|
||||
or (row.required and Strings("Required - %s", row.file)
|
||||
or Strings("Optional - %s", row.file)))
|
||||
Kit.text("micro", Kit.ellipsize("micro", state, textW), innerX, stateY,
|
||||
row.present and PAL.green or (row.required and PAL.yellow or PAL.muted))
|
||||
btn(imp, actionX, cy + (rowH - m.btnH) / 2, actionW, m.btnH,
|
||||
"req-pick-" .. mod.id .. "-" .. importId,
|
||||
row.present and Strings("Replace") or Strings("Choose file"), {
|
||||
kind = row.present and "ghost" or "accent", font = "small",
|
||||
action = function() imp:chooseRequiredImport(mod.id, importId) end })
|
||||
if row.present then
|
||||
local deleteId = mod.id .. ":" .. importId
|
||||
local armed = deleteArmed(imp, "required-import", deleteId, nil)
|
||||
btn(imp, actionX + actionW + math.floor(6 * m.s),
|
||||
cy + (rowH - m.btnH) / 2, removeW, m.btnH,
|
||||
"req-remove-" .. mod.id .. "-" .. row.id, DELETE_LABEL(armed), {
|
||||
kind = "danger", font = "small", keepArm = true,
|
||||
action = function()
|
||||
imp:pressDelete("required-import", deleteId, nil, function()
|
||||
imp:_removeRequiredImport(mod.id, importId)
|
||||
end)
|
||||
end })
|
||||
end
|
||||
cy = cy + rowH + gap
|
||||
end
|
||||
if pagerH > 0 then
|
||||
local newPage = Kit.pager(px + pad, cy, pw - 2 * pad, bounded,
|
||||
#imports, perPage, pageKey)
|
||||
setPage(imp, pageKey, newPage)
|
||||
cy = cy + pagerH + gap
|
||||
end
|
||||
btn(imp, px + pad, cy, pw - 2 * pad, m.btnH, "req-close", Strings("Close"), {
|
||||
font = "small", action = function() imp._modImports = nil end })
|
||||
end
|
||||
|
||||
-- Per-mod popup for FIND MODS: the row is a plain click, and Install /
|
||||
-- Details / Source live here instead of crowding every row.
|
||||
local function buildFindEntryModal(imp, m)
|
||||
@@ -3253,8 +3495,10 @@ end
|
||||
local function modalUp(imp)
|
||||
return (imp._settingsText or imp._settings or imp._rename
|
||||
or imp._indexPrompt or imp._modConfirm or imp._modReleaseNotes
|
||||
or imp._appPatchNotes
|
||||
or imp._findDetails or imp._modVersions or imp._modDepResolver or imp._sortPopup
|
||||
or imp._filterPopup or imp._indexManage or imp._modActions
|
||||
or imp._filterPopup or imp._modScopePopup or imp._indexManage
|
||||
or imp._modActions or imp._modImports
|
||||
or imp._modHeaderActionsPopup or imp._profilesPopup or imp._singleProfileActions or imp._profileSavePrompt
|
||||
or imp._profileRenamePrompt or imp._findEntry or imp._gameManage) ~= nil
|
||||
end
|
||||
@@ -3351,6 +3595,20 @@ local function buildModals(imp, m)
|
||||
return true
|
||||
end
|
||||
if imp._modConfirm then buildConfirmModal(imp, m) return true end
|
||||
if imp._appPatchNotes then
|
||||
local PatchNotes = require("src.update.PatchNotes")
|
||||
local ModUpdate = require("src.mods.ModUpdate")
|
||||
local raw, ver = PatchNotes.body(imp.Check)
|
||||
local body = ModUpdate.cleanBody(raw or "", 0)
|
||||
if body == "" then body = Strings("(No patch notes.)") end
|
||||
local title = Strings("Patch notes")
|
||||
if ver and ver ~= "" then
|
||||
title = title .. " v" .. tostring(ver)
|
||||
end
|
||||
buildTextModal(imp, m, "patch-notes-modal", title, body,
|
||||
function() imp._appPatchNotes = nil end)
|
||||
return true
|
||||
end
|
||||
if imp._modReleaseNotes then
|
||||
local ModUpdate = require("src.mods.ModUpdate")
|
||||
local n = imp._modReleaseNotes
|
||||
@@ -3372,6 +3630,7 @@ local function buildModals(imp, m)
|
||||
end
|
||||
if imp._modVersions then buildVersionsModal(imp, m) return true end
|
||||
if imp._modDepResolver then buildDepResolverModal(imp, m) return true end
|
||||
if imp._modImports then buildRequiredImportsModal(imp, m) return true end
|
||||
-- The lighter popups come after the deep ones on purpose: opening
|
||||
-- Versions or Details from inside an actions popup draws the deeper modal
|
||||
-- while the popup's own state stays set, so closing the deep one drops
|
||||
@@ -3380,6 +3639,7 @@ local function buildModals(imp, m)
|
||||
if imp._profilesPopup then buildProfilesModal(imp, m) return true end
|
||||
if imp._modHeaderActionsPopup then buildModHeaderActionsModal(imp, m) return true end
|
||||
if imp._sortPopup then buildSortModal(imp, m) return true end
|
||||
if imp._modScopePopup then buildModScopeModal(imp, m) return true end
|
||||
if imp._filterPopup then buildFilterModal(imp, m) return true end
|
||||
if imp._indexManage then buildIndexesModal(imp, m) return true end
|
||||
if imp._modActions then buildModActionsModal(imp, m) return true end
|
||||
|
||||
+259
-6
@@ -344,6 +344,14 @@ local function readExternalPath(path)
|
||||
return data
|
||||
end
|
||||
|
||||
local function externalFileSize(path)
|
||||
local file = io.open(path, "rb")
|
||||
if not file then return nil end
|
||||
local size = file:seek("end")
|
||||
file:close()
|
||||
return size
|
||||
end
|
||||
|
||||
local function readDroppedFile(file)
|
||||
local ok, openError = file:open("r")
|
||||
if not ok then return nil, openError end
|
||||
@@ -981,6 +989,25 @@ local function findPendingSav(preferAny, skip)
|
||||
return nil
|
||||
end
|
||||
|
||||
local function pickerHasKind(kind)
|
||||
local fn = love.system.pickFileKinds
|
||||
if type(fn) ~= "function" then return false end
|
||||
local ok, kinds = pcall(fn)
|
||||
if not ok or type(kinds) ~= "string" then return false end
|
||||
for token in kinds:gmatch("[^,%s]+") do
|
||||
if token == kind then return true end
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
local function findPendingRequiredImport()
|
||||
local names = { "picked_required_import.bin", "picked_stadium.z64" }
|
||||
for _, name in ipairs(names) do
|
||||
if love.filesystem.getInfo(name, "file") then return name end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
-- Retire an Android pick once it has been through the installer / importer,
|
||||
-- whether or not it worked: a pick left on disk wins the scans above forever,
|
||||
-- so the next tap re-runs the same failing file and the picker never reopens
|
||||
@@ -1112,6 +1139,39 @@ local function chooseSav()
|
||||
return nil
|
||||
end
|
||||
|
||||
-- Generic user-supplied dependency picker. Keep the native prompt entirely
|
||||
-- engine-owned: manifest labels are untrusted and must never enter shell
|
||||
-- command templates. The LÖVE modal already shows the specific import name.
|
||||
local function chooseRequiredFile()
|
||||
local prompt = shellSafe(Strings("Choose required mod file"))
|
||||
local platform = love.system.getOS()
|
||||
if platform == "OS X" then
|
||||
return commandOutput(
|
||||
([[osascript -e 'POSIX path of (choose file with prompt "%s")' 2>/dev/null]])
|
||||
:format(prompt))
|
||||
elseif platform == "Windows" then
|
||||
local script = table.concat({
|
||||
"Add-Type -AssemblyName System.Windows.Forms;",
|
||||
"$d=New-Object System.Windows.Forms.OpenFileDialog;",
|
||||
"$d.Title='" .. prompt .. "';",
|
||||
"$d.Filter='All files (*.*)|*.*';",
|
||||
"if($d.ShowDialog() -eq 'OK'){",
|
||||
"$t=Join-Path $env:TEMP 'pokeport_required_import.bin';",
|
||||
"Copy-Item -LiteralPath $d.FileName -Destination $t -Force;",
|
||||
"[Console]::OutputEncoding=[Text.Encoding]::UTF8;",
|
||||
"[Console]::Write($t)}",
|
||||
})
|
||||
return commandOutput(
|
||||
'powershell -NoProfile -STA -Command "' .. script .. '"')
|
||||
elseif platform == "Linux" then
|
||||
local path = commandOutput(
|
||||
([[zenity --file-selection --title="%s" 2>/dev/null]]):format(prompt))
|
||||
if path then return path end
|
||||
return commandOutput([[kdialog --getopenfilename "$HOME" 2>/dev/null]])
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
-- The self-updater only surfaces on the real distributed build: a fused,
|
||||
-- interactive launcher with no scripted-run override. A dev / source checkout
|
||||
-- (unfused, where Boot.run already no-ops) or an autopilot / driver /
|
||||
@@ -1230,7 +1290,9 @@ function RomImporter.new(onComplete, opts)
|
||||
-- (refreshed lazily on first draw and after any toggle/install/delete);
|
||||
-- modScroll is the current paged list's inner scroll offset (px, clamped
|
||||
-- in draw); modNotice is the last install/delete result { ok, text }.
|
||||
mods = nil, modScroll = 0, modNotice = nil,
|
||||
-- requiredImportNotice stays inside the imported-files modal so validation
|
||||
-- failures are visible beside the file picker that caused them.
|
||||
mods = nil, modScroll = 0, modNotice = nil, requiredImportNotice = nil,
|
||||
-- Which game the MODS panel is answering for (a GameVersion id, nil =
|
||||
-- every game). Rows resolve their enable-state and their "runs here"
|
||||
-- verdict against it (src/mods/ModTargets.lua).
|
||||
@@ -1420,7 +1482,13 @@ function RomImporter:focus(f)
|
||||
local text = "Could not read the picked file. Reopen the picker and choose "
|
||||
.. "it with the Files (Documents) app, or copy it into: "
|
||||
.. love.filesystem.getSaveDirectory()
|
||||
if pickError:find("picked_mod", 1, true) then
|
||||
if pickError:find("picked_required_import", 1, true)
|
||||
or pickError:find("picked_stadium", 1, true) then
|
||||
self.modNotice = { ok = false, text = text }
|
||||
self.pickerPendingKind = nil
|
||||
self.pickerPendingModId = nil
|
||||
self.pickerPendingImportId = nil
|
||||
elseif pickError:find("picked_mod", 1, true) then
|
||||
self.modNotice = { ok = false, text = text }
|
||||
elseif pickError:find("picked_save", 1, true) then
|
||||
local version = self.androidPendingVersion or self:_savedropTarget()
|
||||
@@ -1431,6 +1499,20 @@ function RomImporter:focus(f)
|
||||
end
|
||||
return
|
||||
end
|
||||
local requiredName = findPendingRequiredImport()
|
||||
if requiredName then
|
||||
local modId, importId = self.pickerPendingModId, self.pickerPendingImportId
|
||||
self.pickerPendingKind = nil
|
||||
self.pickerPendingModId, self.pickerPendingImportId = nil, nil
|
||||
local imported = modId and importId
|
||||
and self:_importRequiredSource(modId, importId, requiredName)
|
||||
consumePick(self, requiredName, requiredName, imported)
|
||||
if not modId or not importId then
|
||||
self.modNotice = { ok = false,
|
||||
text = "A picked dependency file had no pending mod request and was discarded." }
|
||||
end
|
||||
return
|
||||
end
|
||||
local modName = findPendingMod(false, self.pickSkip)
|
||||
if modName then
|
||||
self:_installMod(modName)
|
||||
@@ -1738,6 +1820,152 @@ function RomImporter:chooseMod()
|
||||
if path then self:_installMod(path) end
|
||||
end
|
||||
|
||||
local function requiredManifest(self, modId)
|
||||
for _, row in ipairs(self.mods or {}) do
|
||||
if row.id == modId then return row.manifest, row end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
local function requiredSpec(manifest, importId)
|
||||
for _, candidate in ipairs(require("src.mods.RequiredImports").specs(manifest)) do
|
||||
if candidate.id == importId then return candidate end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
local function requiredImportNotice(self, modId, importId, text)
|
||||
self.requiredImportNotice = {
|
||||
modId = modId,
|
||||
importId = importId,
|
||||
text = tostring(text),
|
||||
}
|
||||
end
|
||||
|
||||
function RomImporter:_importRequiredData(modId, importId, data)
|
||||
local manifest = requiredManifest(self, modId)
|
||||
if not manifest then
|
||||
self.modNotice = { ok = false, text = "Required import failed: mod not found." }
|
||||
return nil
|
||||
end
|
||||
local ok, result = require("src.mods.RequiredImports")
|
||||
.importData(manifest, importId, data)
|
||||
if ok then
|
||||
self.requiredImportNotice = nil
|
||||
self.modNotice = { ok = true, text = "Imported " .. tostring(importId)
|
||||
.. " for " .. tostring(manifest.name or manifest.id) .. "." }
|
||||
self:_refreshMods()
|
||||
return true
|
||||
end
|
||||
-- Keep validation feedback on the imported-files page. A general Mods-page
|
||||
-- notice is hidden by this modal and made MD5 failures especially easy to miss.
|
||||
requiredImportNotice(self, modId, importId, result)
|
||||
self.modNotice = nil
|
||||
return nil
|
||||
end
|
||||
|
||||
function RomImporter:_importRequiredSource(modId, importId, source)
|
||||
local manifest = requiredManifest(self, modId)
|
||||
local spec = manifest and requiredSpec(manifest, importId)
|
||||
if not spec then
|
||||
requiredImportNotice(self, modId, importId, "Import declaration was not found.")
|
||||
self.modNotice = nil
|
||||
return nil
|
||||
end
|
||||
local info = love.filesystem.getInfo(source, "file")
|
||||
local size = info and info.size or externalFileSize(source)
|
||||
local sizeErr = require("src.mods.RequiredImports").sizeError(spec, size, false)
|
||||
if sizeErr then
|
||||
requiredImportNotice(self, modId, importId, sizeErr)
|
||||
self.modNotice = nil
|
||||
return nil
|
||||
end
|
||||
local data = love.filesystem.read(source)
|
||||
if not data then data = readExternalPath(source) end
|
||||
if not data then
|
||||
requiredImportNotice(self, modId, importId, "Could not read the selected file.")
|
||||
self.modNotice = nil
|
||||
return nil
|
||||
end
|
||||
return self:_importRequiredData(modId, importId, data)
|
||||
end
|
||||
|
||||
function RomImporter:_removeRequiredImport(modId, importId)
|
||||
local manifest = requiredManifest(self, modId)
|
||||
if not manifest then return end
|
||||
local ok, err = require("src.mods.RequiredImports").remove(manifest, importId)
|
||||
if ok then
|
||||
self.requiredImportNotice = nil
|
||||
self.modNotice = { ok = true, text = "Deleted " .. tostring(importId) .. "." }
|
||||
self:_refreshMods()
|
||||
else
|
||||
requiredImportNotice(self, modId, importId, err)
|
||||
self.modNotice = nil
|
||||
end
|
||||
end
|
||||
|
||||
-- Select and validate one manifest-declared file. NX has no host picker, so
|
||||
-- its equivalent is an engine-owned imports/baseroms inbox that can be filled
|
||||
-- over MTP; every other native/mobile picker lands on the same validation path.
|
||||
function RomImporter:chooseRequiredImport(modId, importId)
|
||||
if self.workState == "working" then return end
|
||||
local manifest = requiredManifest(self, modId)
|
||||
if not manifest then return end
|
||||
local spec = requiredSpec(manifest, importId)
|
||||
if not spec then return end
|
||||
|
||||
if self.isNX then
|
||||
local inbox = "imports/baseroms"
|
||||
love.filesystem.createDirectory(inbox)
|
||||
local lastError
|
||||
for _, name in ipairs(love.filesystem.getDirectoryItems(inbox) or {}) do
|
||||
if name:sub(1, 1) ~= "." then
|
||||
local path = inbox .. "/" .. name
|
||||
local info = love.filesystem.getInfo(path, "file")
|
||||
local sizeErr = info and require("src.mods.RequiredImports")
|
||||
.sizeError(spec, info.size, false)
|
||||
local data = not sizeErr and love.filesystem.read(path) or nil
|
||||
if data and self:_importRequiredData(modId, importId, data) then return end
|
||||
if sizeErr then lastError = sizeErr
|
||||
elseif self.requiredImportNotice
|
||||
and self.requiredImportNotice.modId == modId
|
||||
and self.requiredImportNotice.importId == importId then
|
||||
lastError = self.requiredImportNotice.text
|
||||
end
|
||||
end
|
||||
end
|
||||
requiredImportNotice(self, modId, importId, lastError
|
||||
or "No matching file in imports/baseroms/. Copy it there over MTP, then try again.")
|
||||
self.modNotice = nil
|
||||
return
|
||||
end
|
||||
if self.nativePicker then
|
||||
if self.mobileFileBridge and not pickerHasKind("required_import") then
|
||||
requiredImportNotice(self, modId, importId,
|
||||
"This app build cannot pick required mod files yet. Update the app and try again.")
|
||||
self.modNotice = nil
|
||||
return
|
||||
end
|
||||
self.pickerPendingKind = "required_import"
|
||||
self.pickerPendingModId = modId
|
||||
self.pickerPendingImportId = importId
|
||||
if not pickFile("required_import") then
|
||||
self.pickerPendingKind = nil
|
||||
self.pickerPendingModId = nil
|
||||
self.pickerPendingImportId = nil
|
||||
requiredImportNotice(self, modId, importId, "Could not open the file picker.")
|
||||
self.modNotice = nil
|
||||
elseif self.android then
|
||||
self.pickPending = true
|
||||
self.pickTimer = 0
|
||||
end
|
||||
return
|
||||
end
|
||||
|
||||
local path = chooseRequiredFile()
|
||||
if path then self:_importRequiredSource(modId, importId, path) end
|
||||
end
|
||||
|
||||
-- Which game a dropped .sav imports into: a .sav has no version signature of
|
||||
-- its own, so it lands on the active game tab. When a non-game tab (mods) is
|
||||
-- showing, default to red -- the always-present first game -- rather than
|
||||
@@ -2045,7 +2273,8 @@ function RomImporter:_pollPickedFiles(dt)
|
||||
if not found then
|
||||
for _, name in ipairs(love.filesystem.getDirectoryItems("")) do
|
||||
local n = name:lower()
|
||||
if n:match("%.gbc?$") or n == "picked_mod.zip" or n == "picked_save.sav" then
|
||||
if n:match("%.gbc?$") or n == "picked_mod.zip" or n == "picked_save.sav"
|
||||
or n == "picked_required_import.bin" or n == "picked_stadium.z64" then
|
||||
found = true
|
||||
break
|
||||
end
|
||||
@@ -2174,7 +2403,12 @@ function RomImporter:update(dt)
|
||||
local version = self.pickerPendingVersion
|
||||
self.pickerPendingKind = nil
|
||||
self.pickerPendingVersion = nil
|
||||
if kind == "mod" then
|
||||
if kind == "required_import" then
|
||||
local modId, importId = self.pickerPendingModId, self.pickerPendingImportId
|
||||
self.pickerPendingModId, self.pickerPendingImportId = nil, nil
|
||||
if modId and importId then self:_importRequiredSource(modId, importId, path) end
|
||||
if Platform.isUWP() then os.remove(path) end
|
||||
elseif kind == "mod" then
|
||||
self:_installMod(path)
|
||||
if Platform.isUWP() and self.modNotice and self.modNotice.ok then
|
||||
os.remove(path)
|
||||
@@ -2196,7 +2430,10 @@ function RomImporter:update(dt)
|
||||
local version = self.pickerPendingVersion or self:_savedropTarget()
|
||||
self.pickerPendingKind = nil
|
||||
self.pickerPendingVersion = nil
|
||||
if kind == "mod" then
|
||||
if kind == "required_import" then
|
||||
self.modNotice = { ok = false, text = errorText }
|
||||
self.pickerPendingModId, self.pickerPendingImportId = nil, nil
|
||||
elseif kind == "mod" then
|
||||
self.modNotice = { ok = false, text = errorText }
|
||||
elseif kind == "sav" then
|
||||
self.saveNotice[version] = { ok = false, text = errorText }
|
||||
@@ -2356,6 +2593,20 @@ function RomImporter:_updatePadCursor(dt)
|
||||
local ny = self._padCursor.y + dy * speed * dt
|
||||
self._padCursor.x = math.max(ox, math.min(ox + w, nx))
|
||||
self._padCursor.y = math.max(oy, math.min(oy + h, ny))
|
||||
-- Pushing INTO the top/bottom edge scrolls the page instead of stalling.
|
||||
-- The cursor is clamped to the safe area above, so on a short window the
|
||||
-- rows below the fold are unreachable on a stickless handheld: no mouse
|
||||
-- wheel, no touchscreen, and no right stick to feed the existing wheel
|
||||
-- path. Only the OVERSHOOT scrolls -- parking the cursor at the edge does
|
||||
-- nothing, it has to be actively pushed -- and this block only runs on pad
|
||||
-- input, so a real mouse is unaffected. /48 matches the pixels-per-notch
|
||||
-- LauncherView.draw multiplies back out.
|
||||
local overY = 0
|
||||
if ny > oy + h then overY = ny - (oy + h)
|
||||
elseif ny < oy then overY = ny - oy end
|
||||
if overY ~= 0 and self._flex then
|
||||
require("src.import.LauncherView").wheelmoved(self, 0, -overY / 48)
|
||||
end
|
||||
-- Desktop: FlexLove polls the real mouse, so warp it with the pad pointer.
|
||||
-- NX: the getPosition bridge already returns pad coords — skip setPosition.
|
||||
if not self.isNX and love.mouse.setPosition then
|
||||
@@ -2806,7 +3057,7 @@ function RomImporter:keypressed(key)
|
||||
return
|
||||
end
|
||||
if self._modConfirm or self._modVersions or self._modReleaseNotes
|
||||
or self._findDetails then
|
||||
or self._findDetails or self._appPatchNotes then
|
||||
-- Focus navigation belongs to the visible modal as well as the launcher
|
||||
-- beneath it. Route arrows and an already-armed confirm before this guard
|
||||
-- returns; unarmed Enter still falls through to the modal guard. Keep this
|
||||
@@ -2819,6 +3070,8 @@ function RomImporter:keypressed(key)
|
||||
self._findDetails = nil
|
||||
elseif self._modReleaseNotes then
|
||||
self._modReleaseNotes = nil
|
||||
elseif self._appPatchNotes then
|
||||
self._appPatchNotes = nil
|
||||
else
|
||||
self._modConfirm = nil
|
||||
self._modVersions = nil
|
||||
|
||||
@@ -737,7 +737,8 @@ COVERAGE["src.pokemon.Boxes"] = {
|
||||
COVERAGE["src.world.WorldAPI"] = {
|
||||
kind = "alias", target = "src.world.gen2.WorldAPI",
|
||||
backed = "new __index overworld current mapOverview warpTo toggleObject replaceBlock "
|
||||
.. "spawnNpc removeNpc npc queueScript invalidateMap",
|
||||
.. "spawnNpc removeNpc npc queueScript invalidateMap "
|
||||
.. "availableFieldActions useFieldAction",
|
||||
warned = "setFlag getFlag",
|
||||
absent = "",
|
||||
notes = {
|
||||
@@ -1797,7 +1798,8 @@ local function buildBattleState()
|
||||
"throwBall", "ballChain", "tossAnimFor", "ballFlicker", "ballMissMessage",
|
||||
"storeCaughtMon", "safariAction", "safariEnemyTurn", "drawBallRow",
|
||||
"drawClassic", "isWideBattleLayout", "wideLayout", "bgMode", "uiSize",
|
||||
"sgbPalettes", "trainerPalette", "trainerPicPath", "invalidate",
|
||||
"sgbPalettes", "trainerPalette", "trainerPicPath", "trainerTrueColor",
|
||||
"trainerSprite", "invalidate",
|
||||
"imageBattleScale", "resolveBattleScale", "backPlacement",
|
||||
"frontPlacement", "StatBox", "enter", "exit",
|
||||
}) do
|
||||
@@ -1884,7 +1886,8 @@ COVERAGE["src.battle.BattleState"] = {
|
||||
absent = "newWild newTrainer makeSafari makeGhost makeBattler resolveTurn "
|
||||
.. "computeDamage catchAttempt runRoll enter exit sgbPalettes "
|
||||
.. "isWideBattleLayout wideLayout bgMode uiSize letterboxWhite "
|
||||
.. "holdsUIAnchors BG_WORLD_DIM trainerPalette trainerPicPath invalidate "
|
||||
.. "holdsUIAnchors BG_WORLD_DIM trainerPalette trainerPicPath "
|
||||
.. "trainerTrueColor trainerSprite invalidate "
|
||||
.. "backPlacement frontPlacement StatBox drawClassic drawBallRow "
|
||||
.. "safariAction safariEnemyTurn throwBall storeCaughtMon field ruleset "
|
||||
.. "rng oppClass partyIndex aiUses introText dead",
|
||||
|
||||
@@ -38,6 +38,7 @@ local Version = require("src.core.Version")
|
||||
local SaveData = require("src.core.SaveData")
|
||||
local GameVersion = require("src.core.GameVersion")
|
||||
local CacheFs = require("src.import.CacheFs")
|
||||
local RequiredImports = require("src.mods.RequiredImports")
|
||||
|
||||
local LauncherMods = {}
|
||||
|
||||
@@ -459,13 +460,38 @@ function LauncherMods.list(version)
|
||||
local ok, result = pcall(function()
|
||||
local options = SaveData.loadOptions()
|
||||
local manifests = discover()
|
||||
-- Imports are private player grants. Never scan or copy another mod's
|
||||
-- baseroms: a matching public digest is not permission to share the file.
|
||||
local importState = {}
|
||||
for _, manifest in ipairs(manifests) do
|
||||
local rows, missing, missingOptional = RequiredImports.inspect(manifest)
|
||||
importState[manifest.id] = { rows = rows, missing = missing,
|
||||
missingOptional = missingOptional }
|
||||
end
|
||||
-- The first build containing game-specific switches turns the old shared
|
||||
-- state into one explicit answer per installed mod and game. Saving here
|
||||
-- means users who only visit the launcher still receive the migration.
|
||||
if SaveData.migrateModEnablement(options, manifests) then
|
||||
SaveData.saveOptions(options)
|
||||
end
|
||||
return LauncherMods.deriveList(manifests, options, version)
|
||||
local rows = LauncherMods.deriveList(manifests, options, version)
|
||||
for _, row in ipairs(rows) do
|
||||
local state = importState[row.id]
|
||||
or { rows = {}, missing = 0, missingOptional = 0 }
|
||||
local imports, missing = state.rows, state.missing
|
||||
row.requiredImports, row.missingRequiredImports = imports, missing
|
||||
row.imports = imports
|
||||
row.missingOptionalImports = state.missingOptional or 0
|
||||
if missing > 0 and row.status == "ok" then
|
||||
row.status = "needs_import"
|
||||
local first
|
||||
for _, import in ipairs(imports) do
|
||||
if not import.present then first = import break end
|
||||
end
|
||||
row.statusDetail = "Needs import: " .. (first and first.name or "required file")
|
||||
end
|
||||
end
|
||||
return rows
|
||||
end)
|
||||
if not ok then
|
||||
-- a single bad options/mod file must not blank the launcher
|
||||
@@ -677,6 +703,26 @@ local function copyTree(src, dst)
|
||||
return true
|
||||
end
|
||||
|
||||
-- User-supplied baseroms are install state, not package content. Snapshot
|
||||
-- them before replacing a mod tree so an update cannot make the player select
|
||||
-- the same cartridge again (or destroy their only reusable copy if the new
|
||||
-- archive later fails to copy).
|
||||
local function snapshotTree(path, into, relative)
|
||||
local fs = love.filesystem
|
||||
into, relative = into or {}, relative or ""
|
||||
local info = fs.getInfo(path)
|
||||
if not info then return into end
|
||||
if info.type == "directory" then
|
||||
for _, name in ipairs(fs.getDirectoryItems(path) or {}) do
|
||||
local rel = relative == "" and name or (relative .. "/" .. name)
|
||||
snapshotTree(path .. "/" .. name, into, rel)
|
||||
end
|
||||
elseif relative ~= "" and into[relative] == nil then
|
||||
into[relative] = fs.read(path)
|
||||
end
|
||||
return into
|
||||
end
|
||||
|
||||
-- Delete an installed mod subtree. Enumeration stays on love.filesystem (the
|
||||
-- portable game folder is on its read path), but the deletes go through
|
||||
-- CacheFs so a portable install's real files actually go away instead of
|
||||
@@ -919,14 +965,28 @@ function LauncherMods._installZipInner(source, opts)
|
||||
return nil, ("zip is for '%s', expected '%s'")
|
||||
:format(manifest.id, opts.expectId)
|
||||
end
|
||||
local packagedBaseroms = root .. "/baseroms"
|
||||
if fs.getInfo(packagedBaseroms, "directory")
|
||||
and #(fs.getDirectoryItems(packagedBaseroms) or {}) > 0 then
|
||||
cleanup()
|
||||
return nil, "mod archives must not include user-supplied baseroms/ files"
|
||||
end
|
||||
|
||||
local dest = "mods/" .. manifest.id
|
||||
local baseromRecovery = "imports/baseroms-recovery/" .. manifest.id
|
||||
local existing, installedSomewhere = sameIdTrees(fs, manifest.id)
|
||||
if installedSomewhere and not opts.replace then
|
||||
cleanup()
|
||||
return nil, "a mod named '" .. manifest.id .. "' is already installed"
|
||||
end
|
||||
local preservedBaseroms = {}
|
||||
-- A previous failed update may have staged the user's files outside mods/ so
|
||||
-- discovery cannot mistake recovery debris for an installed mod.
|
||||
snapshotTree(baseromRecovery, preservedBaseroms)
|
||||
if #existing > 0 then
|
||||
for _, path in ipairs(existing) do
|
||||
snapshotTree(path .. "/baseroms", preservedBaseroms)
|
||||
end
|
||||
-- drop every old tree before copy -- mods/<id> and any same-id folder
|
||||
-- under another name, or the survivor keeps winning discover()'s
|
||||
-- first-id-wins race after the "successful" update (#801). A tree with
|
||||
@@ -950,6 +1010,28 @@ function LauncherMods._installZipInner(source, opts)
|
||||
CacheFs.prefix = ""
|
||||
local copied, copyErr = copyTree(root, dest)
|
||||
if not copied then removeTree(dest) end
|
||||
local preserveErr
|
||||
for rel, bytes in pairs(preservedBaseroms) do
|
||||
if bytes ~= nil then
|
||||
local restored, restoreErr = CacheFs.write(dest .. "/baseroms/" .. rel, bytes)
|
||||
if not restored and not preserveErr then
|
||||
preserveErr = "could not preserve baseroms/" .. rel .. ": "
|
||||
.. tostring(restoreErr)
|
||||
end
|
||||
end
|
||||
end
|
||||
if preserveErr then
|
||||
-- Do not report a successful update that discarded user-owned input, and
|
||||
-- do not leave a manifest-less baseroms tree that resembles an install.
|
||||
removeTree(dest)
|
||||
removeTree(baseromRecovery)
|
||||
for rel, bytes in pairs(preservedBaseroms) do
|
||||
if bytes ~= nil then CacheFs.write(baseromRecovery .. "/" .. rel, bytes) end
|
||||
end
|
||||
copied, copyErr = nil, preserveErr
|
||||
elseif copied then
|
||||
removeTree(baseromRecovery)
|
||||
end
|
||||
CacheFs.prefix = savedPrefix
|
||||
if not copied then
|
||||
cleanup()
|
||||
|
||||
+62
-2
@@ -4,6 +4,7 @@ local SaveData = require("src.core.SaveData")
|
||||
local Data = require("src.core.Data")
|
||||
local GameVersion = require("src.core.GameVersion")
|
||||
local Version = require("src.core.Version")
|
||||
local RequiredImports = require("src.mods.RequiredImports")
|
||||
local Assets = require("src.render.Assets")
|
||||
local ModUI = require("src.ui.ModUI")
|
||||
local DateTime = require("src.core.DateTime")
|
||||
@@ -565,7 +566,24 @@ function Loader:_validate()
|
||||
elseif manifest.assets_transforms
|
||||
and not self:_exists(mod.path .. "/" .. manifest.assets_transforms) then
|
||||
reason = "assets_transforms file missing: " .. manifest.assets_transforms
|
||||
elseif manifest.game_version and not devEngine() then
|
||||
end
|
||||
if not reason and #(manifest.required_imports or {}) > 0 then
|
||||
for _, import in ipairs(manifest.required_imports) do
|
||||
local path = mod.path .. "/baseroms/" .. import.file
|
||||
if not self:_exists(path) then
|
||||
reason = "required import missing: " .. import.name
|
||||
break
|
||||
end
|
||||
local valid, importErr = RequiredImports.validateStored(
|
||||
manifest, import, self.fs)
|
||||
if not valid then
|
||||
reason = "required import invalid: " .. import.name
|
||||
.. " (" .. tostring(importErr) .. ")"
|
||||
break
|
||||
end
|
||||
end
|
||||
end
|
||||
if not reason and manifest.game_version and not devEngine() then
|
||||
local ok, err = Semver.satisfies(Version.engine, manifest.game_version)
|
||||
if not ok then
|
||||
reason = ("needs game version %s, engine is %s")
|
||||
@@ -1069,7 +1087,8 @@ function Loader:_api(mod)
|
||||
bucket[key] = value
|
||||
end,
|
||||
},
|
||||
-- Data-only state independent of the vanilla progress checkpoint. The
|
||||
-- Data-only and opaque-byte state independent of the vanilla progress
|
||||
-- checkpoint. The
|
||||
-- engine binds version/playthrough/mod scope and portable persistence;
|
||||
-- callers never receive paths or a raw filesystem handle.
|
||||
storage = {
|
||||
@@ -1077,6 +1096,10 @@ function Loader:_api(mod)
|
||||
selected = function(_, game) return storage:selected(game) end,
|
||||
write = function(_, game, key, value) return storage:write(game, key, value) end,
|
||||
read = function(_, game, key) return storage:read(game, key) end,
|
||||
writeBytes = function(_, game, key, bytes)
|
||||
return storage:writeBytes(game, key, bytes)
|
||||
end,
|
||||
readBytes = function(_, game, key) return storage:readBytes(game, key) end,
|
||||
list = function(_, game, prefix) return storage:list(game, prefix) end,
|
||||
delete = function(_, game, key) return storage:delete(game, key) end,
|
||||
},
|
||||
@@ -1158,6 +1181,39 @@ function Loader:_api(mod)
|
||||
api.content[alias] = self:_contentApi(mod, self.content[canonical],
|
||||
("the %s registry is deprecated; use %s"):format(alias, canonical))
|
||||
end
|
||||
-- A relative path inside this mod, or the mod root when relative is
|
||||
-- omitted. Empty is the one listing case SafePath.safe rejects on
|
||||
-- purpose (it is not a file), so it is special-cased here.
|
||||
local function ownPath(relative, what)
|
||||
if relative == nil or relative == "" then return mod.path end
|
||||
return SafePath.join(mod.path, relative, what)
|
||||
end
|
||||
|
||||
-- Shallow directory listing, the sandboxed stand-in for
|
||||
-- love.filesystem.getDirectoryItems. Names only, sorted, never a host
|
||||
-- path. A missing directory is an empty list, not an error.
|
||||
local function listOwn(_, relative)
|
||||
local dir = ownPath(relative, "mod:list")
|
||||
local fs = loader.fs
|
||||
if not (fs and fs.getDirectoryItems) then return {} end
|
||||
local items = fs.getDirectoryItems(dir) or {}
|
||||
local out = {}
|
||||
for i = 1, #items do out[i] = items[i] end
|
||||
table.sort(out)
|
||||
return out
|
||||
end
|
||||
|
||||
-- love.filesystem.getInfo for a path inside this mod. type is "file" or
|
||||
-- "directory"; size is set for files. nil when the path does not exist.
|
||||
local function infoOwn(_, relative)
|
||||
local path = ownPath(relative, "mod:info")
|
||||
local fs = loader.fs
|
||||
if not (fs and fs.getInfo) then return nil end
|
||||
local info = fs.getInfo(path)
|
||||
if not info then return nil end
|
||||
return { type = info.type, size = info.size }
|
||||
end
|
||||
|
||||
-- assets keeps the v1 alias to the content accessors and adds the file
|
||||
-- helpers on top, so mod.assets.pokemon and mod.assets:image both resolve
|
||||
api.assets = setmetatable({
|
||||
@@ -1174,12 +1230,16 @@ function Loader:_api(mod)
|
||||
loader.imageCache[full] = image
|
||||
return image
|
||||
end,
|
||||
list = listOwn,
|
||||
info = infoOwn,
|
||||
}, { __index = api.content })
|
||||
-- the mod's own directory and nothing above it: PhysFS already refuses a
|
||||
-- climb, but loader.fs is injectable and has no such floor
|
||||
function api:read(relative)
|
||||
return loader.fs.read(SafePath.join(self.path, relative, "mod:read"))
|
||||
end
|
||||
api.list = listOwn
|
||||
api.info = infoOwn
|
||||
-- mod.world materializes on first touch, like the image helper above: a
|
||||
-- headless load must not drag the world stack in, and the Game the facade
|
||||
-- acts on is still being wired when the entry chunk runs
|
||||
|
||||
+100
-1
@@ -132,13 +132,97 @@ local function mergeConflictLists(conflicts, incompatible)
|
||||
return out
|
||||
end
|
||||
|
||||
local scrubUtf8 -- shared by required-import labels and top-level strings
|
||||
|
||||
-- User-supplied files a mod needs beside its own source. The launcher owns
|
||||
-- the picker and the copy; the mod only reads the resulting
|
||||
-- <mod>/baseroms/<file> through its existing scoped mod:read surface. MD5 is
|
||||
-- deliberately the manifest vocabulary here: ROM preservation databases and
|
||||
-- the mods consuming these files commonly identify dumps by MD5, and the
|
||||
-- digest is an identity check rather than a security boundary.
|
||||
local function parseImports(value, field, required)
|
||||
field = field or "required_imports"
|
||||
local out, ids, files = {}, {}, {}
|
||||
for _, entry in ipairs(array(value)) do
|
||||
assert(type(entry) == "table", field .. " entries must be objects")
|
||||
local id = entry.id
|
||||
assert(type(id) == "string" and id:match("^[%w_%-]+$"),
|
||||
field .. " id must contain only letters, numbers, _ or -")
|
||||
assert(not ids[id], "duplicate " .. field .. " id: " .. id)
|
||||
ids[id] = true
|
||||
|
||||
local file = entry.file
|
||||
assert(type(file) == "string" and file ~= "",
|
||||
field .. " file is required")
|
||||
file = SafePath.require(file, field .. " file")
|
||||
assert(not file:find("/", 1, true),
|
||||
field .. " file must be a filename inside baseroms")
|
||||
assert(file:sub(1, 1) ~= ".",
|
||||
field .. " file must not use a hidden metadata filename")
|
||||
assert(not files[file], "duplicate " .. field .. " file: " .. file)
|
||||
files[file] = true
|
||||
|
||||
local hashes = entry.md5
|
||||
if type(hashes) == "string" then hashes = { hashes } end
|
||||
assert(type(hashes) == "table" and #hashes > 0,
|
||||
field .. " md5 must be a hash or non-empty array")
|
||||
local accepted, seen = {}, {}
|
||||
for _, digest in ipairs(hashes) do
|
||||
assert(type(digest) == "string" and digest:match("^[%x]+$")
|
||||
and #digest == 32, field .. " md5 values must be 32 hex characters")
|
||||
digest = digest:lower()
|
||||
if not seen[digest] then
|
||||
seen[digest] = true
|
||||
accepted[#accepted + 1] = digest
|
||||
end
|
||||
end
|
||||
|
||||
local format = entry.format or "raw"
|
||||
assert(format == "raw" or format == "n64",
|
||||
field .. " format must be raw or n64")
|
||||
local name = entry.name or id
|
||||
assert(type(name) == "string" and name ~= "",
|
||||
field .. " name must be a non-empty string")
|
||||
local description = entry.description or entry.hint
|
||||
if description ~= nil then
|
||||
assert(type(description) == "string" and description ~= "",
|
||||
field .. " description must be a non-empty string")
|
||||
end
|
||||
local size = entry.size
|
||||
local maxSize = entry.max_size
|
||||
local function validateSize(value, label)
|
||||
if value == nil then return end
|
||||
assert(type(value) == "number" and value > 0 and value % 1 == 0,
|
||||
field .. " " .. label .. " must be a positive integer")
|
||||
assert(value <= 128 * 1024 * 1024,
|
||||
field .. " " .. label .. " exceeds the 128 MiB hard limit")
|
||||
end
|
||||
validateSize(size, "size")
|
||||
validateSize(maxSize, "max_size")
|
||||
assert(not (size and maxSize) or size <= maxSize,
|
||||
field .. " size must not exceed max_size")
|
||||
out[#out + 1] = {
|
||||
id = id,
|
||||
name = scrubUtf8(name),
|
||||
description = scrubUtf8(description),
|
||||
file = file,
|
||||
md5 = accepted,
|
||||
format = format,
|
||||
size = size,
|
||||
max_size = maxSize,
|
||||
required = required ~= false,
|
||||
}
|
||||
end
|
||||
return out
|
||||
end
|
||||
|
||||
-- Drop bytes that are not valid UTF-8 (malformed sequences, overlongs,
|
||||
-- surrogates, > U+10FFFF) and a leading BOM. LÖVE's text renderer raises
|
||||
-- "Invalid UTF-8" from love.graphics.print/printf, so any manifest string a
|
||||
-- panel may draw must be scrubbed here -- the one place every mod manifest
|
||||
-- passes through -- or a single mangled description crashes the whole MODS
|
||||
-- panel instead of misrendering one card.
|
||||
local function scrubUtf8(s)
|
||||
scrubUtf8 = function(s)
|
||||
if type(s) ~= "string" then return s end
|
||||
s = s:gsub("^\239\187\191", "")
|
||||
local out, i, n = {}, 1, #s
|
||||
@@ -290,6 +374,19 @@ function Manifest.validate(raw, path)
|
||||
|
||||
local conflicts = mergeConflictLists(raw.conflicts, raw.incompatible)
|
||||
|
||||
local requiredImports = parseImports(raw.required_imports,
|
||||
"required_imports", true)
|
||||
local optionalImports = parseImports(raw.optional_imports,
|
||||
"optional_imports", false)
|
||||
local importIds, importFiles = {}, {}
|
||||
for _, list in ipairs({ requiredImports, optionalImports }) do
|
||||
for _, import in ipairs(list) do
|
||||
assert(not importIds[import.id], "duplicate import id: " .. import.id)
|
||||
assert(not importFiles[import.file], "duplicate import file: " .. import.file)
|
||||
importIds[import.id], importFiles[import.file] = true, true
|
||||
end
|
||||
end
|
||||
|
||||
return {
|
||||
id = raw.id,
|
||||
name = raw.name,
|
||||
@@ -318,6 +415,8 @@ function Manifest.validate(raw, path)
|
||||
permissionSet = permissionSet,
|
||||
options_schema = optionalFile(raw.options_schema, "options_schema"),
|
||||
assets_transforms = optionalFile(raw.assets_transforms, "assets_transforms"),
|
||||
required_imports = requiredImports,
|
||||
optional_imports = optionalImports,
|
||||
-- an env var name, not a path, so it keeps the plain string check
|
||||
force_enable_env = optionalString(raw.force_enable_env, "force_enable_env"),
|
||||
path = path,
|
||||
|
||||
@@ -0,0 +1,295 @@
|
||||
-- Engine-owned import handling for files declared by a mod's required or
|
||||
-- optional import arrays. Mods never receive host paths or broader
|
||||
-- filesystem access: accepted bytes are copied into their own
|
||||
-- mods/<id>/baseroms/ tree, where the existing mod:read sandbox can see them.
|
||||
|
||||
local CacheFs = require("src.import.CacheFs")
|
||||
|
||||
local RequiredImports = {}
|
||||
|
||||
local function allSpecs(manifest)
|
||||
local out = {}
|
||||
for _, spec in ipairs((manifest and manifest.required_imports) or {}) do
|
||||
out[#out + 1] = spec
|
||||
end
|
||||
for _, spec in ipairs((manifest and manifest.optional_imports) or {}) do
|
||||
out[#out + 1] = spec
|
||||
end
|
||||
return out
|
||||
end
|
||||
|
||||
local function isRequired(spec)
|
||||
return spec.required ~= false
|
||||
end
|
||||
|
||||
RequiredImports.specs = allSpecs
|
||||
RequiredImports.MAX_BYTES = 128 * 1024 * 1024
|
||||
|
||||
local function sizeLabel(bytes)
|
||||
return ("%.1f MiB"):format(bytes / (1024 * 1024))
|
||||
end
|
||||
|
||||
-- Check size before a caller reads an external or stored file into one large
|
||||
-- Lua string. N64 sources may carry a 512-byte copier header, while stored
|
||||
-- files are always canonical and therefore must match the declared size.
|
||||
function RequiredImports.sizeError(spec, size, stored)
|
||||
if type(size) ~= "number" then return nil end
|
||||
if size > RequiredImports.MAX_BYTES then
|
||||
return ("file is too large (%s; hard limit is %s)")
|
||||
:format(sizeLabel(size), sizeLabel(RequiredImports.MAX_BYTES))
|
||||
end
|
||||
local headerAllowance = not stored and spec and spec.format == "n64" and 512 or 0
|
||||
if spec and spec.size then
|
||||
if size ~= spec.size and size ~= spec.size + headerAllowance then
|
||||
return ("wrong file size (expected %d bytes%s, got %d)")
|
||||
:format(spec.size, headerAllowance > 0 and " or a 512-byte header" or "", size)
|
||||
end
|
||||
end
|
||||
if spec and spec.max_size and size > spec.max_size + headerAllowance then
|
||||
return ("file is too large for this import (maximum %d bytes, got %d)")
|
||||
:format(spec.max_size, size)
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
local N64_MAGIC = {
|
||||
["\128\55\18\64"] = "z64", -- big endian / canonical
|
||||
["\55\128\64\18"] = "v64", -- byte-swapped
|
||||
["\64\18\55\128"] = "n64", -- little endian words
|
||||
}
|
||||
|
||||
local function n64KindAt(data, offset)
|
||||
return N64_MAGIC[data:sub(offset, offset + 3)]
|
||||
end
|
||||
|
||||
-- Return canonical big-endian N64 bytes. A 512-byte copier header is
|
||||
-- recognized only when valid N64 magic follows it, so arbitrary data is never
|
||||
-- shortened just because its size happens to line up.
|
||||
function RequiredImports.normalizeN64(data)
|
||||
if type(data) ~= "string" then return nil, "selected file could not be read" end
|
||||
local offset, kind = 1, n64KindAt(data, 1)
|
||||
if not kind then
|
||||
kind = n64KindAt(data, 513)
|
||||
if kind then offset = 513 end
|
||||
end
|
||||
if not kind then
|
||||
return nil, "expected an N64 ROM (.z64/.v64/.n64); file signature was not recognized"
|
||||
end
|
||||
data = data:sub(offset)
|
||||
if kind == "z64" then return data end
|
||||
|
||||
if kind == "v64" then
|
||||
if #data % 2 ~= 0 then return nil, "byte-swapped N64 ROM has an odd size" end
|
||||
return (data:gsub("(.)(.)", "%2%1"))
|
||||
else
|
||||
if #data % 4 ~= 0 then return nil, "little-endian N64 ROM size is not word aligned" end
|
||||
return (data:gsub("(.)(.)(.)(.)", "%4%3%2%1"))
|
||||
end
|
||||
end
|
||||
|
||||
function RequiredImports.normalize(spec, data)
|
||||
if spec and spec.format == "n64" then
|
||||
return RequiredImports.normalizeN64(data)
|
||||
end
|
||||
if type(data) ~= "string" then return nil, "selected file could not be read" end
|
||||
return data
|
||||
end
|
||||
|
||||
local function hexDigest(data, hashFn)
|
||||
if hashFn then return hashFn(data):lower() end
|
||||
if not (love and love.data and love.data.hash and love.data.encode) then
|
||||
return nil, "MD5 support is unavailable in this build"
|
||||
end
|
||||
local digest = love.data.hash("md5", data)
|
||||
if type(digest) == "userdata" and digest.getString then
|
||||
digest = digest:getString()
|
||||
end
|
||||
return love.data.encode("string", "hex", digest):lower()
|
||||
end
|
||||
|
||||
local function accepts(spec, digest)
|
||||
for _, wanted in ipairs((spec and spec.md5) or {}) do
|
||||
if wanted == digest then return true end
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
function RequiredImports.path(manifest, spec)
|
||||
return manifest.path .. "/baseroms/" .. spec.file
|
||||
end
|
||||
|
||||
local function removedMarker(manifest, spec)
|
||||
return manifest.path .. "/baseroms/.required-import-" .. spec.id .. ".removed"
|
||||
end
|
||||
|
||||
local function receiptPath(manifest, spec)
|
||||
return manifest.path .. "/baseroms/.required-import-" .. spec.id .. ".validated"
|
||||
end
|
||||
|
||||
RequiredImports.receiptPath = receiptPath
|
||||
|
||||
local function parseReceipt(raw)
|
||||
if type(raw) ~= "string" then return nil end
|
||||
local digest, size, modtime = raw:match("^v1\n([%x]+)\n(%d+)\n([^\n]+)\n?$")
|
||||
if not digest then return nil end
|
||||
return digest:lower(), tonumber(size), tonumber(modtime)
|
||||
end
|
||||
|
||||
local function cachedDigest(manifest, spec, fs, info)
|
||||
-- A size alone cannot detect a same-length replacement. Require modtime as
|
||||
-- well; filesystems that do not expose it simply take the safe hash path.
|
||||
if not (fs and fs.read and info and info.size and info.modtime) then return nil end
|
||||
local digest, size, modtime = parseReceipt(fs.read(receiptPath(manifest, spec)))
|
||||
if digest and size == info.size and modtime == info.modtime
|
||||
and accepts(spec, digest) then
|
||||
return digest
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
local function writeReceipt(manifest, spec, digest, info, fs)
|
||||
if not (digest and info and info.size and info.modtime) then return end
|
||||
local path = receiptPath(manifest, spec)
|
||||
local body = ("v1\n%s\n%d\n%s\n")
|
||||
:format(digest, info.size, tostring(info.modtime))
|
||||
if love and fs == love.filesystem then
|
||||
local savedPrefix = CacheFs.prefix
|
||||
CacheFs.prefix = ""
|
||||
CacheFs.write(path, body)
|
||||
CacheFs.prefix = savedPrefix
|
||||
elseif fs and fs.write then
|
||||
fs.write(path, body)
|
||||
end
|
||||
end
|
||||
|
||||
local function removeReceipt(manifest, spec, fs)
|
||||
local path = receiptPath(manifest, spec)
|
||||
if love and fs == love.filesystem then
|
||||
local savedPrefix = CacheFs.prefix
|
||||
CacheFs.prefix = ""
|
||||
CacheFs.remove(path)
|
||||
CacheFs.prefix = savedPrefix
|
||||
elseif fs and fs.remove then
|
||||
fs.remove(path)
|
||||
end
|
||||
end
|
||||
|
||||
-- Validate bytes against a declaration. The returned data is canonicalized
|
||||
-- (notably for N64 byte order/header variants) and is what must be stored.
|
||||
function RequiredImports.validateData(spec, data, hashFn)
|
||||
local sourceSizeErr = type(data) == "string"
|
||||
and RequiredImports.sizeError(spec, #data, false)
|
||||
if sourceSizeErr then return nil, sourceSizeErr end
|
||||
local normalized, normalizeErr = RequiredImports.normalize(spec, data)
|
||||
if not normalized then return nil, normalizeErr end
|
||||
local storedSizeErr = RequiredImports.sizeError(spec, #normalized, true)
|
||||
if storedSizeErr then return nil, storedSizeErr end
|
||||
local digest, hashErr = hexDigest(normalized, hashFn)
|
||||
if not digest then return nil, hashErr end
|
||||
if not accepts(spec, digest) then
|
||||
return nil, ("MD5 mismatch (got %s)"):format(digest)
|
||||
end
|
||||
return normalized, digest
|
||||
end
|
||||
|
||||
-- Validate one installed import without reading it when the engine-authored
|
||||
-- receipt still matches the file's size and modification time.
|
||||
function RequiredImports.validateStored(manifest, spec, fs, hashFn)
|
||||
fs = fs or (love and love.filesystem)
|
||||
if not (fs and fs.getInfo) then return nil, "filesystem is unavailable" end
|
||||
local path = RequiredImports.path(manifest, spec)
|
||||
local info = fs.getInfo(path, "file")
|
||||
if not info then
|
||||
removeReceipt(manifest, spec, fs)
|
||||
return nil, "file is missing"
|
||||
end
|
||||
local sizeErr = RequiredImports.sizeError(spec, info.size, true)
|
||||
if sizeErr then
|
||||
removeReceipt(manifest, spec, fs)
|
||||
return nil, sizeErr
|
||||
end
|
||||
local cached = cachedDigest(manifest, spec, fs, info)
|
||||
if cached then return true, cached, true end
|
||||
removeReceipt(manifest, spec, fs)
|
||||
if not fs.read then return nil, "file could not be read" end
|
||||
local data = fs.read(path)
|
||||
local normalized, detail = RequiredImports.validateStoredData(spec, data, hashFn)
|
||||
if not normalized then return nil, detail end
|
||||
info = fs.getInfo(path, "file") or info
|
||||
info.size = info.size or #data
|
||||
writeReceipt(manifest, spec, detail, info, fs)
|
||||
return true, detail, false
|
||||
end
|
||||
|
||||
function RequiredImports.validateStoredData(spec, data, hashFn)
|
||||
local normalized, detail = RequiredImports.validateData(spec, data, hashFn)
|
||||
if not normalized then return nil, detail end
|
||||
if normalized ~= data then
|
||||
return nil, "stored N64 ROM is not canonical; choose the source file again"
|
||||
end
|
||||
return normalized, detail
|
||||
end
|
||||
|
||||
function RequiredImports.inspect(manifest, fs, hashFn)
|
||||
fs = fs or (love and love.filesystem)
|
||||
local rows, missing, missingOptional = {}, 0, 0
|
||||
for _, spec in ipairs(allSpecs(manifest)) do
|
||||
local path = RequiredImports.path(manifest, spec)
|
||||
local suppressed = fs and fs.getInfo
|
||||
and fs.getInfo(removedMarker(manifest, spec), "file") ~= nil
|
||||
local exists = fs and fs.getInfo and fs.getInfo(path, "file") ~= nil
|
||||
local valid, detail = RequiredImports.validateStored(manifest, spec, fs, hashFn)
|
||||
local row = { id = spec.id, name = spec.name, file = spec.file,
|
||||
description = spec.description, format = spec.format, path = path,
|
||||
present = valid == true, digest = valid and detail or nil,
|
||||
error = exists and not valid and detail or nil,
|
||||
suppressed = suppressed, required = isRequired(spec), spec = spec }
|
||||
if not row.present then
|
||||
if row.required then missing = missing + 1
|
||||
else missingOptional = missingOptional + 1 end
|
||||
end
|
||||
rows[#rows + 1] = row
|
||||
end
|
||||
return rows, missing, missingOptional
|
||||
end
|
||||
|
||||
function RequiredImports.importData(manifest, importId, data, opts)
|
||||
opts = opts or {}
|
||||
local spec
|
||||
for _, candidate in ipairs(allSpecs(manifest)) do
|
||||
if candidate.id == importId then spec = candidate break end
|
||||
end
|
||||
if not spec then return nil, "unknown required import: " .. tostring(importId) end
|
||||
local normalized, digest = RequiredImports.validateData(spec, data, opts.hash)
|
||||
if not normalized then return nil, digest end
|
||||
local savedPrefix = CacheFs.prefix
|
||||
CacheFs.prefix = ""
|
||||
CacheFs.remove(receiptPath(manifest, spec))
|
||||
local ok, err = CacheFs.write(RequiredImports.path(manifest, spec), normalized)
|
||||
if ok then CacheFs.remove(removedMarker(manifest, spec)) end
|
||||
if ok and love and love.filesystem and love.filesystem.getInfo then
|
||||
local info = love.filesystem.getInfo(RequiredImports.path(manifest, spec), "file")
|
||||
writeReceipt(manifest, spec, digest, info, love.filesystem)
|
||||
end
|
||||
CacheFs.prefix = savedPrefix
|
||||
if not ok then return nil, "could not copy import: " .. tostring(err) end
|
||||
return true, digest
|
||||
end
|
||||
|
||||
function RequiredImports.remove(manifest, importId)
|
||||
for _, spec in ipairs(allSpecs(manifest)) do
|
||||
if spec.id == importId then
|
||||
local savedPrefix = CacheFs.prefix
|
||||
CacheFs.prefix = ""
|
||||
CacheFs.remove(RequiredImports.path(manifest, spec))
|
||||
CacheFs.remove(receiptPath(manifest, spec))
|
||||
local marked, markErr = CacheFs.write(removedMarker(manifest, spec), "removed\n")
|
||||
CacheFs.prefix = savedPrefix
|
||||
if not marked then return nil, "could not remember removal: " .. tostring(markErr) end
|
||||
return true
|
||||
end
|
||||
end
|
||||
return nil, "unknown required import: " .. tostring(importId)
|
||||
end
|
||||
|
||||
return RequiredImports
|
||||
@@ -46,11 +46,11 @@ function Sandbox.moduleDenial(name, permissionSet)
|
||||
local reason = DENIED[root]
|
||||
if reason then
|
||||
return ("%s is not available to mods (it grants %s); use mod.storage, "
|
||||
.. "mod:read and the engine API instead"):format(name, reason)
|
||||
.. "mod:read, mod:list and the engine API instead"):format(name, reason)
|
||||
end
|
||||
if DENIED_PREFIX[root] and name ~= root then
|
||||
return ("%s is not available to mods; use mod.storage, mod:read and the "
|
||||
.. "engine API instead"):format(name)
|
||||
return ("%s is not available to mods; use mod.storage, mod:read, mod:list "
|
||||
.. "and the engine API instead"):format(name)
|
||||
end
|
||||
if NETWORK[root] and not (permissionSet or {}).network then
|
||||
return ("%s needs the \"network\" permission in manifest.json"):format(name)
|
||||
@@ -68,7 +68,7 @@ end
|
||||
-- without an edit here.
|
||||
-- value is the replacement to name in the error, or true when there is none
|
||||
local BLOCKED_LOVE = {
|
||||
filesystem = "mod.storage and mod:read", thread = true,
|
||||
filesystem = "mod.storage, mod:read and mod:list", thread = true,
|
||||
system = "mod.device:powerInfo() for battery information, mod.steps for "
|
||||
.. "the step bridge", event = true,
|
||||
}
|
||||
|
||||
@@ -1017,6 +1017,9 @@ R.trainers = {
|
||||
index = f.opt(f.int(0, 255)),
|
||||
-- unused vanilla classes ship without a pic, so it cannot be required
|
||||
pic = f.opt(f.path),
|
||||
-- Full-color portrait: skip the 4-shade SGB/GBC remap, same flag pokemon
|
||||
-- and sprites already carry.
|
||||
trueColor = f.opt(f.bool),
|
||||
-- Optional Advanced-mode OBJ palette source for a custom trainer portrait.
|
||||
-- It follows the same ROM crosswalk form as sprites.paletteSource.
|
||||
paletteSource = f.opt(f.str),
|
||||
@@ -1068,6 +1071,11 @@ R.trainers = {
|
||||
gen2Fields = {
|
||||
id = f.opt(f.str), name = f.str,
|
||||
index = f.opt(f.int(0, 255)),
|
||||
-- class frontpic; when set, this wins over menu_gfx.battleHud.trainerPics
|
||||
pic = f.opt(f.path),
|
||||
-- Full-color portrait: skip the GBC 4-shade remap, same flag Gen 1
|
||||
-- trainers and pokemon already carry.
|
||||
trueColor = f.opt(f.bool),
|
||||
baseMoney = f.opt(f.int(0)),
|
||||
-- the class's battle theme; Gen 1 spells the same idea `battleTheme`,
|
||||
-- but this is the extractor's own key and a strict rename would reject
|
||||
|
||||
+132
-11
@@ -1,4 +1,5 @@
|
||||
-- Data-only per-mod persistence, scoped by game version and opaque playthrough.
|
||||
-- Data-only and opaque-byte per-mod persistence, scoped by game version and
|
||||
-- opaque playthrough.
|
||||
-- This module is engine-private; Loader exposes only the bound facade methods.
|
||||
|
||||
local SaveData = require("src.core.SaveData")
|
||||
@@ -7,6 +8,7 @@ local Version = require("src.core.Version")
|
||||
|
||||
local Storage = {}
|
||||
Storage.__index = Storage
|
||||
Storage.MAX_BYTES = 512 * 1024 * 1024
|
||||
|
||||
local ROOT = "mod_storage"
|
||||
|
||||
@@ -49,6 +51,20 @@ local function decodeAt(fs, path)
|
||||
return data, body
|
||||
end
|
||||
|
||||
local function readOpaqueAt(fs, path)
|
||||
if not (fs.getInfo and fs.getInfo(path)) then return nil end
|
||||
local body = fs.read and fs.read(path)
|
||||
if type(body) ~= "string" then return nil end
|
||||
return body
|
||||
end
|
||||
|
||||
local function hasAny(fs, paths)
|
||||
for _, path in ipairs(paths) do
|
||||
if fs.getInfo(path) then return true end
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
function Storage.new(modId, fs)
|
||||
assert(validSegment(modId), "Storage.new needs a safe mod id")
|
||||
return setmetatable({ modId = modId, injectedFs = fs }, Storage)
|
||||
@@ -132,6 +148,10 @@ function Storage:selected(game)
|
||||
end,
|
||||
read = function(_, key) return self:read(selectedGame, key) end,
|
||||
write = function(_, key, value) return self:write(selectedGame, key, value) end,
|
||||
readBytes = function(_, key) return self:readBytes(selectedGame, key) end,
|
||||
writeBytes = function(_, key, bytes)
|
||||
return self:writeBytes(selectedGame, key, bytes)
|
||||
end,
|
||||
list = function(_, prefix) return self:list(selectedGame, prefix) end,
|
||||
delete = function(_, key) return self:delete(selectedGame, key) end,
|
||||
}
|
||||
@@ -147,7 +167,7 @@ function Storage:context(game)
|
||||
}
|
||||
end
|
||||
|
||||
function Storage:_names(game, key, allowEmpty)
|
||||
function Storage:_names(game, key, allowEmpty, extension)
|
||||
if not validKey(key, allowEmpty) then
|
||||
return failure("invalid_key",
|
||||
"Storage keys use nonempty letters, numbers, underscore, dash and slash segments.")
|
||||
@@ -155,12 +175,19 @@ function Storage:_names(game, key, allowEmpty)
|
||||
local scope, code, message = self:_scope(game)
|
||||
if not scope then return nil, code, message end
|
||||
local path = scope.base .. (key ~= "" and ("/" .. key) or "")
|
||||
return scope, path .. ".lua", path .. ".lua.bak", path .. ".lua.tmp"
|
||||
extension = extension or ".lua"
|
||||
return scope, path .. extension, path .. extension .. ".bak",
|
||||
path .. extension .. ".tmp", path
|
||||
end
|
||||
|
||||
function Storage:write(game, key, value)
|
||||
local scope, main, bak, tmp = self:_names(game, key, false)
|
||||
local scope, main, bak, tmp, path = self:_names(game, key, false)
|
||||
if not scope then return false, main, bak end
|
||||
local fs = scope.fs
|
||||
if hasAny(fs, { path .. ".bin", path .. ".bin.bak", path .. ".bin.tmp" }) then
|
||||
return false, "type_conflict",
|
||||
"A byte value already exists for this storage key; delete it first."
|
||||
end
|
||||
if type(value) ~= "table" then
|
||||
return false, "encode_failed", "Storage values must be data-only tables."
|
||||
end
|
||||
@@ -170,7 +197,6 @@ function Storage:write(game, key, value)
|
||||
.. tostring(encoded)
|
||||
end
|
||||
|
||||
local fs = scope.fs
|
||||
ensureParent(fs, main)
|
||||
local _, previous = decodeAt(fs, main)
|
||||
if not previous then _, previous = decodeAt(fs, bak) end
|
||||
@@ -206,9 +232,13 @@ function Storage:write(game, key, value)
|
||||
end
|
||||
|
||||
function Storage:read(game, key)
|
||||
local scope, main, bak, tmp = self:_names(game, key, false)
|
||||
local scope, main, bak, tmp, path = self:_names(game, key, false)
|
||||
if not scope then return nil, main, bak end
|
||||
local fs = scope.fs
|
||||
if hasAny(fs, { path .. ".bin", path .. ".bin.bak", path .. ".bin.tmp" }) then
|
||||
return failure("type_mismatch",
|
||||
"This storage key contains opaque bytes; use readBytes instead.")
|
||||
end
|
||||
local data, body = decodeAt(fs, main)
|
||||
if data then return data end
|
||||
|
||||
@@ -226,6 +256,80 @@ function Storage:read(game, key)
|
||||
return data
|
||||
end
|
||||
|
||||
function Storage:writeBytes(game, key, bytes)
|
||||
local scope, main, bak, tmp, path = self:_names(game, key, false, ".bin")
|
||||
if not scope then return false, main, bak end
|
||||
if type(bytes) ~= "string" then
|
||||
return false, "invalid_bytes", "Opaque storage values must be strings."
|
||||
end
|
||||
if #bytes > Storage.MAX_BYTES then
|
||||
return false, "size_limit",
|
||||
("Opaque storage values cannot exceed %d bytes."):format(Storage.MAX_BYTES)
|
||||
end
|
||||
|
||||
local fs = scope.fs
|
||||
if hasAny(fs, { path .. ".lua", path .. ".lua.bak", path .. ".lua.tmp" }) then
|
||||
return false, "type_conflict",
|
||||
"A table value already exists for this storage key; delete it first."
|
||||
end
|
||||
|
||||
ensureParent(fs, main)
|
||||
local previous = readOpaqueAt(fs, main)
|
||||
if previous == nil then previous = readOpaqueAt(fs, bak) end
|
||||
|
||||
local ok, err = fs.write(tmp, bytes)
|
||||
if not ok then
|
||||
return false, "write_failed", "Could not stage opaque storage data: " .. tostring(err)
|
||||
end
|
||||
local staged = readOpaqueAt(fs, tmp)
|
||||
if staged == nil or staged ~= bytes then
|
||||
remove(fs, tmp)
|
||||
return false, "verify_failed", "Staged opaque storage data could not be verified."
|
||||
end
|
||||
|
||||
if previous ~= nil then fs.write(bak, previous) end
|
||||
ok, err = fs.write(main, bytes)
|
||||
if not ok then
|
||||
remove(fs, tmp)
|
||||
return false, "write_failed",
|
||||
"Could not replace opaque storage data: " .. tostring(err)
|
||||
end
|
||||
local verified = readOpaqueAt(fs, main)
|
||||
if verified == nil or verified ~= bytes then
|
||||
remove(fs, main)
|
||||
remove(fs, tmp)
|
||||
return false, "verify_failed",
|
||||
"Replacement opaque storage data could not be verified."
|
||||
end
|
||||
|
||||
fs.write(bak, bytes)
|
||||
remove(fs, tmp)
|
||||
return true
|
||||
end
|
||||
|
||||
function Storage:readBytes(game, key)
|
||||
local scope, main, bak, tmp, path = self:_names(game, key, false, ".bin")
|
||||
if not scope then return nil, main, bak end
|
||||
local fs = scope.fs
|
||||
if hasAny(fs, { path .. ".lua", path .. ".lua.bak", path .. ".lua.tmp" }) then
|
||||
return failure("type_mismatch",
|
||||
"This storage key contains table data; use read instead.")
|
||||
end
|
||||
|
||||
local bytes = readOpaqueAt(fs, main)
|
||||
if bytes ~= nil then return bytes end
|
||||
bytes = readOpaqueAt(fs, tmp)
|
||||
if bytes == nil then bytes = readOpaqueAt(fs, bak) end
|
||||
if bytes == nil then
|
||||
return nil, "not_found", "No valid opaque value exists for this key."
|
||||
end
|
||||
|
||||
ensureParent(fs, main)
|
||||
if fs.write(main, bytes) then fs.write(bak, bytes) end
|
||||
remove(fs, tmp)
|
||||
return bytes
|
||||
end
|
||||
|
||||
function Storage:list(game, prefix)
|
||||
prefix = prefix or ""
|
||||
local scope, main, codeOrBak = self:_names(game, prefix, true)
|
||||
@@ -237,13 +341,23 @@ function Storage:list(game, prefix)
|
||||
|
||||
local base = scope.base
|
||||
local start = prefix == "" and base or (base .. "/" .. prefix)
|
||||
local out = {}
|
||||
local out, seen = {}, {}
|
||||
|
||||
local function add(logical)
|
||||
if not seen[logical] then
|
||||
seen[logical] = true
|
||||
out[#out + 1] = logical
|
||||
end
|
||||
end
|
||||
|
||||
local function walk(path, logical)
|
||||
local info = fs.getInfo(path)
|
||||
if not info then return end
|
||||
if info.type == "file" then
|
||||
if path:sub(-4) == ".lua" then out[#out + 1] = logical:sub(1, -5) end
|
||||
local suffix = path:sub(-4)
|
||||
if suffix == ".lua" or suffix == ".bin" then
|
||||
add(logical:sub(1, -5))
|
||||
end
|
||||
return
|
||||
end
|
||||
for _, child in ipairs(fs.getDirectoryItems(path) or {}) do
|
||||
@@ -254,7 +368,9 @@ function Storage:list(game, prefix)
|
||||
|
||||
-- A prefix may identify one exact key or a directory of keys.
|
||||
if fs.getInfo(start .. ".lua") then
|
||||
out[#out + 1] = prefix
|
||||
add(prefix)
|
||||
elseif fs.getInfo(start .. ".bin") then
|
||||
add(prefix)
|
||||
else
|
||||
walk(start, prefix)
|
||||
end
|
||||
@@ -263,15 +379,20 @@ function Storage:list(game, prefix)
|
||||
end
|
||||
|
||||
function Storage:delete(game, key)
|
||||
local scope, main, bak, tmp = self:_names(game, key, false)
|
||||
local scope, main, bak, tmp, path = self:_names(game, key, false)
|
||||
if not scope then return false, main, bak end
|
||||
local fs = scope.fs
|
||||
if not (fs.getInfo(main) or fs.getInfo(bak) or fs.getInfo(tmp)) then
|
||||
local byteMain, byteBak, byteTmp = path .. ".bin", path .. ".bin.bak", path .. ".bin.tmp"
|
||||
if not (fs.getInfo(main) or fs.getInfo(bak) or fs.getInfo(tmp)
|
||||
or fs.getInfo(byteMain) or fs.getInfo(byteBak) or fs.getInfo(byteTmp)) then
|
||||
return false, "not_found", "No stored value exists for this key."
|
||||
end
|
||||
remove(fs, main)
|
||||
remove(fs, bak)
|
||||
remove(fs, tmp)
|
||||
remove(fs, byteMain)
|
||||
remove(fs, byteBak)
|
||||
remove(fs, byteTmp)
|
||||
return true
|
||||
end
|
||||
|
||||
|
||||
@@ -383,6 +383,7 @@ local function pickTargetAndUse(game, battle, id, list)
|
||||
local def = game.data.items[id]
|
||||
local opts = {
|
||||
pickOnly = true,
|
||||
battle = battle,
|
||||
-- HP medicine animates its bar with the picker still up (#252). Only
|
||||
-- out of battle: the in-battle tail closes the bag list underneath
|
||||
-- first, which needs the picker already gone.
|
||||
|
||||
@@ -98,14 +98,14 @@ function OakSpeech.resolvePic(game, desc, speech)
|
||||
local t = desc.type
|
||||
if t == "trainer" then
|
||||
if speech and desc.id == "OPP_PROF_OAK" and speech.oakPic then
|
||||
return speech.oakPic, false, false
|
||||
return speech.oakPic, false, speech.oakTrueColor or false
|
||||
end
|
||||
if speech and desc.id == "OPP_RIVAL1" and speech.rivalPic then
|
||||
return speech.rivalPic, false, false
|
||||
return speech.rivalPic, false, speech.rivalTrueColor or false
|
||||
end
|
||||
local trainers = game.data.trainers or {}
|
||||
local tr = trainers[desc.id]
|
||||
return tryImage(tr and tr.pic), false, false
|
||||
return tryImage(tr and tr.pic), false, tr and tr.trueColor or false
|
||||
elseif t == "pokemon" then
|
||||
if speech and desc.id == speech.demoSpecies and speech.demoPic then
|
||||
return speech.demoPic, desc.flip and true or false, speech.demoTrueColor
|
||||
@@ -241,7 +241,11 @@ function OakSpeech.new(game, onDone)
|
||||
self.answers = {}
|
||||
local trainers = game.data.trainers or {}
|
||||
self.oakPic = tryImage(trainers.OPP_PROF_OAK and trainers.OPP_PROF_OAK.pic)
|
||||
self.oakTrueColor = self.oakPic
|
||||
and trainers.OPP_PROF_OAK and trainers.OPP_PROF_OAK.trueColor or false
|
||||
self.rivalPic = tryImage(trainers.OPP_RIVAL1 and trainers.OPP_RIVAL1.pic)
|
||||
self.rivalTrueColor = self.rivalPic
|
||||
and trainers.OPP_RIVAL1 and trainers.OPP_RIVAL1.trueColor or false
|
||||
local oakGfx = (game.data.field and game.data.field.oakSpeech) or {}
|
||||
self.cfg = oakGfx
|
||||
-- the show-off mon and the name length cap come from data; the vanilla
|
||||
|
||||
+15
-54
@@ -289,6 +289,7 @@ function PartyMenu.new(game, opts)
|
||||
opts = opts or {}
|
||||
local self = setmetatable({}, PartyMenu)
|
||||
self.game = game
|
||||
local party = opts.party or (opts.battle and opts.battle.playerParty)
|
||||
-- PartyMenuInit (home/pokemon.asm) seeds the cursor from
|
||||
-- wPartyAndBillsPCSavedMenuItem rather than from zero, and
|
||||
-- HandlePartyMenuInput writes wCurrentMenuItem back into it on every
|
||||
@@ -297,7 +298,7 @@ function PartyMenu.new(game, opts)
|
||||
-- both zero the byte, which BattleState mirrors. The clamp covers a
|
||||
-- party that shrank (deposit / release) while the saved index was
|
||||
-- pointing past the end. #768
|
||||
local count = #(opts.party or (game.save and game.save.party) or {})
|
||||
local count = #(party or (game.save and game.save.party) or {})
|
||||
self.index = math.min(math.max(1, game.partyMenuSavedIndex or 1),
|
||||
math.max(1, count))
|
||||
self.onSwitch = opts.onSwitch
|
||||
@@ -314,7 +315,7 @@ function PartyMenu.new(game, opts)
|
||||
self.tmhm = opts.tmhm
|
||||
self.forceSwitch = opts.forceSwitch
|
||||
self.battle = opts.battle
|
||||
self.party = opts.party -- link battles pass their clamped copies
|
||||
self.party = party -- link/scoped battles pass their local party view
|
||||
self.swapFrom = nil
|
||||
self.submenu = nil
|
||||
self.subIndex = 1
|
||||
@@ -453,30 +454,7 @@ function PartyMenu:update(dt)
|
||||
refuseBadge(self)
|
||||
return
|
||||
end
|
||||
local TextBox = require("src.render.TextBox")
|
||||
local Transition = require("src.render.Transition")
|
||||
-- .flash prints on any map, but the light it records is this map's:
|
||||
-- home/overworld.asm re-arms wMapPalOffset on the next dark map, so
|
||||
-- a FLASH used in daylight must not carry into Rock Tunnel
|
||||
local wasDark = ow and ow.dark
|
||||
if wasDark then self.game.save.flashLit = true end
|
||||
self.game.stack:push(TextBox.new(self.game,
|
||||
self.game.data.text._FlashLightsAreaText
|
||||
or Strings("A blinding FLASH\nlights the area!"), function()
|
||||
self:close()
|
||||
-- setDark, not a bare field write: ADVANCED carries the darkness
|
||||
-- in a baked atlas, so lighting the cave drops every resident map
|
||||
-- and rebakes this one (#383). It runs HERE, before the blink,
|
||||
-- because start_sub_menus.asm .flash clears wMapPalOffset before
|
||||
-- PrintText and blinks last of all: the cave is already lit by the
|
||||
-- time GBPalWhiteOutWithDelay3 runs. Hanging the rebuild off the
|
||||
-- blink's completion instead left that rebuild's whole cost --
|
||||
-- seconds of per-pixel atlas baking on a phone -- on screen as a
|
||||
-- solid white frame with nothing under it, which reads as a
|
||||
-- lockup (#610).
|
||||
if wasDark then ow:setDark(false) end
|
||||
self.game.stack:push(Transition.whiteFlash(self.game))
|
||||
end))
|
||||
ow:useFlashFieldMove(function() self:close() end)
|
||||
return
|
||||
elseif action == "surf" then
|
||||
-- start_sub_menus.asm .surf: SOULBADGE-gated (useSurfFieldMove),
|
||||
@@ -504,12 +482,7 @@ function PartyMenu:update(dt)
|
||||
-- GBPalWhiteOutWithDelay3 blink, and the simulated pad press
|
||||
-- steps the player forward onto land (or across a connection
|
||||
-- strip when the shore is the next map's edge)
|
||||
self.game.stack:pop()
|
||||
ow.player.surfing = false
|
||||
require("src.core.Music").setSurfing(self.game.data, false)
|
||||
self.game.stack:push(Transition.whiteFlash(self.game, nil, function()
|
||||
ow:stepForwardOrCrossEdge(ow.player.facing)
|
||||
end))
|
||||
ow:stopSurfing(function() self.game.stack:pop() end)
|
||||
return
|
||||
end
|
||||
local TextBox = require("src.render.TextBox")
|
||||
@@ -566,30 +539,18 @@ function PartyMenu:update(dt)
|
||||
-- .strength, GBPalWhiteOutWithDelay3 blinks the screen white
|
||||
-- before CloseTextDisplay returns to the map.
|
||||
local ow = self.game.overworld
|
||||
if ow and not ow:partyKnows("STRENGTH") then
|
||||
refuseBadge(self)
|
||||
if ow and ow.useStrengthFieldMove then
|
||||
if not ow:partyKnows("STRENGTH") then
|
||||
refuseBadge(self)
|
||||
return
|
||||
end
|
||||
ow:useStrengthFieldMove(mon, function() self:close() end)
|
||||
return
|
||||
elseif ow and ow.useFieldMove then
|
||||
ow:useFieldMove("STRENGTH", mon)
|
||||
self:close()
|
||||
return
|
||||
end
|
||||
local TextBox = require("src.render.TextBox")
|
||||
local Transition = require("src.render.Transition")
|
||||
local def = self.game.data.pokemon[mon.species]
|
||||
local name = mon.nickname or def.name
|
||||
ow.strengthActive = true
|
||||
local t1 = (self.game.data.text._UsedStrengthText
|
||||
or Strings("{RAM:wNameBuffer} used\nSTRENGTH.")):gsub("{RAM:wNameBuffer}", name)
|
||||
local t2 = (self.game.data.text._CanMoveBouldersText
|
||||
or Strings("{RAM:wNameBuffer} can\nmove boulders.")):gsub("{RAM:wNameBuffer}", name)
|
||||
-- like surf (#320, #385): both texts print with the party menu
|
||||
-- still on screen, and the blink IS the menu closing afterwards,
|
||||
-- not a flashbang on the empty map
|
||||
self.game.stack:push(TextBox.new(self.game, t1, function()
|
||||
self.game.stack:push(TextBox.new(self.game, t2, function()
|
||||
self:close()
|
||||
self.game.stack:push(Transition.whiteFlash(self.game))
|
||||
end))
|
||||
end, { auto = { sound = function()
|
||||
return require("src.core.Sound").playCry(self.game.data, mon.species)
|
||||
end } }))
|
||||
return
|
||||
elseif action == "softboiled" then
|
||||
-- field SOFTBOILED (StartMenu_Pokemon .softboiled): transfer
|
||||
|
||||
@@ -185,6 +185,19 @@ function BattleState:statusHUDVisible()
|
||||
self) ~= false
|
||||
end
|
||||
|
||||
-- Class frontpic for the battle intro. A trainers-registry `pic` wins over
|
||||
-- the extracted menu_gfx sheet; `trueColor` skips the GBC 4-shade remap.
|
||||
-- Returns path, trueColor.
|
||||
function BattleState.trainerArt(data, classId)
|
||||
if not classId then return nil, false end
|
||||
local classes = data and data.gen2Trainers and data.gen2Trainers.classes
|
||||
local classDef = classes and classes[classId]
|
||||
local hud = data and data.gen2MenuGfx and data.gen2MenuGfx.battleHud
|
||||
local path = (classDef and classDef.pic)
|
||||
or (hud and hud.trainerPics and hud.trainerPics[classId])
|
||||
return path, (classDef and classDef.trueColor) and true or false
|
||||
end
|
||||
|
||||
-- opts: battle (a Battle), onDone(outcome), save
|
||||
function BattleState.new(game, opts)
|
||||
opts = opts or {}
|
||||
@@ -308,6 +321,7 @@ function BattleState.new(game, opts)
|
||||
-- pic is a cache asset, so an import made before the extractor grew that
|
||||
-- stage has none and the mon stands in for the whole intro.
|
||||
self.showEnemyTrainer = false
|
||||
self.enemyTrainerTrueColor = false
|
||||
-- The CLASS CONSTANT (BUG_CATCHER), which is what both tables this looks the
|
||||
-- pic up in are keyed by: menu_gfx's trainerPics is written out of
|
||||
-- constants.trainerClassOrder, and palettes.trainers out of the same names.
|
||||
@@ -317,17 +331,19 @@ function BattleState.new(game, opts)
|
||||
-- no palette for every trainer the world starts, which is all of them.
|
||||
-- `classId` is the trainers.lua key, i.e. the constant; `className` is the
|
||||
-- DISPLAY name ("BUG CATCHER", with the space) and is not a key at all.
|
||||
-- A class record's own `pic` / `trueColor` (the trainers registry) wins
|
||||
-- over the extracted sheet, so a mod can drop in full-color art.
|
||||
local enemyTrainer = self.battle and self.battle.trainer
|
||||
self.enemyTrainerClass = enemyTrainer
|
||||
and (enemyTrainer.classId or enemyTrainer.class)
|
||||
local trainerPics = hudGfx and hudGfx.trainerPics
|
||||
local trainerPath = self.enemyTrainerClass and trainerPics
|
||||
and trainerPics[self.enemyTrainerClass]
|
||||
local trainerPath, trainerTrueColor =
|
||||
BattleState.trainerArt(data, self.enemyTrainerClass)
|
||||
if trainerPath then
|
||||
local ok, image = pcall(Assets.image, trainerPath)
|
||||
if ok and image then
|
||||
self.enemyTrainerImage = image
|
||||
self.enemyTrainerPath = trainerPath
|
||||
self.enemyTrainerTrueColor = trainerTrueColor and true or false
|
||||
self.showEnemyTrainer = true
|
||||
end
|
||||
end
|
||||
@@ -588,7 +604,10 @@ function BattleState:drawPic(mon, back)
|
||||
-- slides it out (InitEnemyTrainer, engine/battle/core.asm:7848).
|
||||
local enemyTrainer = (not back) and self.showEnemyTrainer
|
||||
and self.enemyTrainerImage
|
||||
if enemyTrainer then image, path = enemyTrainer, self.enemyTrainerPath end
|
||||
if enemyTrainer then
|
||||
image, path = enemyTrainer, self.enemyTrainerPath
|
||||
trueColor = self.enemyTrainerTrueColor
|
||||
end
|
||||
if not image then return end
|
||||
local side = back and "player" or "enemy"
|
||||
local anim = self:animPicState(side)
|
||||
|
||||
@@ -154,7 +154,7 @@ function HallOfFame.monPlacements(mon, def)
|
||||
put(out, genderGlyph(mon.gender), 18, 13)
|
||||
-- (8,14) is a bare '/', so the nickname starts at (9,14).
|
||||
put(out, "/", 8, 14)
|
||||
put(out, mon.nickname or mon.species, 9, 14)
|
||||
put(out, mon.nickname or mon.name or mon.species, 9, 14)
|
||||
put(out, levelText(mon.level), 1, 16)
|
||||
end
|
||||
-- '<ID>' '№' '/' at (7,16), (8,16), (9,16), then five digits at (10,16).
|
||||
|
||||
@@ -630,7 +630,7 @@ function PackMenu:openTeachParty(row)
|
||||
if not allowed then
|
||||
if game.say then
|
||||
game:say(("%s can't learn %s!"):format(
|
||||
mon.nickname or mon.species or "?", moveName))
|
||||
require("src.battle.gen2.Mon").displayName(mon), moveName))
|
||||
end
|
||||
return
|
||||
end
|
||||
@@ -638,7 +638,7 @@ function PackMenu:openTeachParty(row)
|
||||
if move.id == moveId then
|
||||
if game.say then
|
||||
game:say(("%s already knows %s!"):format(
|
||||
mon.nickname or mon.species or "?", moveName))
|
||||
require("src.battle.gen2.Mon").displayName(mon), moveName))
|
||||
end
|
||||
return
|
||||
end
|
||||
|
||||
+12
-3
@@ -71,6 +71,9 @@ function Check.parseRelease(jsonText, Json)
|
||||
payloadName = payloadName,
|
||||
payload = Check.pickAsset(doc.assets, payloadName),
|
||||
sums = Check.pickAsset(doc.assets, "sha256sums.txt"),
|
||||
-- GitHub release body: already fetched with the update check, shown by
|
||||
-- the launcher's Patch notes footer button.
|
||||
notes = type(doc.body) == "string" and doc.body or "",
|
||||
}
|
||||
end
|
||||
|
||||
@@ -135,6 +138,10 @@ local function drain()
|
||||
if stateCh then
|
||||
local msg = stateCh:pop()
|
||||
while msg do
|
||||
if type(msg) == "table" and type(msg.notes) ~= "string"
|
||||
and type(cache.notes) == "string" then
|
||||
msg.notes = cache.notes
|
||||
end
|
||||
cache = msg
|
||||
msg = stateCh:pop()
|
||||
end
|
||||
@@ -158,11 +165,11 @@ function Check.start()
|
||||
return
|
||||
end
|
||||
requested = true
|
||||
cache = { status = "checking" }
|
||||
cache = { status = "checking", notes = cache.notes, latest = cache.latest }
|
||||
cmdCh:push({ cmd = "check" })
|
||||
end
|
||||
|
||||
-- Current snapshot: { status, latest, progress, error }. status is one of
|
||||
-- Current snapshot: { status, latest, progress, error, notes }. status is one of
|
||||
-- idle | checking | uptodate | available | downloading | ready | needs_full | error.
|
||||
function Check.state()
|
||||
drain()
|
||||
@@ -171,6 +178,7 @@ function Check.state()
|
||||
latest = cache.latest,
|
||||
progress = cache.progress,
|
||||
error = cache.error,
|
||||
notes = cache.notes,
|
||||
}
|
||||
end
|
||||
|
||||
@@ -180,7 +188,8 @@ function Check.download()
|
||||
drain()
|
||||
if not cmdCh then return end
|
||||
if cache.status ~= "available" then return end
|
||||
cache = { status = "downloading", latest = cache.latest, progress = 0 }
|
||||
cache = { status = "downloading", latest = cache.latest, progress = 0,
|
||||
notes = cache.notes }
|
||||
cmdCh:push({ cmd = "download" })
|
||||
end
|
||||
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
-- Resolve the game's patch notes for the launcher footer modal.
|
||||
--
|
||||
-- Sources, first hit wins:
|
||||
-- 1. The GitHub body the self-updater already fetched (Check.parseRelease)
|
||||
-- 2. PATCH_NOTES.md, if a build packed one
|
||||
-- 3. mobile/ios/app-repo.json -- CI copies each release's notes into
|
||||
-- versions[].localizedDescription, so a checkout already has them
|
||||
-- on disk even when the updater has not run
|
||||
|
||||
local Json = require("src.link.Json")
|
||||
|
||||
local PatchNotes = {}
|
||||
|
||||
PatchNotes.FILES = {
|
||||
"PATCH_NOTES.md",
|
||||
"assets/PATCH_NOTES.md",
|
||||
}
|
||||
|
||||
PatchNotes.REPO_FILES = {
|
||||
"mobile/ios/app-repo.json",
|
||||
}
|
||||
|
||||
local function nonempty(s)
|
||||
return type(s) == "string" and s:find("%S")
|
||||
end
|
||||
|
||||
function PatchNotes.fromCheck(Check)
|
||||
if not (Check and Check.state) then return nil, nil end
|
||||
local ok, st = pcall(Check.state)
|
||||
st = (ok and type(st) == "table") and st or nil
|
||||
if not st then return nil, nil end
|
||||
if nonempty(st.notes) then
|
||||
return st.notes, st.latest
|
||||
end
|
||||
return nil, st.latest
|
||||
end
|
||||
|
||||
local function readPath(path)
|
||||
local fs = love and love.filesystem
|
||||
if fs and fs.read then
|
||||
local ok, text = pcall(fs.read, path)
|
||||
if ok and nonempty(text) then return text end
|
||||
end
|
||||
local f = io.open(path, "rb")
|
||||
if not f then return nil end
|
||||
local text = f:read("*a")
|
||||
f:close()
|
||||
return nonempty(text) and text or nil
|
||||
end
|
||||
|
||||
function PatchNotes.fromFile()
|
||||
for _, path in ipairs(PatchNotes.FILES) do
|
||||
local text = readPath(path)
|
||||
if text then return text end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
-- Newest-first list of { version, notes } from the iOS sidecar.
|
||||
function PatchNotes.parseRepo(jsonText)
|
||||
local doc = Json.decode(jsonText)
|
||||
if type(doc) ~= "table" or type(doc.apps) ~= "table" then return {} end
|
||||
local out = {}
|
||||
for _, app in ipairs(doc.apps) do
|
||||
if type(app) == "table" and type(app.versions) == "table" then
|
||||
for _, row in ipairs(app.versions) do
|
||||
if type(row) == "table" and nonempty(row.localizedDescription) then
|
||||
out[#out + 1] = {
|
||||
version = row.version,
|
||||
notes = row.localizedDescription,
|
||||
}
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
return out
|
||||
end
|
||||
|
||||
function PatchNotes.fromRepo(engine)
|
||||
for _, path in ipairs(PatchNotes.REPO_FILES) do
|
||||
local text = readPath(path)
|
||||
if text then
|
||||
local list = PatchNotes.parseRepo(text)
|
||||
if #list == 0 then return nil, nil end
|
||||
if engine and engine ~= "0.0.0-dev" then
|
||||
for _, row in ipairs(list) do
|
||||
if row.version == engine then
|
||||
return row.notes, row.version
|
||||
end
|
||||
end
|
||||
end
|
||||
return list[1].notes, list[1].version
|
||||
end
|
||||
end
|
||||
return nil, nil
|
||||
end
|
||||
|
||||
function PatchNotes.body(Check)
|
||||
local notes, ver = PatchNotes.fromCheck(Check)
|
||||
if notes then return notes, ver end
|
||||
notes = PatchNotes.fromFile()
|
||||
if notes then return notes, ver end
|
||||
local Version = require("src.core.Version")
|
||||
local engine = (Version and Version.engine) or "?"
|
||||
notes, ver = PatchNotes.fromRepo(engine)
|
||||
if notes then return notes, ver end
|
||||
return "No patch notes loaded yet for gen1recomp v" .. engine .. ".\n\n"
|
||||
.. "They appear here after the launcher checks GitHub for the latest "
|
||||
.. "release.", engine
|
||||
end
|
||||
|
||||
return PatchNotes
|
||||
@@ -45,7 +45,12 @@ local Boot = loadModule("src/update/Boot.lua")
|
||||
local cmdCh = love.thread.getChannel("update_check_cmd")
|
||||
local stateCh = love.thread.getChannel("update_check_state")
|
||||
|
||||
local function post(t) stateCh:push(t) end
|
||||
local function post(t)
|
||||
if pending and type(t) == "table" and t.notes == nil then
|
||||
t.notes = pending.notes
|
||||
end
|
||||
stateCh:push(t)
|
||||
end
|
||||
|
||||
local osName = (love.system and love.system.getOS and love.system.getOS()) or ""
|
||||
local isWindows = osName == "Windows"
|
||||
|
||||
@@ -230,6 +230,7 @@ function OverworldState:enter(mapId, x, y, facing, opts)
|
||||
-- a fresh entry, or a stale flag can freeze player input forever
|
||||
self.engaging = false
|
||||
self.emote = nil
|
||||
self.cancelledTrainerSight = nil
|
||||
-- volatile WRAM state in pokered; never serialize across save/load
|
||||
self.wildEncounterGraceSteps = 0
|
||||
-- survives save/load: a loaded game may start inside a building whose
|
||||
@@ -762,6 +763,70 @@ function OverworldState:bikeAllowed(mapId)
|
||||
return false
|
||||
end
|
||||
|
||||
-- Field-item entry points keep presentation and state transitions in the
|
||||
-- owning world instead of asking a supported facade to reproduce either one.
|
||||
function OverworldState:useBicycle()
|
||||
local name = Game.save.player.name
|
||||
if Game.save.onBike then
|
||||
if Game.save.forcedBike then return false end
|
||||
Game.save.onBike = false
|
||||
require("src.core.Music").playMap(Game.data, self.map.id, false)
|
||||
Game.stack:push(TextBox.new(Game,
|
||||
Strings("%s got off\nthe BICYCLE.", name)))
|
||||
elseif self:bikeAllowed(self.map.id) and not self.player.surfing then
|
||||
Game.save.onBike = true
|
||||
require("src.core.Music").playMap(Game.data, self.map.id, true)
|
||||
Game.stack:push(TextBox.new(Game,
|
||||
Strings("%s got on\nthe BICYCLE!", name)))
|
||||
else
|
||||
return false
|
||||
end
|
||||
return true
|
||||
end
|
||||
|
||||
-- Field-move entry points keep presentation and state transitions in the
|
||||
-- overworld. The party menu and supported mod facade both call these, so a
|
||||
-- shortcut cannot drift from the game's own move flow.
|
||||
function OverworldState:useFlashFieldMove(onClose)
|
||||
-- A FLASH used in daylight must not carry into the next dark map. Lighting
|
||||
-- also happens before the blink: setDark may rebake an ADVANCED atlas, and
|
||||
-- putting that work in WhiteFlash:onDone caused the frozen white frame in
|
||||
-- #610.
|
||||
local wasDark = self.dark
|
||||
if wasDark then Game.save.flashLit = true end
|
||||
Game.stack:push(TextBox.new(Game,
|
||||
Game.data.text._FlashLightsAreaText
|
||||
or Strings("A blinding FLASH\nlights the area!"), function()
|
||||
if onClose then onClose() end
|
||||
if wasDark then self:setDark(false) end
|
||||
Game.stack:push(Transition.whiteFlash(Game))
|
||||
end))
|
||||
return true
|
||||
end
|
||||
|
||||
function OverworldState:useStrengthFieldMove(mon, onClose)
|
||||
mon = mon or self:partyKnows("STRENGTH")
|
||||
if not mon then return false end
|
||||
local def = Game.data.pokemon[mon.species]
|
||||
local name = mon.nickname or def.name
|
||||
self.strengthActive = true
|
||||
local first = (Game.data.text._UsedStrengthText
|
||||
or Strings("{RAM:wNameBuffer} used\nSTRENGTH."))
|
||||
:gsub("{RAM:wNameBuffer}", name)
|
||||
local second = (Game.data.text._CanMoveBouldersText
|
||||
or Strings("{RAM:wNameBuffer} can\nmove boulders."))
|
||||
:gsub("{RAM:wNameBuffer}", name)
|
||||
Game.stack:push(TextBox.new(Game, first, function()
|
||||
Game.stack:push(TextBox.new(Game, second, function()
|
||||
if onClose then onClose() end
|
||||
Game.stack:push(Transition.whiteFlash(Game))
|
||||
end))
|
||||
end, { auto = { sound = function()
|
||||
return require("src.core.Sound").playCry(Game.data, mon.species)
|
||||
end } }))
|
||||
return true
|
||||
end
|
||||
|
||||
-- The battle transition's dungeon wipe uses the explicit map lists in
|
||||
-- data/maps/dungeon_maps.asm (field.dungeonTransitionMaps): singles plus
|
||||
-- inclusive map-id ranges -- faithful to the original's omissions
|
||||
@@ -1725,6 +1790,12 @@ function OverworldState:goFishing(rod)
|
||||
end))
|
||||
end
|
||||
|
||||
function OverworldState:useFishingRod(rod)
|
||||
if self.player.surfing or not self:facingIsShoreOrWater() then return false end
|
||||
self:goFishing(rod)
|
||||
return true
|
||||
end
|
||||
|
||||
-- Fly to a visited town (called from the party menu).
|
||||
function OverworldState:flyTo(mapId)
|
||||
local spot = Game.data.field.flyWarps[mapId]
|
||||
@@ -2472,6 +2543,15 @@ function OverworldState:trySurf(fx, fy, onClose)
|
||||
end))
|
||||
end
|
||||
|
||||
function OverworldState:stopSurfing(onClose)
|
||||
if onClose then onClose() end
|
||||
self.player.surfing = false
|
||||
require("src.core.Music").setSurfing(Game.data, false)
|
||||
Game.stack:push(Transition.whiteFlash(Game, nil, function()
|
||||
self:stepForwardOrCrossEdge(self.player.facing)
|
||||
end))
|
||||
end
|
||||
|
||||
function OverworldState:tryCut(fx, fy)
|
||||
-- UsedCut (engine/overworld/cut.asm) gates on the TILESET before
|
||||
-- anything else: only OVERWORLD (tree tile $3d) and GYM (plant tile
|
||||
@@ -3092,6 +3172,32 @@ local function meetTrainerTheme(cls)
|
||||
or "Music_MeetMaleTrainer"
|
||||
end
|
||||
|
||||
-- Public pre-trainer gate. A mod may retain continueBattle while a registered
|
||||
-- preparation screen is on top, then resume once with an optional ordered
|
||||
-- save-party index scope. The hook is cold on a no-mod boot.
|
||||
function OverworldState.prepareTrainerBattle(game, context, startBattle,
|
||||
cancelBattle)
|
||||
if not Runtime.wantsHook("trainer.before_battle") then
|
||||
startBattle()
|
||||
return false
|
||||
end
|
||||
local started = false
|
||||
local function continueBattle(options)
|
||||
if started then return false end
|
||||
started = true
|
||||
if type(options) == "table" and options.cancel == true then
|
||||
if cancelBattle then cancelBattle() end
|
||||
else
|
||||
startBattle(options)
|
||||
end
|
||||
return true
|
||||
end
|
||||
local deferred = Runtime.call("trainer.before_battle",
|
||||
function() return false end, game, context, continueBattle)
|
||||
if deferred ~= true and not started then continueBattle() end
|
||||
return deferred == true
|
||||
end
|
||||
|
||||
-- Run the pre-battle text -> battle -> won text -> flags sequence.
|
||||
-- skipBattleText is for map scripts shaped like SilphCo11FDefaultScript
|
||||
-- (scripts/SilphCo11F.asm), which DisplayTextID the challenge line BEFORE
|
||||
@@ -3119,7 +3225,8 @@ function OverworldState:engageTrainer(npc, onDone, endBattleText, skipBattleText
|
||||
or (header and header.won and Game.data.text[header.won])
|
||||
|
||||
local BattleState = require("src.battle.BattleState")
|
||||
local function startBattle()
|
||||
local function startBattle(options)
|
||||
self.cancelledTrainerSight = nil
|
||||
-- TalkToTrainer (home/trainers.asm:88) prints the before-battle text
|
||||
-- FIRST and only then runs `call EngageMapTrainer` / `jp
|
||||
-- StartTrainerBattle`, so a trainer challenged on foot gets the sting
|
||||
@@ -3134,7 +3241,8 @@ function OverworldState:engageTrainer(npc, onDone, endBattleText, skipBattleText
|
||||
local theme = meetTrainerTheme(d.trainerClass)
|
||||
if theme then require("src.core.Music").play(Game.data, theme) end
|
||||
end
|
||||
local battle = BattleState.newTrainer(Game, d.trainerClass, d.trainerParty)
|
||||
local battle = BattleState.newTrainer(Game, d.trainerClass, d.trainerParty,
|
||||
options)
|
||||
battle.checkpointOrigin = {
|
||||
kind = "trainer_encounter",
|
||||
map = self.map.id,
|
||||
@@ -3171,10 +3279,31 @@ function OverworldState:engageTrainer(npc, onDone, endBattleText, skipBattleText
|
||||
end
|
||||
self:pushBattle(battle)
|
||||
end
|
||||
local function prepareBattle()
|
||||
if not Runtime.wantsHook("trainer.before_battle") then
|
||||
startBattle()
|
||||
return
|
||||
end
|
||||
OverworldState.prepareTrainerBattle(Game, {
|
||||
trainerClass = d.trainerClass,
|
||||
partyIndex = d.trainerParty or 1,
|
||||
mapId = self.map.id,
|
||||
npcId = npc.id,
|
||||
}, startBattle, function()
|
||||
if self.player then
|
||||
self.cancelledTrainerSight = {
|
||||
npcId = npc.id,
|
||||
playerX = self.player.cellX,
|
||||
playerY = self.player.cellY,
|
||||
}
|
||||
end
|
||||
if onDone then onDone() end
|
||||
end)
|
||||
end
|
||||
if skipBattleText then
|
||||
startBattle()
|
||||
prepareBattle()
|
||||
else
|
||||
Game.stack:push(TextBox.new(Game, battleText, startBattle))
|
||||
Game.stack:push(TextBox.new(Game, battleText, prepareBattle))
|
||||
end
|
||||
end
|
||||
|
||||
@@ -3372,11 +3501,18 @@ function OverworldState:checkTrainerSight()
|
||||
if self.player.moving or self.engaging then return end
|
||||
if Game.stack:top() ~= self then return end
|
||||
local p = self.player
|
||||
local cancelled = self.cancelledTrainerSight
|
||||
if cancelled and (cancelled.playerX ~= p.cellX
|
||||
or cancelled.playerY ~= p.cellY) then
|
||||
self.cancelledTrainerSight = nil
|
||||
cancelled = nil
|
||||
end
|
||||
for _, npc in ipairs(self.npcs) do
|
||||
local d = npc.def
|
||||
-- CheckFightingMapTrainers engages ANY aligned trainer sprite,
|
||||
-- walkers included (they sight between steps)
|
||||
if d.trainerClass and not npc.moving
|
||||
and not (cancelled and cancelled.npcId == npc.id)
|
||||
and not self:trainerDefeated(npc)
|
||||
and not mapScripts.talkScript(self.map.id, d.text)
|
||||
and trainerSpriteOnScreen(npc, p) then
|
||||
|
||||
@@ -6,6 +6,8 @@
|
||||
-- stays unsupported; anything a mod legitimately needs belongs here.
|
||||
|
||||
local Logger = require("src.core.Logger")
|
||||
local FieldDefaults = require("src.world.FieldDefaults")
|
||||
local Map = require("src.world.Map")
|
||||
local MapLoader = require("src.world.MapLoader")
|
||||
local MapOverview = require("src.world.MapOverview")
|
||||
local Party = require("src.pokemon.Party")
|
||||
@@ -15,6 +17,9 @@ local WorldAPI = {}
|
||||
WorldAPI.__index = WorldAPI
|
||||
|
||||
local NO_OVERWORLD = "no overworld"
|
||||
local DIG_TILESETS = { FOREST = true, CEMETERY = true, CAVERN = true,
|
||||
FACILITY = true, INTERIOR = true }
|
||||
local RODS = { "OLD_ROD", "GOOD_ROD", "SUPER_ROD" }
|
||||
|
||||
local function acceptsMenuInput(game, ow)
|
||||
local stack = game and game.stack
|
||||
@@ -86,6 +91,104 @@ function WorldAPI:reorderParty(fromSlot, toSlot)
|
||||
return true
|
||||
end
|
||||
|
||||
-- Contextual field-item shortcuts. Only actions that can start immediately
|
||||
-- are listed; callers receive copied labels and never inspect world internals.
|
||||
function WorldAPI:availableFieldActions()
|
||||
local game, ow, out = self.game, self:overworld(), {}
|
||||
if not (game and game.save and ow and ow.map and ow.player)
|
||||
or not acceptsMenuInput(game, ow) then return out end
|
||||
local save, inventory = game.save, game.save.inventory or {}
|
||||
local items = game.data and game.data.items or {}
|
||||
|
||||
if (inventory.BICYCLE or 0) > 0 and not ow.player.surfing
|
||||
and not (save.onBike and save.forcedBike)
|
||||
and (save.onBike or ow:bikeAllowed(ow.map.id)) then
|
||||
out[#out + 1] = { id = "bicycle",
|
||||
label = save.onBike and "BIKE OFF" or "BICYCLE" }
|
||||
end
|
||||
|
||||
if not ow.player.surfing and ow:facingIsShoreOrWater() then
|
||||
local rods = {}
|
||||
for _, id in ipairs(RODS) do
|
||||
if (inventory[id] or 0) > 0 then
|
||||
local def = items[id]
|
||||
rods[#rods + 1] = { id = id, label = def and def.name or id }
|
||||
end
|
||||
end
|
||||
if #rods > 0 then
|
||||
out[#out + 1] = { id = "fish", label = "FISH", rods = rods }
|
||||
end
|
||||
end
|
||||
|
||||
if ow:useCutFieldMove() == "ok" then
|
||||
out[#out + 1] = { id = "cut", label = "CUT" }
|
||||
end
|
||||
local surf = ow:useSurfFieldMove()
|
||||
if surf == "ok" or surf == "dismount" then
|
||||
out[#out + 1] = { id = "surf",
|
||||
label = surf == "dismount" and "LEAVE WATER" or "SURF" }
|
||||
end
|
||||
|
||||
if not ow.strengthActive and ow:partyKnows("STRENGTH") then
|
||||
out[#out + 1] = { id = "strength", label = "STRENGTH" }
|
||||
end
|
||||
if ow.dark and ow:partyKnows("FLASH") then
|
||||
out[#out + 1] = { id = "flash", label = "FLASH" }
|
||||
end
|
||||
if DIG_TILESETS[ow.map.def.tileset] and ow.map.id ~= "AGATHAS_ROOM"
|
||||
and ow:partyKnows("DIG") then
|
||||
out[#out + 1] = { id = "dig", label = "DIG" }
|
||||
end
|
||||
if ow:partyKnows("TELEPORT") and Map.isOutside(ow.map.def,
|
||||
FieldDefaults.field(game.data, "outsideTilesets")) then
|
||||
out[#out + 1] = { id = "teleport", label = "TELEPORT" }
|
||||
end
|
||||
return out
|
||||
end
|
||||
|
||||
function WorldAPI:useFieldAction(id, opts)
|
||||
local game, ow = self.game, self:overworld()
|
||||
if not ow then return nil, NO_OVERWORLD end
|
||||
if not acceptsMenuInput(game, ow) then return nil, "world is busy" end
|
||||
local found
|
||||
for _, action in ipairs(self:availableFieldActions()) do
|
||||
if action.id == id then found = action break end
|
||||
end
|
||||
if not found then return nil, "field action unavailable" end
|
||||
|
||||
if id == "bicycle" then
|
||||
if ow:useBicycle() then return true end
|
||||
elseif id == "cut" then
|
||||
local x, y = ow.player:facingCell()
|
||||
if ow:tryCut(x, y) then return true end
|
||||
elseif id == "surf" then
|
||||
local mode = ow:useSurfFieldMove()
|
||||
if mode == "dismount" then
|
||||
ow:stopSurfing()
|
||||
return true
|
||||
elseif mode == "ok" then
|
||||
local x, y = ow.player:facingCell()
|
||||
ow:trySurf(x, y)
|
||||
return true
|
||||
end
|
||||
elseif id == "fish" then
|
||||
local rod = opts and opts.rod
|
||||
if not rod and #found.rods == 1 then rod = found.rods[1].id end
|
||||
for _, choice in ipairs(found.rods) do
|
||||
if choice.id == rod and ow:useFishingRod(rod) then return true end
|
||||
end
|
||||
return nil, "fishing rod unavailable"
|
||||
elseif id == "strength" then
|
||||
if ow:useStrengthFieldMove() then return true end
|
||||
elseif id == "flash" then
|
||||
if ow:useFlashFieldMove() then return true end
|
||||
elseif id == "dig" or id == "teleport" then
|
||||
ow:beginTeleportOut()
|
||||
return true
|
||||
end
|
||||
return nil, "field action unavailable"
|
||||
end
|
||||
|
||||
-- A compact, read-only view of the active map for minimaps and companion UIs.
|
||||
-- `rows` describes collision terrain; optional `tileRows` reduces each real
|
||||
-- 8x8 map tile to its average Game Boy shade ("0" lightest, "3" darkest).
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
-- Bake a Gen 2 map canvas without a World instance. The save editor's map
|
||||
-- tab uses this so Gold rooms draw the same tiles the overworld would,
|
||||
-- instead of the checkerboard fallback Map2 has no renderer for.
|
||||
|
||||
local Assets = require("src.render.Assets")
|
||||
local BorderFill = require("src.world.gen2.BorderFill")
|
||||
local GbcPalette = require("src.render.GbcPalette")
|
||||
local Palettes = require("src.world.gen2.Palettes")
|
||||
|
||||
local MapPreview = {}
|
||||
|
||||
local ROOF_TILESETS = {
|
||||
TILESET_JOHTO = true,
|
||||
TILESET_JOHTO_MODERN = true,
|
||||
TILESET_KANTO = true,
|
||||
}
|
||||
|
||||
local function applyRoofOverlay(atlasPath, roofPath, tilesPerRow)
|
||||
local atlasData = love.image.newImageData(Assets.resolve(atlasPath))
|
||||
local roofData = love.image.newImageData(Assets.resolve(roofPath))
|
||||
for t = 0, 8 do
|
||||
local destId = 0x0a + t
|
||||
local dx = (destId % tilesPerRow) * 8
|
||||
local dy = math.floor(destId / tilesPerRow) * 8
|
||||
local sx = t * 8
|
||||
for y = 0, 7 do
|
||||
for x = 0, 7 do
|
||||
local r, g, b, a = roofData:getPixel(sx + x, y)
|
||||
atlasData:setPixel(dx + x, dy + y, r, g, b, a)
|
||||
end
|
||||
end
|
||||
end
|
||||
local image = love.graphics.newImage(atlasData)
|
||||
image:setFilter("nearest", "nearest")
|
||||
return image
|
||||
end
|
||||
|
||||
local function optionalTable(data, gen2Key, plainKey, generated)
|
||||
if data[gen2Key] then return data[gen2Key] end
|
||||
if data[plainKey] then return data[plainKey] end
|
||||
local ok, mod = pcall(require, "data.generated." .. generated)
|
||||
if ok and type(mod) == "table" then return mod end
|
||||
return nil
|
||||
end
|
||||
|
||||
function MapPreview.baker(data)
|
||||
data = data or {}
|
||||
return {
|
||||
tilesets = data.gen2Tilesets or data.tilesets or {},
|
||||
-- Data:load does not pull roofs.lua; the Gold cache still has it.
|
||||
roofs = optionalTable(data, "gen2Roofs", "roofs", "roofs"),
|
||||
palettes = optionalTable(data, "gen2Palettes", "palettes", "palettes"),
|
||||
atlasCache = {},
|
||||
mapImages = {},
|
||||
}
|
||||
end
|
||||
|
||||
function MapPreview.atlasFor(baker, mapDef)
|
||||
if not (baker and mapDef) then return nil, nil end
|
||||
local tileset = baker.tilesets and baker.tilesets[mapDef.tileset]
|
||||
if not tileset then return nil, nil end
|
||||
local cacheKey = mapDef.tileset
|
||||
local roofName = nil
|
||||
local roofs = baker.roofs
|
||||
if ROOF_TILESETS[mapDef.tileset] then
|
||||
roofName = roofs and roofs.mapGroupRoofs and roofs.mapGroupRoofs[mapDef.group]
|
||||
end
|
||||
if roofName then cacheKey = cacheKey .. "|" .. roofName end
|
||||
local cached = baker.atlasCache[cacheKey]
|
||||
if cached then return cached, tileset end
|
||||
|
||||
local tilesPerRow = tileset.tilesPerRow or 16
|
||||
local atlas
|
||||
local roofSpec = roofName and roofs and roofs.roofs and roofs.roofs[roofName]
|
||||
if roofSpec and roofSpec.image and love.image and love.image.newImageData then
|
||||
local ok, img = pcall(applyRoofOverlay, tileset.image, roofSpec.image, tilesPerRow)
|
||||
if ok then atlas = img end
|
||||
end
|
||||
if not atlas then
|
||||
if not tileset.image then return nil, tileset end
|
||||
local ok, img = pcall(Assets.image, tileset.image)
|
||||
if not ok then return nil, tileset end
|
||||
atlas = img
|
||||
if atlas.setFilter then atlas:setFilter("nearest", "nearest") end
|
||||
end
|
||||
baker.atlasCache[cacheKey] = atlas
|
||||
return atlas, tileset
|
||||
end
|
||||
|
||||
-- Same bake as World:bakeMapImage (src/world/gen2/World.lua), minus the
|
||||
-- World fields the overworld keeps for anim overlays and cave flicker.
|
||||
function MapPreview.bake(baker, map, daytime)
|
||||
local atlas, tileset = MapPreview.atlasFor(baker, map.def)
|
||||
if not atlas or not tileset then return nil end
|
||||
if not (love.graphics and love.graphics.newCanvas) then return nil end
|
||||
local blocks = tileset.blocks
|
||||
local tilesPerRow = tileset.tilesPerRow or 16
|
||||
local pw, ph = map.width * 32, map.height * 32
|
||||
local okCanvas, canvas = pcall(love.graphics.newCanvas, pw, ph)
|
||||
if not okCanvas or not canvas then return nil end
|
||||
if canvas.setFilter then canvas:setFilter("nearest", "nearest") end
|
||||
local quads = {}
|
||||
local function quadFor(tile)
|
||||
local q = quads[tile]
|
||||
if q then return q end
|
||||
local sx = (tile % tilesPerRow) * 8
|
||||
local sy = math.floor(tile / tilesPerRow) * 8
|
||||
q = love.graphics.newQuad(sx, sy, 8, 8, atlas:getDimensions())
|
||||
quads[tile] = q
|
||||
return q
|
||||
end
|
||||
|
||||
local tilePalettes = tileset.tilePalettes
|
||||
local bgSet = baker.palettes and daytime
|
||||
and Palettes.bgSet(baker.palettes, map.def, daytime) or nil
|
||||
local colored = bgSet and tilePalettes and GbcPalette.available()
|
||||
local clearColor = { 0.15, 0.55, 0.25 }
|
||||
if bgSet and bgSet[1] and bgSet[1][1] then
|
||||
local c = GbcPalette.color(bgSet[1], 1)
|
||||
clearColor = { c[1] / 255, c[2] / 255, c[3] / 255 }
|
||||
end
|
||||
|
||||
local function drawTiles(slot)
|
||||
for by = 0, map.height - 1 do
|
||||
for bx = 0, map.width - 1 do
|
||||
local blockId = BorderFill.blockFor(
|
||||
map.blocks[by * map.width + bx + 1], map.borderBlock)
|
||||
local block = blocks and blocks[(blockId or 0) + 1]
|
||||
if block then
|
||||
for i = 0, 15 do
|
||||
local tile = block[i + 1] or 0
|
||||
local tileSlot = tilePalettes and tilePalettes[tile + 1] or 1
|
||||
if not slot or tileSlot == slot then
|
||||
local tx = bx * 32 + (i % 4) * 8
|
||||
local ty = by * 32 + math.floor(i / 4) * 8
|
||||
love.graphics.draw(atlas, quadFor(tile), tx, ty)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
local function paint()
|
||||
love.graphics.clear(clearColor[1], clearColor[2], clearColor[3], 1)
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
love.graphics.push()
|
||||
love.graphics.origin()
|
||||
if colored then
|
||||
for slot = 1, 8 do
|
||||
GbcPalette.with(bgSet[slot], function() drawTiles(slot) end)
|
||||
end
|
||||
else
|
||||
drawTiles(nil)
|
||||
end
|
||||
love.graphics.pop()
|
||||
end
|
||||
if canvas.renderTo then
|
||||
canvas:renderTo(paint)
|
||||
else
|
||||
paint()
|
||||
end
|
||||
return canvas
|
||||
end
|
||||
|
||||
function MapPreview.imageFor(baker, map)
|
||||
if not (baker and map and map.id) then return nil end
|
||||
local cached = baker.mapImages[map.id]
|
||||
if cached then return cached end
|
||||
local img = MapPreview.bake(baker, map, "DAY")
|
||||
baker.mapImages[map.id] = img or false
|
||||
return img
|
||||
end
|
||||
|
||||
function MapPreview.renderer(baker, map)
|
||||
local img = MapPreview.imageFor(baker, map)
|
||||
if not img then return nil end
|
||||
return {
|
||||
draw = function(_, camX, camY)
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
love.graphics.draw(img, -math.floor(camX or 0), -math.floor(camY or 0))
|
||||
end,
|
||||
}
|
||||
end
|
||||
|
||||
return MapPreview
|
||||
@@ -27,11 +27,27 @@ local Movement = require("src.script.gen2.Movement")
|
||||
local Runtime = require("src.mods.Runtime")
|
||||
local HiddenItems = require("src.world.gen2.HiddenItems")
|
||||
local MapOverview = require("src.world.MapOverview")
|
||||
local Bike = require("src.world.gen2.Bike")
|
||||
local FieldMoves = require("src.world.gen2.FieldMoves")
|
||||
local Permissions = require("src.world.gen2.Permissions")
|
||||
|
||||
local WorldAPI = {}
|
||||
WorldAPI.__index = WorldAPI
|
||||
|
||||
local NO_OVERWORLD = "no overworld"
|
||||
local RODS = { "OLD_ROD", "GOOD_ROD", "SUPER_ROD" }
|
||||
local FIELD_ACTIONS = {
|
||||
{ id = "cut", move = "CUT" },
|
||||
{ id = "surf", move = "SURF" },
|
||||
{ id = "strength", move = "STRENGTH" },
|
||||
{ id = "flash", move = "FLASH" },
|
||||
{ id = "headbutt", move = "HEADBUTT" },
|
||||
{ id = "whirlpool", move = "WHIRLPOOL" },
|
||||
{ id = "waterfall", move = "WATERFALL" },
|
||||
{ id = "sweet_scent", move = "SWEET_SCENT" },
|
||||
{ id = "dig", move = "DIG" },
|
||||
{ id = "teleport", move = "TELEPORT" },
|
||||
}
|
||||
|
||||
function WorldAPI.new(game, modId)
|
||||
return setmetatable({ game = game, modId = modId }, WorldAPI)
|
||||
@@ -52,6 +68,109 @@ function WorldAPI:current()
|
||||
facing = p and p.facing }
|
||||
end
|
||||
|
||||
local function itemLabel(game, id)
|
||||
local def = game and game.data and game.data.items
|
||||
and game.data.items[id]
|
||||
return (def and def.name) or id
|
||||
end
|
||||
|
||||
-- The same field-item contract as Gen 1, resolved through Gold's own bike,
|
||||
-- collision and fishing rules.
|
||||
function WorldAPI:availableFieldActions()
|
||||
local world, game, out = self:overworld(), self.game, {}
|
||||
if not (world and game and game.save and world.map and world.player)
|
||||
or not world:acceptsMenuInput() then return out end
|
||||
local inventory = game.save.inventory or {}
|
||||
|
||||
if (inventory.BICYCLE or 0) > 0 then
|
||||
local bike = Bike.tryBike({
|
||||
state = world.playerState,
|
||||
environment = world.map.def and world.map.def.environment,
|
||||
collision = world:playerCollision(),
|
||||
alwaysOnBike = world:alwaysOnBike(),
|
||||
})
|
||||
if bike == "mount" or bike == "dismount" then
|
||||
out[#out + 1] = { id = "bicycle",
|
||||
label = bike == "dismount" and "BIKE OFF" or "BICYCLE" }
|
||||
end
|
||||
end
|
||||
|
||||
local context = world:fieldContext()
|
||||
if not FieldMoves.isSurfing(world.playerState)
|
||||
and Permissions.isWater(context.facingColl) then
|
||||
local rods = {}
|
||||
for _, id in ipairs(RODS) do
|
||||
if (inventory[id] or 0) > 0 then
|
||||
rods[#rods + 1] = { id = id, label = itemLabel(game, id) }
|
||||
end
|
||||
end
|
||||
if #rods > 0 then
|
||||
out[#out + 1] = { id = "fish", label = "FISH", rods = rods }
|
||||
end
|
||||
end
|
||||
|
||||
for _, row in ipairs(FIELD_ACTIONS) do
|
||||
if not (row.move == "STRENGTH" and world.strengthActive) then
|
||||
local mon = FieldMoves.partyMoveUser(context.party, row.move, context)
|
||||
if mon then
|
||||
context.mon = mon
|
||||
local result = FieldMoves.fromMenu(row.move, context)
|
||||
if result.ok then
|
||||
out[#out + 1] = { id = row.id,
|
||||
label = row.move:gsub("_", " ") }
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
if (inventory.SQUIRTBOTTLE or 0) > 0
|
||||
and world:squirtbottleTreeScript() then
|
||||
out[#out + 1] = { id = "squirtbottle",
|
||||
label = itemLabel(game, "SQUIRTBOTTLE") }
|
||||
end
|
||||
return out
|
||||
end
|
||||
|
||||
function WorldAPI:useFieldAction(id, opts)
|
||||
local world = self:overworld()
|
||||
if not world then return nil, NO_OVERWORLD end
|
||||
if not world:acceptsMenuInput() then return nil, "world is busy" end
|
||||
local found
|
||||
for _, action in ipairs(self:availableFieldActions()) do
|
||||
if action.id == id then found = action break end
|
||||
end
|
||||
if not found then return nil, "field action unavailable" end
|
||||
|
||||
if id == "bicycle" then
|
||||
local outcome = world:useFieldItem("BICYCLE")
|
||||
if outcome and outcome ~= "nowhere" then return true end
|
||||
elseif id == "fish" then
|
||||
local rod = opts and opts.rod
|
||||
if not rod and #found.rods == 1 then rod = found.rods[1].id end
|
||||
for _, choice in ipairs(found.rods) do
|
||||
if choice.id == rod then
|
||||
local outcome = world:useFieldItem(rod)
|
||||
if outcome and outcome ~= "nowhere" then return true end
|
||||
break
|
||||
end
|
||||
end
|
||||
return nil, "fishing rod unavailable"
|
||||
elseif id == "squirtbottle" then
|
||||
local outcome = world:useFieldItem("SQUIRTBOTTLE")
|
||||
if outcome and outcome ~= "nowhere" then return true end
|
||||
end
|
||||
for _, row in ipairs(FIELD_ACTIONS) do
|
||||
if row.id == id then
|
||||
local context = world:fieldContext()
|
||||
local mon = FieldMoves.partyMoveUser(context.party, row.move, context)
|
||||
local result = mon and world:useFieldMove(row.move, mon)
|
||||
if result and result.ok then return true end
|
||||
return nil, "field action unavailable"
|
||||
end
|
||||
end
|
||||
return nil, "field action unavailable"
|
||||
end
|
||||
|
||||
-- The same read-only minimap contract as Gen 1, with Gold's object/event
|
||||
-- visibility rules supplying the semantic markers.
|
||||
function WorldAPI:mapOverview()
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
-- Driver: Oak's Pallet Town escort. Logs the player/Oak separation the
|
||||
-- whole way down and shoots the walk mid-street, so the formation is
|
||||
-- checkable by eye as well as by number (the escort holds one cell,
|
||||
-- ~16px; a desynced Oak drifts off by a cell per two steps).
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
local DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
|
||||
U.teleport(game, "PALLET_TOWN", 10, 3, "up")
|
||||
local ow = game.overworld
|
||||
local function oak()
|
||||
for _, n in ipairs(ow.npcs or {}) do
|
||||
if n.def and n.def.name == "PALLETTOWN_OAK" then return n end
|
||||
end
|
||||
end
|
||||
U.hold(game, "up", 40)
|
||||
local worst, samples = 0, 0
|
||||
for _ = 1, 1200 do
|
||||
if game.stack:top() ~= ow then U.tap(game, "a") end
|
||||
local o = oak()
|
||||
if o and #ow.scriptMoves > 0 and ow.map.id == "PALLET_TOWN" then
|
||||
local d = math.abs(o.px - ow.player.px) + math.abs(o.py - ow.player.py)
|
||||
if samples > 0 or d <= 20 then
|
||||
samples = samples + 1
|
||||
if d > worst then worst = d end
|
||||
if samples == 60 then U.shot(game, DIR .. "/escort_midwalk.png") end
|
||||
if samples % 24 == 1 then
|
||||
U.log(("t=%d player=(%d,%d) oak=(%d,%d) dist=%dpx")
|
||||
:format(samples, ow.player.cellX, ow.player.cellY,
|
||||
o.cellX, o.cellY, d))
|
||||
end
|
||||
end
|
||||
end
|
||||
if ow.map.id == "OAKS_LAB" then break end
|
||||
U.wait(2)
|
||||
end
|
||||
U.log(("ESCORT worst separation: %dpx over %d samples"):format(worst, samples))
|
||||
U.log("map:", ow.map.id, "flag:",
|
||||
tostring(game.save.flags.EVENT_FOLLOWED_OAK_INTO_LAB))
|
||||
love.event.quit()
|
||||
end
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,62 @@
|
||||
-- Mods "Show for" chips collapse to a dropdown when they cannot all fit.
|
||||
-- Landscape / desktop keep the individual game chips. No pokered cite: the
|
||||
-- launcher is port-only chrome.
|
||||
-- luajit tests/engine/launcher_mod_scope_dropdown.lua
|
||||
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
|
||||
local T = require("tests.harness")
|
||||
local check = T.check
|
||||
love = love or require("tests.love_stub")
|
||||
|
||||
love.graphics.setLineJoin = love.graphics.setLineJoin or function() end
|
||||
love.graphics.newShader = love.graphics.newShader or function() return {} end
|
||||
|
||||
local Kit = require("src.ui.kit.Kit")
|
||||
local RomImporter = require("src.import.RomImporter")
|
||||
local LauncherView = require("src.import.LauncherView")
|
||||
|
||||
local function window(w, h)
|
||||
love.graphics.getDimensions = function() return w, h end
|
||||
love.graphics.getPixelDimensions = function() return w, h end
|
||||
end
|
||||
|
||||
local function navIds()
|
||||
local ids = {}
|
||||
for i = 1, (Kit._navPrevN or 0) do
|
||||
local slot = Kit._nav[i]
|
||||
if slot and slot.id then ids[slot.id] = true end
|
||||
end
|
||||
return ids
|
||||
end
|
||||
|
||||
local function drawMods(W, H)
|
||||
window(W, H)
|
||||
local imp = RomImporter.new(function() end, { launcher = true })
|
||||
imp.tab = "mods"
|
||||
imp.ready = { red = true, blue = true, yellow = true, gold = true }
|
||||
local ok, err = pcall(LauncherView.draw, imp)
|
||||
check(ok, ("%dx%d mods draws: %s"):format(W, H, tostring(err)))
|
||||
return navIds()
|
||||
end
|
||||
|
||||
local portrait = drawMods(360, 780)
|
||||
check(portrait["mod-scope-menu"] == true,
|
||||
"portrait: Show-for collapses to a dropdown when the chips cannot all fit")
|
||||
check(portrait["mod-scope-gold"] == nil
|
||||
and portrait["mod-scope-red"] == nil
|
||||
and portrait["mod-scope-blue"] == nil
|
||||
and portrait["mod-scope-yellow"] == nil,
|
||||
"portrait: individual version chips are not drawn beside the dropdown")
|
||||
|
||||
local desktop = drawMods(1280, 720)
|
||||
check(desktop["mod-scope-menu"] == nil,
|
||||
"desktop: chips fit, so there is no dropdown")
|
||||
check(desktop["mod-scope-all"] == true
|
||||
and desktop["mod-scope-red"] == true
|
||||
and desktop["mod-scope-blue"] == true
|
||||
and desktop["mod-scope-yellow"] == true
|
||||
and desktop["mod-scope-gold"] == true,
|
||||
"desktop: every Show-for chip stays reachable")
|
||||
|
||||
print("ok launcher mod scope dropdown")
|
||||
@@ -23,7 +23,7 @@ local function importer(field)
|
||||
end
|
||||
|
||||
local modalFields = {
|
||||
"_modConfirm", "_modVersions", "_modReleaseNotes", "_findDetails",
|
||||
"_modConfirm", "_modVersions", "_modReleaseNotes", "_appPatchNotes", "_findDetails",
|
||||
}
|
||||
|
||||
for _, field in ipairs(modalFields) do
|
||||
@@ -77,6 +77,12 @@ do
|
||||
imp:keypressed("escape")
|
||||
eq(imp._modReleaseNotes, nil, "Escape closes release notes")
|
||||
end
|
||||
do
|
||||
resetFocus()
|
||||
local imp = importer("_appPatchNotes")
|
||||
imp:keypressed("escape")
|
||||
eq(imp._appPatchNotes, nil, "Escape closes patch notes")
|
||||
end
|
||||
do
|
||||
resetFocus()
|
||||
local imp = importer("_modVersions")
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
-- Launcher footer Patch notes control. The GitHub release body is already
|
||||
-- fetched by the updater; this is the in-app viewer for it.
|
||||
-- luajit tests/engine/launcher_patch_notes.lua
|
||||
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
|
||||
local T = require("tests.harness")
|
||||
local check, eq = T.check, T.eq
|
||||
love = love or require("tests.love_stub")
|
||||
|
||||
love.graphics.setLineJoin = love.graphics.setLineJoin or function() end
|
||||
love.graphics.newShader = love.graphics.newShader or function() return {} end
|
||||
|
||||
local Kit = require("src.ui.kit.Kit")
|
||||
local RomImporter = require("src.import.RomImporter")
|
||||
local LauncherView = require("src.import.LauncherView")
|
||||
local PatchNotes = require("src.update.PatchNotes")
|
||||
|
||||
local function window(w, h)
|
||||
love.graphics.getDimensions = function() return w, h end
|
||||
love.graphics.getPixelDimensions = function() return w, h end
|
||||
end
|
||||
|
||||
local function freshLauncher()
|
||||
return RomImporter.new(function() end, { launcher = true })
|
||||
end
|
||||
|
||||
local realPrint = love.graphics.print
|
||||
local function drawAndCapture(imp)
|
||||
local seen = {}
|
||||
love.graphics.print = function(str, ...)
|
||||
seen[#seen + 1] = tostring(str)
|
||||
return realPrint(str, ...)
|
||||
end
|
||||
local ok, err = pcall(LauncherView.draw, imp)
|
||||
love.graphics.print = realPrint
|
||||
check(ok, "the frame draws: " .. tostring(err))
|
||||
return table.concat(seen, "\n")
|
||||
end
|
||||
|
||||
window(1280, 720)
|
||||
local imp = freshLauncher()
|
||||
local text = drawAndCapture(imp)
|
||||
check(text:find("Patch notes", 1, true) ~= nil,
|
||||
"desktop footer prints Patch notes")
|
||||
|
||||
window(360, 780)
|
||||
local phone = freshLauncher()
|
||||
check(drawAndCapture(phone):find("Patch notes", 1, true) ~= nil,
|
||||
"portrait footer still prints Patch notes")
|
||||
|
||||
do
|
||||
local body, ver = PatchNotes.body({
|
||||
state = function()
|
||||
return { notes = "## Issues closed\n\n- #12 cart padding", latest = "1.4.2" }
|
||||
end,
|
||||
})
|
||||
eq(body, "## Issues closed\n\n- #12 cart padding",
|
||||
"PatchNotes prefers the updater's GitHub body")
|
||||
eq(ver, "1.4.2", "PatchNotes carries the release version")
|
||||
end
|
||||
|
||||
do
|
||||
local body, ver = PatchNotes.body(nil)
|
||||
check(type(body) == "string" and (body:find("Download", 1, true) or body:find("Issues", 1, true)),
|
||||
"without a check result PatchNotes uses the stashed iOS app-repo notes")
|
||||
check(type(ver) == "string" and ver:find("^%d+%.%d+%.%d+$") ~= nil,
|
||||
"stashed notes name a release version")
|
||||
end
|
||||
|
||||
do
|
||||
local f = assert(io.open("mobile/ios/app-repo.json", "rb"))
|
||||
local list = PatchNotes.parseRepo(f:read("*a"))
|
||||
f:close()
|
||||
check(#list >= 2, "app-repo.json stashes more than one release")
|
||||
local notes, ver = PatchNotes.fromRepo(list[2].version)
|
||||
eq(ver, list[2].version, "fromRepo can pick a specific stashed version")
|
||||
eq(notes, list[2].notes, "fromRepo returns that version's notes")
|
||||
end
|
||||
|
||||
imp._appPatchNotes = true
|
||||
local modal = drawAndCapture(imp)
|
||||
check(modal:find("Patch notes", 1, true) ~= nil, "the modal titles itself")
|
||||
check(modal:find("Close", 1, true) ~= nil, "the modal can be closed")
|
||||
|
||||
imp:keypressed("escape")
|
||||
eq(imp._appPatchNotes, nil, "Escape dismisses patch notes")
|
||||
|
||||
T.finish("launcher patch notes")
|
||||
@@ -0,0 +1,131 @@
|
||||
-- Trainer battles may use a battle-local view of save-party records without
|
||||
-- mutating, reordering, or hiding those records in the authoritative save.
|
||||
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
love = love or require("tests.love_stub")
|
||||
|
||||
local T = require("tests.harness").suite("trainer battle party scope")
|
||||
local BattleState = require("src.battle.BattleState")
|
||||
local BagMenu = require("src.ui.BagMenu")
|
||||
local Fixtures = require("tests.modkit").fixtures
|
||||
local PartyMenu = require("src.ui.PartyMenu")
|
||||
local Pokemon = require("src.pokemon.Pokemon")
|
||||
local SaveData = require("src.core.SaveData")
|
||||
local Bag = require("src.inventory.Bag")
|
||||
|
||||
local Data = Fixtures.fresh()
|
||||
Data.items.POTION = { id = "POTION", index = 99, name = "POTION",
|
||||
price = 300, tossable = true }
|
||||
|
||||
local function makeGame()
|
||||
local save = SaveData.newGame()
|
||||
save.party = {
|
||||
Pokemon.new(Data, "FIXMON_A", 10),
|
||||
Pokemon.new(Data, "FIXMON_B", 11),
|
||||
Pokemon.new(Data, "FIXMON_C", 12),
|
||||
}
|
||||
local stack = { states = {} }
|
||||
function stack:push(value) self.states[#self.states + 1] = value end
|
||||
function stack:pop() return table.remove(self.states) end
|
||||
function stack:top() return self.states[#self.states] end
|
||||
return { data = Data, save = save, stack = stack }
|
||||
end
|
||||
|
||||
local game = makeGame()
|
||||
local originalParty = game.save.party
|
||||
local first, second, third = unpack(originalParty)
|
||||
local battle = BattleState.newTrainer(game, "OPP_FIX_YOUNGSTER", 1, {
|
||||
playerPartyIndices = { 2, 3 },
|
||||
})
|
||||
T.check(game.save.party == originalParty,
|
||||
"scoping never replaces the authoritative save-party table")
|
||||
T.check(game.save.party[1] == first and game.save.party[2] == second
|
||||
and game.save.party[3] == third,
|
||||
"scoping never reorders authoritative save-party records")
|
||||
T.same(battle.playerPartyIndices, { 2, 3 },
|
||||
"the battle records normalized save-party indices")
|
||||
T.check(battle.playerParty[1] == second and battle.playerParty[2] == third,
|
||||
"the local party view contains the same selected Pokemon records")
|
||||
T.check(battle.player.mon == second,
|
||||
"initial send chooses the first healthy scoped member")
|
||||
|
||||
local menu = PartyMenu.new(game, { battle = battle })
|
||||
T.check(menu.party == battle.playerParty,
|
||||
"battle party menus traverse only the local eligible view")
|
||||
|
||||
Bag.add(game.save, "POTION", 1)
|
||||
local bag = BagMenu.new(game, { battle = battle })
|
||||
local potion
|
||||
for _, row in ipairs(bag.items) do
|
||||
if row.value == "POTION" then potion = row; break end
|
||||
end
|
||||
T.check(potion ~= nil, "the fixture potion is available for target selection")
|
||||
bag.onChoose(potion, bag)
|
||||
local targetPicker = game.stack:top()
|
||||
T.check(targetPicker and targetPicker.party == battle.playerParty,
|
||||
"in-battle item target selection traverses only eligible members")
|
||||
|
||||
second.hp = 0
|
||||
battle.player.mon.hp = 0
|
||||
battle:playerMonFainted()
|
||||
T.eq(battle.result, nil,
|
||||
"a healthy scoped replacement prevents premature exhaustion")
|
||||
third.hp = 0
|
||||
battle:playerMonFainted()
|
||||
T.eq(battle.result, "lose",
|
||||
"an excluded healthy save-party member cannot prevent scoped exhaustion")
|
||||
T.check(first.hp > 0, "the excluded save-party member remains untouched")
|
||||
|
||||
local expGame = makeGame()
|
||||
expGame.save.inventory.EXP_ALL = 1
|
||||
local expBattle = BattleState.newTrainer(expGame,
|
||||
"OPP_FIX_YOUNGSTER", 1, { playerPartyIndices = { 2, 3 } })
|
||||
local excludedExp = expGame.save.party[1].exp
|
||||
local participantExp = expGame.save.party[2].exp
|
||||
local sharedExp = expGame.save.party[3].exp
|
||||
expBattle.participants = { [expGame.save.party[2]] = true }
|
||||
expBattle:awardExp()
|
||||
T.eq(expGame.save.party[1].exp, excludedExp,
|
||||
"EXP.ALL cannot award an excluded save-party member")
|
||||
T.check(expGame.save.party[2].exp > participantExp,
|
||||
"a scoped participant receives battle experience")
|
||||
T.check(expGame.save.party[3].exp > sharedExp,
|
||||
"EXP.ALL traverses other eligible scoped members")
|
||||
|
||||
local fallbackGame = makeGame()
|
||||
local fallback = BattleState.newTrainer(fallbackGame,
|
||||
"OPP_FIX_YOUNGSTER", 1, { playerPartyIndices = { 0, 99, 1.5, 0 } })
|
||||
T.eq(fallback.playerParty, nil,
|
||||
"a malformed or empty scope degrades to the vanilla full-party path")
|
||||
T.eq(fallback.player.mon, fallbackGame.save.party[1],
|
||||
"invalid scope fallback preserves vanilla initial send")
|
||||
|
||||
local partialGame = makeGame()
|
||||
local partial = BattleState.newTrainer(partialGame,
|
||||
"OPP_FIX_YOUNGSTER", 1, { playerPartyIndices = { 2, 99 } })
|
||||
T.eq(partial.playerParty, nil,
|
||||
"one invalid member makes the entire scope fall back")
|
||||
|
||||
local duplicateGame = makeGame()
|
||||
local duplicate = BattleState.newTrainer(duplicateGame,
|
||||
"OPP_FIX_YOUNGSTER", 1, { playerPartyIndices = { 2, 2 } })
|
||||
T.eq(duplicate.playerParty, nil,
|
||||
"duplicate members make the entire scope fall back")
|
||||
|
||||
local malformedOptionsGame = makeGame()
|
||||
local malformedOptions = BattleState.newTrainer(malformedOptionsGame,
|
||||
"OPP_FIX_YOUNGSTER", 1, 7)
|
||||
T.eq(malformedOptions.playerParty, nil,
|
||||
"a malformed options value degrades to the vanilla full-party path")
|
||||
|
||||
local linkGame = makeGame()
|
||||
local linkBattle = BattleState.newTrainer(linkGame,
|
||||
"OPP_FIX_YOUNGSTER", 1, { playerPartyIndices = { 2, 3 } })
|
||||
linkBattle.kind = "link"
|
||||
linkBattle.result = "guestWin"
|
||||
linkGame.save.party[2].hp, linkGame.save.party[3].hp = 0, 0
|
||||
linkBattle:finish()
|
||||
T.eq(linkBattle.result, "guestWin",
|
||||
"link spectator outcomes are not rewritten by trainer eligibility scope")
|
||||
|
||||
T.finish()
|
||||
@@ -13,6 +13,8 @@ package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
|
||||
local T = require("tests.modkit")
|
||||
local OW = require("src.world.OverworldController")
|
||||
local Hooks = require("src.mods.Hooks")
|
||||
local Runtime = require("src.mods.Runtime")
|
||||
|
||||
local function setUpvalue(fn, name, val)
|
||||
local i = 1
|
||||
@@ -96,6 +98,89 @@ T.eq(rivalCount, 0, "rival classes play no encounter sting here")
|
||||
local _, seenCount = stingFor("OPP_LASS", true)
|
||||
T.eq(seenCount, 0, "self.engaging suppresses a second sting")
|
||||
|
||||
-- A deferred preparation may cancel instead of constructing a battle. For a
|
||||
-- sight trainer, that must leave a one-position latch: otherwise the still
|
||||
-- undefeated adjacent trainer sees the stationary player again next frame and
|
||||
-- immediately reopens the preparation screen.
|
||||
local oldEvents, oldHooks, oldErrors = Runtime.events, Runtime.hooks,
|
||||
Runtime.errors
|
||||
local cancelHooks = Hooks.new()
|
||||
Runtime.install(oldEvents, cancelHooks, oldErrors)
|
||||
cancelHooks:wrap("trainer.before_battle", function(_, _, _, continue)
|
||||
continue({ cancel = true })
|
||||
return true
|
||||
end, 0, "cancel_probe")
|
||||
local cancelNpc = { id = "npc#cancel", cellX = 0, cellY = -1,
|
||||
facing = "down", moving = false, def = { trainerClass = "OPP_LASS",
|
||||
trainerParty = 1, index = 1 } }
|
||||
fakeSelf.player = { cellX = 0, cellY = 0, moving = false }
|
||||
fakeSelf.map.id = "FIX_ROUTE"
|
||||
fakeSelf.engaging = false
|
||||
pushed, plays = {}, {}
|
||||
local completed = 0
|
||||
fakeSelf:engageTrainer(cancelNpc, function() completed = completed + 1 end)
|
||||
pushed[1].onDone()
|
||||
T.eq(completed, 1, "cancel completes the deferred encounter without a battle")
|
||||
T.same(fakeSelf.cancelledTrainerSight, {
|
||||
npcId = "npc#cancel", playerX = 0, playerY = 0,
|
||||
}, "cancel suppresses immediate sight re-entry at the current player cell")
|
||||
|
||||
local approaches = 0
|
||||
fakeSelf.npcs = { cancelNpc }
|
||||
fakeSelf.trainerDefeated = function() return false end
|
||||
fakeSelf.startTrainerApproach = function() approaches = approaches + 1 end
|
||||
fakeGame.stack.top = function() return fakeSelf end
|
||||
fakeGame.data.trainerHeader = function() return { range = 2 } end
|
||||
T.check(setUpvalue(OW.checkTrainerSight, "mapScripts", {
|
||||
talkScript = function() return nil end,
|
||||
}), "mapScripts upvalue on checkTrainerSight")
|
||||
fakeSelf:checkTrainerSight()
|
||||
T.eq(approaches, 0,
|
||||
"a cancelled adjacent trainer cannot reacquire the stationary player")
|
||||
fakeSelf.player.cellX = 1
|
||||
fakeSelf:checkTrainerSight()
|
||||
T.eq(fakeSelf.cancelledTrainerSight, nil,
|
||||
"moving one cell releases the cancelled sight latch")
|
||||
fakeSelf.player.cellX = 0
|
||||
fakeSelf:checkTrainerSight()
|
||||
T.eq(approaches, 1,
|
||||
"returning to the sight line permits a fresh trainer challenge")
|
||||
Runtime.install(oldEvents, oldHooks, oldErrors)
|
||||
|
||||
-- OverworldState is a singleton reused by StateStack. A title/load cycle must
|
||||
-- clear this volatile latch too, or CONTINUE at the same map and cell inherits
|
||||
-- the cancelled sight suppression from the previous session.
|
||||
local Camera = require("src.render.Camera")
|
||||
local Collision = require("src.world.Collision")
|
||||
local Encounter = require("src.world.Encounter")
|
||||
local ScriptRunner = require("src.script.ScriptRunner")
|
||||
local oldCameraNew, oldCollisionLoad = Camera.new, Collision.load
|
||||
local oldEncounterLoad, oldRunnerNew = Encounter.load, ScriptRunner.new
|
||||
local oldGameModule = package.loaded["src.core.Game"]
|
||||
local oldScriptsModule = package.loaded["data.scripts.init"]
|
||||
Camera.new = function() return {} end
|
||||
Collision.load = function() end
|
||||
Encounter.load = function() end
|
||||
ScriptRunner.new = function() return {} end
|
||||
package.loaded["src.core.Game"] = {
|
||||
data = {}, save = { lastOutdoor = "FIX_ROUTE" },
|
||||
}
|
||||
package.loaded["data.scripts.init"] = {}
|
||||
local lifecycle = setmetatable({
|
||||
cancelledTrainerSight = {
|
||||
npcId = "FIX_ROUTE_obj_1", playerX = 0, playerY = 0,
|
||||
},
|
||||
setMap = function() end,
|
||||
refreshStandingOnWarp = function() end,
|
||||
}, { __index = OW })
|
||||
lifecycle:enter("FIX_ROUTE", 0, 0, "down", { via = "boot" })
|
||||
T.eq(lifecycle.cancelledTrainerSight, nil,
|
||||
"fresh overworld entry clears a cancelled trainer sight latch")
|
||||
Camera.new, Collision.load = oldCameraNew, oldCollisionLoad
|
||||
Encounter.load, ScriptRunner.new = oldEncounterLoad, oldRunnerNew
|
||||
package.loaded["src.core.Game"] = oldGameModule
|
||||
package.loaded["data.scripts.init"] = oldScriptsModule
|
||||
|
||||
if realMusic ~= nil then package.loaded["src.core.Music"] = realMusic
|
||||
else package.loaded["src.core.Music"] = nil end
|
||||
if realBattle ~= nil then package.loaded["src.battle.BattleState"] = realBattle
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
-- trainers.trueColor: the same 4-shade opt-out pokemon and sprites already
|
||||
-- carry, now on the trainers registry. ROM-free.
|
||||
-- luajit tests/engine/trainer_true_color.lua
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
|
||||
local T = require("tests.modkit")
|
||||
local BattleState = require("src.battle.BattleState")
|
||||
local Gen2Battle = require("src.ui.gen2.BattleState")
|
||||
local Schemas = require("src.mods.Schemas")
|
||||
local OakSpeech = require("src.ui.OakSpeech")
|
||||
|
||||
local spec = Schemas.REGISTRIES.trainers
|
||||
T.check(spec.fields.trueColor ~= nil,
|
||||
"Gen 1 trainers schema lists trueColor")
|
||||
T.check(Schemas.check(spec, "trainers", "OPP_BROCK",
|
||||
{ trueColor = true }, "patch"),
|
||||
"a trueColor patch validates")
|
||||
T.check(not Schemas.check(spec, "trainers", "OPP_BROCK",
|
||||
{ trueColor = "yes" }, "patch"),
|
||||
"trueColor rejects a non-boolean")
|
||||
|
||||
local gen2 = Schemas.shapeFor("trainers", spec, 2)
|
||||
T.check(gen2.fields.trueColor ~= nil and gen2.fields.pic ~= nil,
|
||||
"Gold trainers schema lists pic and trueColor")
|
||||
T.check(Schemas.check(spec, "trainers", "BEAUTY",
|
||||
{ pic = "mods/x/beauty.png", trueColor = true },
|
||||
"patch", 2),
|
||||
"a Gold pic+trueColor patch validates")
|
||||
|
||||
T.eq(BattleState.trainerTrueColor(nil, nil), false,
|
||||
"no trainer is not trueColor")
|
||||
T.eq(BattleState.trainerTrueColor(nil, { pic = "a.png" }), false,
|
||||
"a vanilla portrait is not trueColor")
|
||||
T.eq(BattleState.trainerTrueColor(nil, { trueColor = true }), true,
|
||||
"the record's own flag wins")
|
||||
T.eq(BattleState.trainerTrueColor(nil, { trueColor = false }), false,
|
||||
"explicit false stays false")
|
||||
|
||||
local data = {
|
||||
trainers = {
|
||||
BASE = { pic = "base.png", trueColor = true },
|
||||
VANILLA = { pic = "vanilla.png" },
|
||||
},
|
||||
}
|
||||
T.eq(BattleState.trainerTrueColor(data, { basePic = "BASE" }), true,
|
||||
"a basePic reuse inherits the base flag")
|
||||
T.eq(BattleState.trainerTrueColor(data,
|
||||
{ basePic = "BASE", trueColor = false }), false,
|
||||
"an explicit false on the subclass beats the base")
|
||||
T.eq(BattleState.trainerTrueColor(data, { basePic = "VANILLA" }), false,
|
||||
"reusing a vanilla base stays unshaded-off")
|
||||
|
||||
local goldData = {
|
||||
gen2Trainers = {
|
||||
classes = {
|
||||
BEAUTY = { pic = "mods/x/beauty.png", trueColor = true },
|
||||
BUG_CATCHER = {},
|
||||
},
|
||||
},
|
||||
gen2MenuGfx = {
|
||||
battleHud = {
|
||||
trainerPics = {
|
||||
BEAUTY = "assets/generated/trainers/beauty.png",
|
||||
BUG_CATCHER = "assets/generated/trainers/bug_catcher.png",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
local beautyPath, beautyTc = Gen2Battle.trainerArt(goldData, "BEAUTY")
|
||||
T.eq(beautyPath, "mods/x/beauty.png",
|
||||
"a class pic wins over the extracted sheet")
|
||||
T.eq(beautyTc, true, "and keeps trueColor")
|
||||
local bugPath, bugTc = Gen2Battle.trainerArt(goldData, "BUG_CATCHER")
|
||||
T.eq(bugPath, "assets/generated/trainers/bug_catcher.png",
|
||||
"a class without pic keeps the extracted sheet")
|
||||
T.eq(bugTc, false, "and is not trueColor")
|
||||
T.eq(select(1, Gen2Battle.trainerArt(goldData, nil)), nil,
|
||||
"no class is no pic")
|
||||
|
||||
local game = {
|
||||
data = {
|
||||
trainers = {
|
||||
OPP_BROCK = { pic = "brock.png", trueColor = true },
|
||||
OPP_PROF_OAK = { pic = "oak.png", trueColor = true },
|
||||
},
|
||||
},
|
||||
}
|
||||
local _, _, oakTc = OakSpeech.resolvePic(game,
|
||||
{ type = "trainer", id = "OPP_BROCK" })
|
||||
T.eq(oakTc, true, "OakSpeech reports a trainer record's trueColor")
|
||||
|
||||
T.finish("trainer true color")
|
||||
@@ -32,6 +32,15 @@ eq(rel.payloadName, "gen1recomp-1.4.2.love", "payload name derived from version"
|
||||
eq(rel.payload.url, "http://x/love", "payload asset url picked")
|
||||
eq(rel.payload.size, 12345, "payload asset size picked")
|
||||
eq(rel.sums.url, "http://x/sums", "sums asset url picked")
|
||||
eq(rel.notes, "", "missing release body becomes empty notes")
|
||||
|
||||
local withNotes = Check.parseRelease(Json.encode({
|
||||
tag_name = "v1.4.2",
|
||||
body = "## Issues closed\n\n- #1 cart padding",
|
||||
assets = {},
|
||||
}))
|
||||
eq(withNotes.notes, "## Issues closed\n\n- #1 cart padding",
|
||||
"parseRelease keeps the GitHub release body")
|
||||
|
||||
-- a newer release that ships no .love yet: parses, but the payload/sums are nil
|
||||
-- so the worker will route to needs_full rather than an in-place update
|
||||
|
||||
@@ -287,6 +287,7 @@ eq(rel.payload.url, "http://x/love", "parseRelease picks the payload asset url")
|
||||
eq(rel.payload.size, 12345, "parseRelease picks the payload asset size")
|
||||
eq(rel.sums.url, "http://x/sums", "parseRelease picks the sums asset url")
|
||||
eq(rel.sums.size, 99, "parseRelease picks the sums asset size")
|
||||
eq(rel.notes, "", "parseRelease treats a missing body as empty notes")
|
||||
|
||||
-- a release with no .love yet still parses; payload/sums are nil so the worker
|
||||
-- routes to a full reinstall rather than an in-place update
|
||||
|
||||
@@ -17,6 +17,7 @@ local ARCHIVE = {
|
||||
local files, dirs, arch = {}, {}, {}
|
||||
local fileDataMounts, pathMounts, stagedTemps = 0, 0, {}
|
||||
local stagedEver = false
|
||||
local failWriteOnce
|
||||
|
||||
local function resetFs()
|
||||
for k in pairs(files) do files[k] = nil end
|
||||
@@ -25,6 +26,7 @@ local function resetFs()
|
||||
fileDataMounts, pathMounts = 0, 0
|
||||
stagedTemps = {}
|
||||
stagedEver = false
|
||||
failWriteOnce = nil
|
||||
end
|
||||
|
||||
local function dirChild(key, name)
|
||||
@@ -45,6 +47,10 @@ end
|
||||
local vfs = {}
|
||||
|
||||
function vfs.write(name, data)
|
||||
if failWriteOnce == name then
|
||||
failWriteOnce = nil
|
||||
return nil, "simulated write failure"
|
||||
end
|
||||
files[name] = data
|
||||
if name:match("^mod_import_") then
|
||||
stagedTemps[name] = true
|
||||
@@ -171,6 +177,16 @@ eq(staged, 0, "FileData path leaves no staged temp zip")
|
||||
check(files["mods/" .. MOD_ID .. "/manifest.json"] ~= nil,
|
||||
"install wrote manifest into mods/")
|
||||
|
||||
-- A third-party archive cannot bypass modkit's no-baseroms packaging gate.
|
||||
resetFs()
|
||||
ARCHIVE[MOD_ID .. "/baseroms/source.z64"] = "packaged rom"
|
||||
files["imports/mods/packaged-rom.zip"] = "PK\3\4packaged-rom"
|
||||
ok, err = LauncherMods.installZip("imports/mods/packaged-rom.zip")
|
||||
check(not ok, "an archive containing baseroms is rejected")
|
||||
check(tostring(err):find("must not include", 1, true),
|
||||
"baseroms archive rejection explains the policy")
|
||||
ARCHIVE[MOD_ID .. "/baseroms/source.z64"] = nil
|
||||
|
||||
-- Fallback: no newFileData → stage temp + path mount
|
||||
resetFs()
|
||||
vfs.newFileData = nil
|
||||
@@ -205,6 +221,50 @@ check(files["mods/WildsOfKanto-1.5.0/manifest.json"] == nil,
|
||||
check(files["mods/" .. MOD_ID .. "/manifest.json"] ~= nil,
|
||||
"replace still lands in mods/<id>")
|
||||
|
||||
-- User-selected baseroms belong to the installation, not the downloaded mod
|
||||
-- archive, and survive the same replacement path.
|
||||
resetFs()
|
||||
files["mods/OldFolder/manifest.json"] =
|
||||
('{"id":"%s","name":"Old Copy","version":"0.9.0","entry":"main.lua"}')
|
||||
:format(MOD_ID)
|
||||
files["mods/OldFolder/main.lua"] = "return function() end\n"
|
||||
files["mods/OldFolder/baseroms/stadium2.z64"] = "user-owned-rom"
|
||||
files["imports/mods/update-with-rom.zip"] = "PK\3\4update"
|
||||
ok, err = LauncherMods.installZip("imports/mods/update-with-rom.zip",
|
||||
{ replace = true, expectId = MOD_ID })
|
||||
check(ok == true, "replace with a baserom succeeds (" .. tostring(err) .. ")")
|
||||
eq(files["mods/" .. MOD_ID .. "/baseroms/stadium2.z64"], "user-owned-rom",
|
||||
"replace preserves user-owned baseroms under the canonical mod folder")
|
||||
check(files["mods/OldFolder/baseroms/stadium2.z64"] == nil,
|
||||
"the shadow mod tree is still removed after preservation")
|
||||
|
||||
-- A preservation write failure keeps recovery bytes outside mods/, where
|
||||
-- discovery cannot mistake a baseroms-only directory for an installed mod.
|
||||
resetFs()
|
||||
files["mods/OldFolder/manifest.json"] =
|
||||
('{"id":"%s","name":"Old Copy","version":"0.9.0","entry":"main.lua"}')
|
||||
:format(MOD_ID)
|
||||
files["mods/OldFolder/main.lua"] = "return function() end\n"
|
||||
files["mods/OldFolder/baseroms/stadium2.z64"] = "user-owned-rom"
|
||||
files["imports/mods/preserve-fail.zip"] = "PK\3\4update"
|
||||
failWriteOnce = "mods/" .. MOD_ID .. "/baseroms/stadium2.z64"
|
||||
ok, err = LauncherMods.installZip("imports/mods/preserve-fail.zip",
|
||||
{ replace = true, expectId = MOD_ID })
|
||||
check(not ok, "preservation failure rejects the update")
|
||||
check(files["mods/" .. MOD_ID .. "/manifest.json"] == nil,
|
||||
"preservation failure leaves no manifest-less tree under mods")
|
||||
eq(files["imports/baseroms-recovery/" .. MOD_ID .. "/stadium2.z64"],
|
||||
"user-owned-rom", "preservation failure stages recovery outside mods")
|
||||
|
||||
files["imports/mods/preserve-retry.zip"] = "PK\3\4update"
|
||||
ok, err = LauncherMods.installZip("imports/mods/preserve-retry.zip",
|
||||
{ replace = true, expectId = MOD_ID })
|
||||
check(ok == true, "retry restores staged baseroms (" .. tostring(err) .. ")")
|
||||
eq(files["mods/" .. MOD_ID .. "/baseroms/stadium2.z64"], "user-owned-rom",
|
||||
"retry restores the recovered baserom into the installed mod")
|
||||
check(files["imports/baseroms-recovery/" .. MOD_ID .. "/stadium2.z64"] == nil,
|
||||
"successful retry clears baserom recovery debris")
|
||||
|
||||
-- #834: a manifest-less mods/<id> tree (interrupted copy debris) must not
|
||||
-- block a plain re-import as "already installed"
|
||||
resetFs()
|
||||
|
||||
@@ -23,6 +23,7 @@ local MoveEffects = require("src.battle.MoveEffects")
|
||||
local Pokemon = require("src.pokemon.Pokemon")
|
||||
local Runtime = require("src.mods.Runtime")
|
||||
local SaveData = require("src.core.SaveData")
|
||||
local Schemas = require("src.mods.Schemas")
|
||||
local Status = require("src.battle.Status")
|
||||
local TrainerAI = require("src.battle.TrainerAI")
|
||||
local TurnOrder = require("src.battle.TurnOrder")
|
||||
@@ -346,6 +347,20 @@ do
|
||||
check(BattleState.trainerPicPath(Data, { basePic = "OPP_ENGINEER" })
|
||||
== Data.trainers.OPP_ENGINEER.pic,
|
||||
"a custom trainer can reuse a base trainer portrait by id")
|
||||
check(BattleState.trainerTrueColor(Data, { trueColor = true }) == true,
|
||||
"a trainer record's trueColor flag is readable")
|
||||
check(BattleState.trainerTrueColor(Data, { trueColor = false }) == false,
|
||||
"explicit false stays false")
|
||||
check(BattleState.trainerTrueColor(Data, { basePic = "OPP_ENGINEER" })
|
||||
== false,
|
||||
"a vanilla base portrait is not trueColor")
|
||||
check(Schemas.check(Schemas.REGISTRIES.trainers, "trainers", "OPP_BROCK",
|
||||
{ trueColor = true }, "patch"),
|
||||
"a trueColor trainers patch validates against the catalog schema")
|
||||
check(Schemas.check(Schemas.REGISTRIES.trainers, "trainers", "BEAUTY",
|
||||
{ pic = "mods/x/beauty.png", trueColor = true },
|
||||
"patch", 2),
|
||||
"a Gold trainers patch can carry pic and trueColor")
|
||||
end
|
||||
|
||||
do
|
||||
|
||||
@@ -370,6 +370,32 @@ check(math.abs(r - 0.4) < 1e-6 and math.abs(g - 0.7) < 1e-6
|
||||
and math.abs(b - 0.9) < 1e-6,
|
||||
"a trueColor pic keeps a pixel no 4-shade palette contains")
|
||||
|
||||
-- trainers.trueColor is the same opt-out on a class portrait
|
||||
BattleState.invalidate()
|
||||
local trainerPicData = {
|
||||
trainers = {
|
||||
SHADED = { pic = "assets/generated/battle/front/shaded.png" },
|
||||
FULLCOLOR = { pic = "assets/generated/battle/front/full.png",
|
||||
trueColor = true },
|
||||
REUSED = { basePic = "FULLCOLOR" },
|
||||
},
|
||||
palettes = { palettes = { MEWMON = monPalette }, pokemon = {} },
|
||||
}
|
||||
local shadedTrainer = BattleState.trainerSprite(trainerPicData,
|
||||
trainerPicData.trainers.SHADED)
|
||||
r, g, b = shadedTrainer.data:getPixel(0, 0)
|
||||
check(r == 0 and g == 0 and b == 1,
|
||||
"a 4-shade trainer pic is palette-quantized onto its shade bucket")
|
||||
local fullTrainer = BattleState.trainerSprite(trainerPicData,
|
||||
trainerPicData.trainers.FULLCOLOR)
|
||||
r, g, b = fullTrainer.data:getPixel(0, 0)
|
||||
check(math.abs(r - 0.4) < 1e-6 and math.abs(g - 0.7) < 1e-6
|
||||
and math.abs(b - 0.9) < 1e-6,
|
||||
"a trueColor trainer pic keeps a pixel no 4-shade palette contains")
|
||||
check(BattleState.trainerTrueColor(trainerPicData,
|
||||
trainerPicData.trainers.REUSED) == true,
|
||||
"a basePic reuse inherits the base portrait's trueColor flag")
|
||||
|
||||
-- ------- trueColor: the colors == false zone sentinel
|
||||
|
||||
check(PaletteFX.zone(nil, 0, 0, 1, 1) == nil, "nil colors is still no zone")
|
||||
|
||||
@@ -0,0 +1,256 @@
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
love = love or require("tests.love_stub")
|
||||
|
||||
local Manifest = require("src.mods.Manifest")
|
||||
local RequiredImports = require("src.mods.RequiredImports")
|
||||
local Loader = require("src.mods.Loader")
|
||||
local S = require("tests.harness").suite("required mod imports")
|
||||
local check, eq = S.check, S.eq
|
||||
|
||||
local DIGEST = "0123456789abcdef0123456789abcdef"
|
||||
local function fakeHash(data)
|
||||
return data:sub(1, 4) == "\128\55\18\64" and DIGEST
|
||||
or "ffffffffffffffffffffffffffffffff"
|
||||
end
|
||||
|
||||
local manifest = Manifest.validate({
|
||||
id = "stadium_fx", name = "Stadium FX", version = "1.0.0", entry = "main.lua",
|
||||
required_imports = {
|
||||
{ id = "stadium2", name = "Stadium 2", file = "stadium2.z64",
|
||||
description = "USA dump", format = "n64", size = 8,
|
||||
md5 = { DIGEST, DIGEST:upper() } },
|
||||
},
|
||||
}, "mods/stadium_fx")
|
||||
|
||||
eq(#manifest.required_imports, 1, "required import parses")
|
||||
eq(#manifest.required_imports[1].md5, 1, "accepted MD5 values normalize and dedupe")
|
||||
eq(manifest.required_imports[1].md5[1], DIGEST, "MD5 is lowercase")
|
||||
eq(manifest.required_imports[1].description, "USA dump",
|
||||
"import description is preserved for the picker UI")
|
||||
eq(manifest.required_imports[1].size, 8, "exact import size parses")
|
||||
|
||||
local optionalManifest = Manifest.validate({
|
||||
id = "optional_fx", name = "Optional FX", version = "1.0.0", entry = "main.lua",
|
||||
optional_imports = {
|
||||
{ id = "bonus", name = "Bonus ROM", file = "bonus.z64",
|
||||
format = "n64", md5 = DIGEST },
|
||||
},
|
||||
}, "mods/optional_fx")
|
||||
eq(#optionalManifest.optional_imports, 1, "optional import parses")
|
||||
eq(optionalManifest.optional_imports[1].required, false,
|
||||
"optional import is marked non-blocking")
|
||||
local optionalRows, optionalMissing, missingOptional =
|
||||
RequiredImports.inspect(optionalManifest, love.filesystem, fakeHash)
|
||||
eq(optionalMissing, 0, "missing optional import is not required")
|
||||
eq(missingOptional, 1, "missing optional import is reported separately")
|
||||
check(not optionalRows[1].required, "optional row is labeled optional")
|
||||
|
||||
check(not pcall(Manifest.validate, {
|
||||
id = "bad", name = "Bad", version = "1", entry = "main.lua",
|
||||
required_imports = { { id = "rom", file = "../outside.z64", md5 = DIGEST } },
|
||||
}), "required import cannot escape baseroms")
|
||||
check(not pcall(Manifest.validate, {
|
||||
id = "bad", name = "Bad", version = "1", entry = "main.lua",
|
||||
required_imports = { { id = "rom", file = "rom.z64", md5 = "short" } },
|
||||
}), "malformed MD5 is refused")
|
||||
check(not pcall(Manifest.validate, {
|
||||
id = "bad", name = "Bad", version = "1", entry = "main.lua",
|
||||
required_imports = { { id = "rom", file = ".rom.removed", md5 = DIGEST } },
|
||||
}), "hidden import filenames cannot collide with engine metadata")
|
||||
check(not pcall(Manifest.validate, {
|
||||
id = "bad", name = "Bad", version = "1", entry = "main.lua",
|
||||
required_imports = { { id = "rom", file = "rom.bin", md5 = DIGEST,
|
||||
max_size = RequiredImports.MAX_BYTES + 1 } },
|
||||
}), "manifest import sizes cannot exceed the hard limit")
|
||||
|
||||
local canonical = "\128\55\18\64ABCD"
|
||||
local v64 = "\55\128\64\18BADC"
|
||||
local n64 = "\64\18\55\128DCBA"
|
||||
check(RequiredImports.importData(optionalManifest, "bonus", canonical,
|
||||
{ hash = fakeHash }), "optional import uses the normal validation path")
|
||||
eq(RequiredImports.normalizeN64(canonical), canonical, "z64 stays canonical")
|
||||
eq(RequiredImports.normalizeN64(v64), canonical, "v64 pair swap canonicalizes")
|
||||
eq(RequiredImports.normalizeN64(n64), canonical, "n64 word swap canonicalizes")
|
||||
eq(RequiredImports.normalizeN64(string.rep("H", 512) .. v64), canonical,
|
||||
"recognized 512-byte copier header is stripped")
|
||||
check(RequiredImports.normalizeN64(string.rep("H", 520)) == nil,
|
||||
"an arbitrary 512-byte prefix is not treated as a copier header")
|
||||
|
||||
local ok, digest = RequiredImports.importData(manifest, "stadium2", v64,
|
||||
{ hash = fakeHash })
|
||||
check(ok, "validated bytes import")
|
||||
eq(digest, DIGEST, "import reports canonical digest")
|
||||
eq(love.filesystem.read("mods/stadium_fx/baseroms/stadium2.z64"), canonical,
|
||||
"import writes canonical bytes inside the mod")
|
||||
local rows, missing = RequiredImports.inspect(manifest, love.filesystem, fakeHash)
|
||||
eq(missing, 0, "written import satisfies its declaration")
|
||||
check(rows[1].present, "inspection reports ready")
|
||||
|
||||
local target = Manifest.validate({
|
||||
id = "other_fx", name = "Other FX", version = "1.0.0", entry = "main.lua",
|
||||
required_imports = {
|
||||
{ id = "same_rom", file = "source.z64", format = "n64", md5 = DIGEST },
|
||||
},
|
||||
}, "mods/other_fx")
|
||||
local targetRows, targetMissing = RequiredImports.inspect(target,
|
||||
love.filesystem, fakeHash)
|
||||
eq(targetMissing, 1, "matching hashes do not silently share another mod's import")
|
||||
check(not targetRows[1].present,
|
||||
"a mod needs its own explicit player-selected file")
|
||||
check(RequiredImports.remove(target, "same_rom"), "a required import can be removed")
|
||||
eq(love.filesystem.read("mods/other_fx/baseroms/source.z64"), nil,
|
||||
"remove deletes this mod's private copy")
|
||||
check(RequiredImports.importData(target, "same_rom", canonical, { hash = fakeHash }),
|
||||
"choosing the file again clears the removal decision")
|
||||
|
||||
local legacy = Manifest.validate({
|
||||
id = "legacy", name = "Legacy", version = "1.0.0", entry = "main.lua",
|
||||
}, "mods/legacy")
|
||||
local legacyTarget = Manifest.validate({
|
||||
id = "legacy_user", name = "Legacy User", version = "1.0.0", entry = "main.lua",
|
||||
required_imports = {
|
||||
{ id = "rom", file = "legacy-source.z64", format = "n64", md5 = DIGEST },
|
||||
},
|
||||
}, "mods/legacy_user")
|
||||
love.filesystem.write("mods/legacy/baseroms/manually-imported.v64", v64)
|
||||
local legacyRows, legacyMissing = RequiredImports.inspect(legacyTarget,
|
||||
love.filesystem, fakeHash)
|
||||
eq(legacyMissing, 1, "undeclared files in another mod are never indexed")
|
||||
check(not legacyRows[1].present, "legacy baseroms remain private to their mod")
|
||||
|
||||
local capped = Manifest.validate({
|
||||
id = "capped", name = "Capped", version = "1.0.0", entry = "main.lua",
|
||||
required_imports = {
|
||||
{ id = "small", file = "small.bin", md5 = DIGEST, max_size = 4 },
|
||||
},
|
||||
}, "mods/capped")
|
||||
local tooLarge, sizeWhy = RequiredImports.validateData(
|
||||
capped.required_imports[1], "12345", fakeHash)
|
||||
eq(tooLarge, nil, "per-import size cap rejects before hashing")
|
||||
check(tostring(sizeWhy):find("too large", 1, true) ~= nil,
|
||||
"size rejection explains the limit")
|
||||
|
||||
-- A successful validation writes an engine receipt. Matching size + modtime
|
||||
-- lets later launcher refreshes avoid reading and hashing the ROM again.
|
||||
local cacheFiles = {
|
||||
["mods/cache/baseroms/source.z64"] = canonical,
|
||||
}
|
||||
local dataReads = 0
|
||||
local cacheModtime = 123
|
||||
local cacheFs = {
|
||||
getInfo = function(path, kind)
|
||||
local data = cacheFiles[path]
|
||||
if data then return { type = "file", size = #data, modtime = cacheModtime } end
|
||||
return nil
|
||||
end,
|
||||
read = function(path)
|
||||
if path == "mods/cache/baseroms/source.z64" then dataReads = dataReads + 1 end
|
||||
return cacheFiles[path]
|
||||
end,
|
||||
write = function(path, data) cacheFiles[path] = data return true end,
|
||||
remove = function(path) cacheFiles[path] = nil return true end,
|
||||
}
|
||||
local cacheManifest = Manifest.validate({
|
||||
id = "cache", name = "Cache", version = "1.0.0", entry = "main.lua",
|
||||
required_imports = {
|
||||
{ id = "source", file = "source.z64", format = "n64", size = 8,
|
||||
md5 = DIGEST },
|
||||
},
|
||||
}, "mods/cache")
|
||||
local cacheRows = RequiredImports.inspect(cacheManifest, cacheFs, fakeHash)
|
||||
check(cacheRows[1].present, "initial cached import validation succeeds")
|
||||
cacheRows = RequiredImports.inspect(cacheManifest, cacheFs, function()
|
||||
error("unchanged cached import should not be hashed again")
|
||||
end)
|
||||
check(cacheRows[1].present, "validation receipt satisfies the next refresh")
|
||||
eq(dataReads, 1, "unchanged imported ROM is read only once")
|
||||
cacheFiles["mods/cache/baseroms/source.z64"] = "BADBYTES"
|
||||
cacheModtime = 124
|
||||
cacheRows = RequiredImports.inspect(cacheManifest, cacheFs, fakeHash)
|
||||
check(not cacheRows[1].present, "changed imported ROM bypasses a stale receipt")
|
||||
eq(dataReads, 2, "changed imported ROM is read again")
|
||||
eq(cacheFiles[RequiredImports.receiptPath(cacheManifest, cacheManifest.required_imports[1])],
|
||||
nil, "stale validation receipt is removed")
|
||||
|
||||
local rejected, why = RequiredImports.importData(target, "same_rom", "wrong",
|
||||
{ hash = fakeHash })
|
||||
eq(rejected, nil, "mismatched selection is rejected")
|
||||
check(tostring(why):find("N64 ROM (.z64/.v64/.n64)", 1, true) ~= nil,
|
||||
"normalization failure explains the selected format")
|
||||
|
||||
love.filesystem.write("mods/launcher_needs/manifest.json", ([[{
|
||||
"id":"launcher_needs","name":"Launcher Needs","version":"1.0.0",
|
||||
"entry":"main.lua","required_imports":[{"id":"source","name":"Source ROM",
|
||||
"file":"source.bin","md5":"%s"}]
|
||||
}]]):format(DIGEST))
|
||||
love.filesystem.write("mods/launcher_needs/main.lua", "return function(mod) end")
|
||||
local launcherRows = require("src.mods.LauncherMods").list()
|
||||
eq(#launcherRows, 1, "launcher keeps a mod with a missing required import visible")
|
||||
eq(launcherRows[1].missingRequiredImports, 1,
|
||||
"launcher row carries the missing import count")
|
||||
eq(launcherRows[1].status, "needs_import",
|
||||
"missing import changes Ready to Import required")
|
||||
check(launcherRows[1].statusDetail:find("Source ROM", 1, true) ~= nil,
|
||||
"launcher warning names the missing file")
|
||||
|
||||
local function memfs(files)
|
||||
return {
|
||||
read = function(path) return files[path] end,
|
||||
getInfo = function(path)
|
||||
if files[path] then return { type = "file" } end
|
||||
local prefix = path .. "/"
|
||||
for key in pairs(files) do
|
||||
if key:sub(1, #prefix) == prefix then return { type = "directory" } end
|
||||
end
|
||||
end,
|
||||
getDirectoryItems = function(path)
|
||||
if path == "mods" then
|
||||
local out, seen = {}, {}
|
||||
for key in pairs(files) do
|
||||
local id = key:match("^mods/([^/]+)/manifest%.json$")
|
||||
if id and not seen[id] then seen[id] = true; out[#out + 1] = id end
|
||||
end
|
||||
table.sort(out)
|
||||
return out
|
||||
end
|
||||
return {}
|
||||
end,
|
||||
load = function(path)
|
||||
local source = files[path]
|
||||
if not source then return nil, "missing" end
|
||||
return load(source, path)
|
||||
end,
|
||||
}
|
||||
end
|
||||
|
||||
local manifestJson = ([[{
|
||||
"id":"needs_rom","name":"Needs ROM","version":"1.0.0","entry":"main.lua",
|
||||
"required_imports":[{"id":"rom","name":"Source ROM","file":"source.bin",
|
||||
"md5":"%s"}]
|
||||
}]]):format(DIGEST)
|
||||
local loader = Loader.new({ fs = memfs({
|
||||
["mods/needs_rom/manifest.json"] = manifestJson,
|
||||
["mods/needs_rom/main.lua"] = "return function(mod) mod.exports.ran = true end",
|
||||
}) })
|
||||
check(loader:load({}) == false, "missing required import blocks the enabled mod")
|
||||
local status = loader:status().available[1]
|
||||
eq(status.state, "invalid", "blocked mod reports invalid")
|
||||
check(status.error:find("Source ROM", 1, true) ~= nil,
|
||||
"loader failure names the required import")
|
||||
check(not (loader.exports.needs_rom and loader.exports.needs_rom.ran),
|
||||
"blocked mod entry never executes")
|
||||
|
||||
local optionalJson = ([[{
|
||||
"id":"optional_rom","name":"Optional ROM","version":"1.0.0","entry":"main.lua",
|
||||
"optional_imports":[{"id":"rom","name":"Bonus ROM","file":"bonus.bin",
|
||||
"md5":"%s"}]
|
||||
}]]):format(DIGEST)
|
||||
local optionalLoader = Loader.new({ fs = memfs({
|
||||
["mods/optional_rom/manifest.json"] = optionalJson,
|
||||
["mods/optional_rom/main.lua"] = "return function(mod) mod.exports.ran = true end",
|
||||
}) })
|
||||
check(optionalLoader:load({}), "missing optional import does not block the mod")
|
||||
check(optionalLoader.exports.optional_rom.ran,
|
||||
"mod entry executes without its optional import")
|
||||
|
||||
S.finish()
|
||||
@@ -358,11 +358,13 @@ T.same(checkpoints:capture(game), beforeFailure,
|
||||
-- The same public facade must carry a real battle checkpoint end to end. The
|
||||
-- engine-side fixture is deliberately constructed outside the probe mod; the
|
||||
-- mod sees and calls only mod.checkpoints.
|
||||
local function makeBattleGame()
|
||||
local function makeBattleGame(kind)
|
||||
local data = Fixtures.fresh()
|
||||
local save = SaveData.newGame()
|
||||
save.meta.playthroughId = "public-battle-playthrough"
|
||||
save.party = { Pokemon.new(data, "FIXMON_A", 20) }
|
||||
save.party = { Pokemon.new(data, "FIXMON_A", 20),
|
||||
Pokemon.new(data, "FIXMON_B", 19),
|
||||
Pokemon.new(data, "FIXMON_C", 18) }
|
||||
-- The tiny fixture registry intentionally omits several full-game defaults.
|
||||
-- Normalize those once, then place the save on its fixture map.
|
||||
SaveData.validate(save, data)
|
||||
@@ -387,7 +389,8 @@ local function makeBattleGame()
|
||||
self.player = { cellX = x, cellY = y, facing = facing, surfing = false }
|
||||
end
|
||||
function battleOw:restoreBattleContinuation(restoredBattle, origin)
|
||||
if origin.kind ~= "wild_encounter" or origin.map ~= self.map.id then
|
||||
local expected = kind == "trainer" and "trainer_encounter" or "wild_encounter"
|
||||
if origin.kind ~= expected or origin.map ~= self.map.id then
|
||||
return false
|
||||
end
|
||||
restoredBattle.onFinish = function() end
|
||||
@@ -397,9 +400,19 @@ local function makeBattleGame()
|
||||
data = data, save = save, stack = stack, overworld = battleOw,
|
||||
}, { __index = GameMethods })
|
||||
stack.states[1] = battleOw
|
||||
local battle = BattleState.newWild(battleGame, "FIXMON_B", 12)
|
||||
local battle
|
||||
if kind == "trainer" then
|
||||
battle = BattleState.newTrainer(battleGame, "OPP_FIX_YOUNGSTER", 1, {
|
||||
playerPartyIndices = { 2, 3 },
|
||||
})
|
||||
else
|
||||
battle = BattleState.newWild(battleGame, "FIXMON_B", 12)
|
||||
end
|
||||
battle.phase, battle.queue = "menu", {}
|
||||
battle.checkpointOrigin = { kind = "wild_encounter", map = "FIX_TOWN" }
|
||||
battle.checkpointOrigin = kind == "trainer"
|
||||
and { kind = "trainer_encounter", map = "FIX_TOWN", npcId = "TRAINER_1",
|
||||
trainerClass = "OPP_FIX_YOUNGSTER", partyIndex = 1 }
|
||||
or { kind = "wild_encounter", map = "FIX_TOWN" }
|
||||
battle.musicKind = battle:computeMusicKind()
|
||||
battle.onFinish = function() end
|
||||
stack.states[2] = battle
|
||||
@@ -437,6 +450,62 @@ if battleSnapshot then
|
||||
"public battle capture/restore/capture is a normalized differential roundtrip")
|
||||
end
|
||||
|
||||
checkpointRngState = "scoped-trainer-rng-A"
|
||||
local scopedGame, scopedBattle = makeBattleGame("trainer")
|
||||
local scopedSnapshot, scopedCaptureCode = checkpoints:capture(scopedGame)
|
||||
T.check(scopedSnapshot ~= nil,
|
||||
"public checkpoints capture a scoped trainer battle: "
|
||||
.. tostring(scopedCaptureCode))
|
||||
if scopedSnapshot then
|
||||
T.same(scopedSnapshot.runtime.battle.playerPartyIndices, { 2, 3 },
|
||||
"capture stores the battle-local save-party index scope")
|
||||
local restored, code, message = checkpoints:restore(scopedGame, scopedSnapshot)
|
||||
T.check(restored == true,
|
||||
"public checkpoints restore a scoped trainer battle: "
|
||||
.. tostring(code) .. " / " .. tostring(message))
|
||||
local scopedRestored = scopedGame.stack:top()
|
||||
T.same(scopedRestored.playerPartyIndices, { 2, 3 },
|
||||
"restore reconstructs the same ordered party scope")
|
||||
T.check(scopedRestored.playerParty[1] == scopedGame.save.party[2]
|
||||
and scopedRestored.playerParty[2] == scopedGame.save.party[3],
|
||||
"restored scope points at authoritative save-party records")
|
||||
|
||||
scopedSnapshot.runtime.battle.playerPartyIndices = nil
|
||||
local oldRestored, oldCode = checkpoints:restore(scopedGame, scopedSnapshot)
|
||||
T.check(oldRestored == true,
|
||||
"an old checkpoint without party scope remains compatible: "
|
||||
.. tostring(oldCode))
|
||||
T.eq(scopedGame.stack:top().playerParty, nil,
|
||||
"an old checkpoint restores the vanilla full-party view")
|
||||
end
|
||||
|
||||
local function scopedCheckpoint()
|
||||
local freshGame = makeBattleGame("trainer")
|
||||
local snapshot = assert(checkpoints:capture(freshGame))
|
||||
return freshGame, snapshot
|
||||
end
|
||||
|
||||
local excludedGame, excludedSnapshot = scopedCheckpoint()
|
||||
excludedSnapshot.runtime.battle.player.index = 1
|
||||
local excludedRestored, excludedCode = checkpoints:restore(excludedGame,
|
||||
excludedSnapshot)
|
||||
T.check(excludedRestored == false and excludedCode == "invalid_checkpoint",
|
||||
"a scoped checkpoint rejects an active battler outside the eligible view")
|
||||
|
||||
local malformedGame, malformedSnapshot = scopedCheckpoint()
|
||||
malformedSnapshot.runtime.battle.playerPartyIndices.extra = 3
|
||||
local malformedRestored, malformedCode = checkpoints:restore(malformedGame,
|
||||
malformedSnapshot)
|
||||
T.check(malformedRestored == false and malformedCode == "invalid_checkpoint",
|
||||
"a scoped checkpoint rejects non-array scope members instead of failing open")
|
||||
|
||||
local participantGame, participantSnapshot = scopedCheckpoint()
|
||||
participantSnapshot.runtime.battle.participants = { 1 }
|
||||
local participantRestored, participantCode = checkpoints:restore(
|
||||
participantGame, participantSnapshot)
|
||||
T.check(participantRestored == false and participantCode == "invalid_checkpoint",
|
||||
"a scoped checkpoint rejects excluded participant references")
|
||||
|
||||
-- The mod receives the normal public hook facade, never BattleState. START
|
||||
-- at the restored safe decision reaches its semantic auxiliary action without
|
||||
-- selecting a native command.
|
||||
|
||||
@@ -69,6 +69,16 @@ local PROBE = [[
|
||||
out.readBackslash = attempt(function() return mod:read("..\\secret.txt") end)
|
||||
out.assetsEscape = attempt(function() return mod.assets:path("../../x.png") end)
|
||||
out.readOwn = mod:read("data/note.txt")
|
||||
out.listAssets = mod:list("assets")
|
||||
out.listSprites = mod:list("assets/sprites")
|
||||
out.listRoot = mod:list()
|
||||
out.assetsList = mod.assets:list("assets")
|
||||
out.infoAssets = mod:info("assets")
|
||||
out.infoNote = mod:info("data/note.txt")
|
||||
out.infoMissing = mod:info("nope")
|
||||
out.listMissing = mod:list("nope")
|
||||
out.listEscape = attempt(function() return mod:list("../secret") end)
|
||||
out.infoEscape = attempt(function() return mod:info("../../x") end)
|
||||
|
||||
_G.SANDBOX_LEAK = "escaped"
|
||||
out.globalsAreOwn = _G ~= nil and _G.SANDBOX_LEAK == "escaped"
|
||||
@@ -81,6 +91,8 @@ local FILES = {
|
||||
["mods/fix_sandbox/manifest.json"] = manifest("fix_sandbox"),
|
||||
["mods/fix_sandbox/main.lua"] = PROBE,
|
||||
["mods/fix_sandbox/data/note.txt"] = "own file",
|
||||
["mods/fix_sandbox/assets/front.png"] = "png",
|
||||
["mods/fix_sandbox/assets/sprites/walk.png"] = "png",
|
||||
}
|
||||
|
||||
local run = T.sdk.loadMods({ "mods/fix_sandbox" }, { fs = T.sdk.memfs(FILES) })
|
||||
@@ -155,6 +167,24 @@ T.check(out.readAbsolute ~= false, "mod:read refuses an absolute path")
|
||||
T.check(out.readBackslash ~= false, "mod:read refuses a backslash climb")
|
||||
T.check(out.assetsEscape ~= false, "mod.assets:path refuses a climb")
|
||||
T.eq(out.readOwn, "own file", "and the mod's own files still read")
|
||||
T.same(out.listAssets, { "front.png", "sprites" },
|
||||
"mod:list names the children of a directory inside the mod")
|
||||
T.same(out.listSprites, { "walk.png" },
|
||||
"and a nested directory")
|
||||
T.check(out.listRoot and out.listRoot[1] ~= nil,
|
||||
"mod:list() with no path lists the mod root")
|
||||
T.same(out.assetsList, out.listAssets,
|
||||
"mod.assets:list is the same listing")
|
||||
T.eq(out.infoAssets and out.infoAssets.type, "directory",
|
||||
"mod:info reports a directory")
|
||||
T.eq(out.infoNote and out.infoNote.type, "file",
|
||||
"and a file")
|
||||
T.eq(out.infoMissing, nil, "mod:info is nil for a missing path")
|
||||
T.same(out.listMissing, {}, "mod:list of a missing path is empty, not an error")
|
||||
T.check(out.listEscape and out.listEscape:find("must stay inside", 1, true),
|
||||
"mod:list cannot climb out of the mod directory: " .. tostring(out.listEscape))
|
||||
T.check(out.infoEscape and out.infoEscape:find("must stay inside", 1, true),
|
||||
"mod:info cannot climb either")
|
||||
run.release()
|
||||
|
||||
-- ------- the grammar itself
|
||||
|
||||
@@ -7,6 +7,7 @@ love = love or require("tests.love_stub")
|
||||
local T = require("tests.harness").suite("mod storage")
|
||||
local Loader = require("src.mods.Loader")
|
||||
local Runtime = require("src.mods.Runtime")
|
||||
local Storage = require("src.mods.Storage")
|
||||
local Version = require("src.core.Version")
|
||||
|
||||
local savedEvents, savedHooks = Runtime.events, Runtime.hooks
|
||||
@@ -22,7 +23,9 @@ local function memfs(files)
|
||||
function fs.read(path) return files[path] end
|
||||
function fs.write(path, body)
|
||||
if fs.failTmp and path:sub(-4) == ".tmp" then return false, "tmp denied" end
|
||||
if fs.failMain and path:sub(-4) == ".lua" then return false, "main denied" end
|
||||
if fs.failMain and (path:sub(-4) == ".lua" or path:sub(-4) == ".bin") then
|
||||
return false, "main denied"
|
||||
end
|
||||
files[path] = body
|
||||
return true
|
||||
end
|
||||
@@ -107,6 +110,50 @@ T.same(loaded, payload, "stored payload roundtrips as data")
|
||||
T.check(loaded ~= payload and loaded.nested ~= payload.nested,
|
||||
"read returns decoded data rather than the caller's live table")
|
||||
|
||||
T.check(type(alpha.writeBytes) == "function"
|
||||
and type(alpha.readBytes) == "function",
|
||||
"mod.storage exposes opaque byte read/write methods")
|
||||
if type(alpha.writeBytes) == "function" and type(alpha.readBytes) == "function" then
|
||||
local binary = "MESH\0\1\255\128\nreturn _G.MOD_STORAGE_EXECUTED = true"
|
||||
local binaryOk, binaryCode, binaryMessage =
|
||||
alpha:writeBytes(current, "states/quick/blob", binary)
|
||||
T.check(binaryOk == true,
|
||||
"opaque bytes write exactly: " .. tostring(binaryCode or binaryMessage))
|
||||
local binaryLoaded, binaryReadCode =
|
||||
alpha:readBytes(current, "states/quick/blob")
|
||||
T.eq(binaryLoaded, binary,
|
||||
"opaque bytes round-trip without text or Lua decoding")
|
||||
T.eq(binaryReadCode, nil, "successful opaque byte read has no error")
|
||||
T.eq(_G.MOD_STORAGE_EXECUTED, nil,
|
||||
"Lua-looking opaque bytes are never executed")
|
||||
|
||||
local emptyOk = alpha:writeBytes(current, "binary/empty", "")
|
||||
T.check(emptyOk == true, "empty opaque byte payloads are valid")
|
||||
T.eq(alpha:readBytes(current, "binary/empty"), "",
|
||||
"empty opaque byte payloads round-trip")
|
||||
|
||||
local badBytes, badBytesCode =
|
||||
alpha:writeBytes(current, "binary/bad-type", { byte = true })
|
||||
T.check(not badBytes and badBytesCode == "invalid_bytes",
|
||||
"non-string opaque payloads are rejected")
|
||||
|
||||
local savedLimit = Storage.MAX_BYTES
|
||||
Storage.MAX_BYTES = 4
|
||||
local tooLarge, tooLargeCode =
|
||||
alpha:writeBytes(current, "binary/too-large", "12345")
|
||||
Storage.MAX_BYTES = savedLimit
|
||||
T.check(not tooLarge and tooLargeCode == "size_limit",
|
||||
"opaque payloads over the per-key limit are rejected")
|
||||
|
||||
local tableConflict, tableConflictCode =
|
||||
alpha:writeBytes(current, "states/quick/q1", "table-key-conflict")
|
||||
T.check(not tableConflict and tableConflictCode == "type_conflict",
|
||||
"bytes cannot replace a table record without deletion")
|
||||
local wrongType, wrongTypeCode = alpha:read(current, "states/quick/blob")
|
||||
T.check(wrongType == nil and wrongTypeCode == "type_mismatch",
|
||||
"table reads identify byte records as the wrong storage type")
|
||||
end
|
||||
|
||||
local bad, badCode = alpha:write(current, "states/bad", { callback = function() end })
|
||||
T.check(not bad and badCode == "encode_failed",
|
||||
"functions are rejected with a stable data-only error")
|
||||
@@ -120,19 +167,33 @@ T.check(alpha:write(current, "states/quick/zeta", { n = 2 }), "write zeta")
|
||||
T.check(alpha:write(current, "states/quick/alpha", { n = 1 }), "write alpha")
|
||||
T.check(alpha:write(current, "settings", { enabled = true }), "write settings")
|
||||
local keys = alpha:list(current, "states/quick")
|
||||
T.same(keys, { "states/quick/alpha", "states/quick/q1", "states/quick/zeta" },
|
||||
T.same(keys, { "states/quick/alpha", "states/quick/blob",
|
||||
"states/quick/q1", "states/quick/zeta" },
|
||||
"list returns sorted logical keys under the requested prefix")
|
||||
|
||||
-- Mod, playthrough, and game namespaces cannot observe each other.
|
||||
local missing, missingCode = beta:read(current, "states/quick/q1")
|
||||
T.check(missing == nil and missingCode == "not_found",
|
||||
"another mod cannot read the first mod's payload")
|
||||
if type(alpha.readBytes) == "function" then
|
||||
missing, missingCode = beta:readBytes(current, "states/quick/blob")
|
||||
T.check(missing == nil and missingCode == "not_found",
|
||||
"another mod cannot read the first mod's opaque payload")
|
||||
end
|
||||
missing, missingCode = alpha:read(game("red", "play-b"), "states/quick/q1")
|
||||
T.check(missing == nil and missingCode == "not_found",
|
||||
"another playthrough cannot read the payload")
|
||||
missing, missingCode = alpha:read(game("blue", "play-a"), "states/quick/q1")
|
||||
T.check(missing == nil and missingCode == "not_found",
|
||||
"another game version cannot read the payload")
|
||||
if type(alpha.readBytes) == "function" then
|
||||
missing, missingCode = alpha:readBytes(game("red", "play-b"), "states/quick/blob")
|
||||
T.check(missing == nil and missingCode == "not_found",
|
||||
"another playthrough cannot read the opaque payload")
|
||||
missing, missingCode = alpha:readBytes(game("blue", "play-a"), "states/quick/blob")
|
||||
T.check(missing == nil and missingCode == "not_found",
|
||||
"another game version cannot read the opaque payload")
|
||||
end
|
||||
|
||||
-- Find the implementation-owned file only to inject corruption; assertions stay
|
||||
-- on public read behavior, not the path shape.
|
||||
@@ -142,6 +203,12 @@ local function mainFor(fragment)
|
||||
end
|
||||
end
|
||||
|
||||
local function byteMainFor(fragment)
|
||||
for path in pairs(files) do
|
||||
if path:find(fragment, 1, true) and path:sub(-4) == ".bin" then return path end
|
||||
end
|
||||
end
|
||||
|
||||
local q1Main = mainFor("q1")
|
||||
T.check(type(q1Main) == "string", "failure fixture locates the persisted q1")
|
||||
files[q1Main] = "not a serialized table"
|
||||
@@ -158,6 +225,47 @@ T.check(not ok and code == "write_failed", "staging failure is reported")
|
||||
T.same(alpha:read(current, "replace"), { version = 1 },
|
||||
"staging failure leaves the prior value readable")
|
||||
|
||||
if type(alpha.writeBytes) == "function" and type(alpha.readBytes) == "function" then
|
||||
T.check(alpha:writeBytes(current, "binary/recover", "old-bytes"),
|
||||
"seed opaque recovery value")
|
||||
local recoverMain = byteMainFor("binary/recover")
|
||||
T.check(type(recoverMain) == "string", "failure fixture locates opaque recovery data")
|
||||
files[recoverMain] = nil
|
||||
T.eq(alpha:readBytes(current, "binary/recover"), "old-bytes",
|
||||
"missing opaque main recovers the last verified backup")
|
||||
|
||||
T.check(alpha:writeBytes(current, "binary/replace", "version-1"),
|
||||
"seed opaque replacement value")
|
||||
fs.failTmp = true
|
||||
ok, code = alpha:writeBytes(current, "binary/replace", "version-2")
|
||||
fs.failTmp = false
|
||||
T.check(not ok and code == "write_failed",
|
||||
"opaque staging failure is reported")
|
||||
T.eq(alpha:readBytes(current, "binary/replace"), "version-1",
|
||||
"opaque staging failure leaves the prior value readable")
|
||||
|
||||
fs.failMain = true
|
||||
ok, code = alpha:writeBytes(current, "binary/replace", "version-3")
|
||||
fs.failMain = false
|
||||
T.check(not ok and code == "write_failed",
|
||||
"opaque replacement failure is reported")
|
||||
T.eq(alpha:readBytes(current, "binary/replace"), "version-1",
|
||||
"opaque replacement failure leaves the prior value readable")
|
||||
|
||||
local byteConflict, byteConflictCode =
|
||||
alpha:write(current, "binary/replace", { version = 3 })
|
||||
T.check(not byteConflict and byteConflictCode == "type_conflict",
|
||||
"tables cannot replace a byte record without deletion")
|
||||
|
||||
T.check(alpha:writeBytes(current, "binary/delete", "delete-me"),
|
||||
"seed opaque delete target")
|
||||
T.check(alpha:delete(current, "binary/delete") == true,
|
||||
"delete removes an opaque record")
|
||||
missing, missingCode = alpha:readBytes(current, "binary/delete")
|
||||
T.check(missing == nil and missingCode == "not_found",
|
||||
"deleted opaque key is unavailable")
|
||||
end
|
||||
|
||||
-- Delete is exact and idempotent-not-found is explicit.
|
||||
T.check(alpha:write(current, "delete/me", { yes = true }), "seed delete target")
|
||||
T.check(alpha:write(current, "delete/keep", { yes = true }), "seed delete neighbor")
|
||||
|
||||
@@ -135,6 +135,17 @@ if type(storage) == "table" then
|
||||
"title binding supports safe same-namespace durable operations")
|
||||
T.same(selected:read("history/title-operation"), { allowed = true },
|
||||
"title durable operation remains scoped to the selected playthrough")
|
||||
T.check(type(selected.writeBytes) == "function"
|
||||
and type(selected.readBytes) == "function",
|
||||
"selected storage exposes opaque byte methods")
|
||||
if type(selected.writeBytes) == "function"
|
||||
and type(selected.readBytes) == "function" then
|
||||
local titleBytes = "TITLE\0\255-cache"
|
||||
T.check(selected:writeBytes("history/title-bytes", titleBytes) == true,
|
||||
"title binding writes opaque bytes in the selected namespace")
|
||||
T.eq(selected:readBytes("history/title-bytes"), titleBytes,
|
||||
"title binding reads opaque bytes in the selected namespace")
|
||||
end
|
||||
end
|
||||
T.check(title.save.meta.playthroughId == nil,
|
||||
"opening title history never allocates or adopts a playthrough identity")
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
-- A sandboxed mod can defer an ordinary trainer engagement and later resume
|
||||
-- it with a battle-local player-party scope, using only public mod surfaces.
|
||||
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
love = love or require("tests.love_stub")
|
||||
|
||||
local T = require("tests.modkit")
|
||||
local OW = require("src.world.OverworldController")
|
||||
|
||||
local FIXTURE = {
|
||||
["mods/scope_probe/manifest.json"] = [[{
|
||||
"id": "scope_probe",
|
||||
"name": "Scope Probe",
|
||||
"version": "1.0.0",
|
||||
"entry": "main.lua",
|
||||
"api": 2
|
||||
}]],
|
||||
["mods/scope_probe/main.lua"] = [[
|
||||
local mod = ...
|
||||
mod.hooks:wrap("trainer.before_battle", function(next, game, context, continue)
|
||||
mod.exports.game = game
|
||||
mod.exports.context = context
|
||||
mod.exports.continue = continue
|
||||
return true
|
||||
end)
|
||||
]],
|
||||
}
|
||||
|
||||
local vanilla = T.sdk.loadNone({})
|
||||
local vanillaCalls, vanillaOptions = 0
|
||||
OW.prepareTrainerBattle({ id = "game" }, {
|
||||
trainerClass = "OPP_FIX_YOUNGSTER", partyIndex = 1,
|
||||
mapId = "FIX_ROUTE", npcId = "TRAINER_1",
|
||||
}, function(options)
|
||||
vanillaCalls, vanillaOptions = vanillaCalls + 1, options
|
||||
end)
|
||||
T.eq(vanillaCalls, 1, "no mod starts the trainer battle exactly once")
|
||||
T.eq(vanillaOptions, nil, "no mod supplies no battle-local party scope")
|
||||
vanilla.release()
|
||||
|
||||
local run = T.sdk.loadMods({ "mods/scope_probe" }, {
|
||||
fs = T.sdk.memfs(FIXTURE),
|
||||
})
|
||||
T.eq(#run.errors, 0,
|
||||
"the public preparation probe loads clean (" .. tostring(run.errors[1]) .. ")")
|
||||
local game = { id = "live-game" }
|
||||
local calls, options = 0
|
||||
OW.prepareTrainerBattle(game, {
|
||||
trainerClass = "OPP_FIX_YOUNGSTER", partyIndex = 2,
|
||||
mapId = "FIX_ROUTE", npcId = "TRAINER_7",
|
||||
}, function(value)
|
||||
calls, options = calls + 1, value
|
||||
end)
|
||||
T.eq(calls, 0, "a claiming public hook defers battle construction")
|
||||
local out = run.loader.exports.scope_probe or {}
|
||||
T.check(out.game == game, "the hook receives the live game")
|
||||
T.same(out.context, {
|
||||
trainerClass = "OPP_FIX_YOUNGSTER", partyIndex = 2,
|
||||
mapId = "FIX_ROUTE", npcId = "TRAINER_7",
|
||||
}, "the hook receives data-only trainer identity context")
|
||||
T.eq(out.continue({ playerPartyIndices = { 2, 4 } }), true,
|
||||
"the retained continuation resumes the deferred battle")
|
||||
T.eq(calls, 1, "resume constructs the battle exactly once")
|
||||
T.same(options, { playerPartyIndices = { 2, 4 } },
|
||||
"ordered eligible indices cross the public seam unchanged")
|
||||
T.eq(out.continue({ playerPartyIndices = { 1 } }), false,
|
||||
"the continuation refuses a second invocation")
|
||||
T.eq(calls, 1, "a duplicate resume cannot start a second battle")
|
||||
run.release()
|
||||
|
||||
local cancelRun = T.sdk.loadMods({ "mods/scope_probe" }, {
|
||||
fs = T.sdk.memfs(FIXTURE),
|
||||
})
|
||||
local starts, cancels = 0, 0
|
||||
OW.prepareTrainerBattle(game, {
|
||||
trainerClass = "OPP_FIX_YOUNGSTER", partyIndex = 2,
|
||||
mapId = "FIX_ROUTE", npcId = "TRAINER_7",
|
||||
}, function()
|
||||
starts = starts + 1
|
||||
end, function()
|
||||
cancels = cancels + 1
|
||||
end)
|
||||
local cancelOut = cancelRun.loader.exports.scope_probe or {}
|
||||
T.eq(cancelOut.continue({ cancel = true }), true,
|
||||
"the retained continuation can cancel a deferred encounter")
|
||||
T.eq(starts, 0, "cancelling never constructs a trainer battle")
|
||||
T.eq(cancels, 1, "cancelling invokes the encounter's completion callback")
|
||||
T.eq(cancelOut.continue(), false,
|
||||
"a cancelled continuation remains one-shot")
|
||||
cancelRun.release()
|
||||
|
||||
T.finish("trainer_before_battle")
|
||||
@@ -0,0 +1,173 @@
|
||||
-- Contextual field actions share one public contract in both generations
|
||||
-- while each engine keeps ownership of its own field-item and move paths.
|
||||
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
|
||||
local T = require("tests.harness").suite("mod world field actions")
|
||||
|
||||
local facingWater = false
|
||||
local redCut, redSurf = false, "no_water"
|
||||
local redMoves = {}
|
||||
local redWorld = {
|
||||
isOverworld = true,
|
||||
map = { id = "ROUTE_1", def = { tileset = "OVERWORLD" } },
|
||||
player = { moving = false, inputLocked = false, surfing = false },
|
||||
runner = { isRunning = function() return false end },
|
||||
scriptMoves = {},
|
||||
bikeAllowed = function() return true end,
|
||||
facingIsShoreOrWater = function() return facingWater end,
|
||||
useCutFieldMove = function() return redCut and "ok" or "nothing" end,
|
||||
useSurfFieldMove = function() return redSurf end,
|
||||
partyKnows = function(_, move) return redMoves[move] end,
|
||||
useBicycle = function(self) self.bikeUsed = true return true end,
|
||||
useFishingRod = function(self, rod) self.rodUsed = rod return true end,
|
||||
}
|
||||
local redGame = {
|
||||
data = { field = { outsideTilesets = { "OVERWORLD" } },
|
||||
items = { OLD_ROD = { name = "OLD ROD" } } },
|
||||
save = { player = { name = "RED" }, party = {},
|
||||
inventory = { BICYCLE = 1, OLD_ROD = 1 } },
|
||||
stack = { states = { redWorld } },
|
||||
overworld = redWorld,
|
||||
}
|
||||
function redGame.stack:top() return self.states[#self.states] end
|
||||
|
||||
local RedAPI = require("src.world.WorldAPI")
|
||||
local red = RedAPI.new(redGame, "fixture")
|
||||
local RedWorld = require("src.world.OverworldController")
|
||||
T.check(type(RedWorld.useBicycle) == "function"
|
||||
and type(RedWorld.useFishingRod) == "function"
|
||||
and type(RedWorld.useFlashFieldMove) == "function"
|
||||
and type(RedWorld.useStrengthFieldMove) == "function"
|
||||
and type(RedWorld.stopSurfing) == "function",
|
||||
"Red keeps field-action execution in its world")
|
||||
local actions = red:availableFieldActions()
|
||||
T.eq(actions[1].id, "bicycle", "Red lists an owned usable bicycle")
|
||||
T.check(red:useFieldAction("bicycle"), "Red accepts the listed bicycle")
|
||||
T.check(redWorld.bikeUsed, "Red delegates to its world-owned bicycle path")
|
||||
|
||||
facingWater = true
|
||||
actions = red:availableFieldActions()
|
||||
T.eq(actions[2].rods[1].id, "OLD_ROD", "Red lists owned rods at water")
|
||||
T.check(red:useFieldAction("fish", { rod = "OLD_ROD" }),
|
||||
"Red accepts a listed rod")
|
||||
T.eq(redWorld.rodUsed, "OLD_ROD", "Red delegates to its fishing path")
|
||||
local used = redWorld.rodUsed
|
||||
local ok, err = red:useFieldAction("fish", { rod = "SUPER_ROD" })
|
||||
T.check(not ok and err == "fishing rod unavailable",
|
||||
"Red rejects an unowned rod")
|
||||
T.eq(redWorld.rodUsed, used, "a rejected Red rod changes nothing")
|
||||
|
||||
redWorld.player.moving = true
|
||||
T.eq(#red:availableFieldActions(), 0, "Red hides actions while moving")
|
||||
ok, err = red:useFieldAction("bicycle")
|
||||
T.check(not ok and err == "world is busy",
|
||||
"Red refuses a stale action while busy")
|
||||
|
||||
redWorld.player.moving = false
|
||||
facingWater, redCut, redSurf = false, true, "ok"
|
||||
redWorld.dark = true
|
||||
for _, move in ipairs({ "STRENGTH", "FLASH", "TELEPORT" }) do
|
||||
redMoves[move] = { species = "MEW", moves = { { id = move } } }
|
||||
end
|
||||
redWorld.player.facingCell = function() return 4, 5 end
|
||||
redWorld.tryCut = function(self) self.cutUsed = true return true end
|
||||
redWorld.trySurf = function(self) self.surfUsed = true end
|
||||
redWorld.useStrengthFieldMove = function(self) self.strengthUsed = true return true end
|
||||
redWorld.useFlashFieldMove = function(self) self.flashUsed = true return true end
|
||||
redWorld.beginTeleportOut = function(self) self.teleportUsed = true end
|
||||
actions = red:availableFieldActions()
|
||||
local byId = {}
|
||||
for _, action in ipairs(actions) do byId[action.id] = action end
|
||||
T.check(byId.cut and byId.surf and byId.strength and byId.flash
|
||||
and byId.teleport, "Red lists field moves that can start now")
|
||||
for _, id in ipairs({ "cut", "surf", "strength", "flash", "teleport" }) do
|
||||
T.check(red:useFieldAction(id), "Red accepts listed " .. id)
|
||||
end
|
||||
T.check(redWorld.cutUsed and redWorld.surfUsed and redWorld.strengthUsed
|
||||
and redWorld.flashUsed and redWorld.teleportUsed,
|
||||
"Red delegates every move to its overworld path")
|
||||
|
||||
redSurf = "dismount"
|
||||
redWorld.player.surfing = true
|
||||
redWorld.stopSurfing = function(self) self.dismounted = true end
|
||||
T.check(red:useFieldAction("surf") and redWorld.dismounted,
|
||||
"Red delegates the contextual SURF dismount")
|
||||
redWorld.player.surfing, redSurf = false, "ok"
|
||||
|
||||
redCut = false
|
||||
ok, err = red:useFieldAction("cut")
|
||||
T.check(not ok and err == "field action unavailable",
|
||||
"Red revalidates a stale field move")
|
||||
|
||||
redWorld.map.id = "ROCK_TUNNEL_1F"
|
||||
redWorld.map.def.tileset = "CAVERN"
|
||||
redMoves.DIG = { species = "MEW", moves = { { id = "DIG" } } }
|
||||
redWorld.beginTeleportOut = function(self) self.digUsed = true end
|
||||
byId = {}
|
||||
for _, action in ipairs(red:availableFieldActions()) do byId[action.id] = action end
|
||||
T.check(byId.dig and not byId.teleport,
|
||||
"Red distinguishes dungeon DIG from outdoor TELEPORT")
|
||||
T.check(red:useFieldAction("dig") and redWorld.digUsed,
|
||||
"Red delegates DIG to its escape path")
|
||||
|
||||
local goldWorld = {
|
||||
map = { id = "ROUTE_29", def = { environment = "ROUTE" } },
|
||||
player = {}, playerState = "normal",
|
||||
acceptsMenuInput = function() return true end,
|
||||
playerCollision = function() return 0x00 end,
|
||||
alwaysOnBike = function() return false end,
|
||||
useFieldItem = function(self, item) self.itemUsed = item return "used" end,
|
||||
squirtbottleTreeScript = function() return { { op = "end" } } end,
|
||||
useFieldMove = function(self, move)
|
||||
self.moveUsed = move
|
||||
return { ok = true }
|
||||
end,
|
||||
}
|
||||
local goldGame = {
|
||||
data = { items = { OLD_ROD = { name = "OLD ROD" },
|
||||
SQUIRTBOTTLE = { name = "SQUIRTBOTTLE" } } },
|
||||
save = { inventory = { BICYCLE = 1, OLD_ROD = 1, SQUIRTBOTTLE = 1 },
|
||||
player = { badges = { FOG = true } },
|
||||
party = { { moves = { { id = "SURF" }, { id = "SWEET_SCENT" },
|
||||
{ id = "TELEPORT" } } } } },
|
||||
world = goldWorld,
|
||||
}
|
||||
goldWorld.fieldContext = function(_, mon) return {
|
||||
save = goldGame.save, party = goldGame.save.party, mon = mon,
|
||||
facing = "right", facingColl = 0x29, playerColl = 0,
|
||||
environment = "ROUTE", playerState = "normal", alwaysOnBike = false,
|
||||
dark = false, canEscapeRope = false,
|
||||
} end
|
||||
|
||||
local GoldAPI = require("src.world.gen2.WorldAPI")
|
||||
local gold = GoldAPI.new(goldGame, "fixture")
|
||||
actions = gold:availableFieldActions()
|
||||
byId = {}
|
||||
for _, action in ipairs(actions) do byId[action.id] = action end
|
||||
T.eq(actions[1].id, "bicycle", "Gold shares the bicycle action id")
|
||||
T.eq(actions[2].id, "fish", "Gold preserves the original action order")
|
||||
T.check(byId.surf and byId.sweet_scent and byId.teleport,
|
||||
"Gold lists field moves through its generic dispatcher")
|
||||
T.eq(byId.fish.rods[1].id, "OLD_ROD", "Gold shares the rod shape")
|
||||
T.check(byId.squirtbottle,
|
||||
"Gold lists the SquirtBottle only at its matching tree")
|
||||
T.check(gold:useFieldAction("sweet_scent"),
|
||||
"Gold accepts a listed field move")
|
||||
T.eq(goldWorld.moveUsed, "SWEET_SCENT",
|
||||
"Gold delegates moves to its own field-move path")
|
||||
T.check(gold:useFieldAction("fish", { rod = "OLD_ROD" }),
|
||||
"Gold accepts the same fishing request")
|
||||
T.eq(goldWorld.itemUsed, "OLD_ROD",
|
||||
"Gold delegates to its own field-item path")
|
||||
used = goldWorld.itemUsed
|
||||
ok, err = gold:useFieldAction("fish", { rod = "SUPER_ROD" })
|
||||
T.check(not ok and err == "fishing rod unavailable",
|
||||
"Gold rejects an unowned rod")
|
||||
T.eq(goldWorld.itemUsed, used, "a rejected Gold rod changes nothing")
|
||||
T.check(gold:useFieldAction("squirtbottle"),
|
||||
"Gold accepts the contextual SquirtBottle")
|
||||
T.eq(goldWorld.itemUsed, "SQUIRTBOTTLE",
|
||||
"Gold delegates the SquirtBottle to its field-item path")
|
||||
|
||||
T.finish()
|
||||
@@ -463,6 +463,8 @@ return function(mod)
|
||||
end
|
||||
]])
|
||||
write(bad .. "/hack.gb", "GBDATA")
|
||||
os.execute((mkdir .. " %q"):format(bad .. "/baseroms"))
|
||||
write(bad .. "/baseroms/stadium2.z64", "USER ROM")
|
||||
write(bad .. "/cachepath.lua",
|
||||
'return { pic = "assets/generated/battle/front/mew.png" }')
|
||||
|
||||
@@ -474,6 +476,8 @@ check(out:find("MK101", 1, true) ~= nil, "schema typo reported as MK101")
|
||||
check(out:find("base_stats", 1, true) ~= nil, "MK101 names the bad field")
|
||||
check(out:find("MK301", 1, true) ~= nil, "cache reference reported as MK301")
|
||||
check(out:find("MK303", 1, true) ~= nil, "ROM patch file reported as MK303")
|
||||
check(out:find("MK307", 1, true) ~= nil,
|
||||
"a user-supplied baseroms file is refused explicitly")
|
||||
|
||||
out, code = run(("%s tools/modkit.py pack %q -o %q --base fixture")
|
||||
:format(python, bad, root .. "/bad.modpkg"))
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
-- Parity test: escort lockstep cadence. An NPC walking the player to a
|
||||
-- destination moves under DoScriptedNPCMovement (engine/overworld/
|
||||
-- movement.asm:737), whose wScriptedNPCWalkCounter is 8 ticks of 2px --
|
||||
-- the player's own frames per cell, not MoveSprite's doubled NPC walk.
|
||||
-- Self-contained: run via `luajit tests/parity_escort_lockstep.lua`; also
|
||||
-- dofile'd by tests/run_tests.lua's aggregator.
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
if not _G.love then _G.love = require("tests.love_stub") end
|
||||
local Data = require("src.core.Data")
|
||||
if not (Data.maps and Data.maps.PALLET_TOWN) then Data:load() end
|
||||
local S = require("tests.harness").suite("parity escort lockstep")
|
||||
local check, eq = S.check, S.eq
|
||||
|
||||
local SaveData = require("src.core.SaveData")
|
||||
local Game = require("src.core.Game")
|
||||
local StateStack = require("src.core.StateStack")
|
||||
local OverworldState = require("src.world.OverworldController")
|
||||
|
||||
local prev = { data = Game.data, save = Game.save, stack = Game.stack,
|
||||
input = Game.input, renderer = Game.renderer,
|
||||
overworld = Game.overworld }
|
||||
Game.data = Data
|
||||
Game.save = SaveData.newGame(Data)
|
||||
Game.save.player.name = "RED"
|
||||
StateStack:init()
|
||||
Game.stack = StateStack
|
||||
Game.input = {
|
||||
isDown = function() return false end,
|
||||
wasPressed = function() return false end,
|
||||
step = function() end, state = {}, pressQueue = {},
|
||||
}
|
||||
Game.renderer = {
|
||||
beginWorldPass = function() end, endWorldPass = function() end,
|
||||
beginUIPass = function() end, endUIPass = function() end,
|
||||
worldViewSize = function() return 160, 144 end,
|
||||
setSGBZones = function() end,
|
||||
}
|
||||
StateStack:push(OverworldState, "PALLET_TOWN", 10, 5, "down")
|
||||
local ow = OverworldState
|
||||
Game.overworld = ow
|
||||
|
||||
-- A PALLET_TOWN object parked one cell ahead of the player, then walked
|
||||
-- in the paired-step pattern the escorts use: the NPC's step is queued
|
||||
-- without a callback and the player's carries the chain.
|
||||
local function runEscort(sync, steps)
|
||||
local npc = ow.npcs[1]
|
||||
local p = ow.player
|
||||
p.cellX, p.cellY, p.px, p.py = 10, 5, 160, 80
|
||||
p.moving, p.progress, p.targetX, p.targetY = false, 0, nil, nil
|
||||
npc.cellX, npc.cellY, npc.px, npc.py = 10, 4, 160, 64
|
||||
npc.moving, npc.progress, npc.targetX, npc.targetY = false, 0, nil, nil
|
||||
ow.scriptMoves = {}
|
||||
npc.stepFrames = sync and (p.stepFramesCur or p.stepFrames) or nil
|
||||
local worstDrift, done, i = 0, false, 0
|
||||
local function tick()
|
||||
i = i + 1
|
||||
if i > steps then done = true; return end
|
||||
ow:scriptMove(npc, "down", 1)
|
||||
ow:scriptMove(p, "down", 1, tick)
|
||||
end
|
||||
tick()
|
||||
-- NPCs update before updateScriptMoves and the player after, so a
|
||||
-- paired step leaves the NPC one frame (1px) behind for its whole cell
|
||||
for _ = 1, 60 * steps do
|
||||
ow:update(1)
|
||||
local drift = math.abs((p.py - npc.py) - 16)
|
||||
if drift > worstDrift then worstDrift = drift end
|
||||
if done then break end
|
||||
end
|
||||
npc.stepFrames = nil
|
||||
return { done = done, drift = worstDrift,
|
||||
npcY = npc.cellY, playerY = p.cellY }
|
||||
end
|
||||
|
||||
local synced = runEscort(true, 6)
|
||||
check(synced.done, "synced escort finishes")
|
||||
check(synced.drift <= 2,
|
||||
("synced NPC holds formation (worst drift %dpx)"):format(synced.drift))
|
||||
eq(synced.playerY - synced.npcY, 1, "synced NPC stays one cell ahead")
|
||||
|
||||
-- The default NPC walk is MoveSprite's, half the player's rate: without
|
||||
-- the sync the escort desyncs, which is the bug this guards.
|
||||
local plain = runEscort(false, 6)
|
||||
check(plain.drift >= 16,
|
||||
("unsynced NPC lags a cell or more (worst drift %dpx)"):format(plain.drift))
|
||||
|
||||
for k, v in pairs(prev) do Game[k] = v end
|
||||
S.finish()
|
||||
@@ -14,6 +14,7 @@ love.system = love.system or {}
|
||||
local saved = {
|
||||
getOS = love.system.getOS,
|
||||
pickFile = love.system.pickFile,
|
||||
pickFileKinds = love.system.pickFileKinds,
|
||||
}
|
||||
|
||||
local pickCalls = {}
|
||||
@@ -22,6 +23,7 @@ love.system.pickFile = function(kind)
|
||||
pickCalls[#pickCalls + 1] = kind or "rom"
|
||||
return true
|
||||
end
|
||||
love.system.pickFileKinds = function() return "rom,mod,sav,required_import" end
|
||||
|
||||
local function freshImporter(ready)
|
||||
return setmetatable({
|
||||
@@ -94,11 +96,83 @@ eq(ri._imported.source, "picked_save.sav", "focus reads the SAF save filename")
|
||||
check(love.filesystem.getInfo("picked_save.sav") == nil,
|
||||
"successful focus import removes picked_save.sav")
|
||||
|
||||
-- Required mod files use their own safe picker kind and pending filename.
|
||||
pickCalls = {}
|
||||
ri = freshImporter({ red = true, blue = true })
|
||||
ri.nativePicker = true
|
||||
ri.mobileFileBridge = true
|
||||
ri.mods = { {
|
||||
id = "needs_source",
|
||||
manifest = { id = "needs_source", name = "Needs Source",
|
||||
required_imports = { { id = "source", name = "Source", file = "source.bin",
|
||||
format = "raw", md5 = { "00000000000000000000000000000000" } } } },
|
||||
} }
|
||||
ri:chooseRequiredImport("needs_source", "source")
|
||||
eq(pickCalls[1], "required_import",
|
||||
"required file asks for the dedicated picker kind")
|
||||
eq(ri.pickerPendingModId, "needs_source", "pending mod is remembered")
|
||||
eq(ri.pickerPendingImportId, "source", "pending import is remembered")
|
||||
|
||||
-- A rejected selection stays on the imported-files page, where the player can
|
||||
-- see it before choosing another file, instead of behind the modal.
|
||||
ri.nativePicker = false
|
||||
ri._importRequiredData = RomImporter._importRequiredData
|
||||
local savedData = love.data
|
||||
love.data = {
|
||||
hash = function() return "not accepted" end,
|
||||
encode = function() return "ffffffffffffffffffffffffffffffff" end,
|
||||
}
|
||||
ri:_importRequiredData("needs_source", "source", "wrong source bytes")
|
||||
love.data = savedData
|
||||
check(ri.requiredImportNotice ~= nil,
|
||||
"required import rejection creates an in-modal notice")
|
||||
eq(ri.requiredImportNotice.modId, "needs_source",
|
||||
"required import notice identifies its mod")
|
||||
eq(ri.requiredImportNotice.importId, "source",
|
||||
"required import notice identifies its file")
|
||||
check(ri.requiredImportNotice.text:find("MD5 mismatch", 1, true) ~= nil,
|
||||
"required import notice includes the MD5 failure")
|
||||
check(ri.modNotice == nil,
|
||||
"required import rejection is not hidden in the general Mods notice")
|
||||
|
||||
-- Reported size is checked before the selected file is read into Lua.
|
||||
local savedGetInfo = love.filesystem.getInfo
|
||||
love.filesystem.getInfo = function(name, kind)
|
||||
if name == "oversized_required.bin" then
|
||||
return { type = "file", size = 10 }
|
||||
end
|
||||
return savedGetInfo(name, kind)
|
||||
end
|
||||
ri.mods[1].manifest.required_imports[1].max_size = 4
|
||||
ri._importRequiredSource = RomImporter._importRequiredSource
|
||||
ri._importRequiredData = function(self) self._oversizedWasRead = true end
|
||||
ri:_importRequiredSource("needs_source", "source", "oversized_required.bin")
|
||||
check(not ri._oversizedWasRead, "oversized required file is rejected before import")
|
||||
check(ri.requiredImportNotice.text:find("too large", 1, true) ~= nil,
|
||||
"oversized required file reports its size error in the modal")
|
||||
ri.mods[1].manifest.required_imports[1].max_size = nil
|
||||
love.filesystem.getInfo = savedGetInfo
|
||||
|
||||
ri.nativePicker = true
|
||||
ri._importRequiredSource = function(self, modId, importId, source)
|
||||
self._requiredImported = { modId = modId, importId = importId, source = source }
|
||||
return true
|
||||
end
|
||||
love.filesystem.write("picked_required_import.bin", "source bytes")
|
||||
ri:focus(true)
|
||||
check(ri._requiredImported ~= nil, "focus consumes a required-file SAF pick")
|
||||
eq(ri._requiredImported.modId, "needs_source", "focus routes to the pending mod")
|
||||
eq(ri._requiredImported.importId, "source", "focus routes to the pending declaration")
|
||||
check(love.filesystem.getInfo("picked_required_import.bin") == nil,
|
||||
"focus removes the staged required-file pick")
|
||||
|
||||
love.system.getOS = saved.getOS
|
||||
love.system.pickFile = saved.pickFile
|
||||
love.system.pickFileKinds = saved.pickFileKinds
|
||||
-- leftover cleanup if a failed assertion left files behind
|
||||
love.filesystem.remove("usb_mod.zip")
|
||||
love.filesystem.remove("picked_mod.zip")
|
||||
love.filesystem.remove("picked_save.sav")
|
||||
love.filesystem.remove("picked_required_import.bin")
|
||||
|
||||
S.finish()
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
-- tests/save_editor_task7_tests.lua
|
||||
-- tests/save_editor_task8_tests.lua
|
||||
-- tests/save_editor_mod_tests.lua
|
||||
-- tests/save_editor_gen2_tests.lua
|
||||
-- See tools/save-editor/README.md for the full list.
|
||||
--
|
||||
-- All of them drive tools/save-editor/Ops.lua rather than clicking pixel
|
||||
|
||||
@@ -3475,6 +3475,8 @@ do
|
||||
local lua = (arg and arg[-1]) or "luajit"
|
||||
local status = os.execute(("%q tests/save_editor_mod_tests.lua"):format(lua))
|
||||
check(status == 0 or status == true, "save_editor_mod_tests suite")
|
||||
status = os.execute(("%q tests/save_editor_gen2_tests.lua"):format(lua))
|
||||
check(status == 0 or status == true, "save_editor_gen2_tests suite")
|
||||
end
|
||||
|
||||
-- ---------------------------------------------- input hold regressions
|
||||
|
||||
@@ -0,0 +1,377 @@
|
||||
-- Headless Gold save-editor rules. Run from repo root:
|
||||
-- luajit tests/save_editor_gen2_tests.lua
|
||||
package.path = package.path .. ";./?.lua;./?/init.lua;./tools/save-editor/?.lua"
|
||||
.. ";./tools/save-editor/panels/?.lua"
|
||||
|
||||
local love_stub = require("tests.love_stub")
|
||||
love = love_stub
|
||||
|
||||
local passed, failed = 0, 0
|
||||
|
||||
local function check(cond, msg)
|
||||
if cond then
|
||||
passed = passed + 1
|
||||
else
|
||||
failed = failed + 1
|
||||
print("FAIL: " .. msg)
|
||||
end
|
||||
end
|
||||
|
||||
local function eq(a, b, msg)
|
||||
check(a == b, msg .. string.format(" (got %s, want %s)", tostring(a), tostring(b)))
|
||||
end
|
||||
|
||||
print("== save editor gen2 tests ==")
|
||||
|
||||
local Gen = require("Gen")
|
||||
local Catalog = require("Catalog")
|
||||
local MonOps = require("MonOps")
|
||||
local Ops = require("Ops")
|
||||
local State = require("State")
|
||||
local Save2 = require("src.core.gen2.Save")
|
||||
local SaveData = require("src.core.SaveData")
|
||||
local GameVersion = require("src.core.GameVersion")
|
||||
|
||||
local data = {
|
||||
pokemon = {
|
||||
CYNDAQUIL = {
|
||||
id = "CYNDAQUIL", name = "CYNDAQUIL", dex = 155,
|
||||
types = { "FIRE" },
|
||||
baseStats = {
|
||||
hp = 39, attack = 52, defense = 43, speed = 65,
|
||||
specialAttack = 60, specialDefense = 50,
|
||||
},
|
||||
catchRate = 45, baseExp = 65,
|
||||
growthRate = "MEDIUM_FAST",
|
||||
levelMoves = { { level = 1, move = "TACKLE" } },
|
||||
genderRatio = 31,
|
||||
},
|
||||
TOTODILE = {
|
||||
id = "TOTODILE", name = "TOTODILE", dex = 158,
|
||||
types = { "WATER" },
|
||||
baseStats = {
|
||||
hp = 50, attack = 65, defense = 64, speed = 43,
|
||||
specialAttack = 44, specialDefense = 48,
|
||||
},
|
||||
catchRate = 45, baseExp = 66,
|
||||
growthRate = "MEDIUM_FAST",
|
||||
levelMoves = { { level = 1, move = "SCRATCH" } },
|
||||
genderRatio = 31,
|
||||
},
|
||||
},
|
||||
moves = {
|
||||
TACKLE = { pp = 35 },
|
||||
SCRATCH = { pp = 35 },
|
||||
},
|
||||
items = {
|
||||
POTION = { pocket = "ITEM" },
|
||||
MASTER_BALL = { pocket = "BALL" },
|
||||
FLOWER_MAIL = { pocket = "ITEM" },
|
||||
},
|
||||
maps = {},
|
||||
}
|
||||
|
||||
local function newState()
|
||||
local S = State.new()
|
||||
S.data = data
|
||||
S.cat = Catalog.build(data)
|
||||
S.save = Save2.newGame()
|
||||
S.version = "gold"
|
||||
Gen.ensureBoxes(S.save)
|
||||
return S
|
||||
end
|
||||
|
||||
do
|
||||
GameVersion.set("gold")
|
||||
eq(Gen.of({ generation = 2 }), 2, "Gen.of generation field")
|
||||
eq(Gen.of({ version = "gold" }), 2, "Gen.of version gold")
|
||||
eq(Gen.of(SaveData.newGame()), 1, "Gen.of gen1 newGame")
|
||||
eq(Gen.of(Save2.newGame()), 2, "Gen.of gold newGame")
|
||||
end
|
||||
|
||||
do
|
||||
local S = newState()
|
||||
check(Ops.speciesUsable(S, "CYNDAQUIL"), "spa/spd species is usable")
|
||||
local S1 = State.new()
|
||||
S1.data = {
|
||||
pokemon = {
|
||||
PIDGEY = { baseStats = { hp = 40, attack = 45, defense = 40, speed = 56, special = 35 } },
|
||||
BROKEN = { baseStats = { hp = 1 } },
|
||||
},
|
||||
}
|
||||
check(Ops.speciesUsable(S1, "PIDGEY"), "gen1 special species is usable")
|
||||
check(not Ops.speciesUsable(S1, "BROKEN"), "partial record is not usable")
|
||||
end
|
||||
|
||||
do
|
||||
local S = newState()
|
||||
Ops.partyAdd(S)
|
||||
eq(#S.save.party, 1, "partyAdd on gold")
|
||||
local mon = S.save.party[1]
|
||||
eq(mon.species, "CYNDAQUIL", "first catalog species")
|
||||
check(mon.experience ~= nil, "gold mon has experience")
|
||||
check(mon.exp == nil or mon.experience ~= nil, "does not rely on gen1 exp")
|
||||
check(mon.stats.specialAttack and mon.stats.specialDefense,
|
||||
"gold stats have spa/spd")
|
||||
check(mon.happiness ~= nil, "gold mon has happiness")
|
||||
eq(mon.ot, S.save.player.name, "stampOT copies player name")
|
||||
|
||||
Ops.setLevel(S, mon, 20)
|
||||
eq(mon.level, 20, "setLevel 20")
|
||||
check(mon.experience > 0, "experience resynced")
|
||||
|
||||
Ops.setHappiness(S, mon, 200)
|
||||
eq(mon.happiness, 200, "happiness 200")
|
||||
Ops.setPokerus(S, mon, 15)
|
||||
eq(mon.pokerus, 15, "pokerus byte")
|
||||
Ops.setHeldItem(S, mon, "POTION")
|
||||
eq(mon.item, "POTION", "held item")
|
||||
|
||||
eq(mon.name, "CYNDAQUIL", "new mon copies species display name")
|
||||
Ops.setSpecies(S, mon, "TOTODILE")
|
||||
eq(mon.species, "TOTODILE", "setSpecies id")
|
||||
eq(mon.name, "TOTODILE", "setSpecies rewrites the Gold display name")
|
||||
check(mon.nickname == nil, "setSpecies does not invent a nickname")
|
||||
eq(mon.types[1], "WATER", "setSpecies rewrites copied types")
|
||||
end
|
||||
|
||||
do
|
||||
local S = newState()
|
||||
Ops.partyAdd(S)
|
||||
Ops.partyAdd(S)
|
||||
local Mail = require("src.core.gen2.Mail")
|
||||
Mail.set(S.save, 1, Mail.entry("FLOWER_MAIL", "hi", "GOLD", 1, "CYNDAQUIL"))
|
||||
Mail.set(S.save, 2, Mail.entry("SURF_MAIL", "bye", "GOLD", 1, "CYNDAQUIL"))
|
||||
S.selectedParty = 1
|
||||
Ops.partyMove(S, 1)
|
||||
eq(Mail.state(S.save).party[1].message, "bye", "partyMove carries mail with the mon")
|
||||
eq(Mail.state(S.save).party[2].message, "hi", "partyMove swaps the other letter")
|
||||
S.selectedParty = 1
|
||||
check(Ops.partyRemove(S) == false, "partyRemove arms")
|
||||
check(Ops.partyRemove(S) == true, "partyRemove commits")
|
||||
eq(Mail.state(S.save).party[1].message, "hi", "partyRemove shifts leftover mail up")
|
||||
check(Mail.state(S.save).party[2] == nil, "partyRemove clears the vacated slot")
|
||||
end
|
||||
|
||||
do
|
||||
local S = newState()
|
||||
Ops.partyAdd(S)
|
||||
local mon = S.save.party[1]
|
||||
local Mail = require("src.core.gen2.Mail")
|
||||
Ops.setHeldItem(S, mon, "FLOWER_MAIL")
|
||||
eq(mon.item, "FLOWER_MAIL", "held mail item")
|
||||
local letter = Mail.state(S.save).party[1]
|
||||
check(letter ~= nil, "giving mail writes sPartyMail")
|
||||
eq(letter.species, "CYNDAQUIL", "new letter stamps current species")
|
||||
Ops.setSpecies(S, mon, "TOTODILE")
|
||||
eq(Mail.state(S.save).party[1].species, "TOTODILE",
|
||||
"setSpecies updates the letter's species copy")
|
||||
Ops.setHeldItem(S, mon, "POTION")
|
||||
check(Mail.state(S.save).party[1] == nil, "non-mail held item drops the letter")
|
||||
end
|
||||
|
||||
do
|
||||
local Mon = require("src.battle.gen2.Mon")
|
||||
local S = newState()
|
||||
local stale = Mon.new(data, "CYNDAQUIL", 5)
|
||||
stale.species = "TOTODILE"
|
||||
stale.name = "CYNDAQUIL"
|
||||
stale.types = { "FIRE" }
|
||||
S.save.dayCare = { man = { mon = stale }, lady = {} }
|
||||
Mon.syncSaveIdentity(S.save, data)
|
||||
eq(stale.name, "TOTODILE", "syncSaveIdentity rewrites Day-Care display name")
|
||||
eq(stale.types[1], "WATER", "syncSaveIdentity rewrites Day-Care types")
|
||||
eq(Mon.displayName({ nickname = nil, name = "ABRA", species = "RAYQUAZA" }),
|
||||
"ABRA", "displayName prefers the species copy over the id")
|
||||
eq(Mon.displayName({ nickname = "BOB", name = "ABRA", species = "RAYQUAZA" }),
|
||||
"BOB", "displayName prefers nickname")
|
||||
end
|
||||
|
||||
do
|
||||
local S = newState()
|
||||
eq(Ops.boxCount(S), 14, "14 gold boxes")
|
||||
eq(Ops.boxCapacity(S), 20, "20 per box")
|
||||
Ops.boxAdd(S)
|
||||
eq(#Ops.boxes(S)[1], 1, "boxAdd into box 1")
|
||||
end
|
||||
|
||||
do
|
||||
local S = newState()
|
||||
Ops.partyAdd(S)
|
||||
S.save.party[1].hp = 0
|
||||
Ops.partyAdd(S)
|
||||
S.selectedParty = 1
|
||||
S.selectedBox = 1
|
||||
local ok = Ops.deposit(S)
|
||||
check(ok, "deposit fainted mon while a healthy remains")
|
||||
end
|
||||
|
||||
do
|
||||
local S = newState()
|
||||
Ops.partyAdd(S)
|
||||
Ops.partyAdd(S)
|
||||
S.selectedParty = 1
|
||||
S.selectedBox = 1
|
||||
local ok = Ops.deposit(S)
|
||||
check(ok, "deposit one of two healthy mons")
|
||||
end
|
||||
|
||||
do
|
||||
local S = newState()
|
||||
Ops.partyAdd(S)
|
||||
S.selectedParty = 1
|
||||
S.selectedBox = 1
|
||||
local ok = Ops.deposit(S)
|
||||
check(not ok, "refuse depositing last healthy mon")
|
||||
check(S.status:lower():find("last", 1, true) or S.status:find("POKéMON")
|
||||
or S.status:find("POKEMON") or S.status:find("last"),
|
||||
"deposit refusal names the last-healthy rule: " .. tostring(S.status))
|
||||
eq(#S.save.party, 1, "party still has the mon")
|
||||
end
|
||||
|
||||
do
|
||||
local S = newState()
|
||||
eq(Gen.money(S.save), 3000, "gold start money on player")
|
||||
Ops.addMoney(S, 1000)
|
||||
eq(S.save.player.money, 4000, "money writes player.money")
|
||||
check(S.save.money == nil or S.save.money ~= 4000, "does not write save.money")
|
||||
Ops.maxMoney(S)
|
||||
eq(S.save.player.money, 999999, "money cap")
|
||||
Ops.addCoins(S, 250)
|
||||
eq(S.save.player.coins, 250, "coins write player.coins")
|
||||
end
|
||||
|
||||
do
|
||||
local S = newState()
|
||||
check(not Gen.hasBadge(S.save, "ZEPHYR"), "no zephyr yet")
|
||||
Ops.toggleBadge(S, "ZEPHYR")
|
||||
check(Gen.hasBadge(S.save, "ZEPHYR"), "zephyr earned")
|
||||
check(S.save.player.badges.ZEPHYR, "stored on player.badges")
|
||||
Ops.toggleBadge(S, "BOULDER")
|
||||
check(S.save.player.kantoBadges.BOULDER, "kanto badge store")
|
||||
end
|
||||
|
||||
do
|
||||
local S = newState()
|
||||
Ops.dexOwned(S, "CYNDAQUIL", true)
|
||||
check(S.save.pokedex.caught.CYNDAQUIL, "dex writes caught")
|
||||
check(S.save.pokedex.owned == nil or S.save.pokedex.owned.CYNDAQUIL == nil,
|
||||
"does not write owned on gold")
|
||||
check(S.save.pokedex.seen.CYNDAQUIL, "owned implies seen")
|
||||
local _, owned = Ops.dexCounts(S)
|
||||
eq(owned, 1, "dexCounts reads caught")
|
||||
end
|
||||
|
||||
do
|
||||
local S = newState()
|
||||
local name = "EVENT_BEAT_FALKNER"
|
||||
Ops.setFlag(S, name, true)
|
||||
check(Gen.getFlag(S.save, name), "gold EVENT_ sets bitfield")
|
||||
check(S.save.flags[name] == nil, "numeric flags are not string keys")
|
||||
Ops.setFlag(S, "MOD_EDITMON_GIFT", true)
|
||||
check(S.save.flags.MOD_EDITMON_GIFT, "mod flags stay named on gold")
|
||||
end
|
||||
|
||||
do
|
||||
local S = newState()
|
||||
S.mapId = "NEW_BARK_TOWN"
|
||||
S.mapClickCell = { cx = 4, cy = 5 }
|
||||
Ops.setPlayerHere(S)
|
||||
eq(S.save.position.map, "NEW_BARK_TOWN", "position.map")
|
||||
eq(S.save.position.x, 4, "position.x")
|
||||
eq(S.save.position.y, 5, "position.y")
|
||||
check(S.save.player.map == nil or S.save.player.map ~= "NEW_BARK_TOWN",
|
||||
"does not write player.map on gold")
|
||||
end
|
||||
|
||||
do
|
||||
local encoded = SaveData.encode(Save2.newGame())
|
||||
local back = SaveData.decode(encoded)
|
||||
eq(back.generation, 2, "round-trip keeps generation 2")
|
||||
end
|
||||
|
||||
do
|
||||
local names = Catalog.goldEventList()
|
||||
local hasFalkner = false
|
||||
for _, n in ipairs(names) do
|
||||
if n == "EVENT_BEAT_FALKNER" then hasFalkner = true break end
|
||||
end
|
||||
check(hasFalkner, "gold event list includes EVENT_BEAT_FALKNER")
|
||||
end
|
||||
|
||||
do
|
||||
local maps = Gen.maps({ gen2Maps = { AZALEA_GYM = true }, maps = { PALLET_TOWN = true } })
|
||||
check(maps.AZALEA_GYM, "Gen.maps includes gen2Maps")
|
||||
check(maps.PALLET_TOWN, "Gen.maps keeps Data:load maps beside gen2Maps")
|
||||
check(Gen.maps({ maps = { PALLET_TOWN = true } }).PALLET_TOWN,
|
||||
"Gen.maps falls back to maps")
|
||||
local mansion = Gen.maps({
|
||||
maps = {
|
||||
CELADON_MANSION_2F = { id = "CELADON_MANSION_2F", width = 4, height = 5 },
|
||||
},
|
||||
gen2Maps = {
|
||||
CELADON_MANSION_2F = { objects = { { name = "NPC" } } },
|
||||
BERRY_FARM = { id = "BERRY_FARM", width = 19, height = 12 },
|
||||
},
|
||||
})
|
||||
eq(mansion.CELADON_MANSION_2F.width, 4,
|
||||
"Gen.maps keeps extractor width under a gen2Maps objects patch")
|
||||
eq(mansion.CELADON_MANSION_2F.objects[1].name, "NPC",
|
||||
"Gen.maps still applies the gen2Maps patch fields")
|
||||
eq(mansion.BERRY_FARM.width, 19, "Gen.maps keeps mod maps only on gen2Maps")
|
||||
local bound = Gen.bindGoldData({ maps = { A = true }, tilesets = { T = true } })
|
||||
check(bound.gen2Maps == bound.maps, "bindGoldData aliases gen2Maps")
|
||||
check(bound.gen2Tilesets == bound.tilesets, "bindGoldData aliases gen2Tilesets")
|
||||
check(Gen.tilesets({ gen2Tilesets = { TILESET_GYM = true } }).TILESET_GYM,
|
||||
"Gen.tilesets prefers gen2Tilesets")
|
||||
end
|
||||
|
||||
do
|
||||
local Map2 = require("src.world.gen2.Map")
|
||||
local MapPreview = require("src.world.gen2.MapPreview")
|
||||
local def = {
|
||||
id = "AZALEA_GYM", tileset = "TILESET_GYM",
|
||||
width = 1, height = 1, blocks = { 1 }, borderBlock = 1,
|
||||
warps = {}, environment = "INDOOR",
|
||||
}
|
||||
local tileset = {
|
||||
id = "TILESET_GYM",
|
||||
image = "assets/generated/tilesets/gym.png",
|
||||
tilesPerRow = 16,
|
||||
blocks = { { 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 } },
|
||||
}
|
||||
local map = Map2.new(def, tileset)
|
||||
check(map.renderer == nil, "Map2 does not ship a renderer")
|
||||
local baker = MapPreview.baker({ tilesets = { TILESET_GYM = tileset } })
|
||||
local renderer = MapPreview.renderer(baker, map)
|
||||
check(renderer ~= nil and renderer.draw ~= nil,
|
||||
"MapPreview attaches a draw for a Gold map")
|
||||
end
|
||||
|
||||
do
|
||||
local memfs = {
|
||||
files = {
|
||||
["saves/gold/slot1.lua"] = 'return { version = "gold", generation = 2, player = { name = "GOLD" } }',
|
||||
["options.lua"] = 'return { textSpeed = 3 }',
|
||||
},
|
||||
getInfo = function(self, path)
|
||||
return self.files[path] and { type = "file" } or nil
|
||||
end,
|
||||
read = function(self, path)
|
||||
return self.files[path]
|
||||
end,
|
||||
write = function(self, path, data)
|
||||
self.files[path] = data
|
||||
return true
|
||||
end,
|
||||
remove = function(self, path)
|
||||
self.files[path] = nil
|
||||
return true
|
||||
end,
|
||||
}
|
||||
local main, _, _ = SaveData.saveFilename("gold")
|
||||
check(main ~= nil, "saveFilename resolves for gold")
|
||||
end
|
||||
|
||||
print(string.format("save editor gen2 tests: %d passed, %d failed", passed, failed))
|
||||
if failed > 0 then os.exit(1) end
|
||||
@@ -142,6 +142,41 @@ local ok, err = pcall(function()
|
||||
local report = SaveData.validate(probe, Data)
|
||||
check(#report.lostMons == 0 and probe.party[1].species == "EDITMON",
|
||||
"validate keeps the modded mon while the mod is enabled")
|
||||
|
||||
-- Gold-targeted mods: spa/spd records are usable, and a Gen 1-only
|
||||
-- manifest stays out of Gold (no ROM cache / App.load("gold") required).
|
||||
local ModTargets = require("src.mods.ModTargets")
|
||||
local Ops = require("Ops")
|
||||
check(not ModTargets.supports({}, "gold"),
|
||||
"legacy gen1-only fixture does not support gold")
|
||||
check(not ModTargets.supports({ games = { "red" } }, "gold"),
|
||||
"explicit gen1 games list does not support gold")
|
||||
check(ModTargets.supports({ games = { "gold" } }, "gold"),
|
||||
"gold-targeted manifest supports gold")
|
||||
check(ModTargets.supports({ gen2compat = true }, "gold"),
|
||||
"gen2compat legacy still supports gold")
|
||||
|
||||
local goldS = {
|
||||
data = {
|
||||
pokemon = {
|
||||
EDITMON = {
|
||||
baseStats = {
|
||||
hp = 50, attack = 50, defense = 50, speed = 50,
|
||||
specialAttack = 50, specialDefense = 50,
|
||||
},
|
||||
},
|
||||
G1ONLY = {
|
||||
baseStats = {
|
||||
hp = 50, attack = 50, defense = 50, speed = 50, special = 50,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
check(Ops.speciesUsable(goldS, "EDITMON"),
|
||||
"spa/spd EDITMON is usable on gold")
|
||||
check(Ops.speciesUsable(goldS, "G1ONLY"),
|
||||
"gen1 special record remains usable (dual-key gate)")
|
||||
end)
|
||||
|
||||
os.remove(MOD_ROOT .. "/main.lua")
|
||||
@@ -154,7 +189,7 @@ os.remove(tmpPath)
|
||||
love.filesystem = savedFS
|
||||
-- leave shared singletons the way we found them (the fixture merged one
|
||||
-- record into Data.pokemon)
|
||||
Data.pokemon.EDITMON = nil
|
||||
if Data.pokemon then Data.pokemon.EDITMON = nil end
|
||||
Assets.loader = savedBridge
|
||||
Assets.invalidate()
|
||||
Runtime.install(savedEvents, savedHooks, savedErrors)
|
||||
|
||||
@@ -12,7 +12,7 @@ package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
local Flags = dofile("tools/goldwalk/flags.lua")
|
||||
|
||||
local ROOT = arg[1] or "../pokegold"
|
||||
local OUT = "tests/drivers/gold/flag_names.lua"
|
||||
local OUT = "src/core/gen2/FlagNames.lua"
|
||||
|
||||
local events, _ = Flags.parse(ROOT .. "/constants/event_flags.asm")
|
||||
local engine, _ = Flags.parse(ROOT .. "/constants/engine_flags.asm")
|
||||
|
||||
@@ -1045,6 +1045,14 @@ def lint_dir(repo, mod_dir, manifest):
|
||||
for rel in mod_files(mod_dir):
|
||||
path = os.path.join(mod_dir, rel)
|
||||
ext = os.path.splitext(rel)[1].lower()
|
||||
# MK307: required_imports are user-owned installation state. Keeping
|
||||
# baseroms in the walk without an explicit gate would let `pack`
|
||||
# silently bundle exactly the ROM this feature exists not to ship.
|
||||
if rel.startswith("baseroms/"):
|
||||
findings.append(Finding(
|
||||
"MK307", "error",
|
||||
"user-supplied baseroms must not be distributed", rel))
|
||||
continue
|
||||
# MK301: nothing may live in (or point into) the generated trees
|
||||
if rel.startswith(("data/generated/", "assets/generated/")):
|
||||
findings.append(Finding(
|
||||
|
||||
+49
-42
@@ -25,6 +25,7 @@ local State = require("State")
|
||||
local Kit = require("Kit")
|
||||
local Theme = require("Theme")
|
||||
local Ops = require("Ops")
|
||||
local Gen = require("Gen")
|
||||
local PadInput = require("PadInput")
|
||||
local PAL = Theme.PAL
|
||||
|
||||
@@ -87,56 +88,43 @@ local function applyLoaded(path, statusVerb)
|
||||
if save then
|
||||
S.save = save
|
||||
S.status = statusVerb .. " " .. path
|
||||
S.mapId = save.player.map
|
||||
S.loadError = false
|
||||
S.allowSave = true
|
||||
elseif existed then
|
||||
-- File is present but SaveIO.load couldn't decode it: treat it as a
|
||||
-- real (corrupt) save, not a missing one. Editing a stub here is fine,
|
||||
-- but Save must stay disabled so we never clobber the corrupt file
|
||||
-- until the user fixes it and Reload succeeds.
|
||||
S.save = require("src.core.SaveData").newGame()
|
||||
S.save = Gen.newGame(S.version)
|
||||
S.status = "Corrupt save at " .. path .. " (" .. tostring(err) ..
|
||||
"), Save disabled, use Reload after fixing the file"
|
||||
S.mapId = S.save.player.map
|
||||
S.loadError = true
|
||||
S.allowSave = false
|
||||
else
|
||||
S.save = require("src.core.SaveData").newGame()
|
||||
S.save = Gen.newGame(S.version)
|
||||
S.status = "No save at " .. path .. " (" .. tostring(err) ..
|
||||
"), editing new game stub"
|
||||
S.mapId = S.save.player.map
|
||||
S.loadError = false
|
||||
S.allowSave = true
|
||||
end
|
||||
local mapId = Gen.playerMap(S.save)
|
||||
S.mapId = mapId
|
||||
S.dirty = false
|
||||
S._quitArmed = false
|
||||
S._openArmed = false
|
||||
S.editingMon = nil
|
||||
Ops.disarm(S)
|
||||
local boxes = require("src.pokemon.Boxes").ensure(S.save)
|
||||
-- Imported .sav box mons have no stat block (box_struct stops before
|
||||
-- MON_STATS). The game derives them in SaveData.validate; the editor
|
||||
-- only validates a copy, so hydrate here for Boxes/Party/MonEditor.
|
||||
local Stats = require("src.pokemon.Stats")
|
||||
local function ensureStats(mon)
|
||||
Stats.ensure(Data.pokemon and Data.pokemon[mon.species], mon)
|
||||
end
|
||||
for _, mon in ipairs(S.save.party or {}) do ensureStats(mon) end
|
||||
for _, box in ipairs(boxes) do
|
||||
for _, mon in ipairs(box) do ensureStats(mon) end
|
||||
end
|
||||
if S.save.daycare and S.save.daycare.mon then
|
||||
ensureStats(S.save.daycare.mon)
|
||||
end
|
||||
-- what the running game would quarantine, computed on a copy so the
|
||||
-- editor never mutates the file behind the user's back
|
||||
local SaveData = require("src.core.SaveData")
|
||||
Gen.ensureBoxes(S.save)
|
||||
Gen.hydrateSave(Data, S.save)
|
||||
local probe = require("src.mods.Merge").deepCopy(S.save)
|
||||
S.validation = SaveData.validate(probe, Data)
|
||||
if not SaveData.emptyReport(S.validation) then
|
||||
S.status = S.status .. string.format(", game would quarantine: %d mons, %d items, %d maps",
|
||||
#S.validation.lostMons, #S.validation.lostItems, #S.validation.remappedMaps)
|
||||
S.validation = Gen.validate(probe, Data)
|
||||
if not Gen.emptyReport(S.save, S.validation) then
|
||||
if Gen.of(S.save, S.version) == 2 then
|
||||
S.status = S.status .. string.format(
|
||||
", game would quarantine: %d script bytes, %d mail, %d events",
|
||||
#(S.validation.lostScriptMem or {}),
|
||||
#(S.validation.lostMail or {}),
|
||||
#(S.validation.lostEvents or {}))
|
||||
else
|
||||
S.status = S.status .. string.format(", game would quarantine: %d mons, %d items, %d maps",
|
||||
#S.validation.lostMons, #S.validation.lostItems, #S.validation.remappedMaps)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -152,6 +140,9 @@ function App.load(pathOverride, opts)
|
||||
S.slotId = opts.slotId
|
||||
S.embedded = opts.embedded or false
|
||||
S.onClose = opts.onClose
|
||||
if opts.version then
|
||||
require("src.core.GameVersion").set(opts.version)
|
||||
end
|
||||
-- the same mod set the game loads, merged into Data before the catalogs
|
||||
-- build, so modded species/items/moves are editable and MonOps stops
|
||||
-- asserting on them
|
||||
@@ -163,6 +154,10 @@ function App.load(pathOverride, opts)
|
||||
-- loaded at least once, so it doubles as the "needs evicting" marker.
|
||||
if Data._pristineKeys then Data:unloadGenerated() end
|
||||
Data:load()
|
||||
if Gen.of(nil, opts.version) == 2
|
||||
or require("src.core.GameVersion").generation() == 2 then
|
||||
Gen.bindGoldData(Data)
|
||||
end
|
||||
local ModLoader = require("src.mods.Loader")
|
||||
mods = ModLoader.new()
|
||||
mods:load(Data)
|
||||
@@ -174,9 +169,16 @@ function App.load(pathOverride, opts)
|
||||
for _, mod in ipairs(S.mods:status().loaded) do
|
||||
modRoots[#modRoots + 1] = mod.path
|
||||
end
|
||||
S.events = Catalog.scrapeEvents("data/scripts", "data/generated/trainer_headers.lua",
|
||||
nil, modRoots)
|
||||
if Gen.of(nil, opts.version) == 2 or require("src.core.GameVersion").generation() == 2 then
|
||||
S.events = Catalog.goldEventList(modRoots)
|
||||
else
|
||||
S.events = Catalog.scrapeEvents("data/scripts", "data/generated/trainer_headers.lua",
|
||||
nil, modRoots)
|
||||
end
|
||||
applyLoaded(pathOverride or SaveIO.defaultPath(), "Loaded")
|
||||
if Gen.of(S.save, S.version) == 2 then
|
||||
S.events = Catalog.goldEventList(modRoots)
|
||||
end
|
||||
end
|
||||
|
||||
-- Switch to another save file (Open button, drag-drop, or --save arg).
|
||||
@@ -582,11 +584,9 @@ local function tabCount(id)
|
||||
return tostring(n)
|
||||
elseif id == "items" then
|
||||
local Bag = require("src.inventory.Bag")
|
||||
return ("%d/%d"):format(Bag.slots(S.save), Bag.capacity(S.data))
|
||||
return ("%d/%d"):format(Bag.slots(S.save, S.data), Bag.capacity(S.data))
|
||||
elseif id == "events" then
|
||||
local n = 0
|
||||
for _ in pairs(S.save.flags or {}) do n = n + 1 end
|
||||
return tostring(n)
|
||||
return tostring(Gen.flagCount(S.save))
|
||||
elseif id == "map" then
|
||||
-- map ids run long (REDS_HOUSE_2F); the rail is a summary, not a label
|
||||
return Kit.ellipsize("tiny", S.mapId or "", 110 * Kit.scale)
|
||||
@@ -602,21 +602,28 @@ end
|
||||
-- something. Returns what to draw plus the tab that owns the first problem,
|
||||
-- so the rail can reserve the pill's width before laying out the tiles.
|
||||
local function validationPill()
|
||||
local SaveData = require("src.core.SaveData")
|
||||
local report = S.validation
|
||||
if not report or SaveData.emptyReport(report) then
|
||||
if not report or Gen.emptyReport(S.save, report) then
|
||||
return "Save validates clean", PAL.green, nil, true
|
||||
end
|
||||
local parts = {}
|
||||
local target
|
||||
local function add(n, singular, plural, tab)
|
||||
n = n or 0
|
||||
if n <= 0 then return end
|
||||
parts[#parts + 1] = ("%d %s"):format(n, n == 1 and singular or plural)
|
||||
target = target or tab
|
||||
end
|
||||
add(#report.lostMons, "mon", "mons", "party")
|
||||
add(#report.lostItems, "item", "items", "items")
|
||||
add(#report.remappedMaps, "map", "maps", "map")
|
||||
if Gen.of(S.save, S.version) == 2 then
|
||||
add(#(report.lostScriptMem or {}), "script byte", "script bytes", "events")
|
||||
add(#(report.lostMail or {}), "mail", "mail", "party")
|
||||
add(#(report.lostEvents or {}), "event", "events", "events")
|
||||
add(#(report.lostMapScenes or {}), "map scene", "map scenes", "map")
|
||||
else
|
||||
add(#(report.lostMons or {}), "mon", "mons", "party")
|
||||
add(#(report.lostItems or {}), "item", "items", "items")
|
||||
add(#(report.remappedMaps or {}), "map", "maps", "map")
|
||||
end
|
||||
return "Would quarantine " .. table.concat(parts, ", "), PAL.yellow, target, false
|
||||
end
|
||||
|
||||
|
||||
@@ -91,7 +91,8 @@ function Catalog.scrapeEvents(scriptDir, headerPath, listFiles, extraDirs)
|
||||
end
|
||||
end
|
||||
|
||||
local dirs = { scriptDir }
|
||||
local dirs = {}
|
||||
if scriptDir then dirs[#dirs + 1] = scriptDir end
|
||||
for _, dir in ipairs(extraDirs or {}) do
|
||||
dirs[#dirs + 1] = dir
|
||||
end
|
||||
@@ -110,4 +111,25 @@ function Catalog.scrapeEvents(scriptDir, headerPath, listFiles, extraDirs)
|
||||
return sortedKeys(found)
|
||||
end
|
||||
|
||||
function Catalog.goldEventList(extraDirs)
|
||||
local names = {}
|
||||
local ok, flags = pcall(require, "src.core.gen2.FlagNames")
|
||||
if ok and flags and flags.events then
|
||||
for name in pairs(flags.events) do
|
||||
names[#names + 1] = name
|
||||
end
|
||||
end
|
||||
table.sort(names)
|
||||
local modFlags = Catalog.scrapeEvents(nil, nil, nil, extraDirs)
|
||||
local seen = {}
|
||||
for _, name in ipairs(names) do seen[name] = true end
|
||||
for _, name in ipairs(modFlags) do
|
||||
if not seen[name] then
|
||||
names[#names + 1] = name
|
||||
seen[name] = true
|
||||
end
|
||||
end
|
||||
return names
|
||||
end
|
||||
|
||||
return Catalog
|
||||
|
||||
@@ -0,0 +1,350 @@
|
||||
-- Generation adapter for the save editor. Panels stay generation-blind;
|
||||
-- Ops and App read Gold vs RBY through this module so a Gold write never
|
||||
-- lands in Gen 1 fields (save.money, pokedex.owned, 12 boxes, ...).
|
||||
|
||||
local GameVersion = require("src.core.GameVersion")
|
||||
|
||||
local Gen = {}
|
||||
|
||||
local function versionGeneration(version)
|
||||
if type(version) ~= "string" then return nil end
|
||||
local info = GameVersion.info(version)
|
||||
if info then return info.generation or 1 end
|
||||
return nil
|
||||
end
|
||||
|
||||
function Gen.of(save, version)
|
||||
if type(save) == "table" then
|
||||
if save.generation == 2 then return 2 end
|
||||
local fromVersion = versionGeneration(save.version)
|
||||
if fromVersion then return fromVersion end
|
||||
end
|
||||
local fromArg = versionGeneration(version)
|
||||
if fromArg then return fromArg end
|
||||
return GameVersion.generation()
|
||||
end
|
||||
|
||||
function Gen.ofState(S)
|
||||
if not S then return GameVersion.generation() end
|
||||
return Gen.of(S.save, S.version)
|
||||
end
|
||||
|
||||
function Gen.is2(save, version)
|
||||
return Gen.of(save, version) == 2
|
||||
end
|
||||
|
||||
-- Data:load writes Gold maps/tilesets to the Gen 1 keys; Game2 and the mod
|
||||
-- merge write gen2Maps / gen2Tilesets. Overlay the gen2 table on the loaded
|
||||
-- cache so a mod patch that landed on an empty gen2Maps (objects only, no
|
||||
-- width) does not hide the extractor's record, and a new map like BERRY_FARM
|
||||
-- still appears.
|
||||
local function overlayRecords(base, overlay)
|
||||
if not overlay then return base or {} end
|
||||
if not base or base == overlay then return overlay end
|
||||
local out = {}
|
||||
for id, def in pairs(base) do out[id] = def end
|
||||
for id, def in pairs(overlay) do
|
||||
local prior = out[id]
|
||||
if type(def) == "table" and type(prior) == "table" then
|
||||
local merged = {}
|
||||
for k, v in pairs(prior) do merged[k] = v end
|
||||
for k, v in pairs(def) do merged[k] = v end
|
||||
out[id] = merged
|
||||
else
|
||||
out[id] = def
|
||||
end
|
||||
end
|
||||
return out
|
||||
end
|
||||
|
||||
function Gen.maps(data)
|
||||
if type(data) ~= "table" then return {} end
|
||||
return overlayRecords(data.maps, data.gen2Maps)
|
||||
end
|
||||
|
||||
function Gen.tilesets(data)
|
||||
if type(data) ~= "table" then return {} end
|
||||
return overlayRecords(data.tilesets, data.gen2Tilesets)
|
||||
end
|
||||
|
||||
-- Point the Gen 2 Data keys at the tables Data:load already filled, before
|
||||
-- mods:load folds into gen2Maps. Same wiring Game2 does when it boots Gold.
|
||||
function Gen.bindGoldData(data)
|
||||
if type(data) ~= "table" then return data end
|
||||
if data.maps and data.gen2Maps == nil then data.gen2Maps = data.maps end
|
||||
if data.tilesets and data.gen2Tilesets == nil then
|
||||
data.gen2Tilesets = data.tilesets
|
||||
end
|
||||
if data.palettes and data.gen2Palettes == nil then
|
||||
data.gen2Palettes = data.palettes
|
||||
end
|
||||
if data.gen2Roofs == nil and data.roofs == nil then
|
||||
local ok, roofs = pcall(require, "data.generated.roofs")
|
||||
if ok and type(roofs) == "table" then
|
||||
data.roofs = roofs
|
||||
data.gen2Roofs = roofs
|
||||
end
|
||||
elseif data.roofs and data.gen2Roofs == nil then
|
||||
data.gen2Roofs = data.roofs
|
||||
end
|
||||
return data
|
||||
end
|
||||
|
||||
function Gen.newGame(version)
|
||||
if (versionGeneration(version) or GameVersion.generation(version)) == 2 then
|
||||
return require("src.core.gen2.Save").newGame()
|
||||
end
|
||||
return require("src.core.SaveData").newGame()
|
||||
end
|
||||
|
||||
function Gen.validate(save, data)
|
||||
if Gen.of(save) == 2 then
|
||||
return require("src.core.gen2.Save").validate(save)
|
||||
end
|
||||
return require("src.core.SaveData").validate(save, data)
|
||||
end
|
||||
|
||||
function Gen.emptyReport(save, report)
|
||||
if Gen.of(save) == 2 then
|
||||
return require("src.core.gen2.Save").emptyReport(report)
|
||||
end
|
||||
return require("src.core.SaveData").emptyReport(report)
|
||||
end
|
||||
|
||||
function Gen.hydrateMon(data, mon)
|
||||
if type(mon) ~= "table" then return mon end
|
||||
local def = data and data.pokemon and data.pokemon[mon.species]
|
||||
local gen2 = (mon.stats and mon.stats.specialAttack)
|
||||
or (def and def.baseStats and def.baseStats.specialAttack)
|
||||
or mon.experience ~= nil
|
||||
if gen2 then
|
||||
require("src.battle.gen2.Mon").refreshStats(mon, data)
|
||||
else
|
||||
require("src.pokemon.Stats").ensure(def, mon)
|
||||
end
|
||||
return mon
|
||||
end
|
||||
|
||||
-- Party, boxes, Day-Care. Gold's dayCare.man/lady/egg are not save.daycare.
|
||||
function Gen.hydrateSave(data, save)
|
||||
if type(save) ~= "table" then return save end
|
||||
if Gen.of(save) == 2 then
|
||||
local Mon = require("src.battle.gen2.Mon")
|
||||
Mon.eachSaveMon(save, function(mon) Mon.refreshStats(mon, data) end)
|
||||
return save
|
||||
end
|
||||
for _, mon in ipairs(save.party or {}) do Gen.hydrateMon(data, mon) end
|
||||
for _, box in ipairs(save.boxes or {}) do
|
||||
if type(box) == "table" then
|
||||
for _, mon in ipairs(box) do Gen.hydrateMon(data, mon) end
|
||||
end
|
||||
end
|
||||
if save.daycare and save.daycare.mon then
|
||||
Gen.hydrateMon(data, save.daycare.mon)
|
||||
end
|
||||
return save
|
||||
end
|
||||
|
||||
function Gen.ensureBoxes(save)
|
||||
if Gen.of(save) == 2 then
|
||||
local Boxes2 = require("src.core.gen2.Boxes")
|
||||
save.boxes = save.boxes or {}
|
||||
for i = 1, Boxes2.NUM_BOXES do
|
||||
save.boxes[i] = save.boxes[i] or {}
|
||||
end
|
||||
save.currentBox = math.max(1, math.min(Boxes2.NUM_BOXES, save.currentBox or 1))
|
||||
return save.boxes
|
||||
end
|
||||
return require("src.pokemon.Boxes").ensure(save)
|
||||
end
|
||||
|
||||
function Gen.boxCount(save)
|
||||
if Gen.of(save) == 2 then
|
||||
return require("src.core.gen2.Boxes").NUM_BOXES
|
||||
end
|
||||
return require("src.pokemon.Boxes").COUNT
|
||||
end
|
||||
|
||||
function Gen.boxCapacity(save)
|
||||
if Gen.of(save) == 2 then
|
||||
return require("src.core.gen2.Boxes").MONS_PER_BOX
|
||||
end
|
||||
return require("src.pokemon.Boxes").CAPACITY
|
||||
end
|
||||
|
||||
function Gen.money(save)
|
||||
if Gen.of(save) == 2 then
|
||||
return (save.player and save.player.money) or 0
|
||||
end
|
||||
return save.money or 0
|
||||
end
|
||||
|
||||
function Gen.setMoney(save, amount)
|
||||
if Gen.of(save) == 2 then
|
||||
save.player = save.player or {}
|
||||
save.player.money = amount
|
||||
else
|
||||
save.money = amount
|
||||
end
|
||||
end
|
||||
|
||||
function Gen.coins(save)
|
||||
if Gen.of(save) == 2 then
|
||||
return (save.player and save.player.coins) or 0
|
||||
end
|
||||
return save.coins or 0
|
||||
end
|
||||
|
||||
function Gen.setCoins(save, amount)
|
||||
if Gen.of(save) == 2 then
|
||||
save.player = save.player or {}
|
||||
save.player.coins = amount
|
||||
else
|
||||
save.coins = amount
|
||||
end
|
||||
end
|
||||
|
||||
function Gen.dexOwnedKey(save)
|
||||
if Gen.of(save) == 2 then return "caught" end
|
||||
return "owned"
|
||||
end
|
||||
|
||||
function Gen.playerMap(save)
|
||||
if Gen.of(save) == 2 then
|
||||
local p = save.position
|
||||
if p and p.map then return p.map, p.x or 0, p.y or 0, p.facing end
|
||||
return save.spawn, 0, 0
|
||||
end
|
||||
local p = save.player or {}
|
||||
return p.map, p.x or 0, p.y or 0
|
||||
end
|
||||
|
||||
function Gen.setPlayerHere(save, mapId, x, y, facing)
|
||||
if Gen.of(save) == 2 then
|
||||
local prev = save.position or {}
|
||||
save.position = {
|
||||
map = mapId,
|
||||
x = x,
|
||||
y = y,
|
||||
facing = facing or prev.facing or "down",
|
||||
}
|
||||
return
|
||||
end
|
||||
save.player = save.player or {}
|
||||
save.player.map = mapId
|
||||
save.player.x = x
|
||||
save.player.y = y
|
||||
end
|
||||
|
||||
local JOHTO = {
|
||||
"ZEPHYR", "HIVE", "PLAIN", "FOG", "MINERAL", "STORM", "GLACIER", "RISING",
|
||||
}
|
||||
local KANTO = {
|
||||
"BOULDER", "CASCADE", "THUNDER", "RAINBOW",
|
||||
"SOUL", "MARSH", "VOLCANO", "EARTH",
|
||||
}
|
||||
|
||||
function Gen.badgeIds(save, cat)
|
||||
if Gen.of(save) == 2 then
|
||||
local ids = {}
|
||||
for _, name in ipairs(JOHTO) do ids[#ids + 1] = name end
|
||||
for _, name in ipairs(KANTO) do ids[#ids + 1] = name end
|
||||
return ids
|
||||
end
|
||||
local ids = {}
|
||||
for _, id in ipairs((cat and cat.items) or {}) do
|
||||
if tostring(id):find("BADGE", 1, true) then ids[#ids + 1] = id end
|
||||
end
|
||||
return ids
|
||||
end
|
||||
|
||||
local KANTO_SET = {}
|
||||
for _, name in ipairs(KANTO) do KANTO_SET[name] = true end
|
||||
|
||||
function Gen.hasBadge(save, id)
|
||||
if Gen.of(save) == 2 then
|
||||
local p = save.player or {}
|
||||
local store = KANTO_SET[id] and (p.kantoBadges or {}) or (p.badges or {})
|
||||
if store[id] then return true end
|
||||
local list = KANTO_SET[id] and KANTO or JOHTO
|
||||
for index, name in ipairs(list) do
|
||||
if name == id then return store[index] == true end
|
||||
end
|
||||
return false
|
||||
end
|
||||
return save.inventory and save.inventory[id] and true or false
|
||||
end
|
||||
|
||||
function Gen.toggleBadge(save, id)
|
||||
if Gen.of(save) == 2 then
|
||||
save.player = save.player or {}
|
||||
local storeName = KANTO_SET[id] and "kantoBadges" or "badges"
|
||||
save.player[storeName] = save.player[storeName] or {}
|
||||
local store = save.player[storeName]
|
||||
local on = Gen.hasBadge(save, id)
|
||||
store[id] = (not on) and true or nil
|
||||
for index, name in ipairs(KANTO_SET[id] and KANTO or JOHTO) do
|
||||
if name == id then store[index] = nil end
|
||||
end
|
||||
return not on
|
||||
end
|
||||
local on = save.inventory[id] and true or false
|
||||
save.inventory[id] = (not on) and 1 or nil
|
||||
return not on
|
||||
end
|
||||
|
||||
local function goldFlagId(name)
|
||||
local flags = require("src.core.gen2.FlagNames")
|
||||
return flags.events and flags.events[name]
|
||||
end
|
||||
|
||||
function Gen.getFlag(save, name)
|
||||
if Gen.of(save) == 2 then
|
||||
local id = goldFlagId(name)
|
||||
if id then
|
||||
local Events2 = require("src.world.gen2.Events")
|
||||
local ev = Events2.new()
|
||||
ev:restore(save.events)
|
||||
return ev:get(id)
|
||||
end
|
||||
return save.flags and save.flags[name] == true
|
||||
end
|
||||
return save.flags and save.flags[name] == true
|
||||
end
|
||||
|
||||
function Gen.setFlag(save, name, on)
|
||||
if Gen.of(save) == 2 then
|
||||
local id = goldFlagId(name)
|
||||
if id then
|
||||
local Events2 = require("src.world.gen2.Events")
|
||||
local ev = Events2.new()
|
||||
ev:restore(save.events)
|
||||
ev:set(id, on and true or false)
|
||||
save.events = ev:serialize()
|
||||
return
|
||||
end
|
||||
save.flags = save.flags or {}
|
||||
save.flags[name] = on and true or nil
|
||||
return
|
||||
end
|
||||
save.flags = save.flags or {}
|
||||
save.flags[name] = on and true or nil
|
||||
end
|
||||
|
||||
function Gen.flagCount(save)
|
||||
if Gen.of(save) == 2 then
|
||||
local n = 0
|
||||
for _ in pairs(save.events or {}) do n = n + 1 end
|
||||
for _ in pairs(save.flags or {}) do n = n + 1 end
|
||||
return n
|
||||
end
|
||||
local n = 0
|
||||
for _ in pairs(save.flags or {}) do n = n + 1 end
|
||||
return n
|
||||
end
|
||||
|
||||
function Gen.exp(mon)
|
||||
return mon.experience or mon.exp or 0
|
||||
end
|
||||
|
||||
return Gen
|
||||
@@ -4,23 +4,40 @@ local Growth = require("src.pokemon.Growth")
|
||||
|
||||
local MonOps = {}
|
||||
|
||||
function MonOps.create(data, species, level)
|
||||
function MonOps.create(data, species, level, gen)
|
||||
if gen == 2 then
|
||||
local Mon = require("src.battle.gen2.Mon")
|
||||
local mon = Mon.new(data, species, level)
|
||||
assert(mon, "unknown species " .. tostring(species))
|
||||
return mon
|
||||
end
|
||||
return Pokemon.new(data, species, level)
|
||||
end
|
||||
|
||||
function MonOps.recalc(data, mon)
|
||||
function MonOps.recalc(data, mon, gen)
|
||||
if gen == 2 or (mon.stats and mon.stats.specialAttack) or mon.experience then
|
||||
require("src.battle.gen2.Mon").refreshStats(mon, data)
|
||||
return
|
||||
end
|
||||
local def = data.pokemon[mon.species]
|
||||
assert(def, "unknown species")
|
||||
mon.stats = Stats.calc(def, mon.level, mon.dvs, mon.statExp)
|
||||
mon.hp = math.max(0, math.min(mon.hp or mon.stats.hp, mon.stats.hp))
|
||||
end
|
||||
|
||||
function MonOps.setLevel(data, mon, level)
|
||||
function MonOps.setLevel(data, mon, level, gen)
|
||||
level = math.max(1, math.min(100, math.floor(level)))
|
||||
local def = data.pokemon[mon.species]
|
||||
mon.level = level
|
||||
if gen == 2 or mon.experience ~= nil then
|
||||
local Mon = require("src.battle.gen2.Mon")
|
||||
local growth = Mon.growthFor(data, def.growthRate)
|
||||
mon.experience = Mon.experienceForLevel(growth, level)
|
||||
Mon.refreshStats(mon, data)
|
||||
return
|
||||
end
|
||||
mon.exp = Growth.expForLevel(def.growthRate, level)
|
||||
MonOps.recalc(data, mon)
|
||||
MonOps.recalc(data, mon, gen)
|
||||
end
|
||||
|
||||
function MonOps.setMove(data, mon, slot, moveId)
|
||||
@@ -32,6 +49,7 @@ function MonOps.setMove(data, mon, slot, moveId)
|
||||
id = moveId,
|
||||
pp = mdef.pp + ((mon.moves[slot] and mon.moves[slot].ppUps) or 0) * math.floor(mdef.pp / 5),
|
||||
ppUps = mon.moves[slot] and mon.moves[slot].ppUps or nil,
|
||||
maxPp = mdef.pp,
|
||||
}
|
||||
end
|
||||
|
||||
@@ -42,19 +60,38 @@ function MonOps.syncHpDv(dvs)
|
||||
return dvs
|
||||
end
|
||||
|
||||
function MonOps.setDv(data, mon, key, value)
|
||||
function MonOps.setDv(data, mon, key, value, gen)
|
||||
mon.dvs[key] = math.max(0, math.min(15, math.floor(value)))
|
||||
if key ~= "hp" then
|
||||
MonOps.syncHpDv(mon.dvs)
|
||||
end
|
||||
MonOps.recalc(data, mon)
|
||||
if gen == 2 or (mon.stats and mon.stats.specialAttack) then
|
||||
local Mon = require("src.battle.gen2.Mon")
|
||||
mon.dvs.hp = Mon.hpDV(mon.dvs)
|
||||
local def = data.pokemon[mon.species]
|
||||
if def then
|
||||
mon.gender = Mon.gender(def, mon.dvs, { species = mon.species, level = mon.level })
|
||||
mon.shiny = Mon.isShiny(mon.dvs, { species = mon.species, def = def, level = mon.level })
|
||||
local Unown = require("src.core.gen2.Unown")
|
||||
if mon.species == Unown.SPECIES then
|
||||
mon.unownLetter = Unown.letterFromDVs(mon.dvs)
|
||||
end
|
||||
end
|
||||
end
|
||||
MonOps.recalc(data, mon, gen)
|
||||
end
|
||||
|
||||
-- Keep level; resync exp to the species growth curve (species changes).
|
||||
function MonOps.setSpecies(data, mon, species)
|
||||
-- Gold also copies def.name onto mon.name: the party list and SUMMARY print
|
||||
-- that field when nickname is nil, so leaving the previous species' name
|
||||
-- made a swapped mon still read as ABRA (or whoever was added first).
|
||||
function MonOps.setSpecies(data, mon, species, gen)
|
||||
assert(data.pokemon[species], "unknown species")
|
||||
mon.species = species
|
||||
MonOps.setLevel(data, mon, mon.level)
|
||||
MonOps.setLevel(data, mon, mon.level, gen)
|
||||
if gen == 2 or mon.experience ~= nil or (mon.stats and mon.stats.specialAttack) then
|
||||
require("src.battle.gen2.Mon").syncIdentity(mon, data)
|
||||
end
|
||||
end
|
||||
|
||||
return MonOps
|
||||
|
||||
+246
-70
@@ -17,6 +17,7 @@ local BoxesMod = require("src.pokemon.Boxes")
|
||||
local Bag = require("src.inventory.Bag")
|
||||
local MonOps = require("MonOps")
|
||||
local Charmap = require("src.save_convert.data.charmap")
|
||||
local Gen = require("Gen")
|
||||
|
||||
local Ops = {}
|
||||
|
||||
@@ -35,6 +36,62 @@ local function clamp(n, lo, hi)
|
||||
end
|
||||
Ops.clamp = clamp
|
||||
|
||||
local function stampNewMon(S, mon)
|
||||
if Gen.ofState(S) == 2 then
|
||||
require("src.battle.gen2.Mon").stampOT(S.save, mon)
|
||||
else
|
||||
mon.ot = S.save.player.name
|
||||
mon.otId = S.save.player.id
|
||||
end
|
||||
return mon
|
||||
end
|
||||
|
||||
local function createMon(S, species, level)
|
||||
local mon = MonOps.create(S.data, species, level, Gen.ofState(S))
|
||||
return stampNewMon(S, mon)
|
||||
end
|
||||
|
||||
local function partySlot(S, mon)
|
||||
for i, member in ipairs(S.save.party or {}) do
|
||||
if member == mon then return i end
|
||||
end
|
||||
end
|
||||
|
||||
-- Portrait mail (and CheckPokeMail) store species on the letter, not the mon.
|
||||
local function partyMailEntry(S, slot)
|
||||
local Mail = require("src.core.gen2.Mail")
|
||||
return Mail.state(S.save).party[slot]
|
||||
end
|
||||
|
||||
local function syncPartyMailSpecies(S, mon)
|
||||
if Gen.ofState(S) ~= 2 then return end
|
||||
local slot = partySlot(S, mon)
|
||||
if not slot then return end
|
||||
local entry = partyMailEntry(S, slot)
|
||||
if entry then entry.species = mon.species end
|
||||
end
|
||||
|
||||
local function syncPartyMailHeldItem(S, mon, prevItem, newItem)
|
||||
if Gen.ofState(S) ~= 2 then return end
|
||||
local slot = partySlot(S, mon)
|
||||
if not slot then return end
|
||||
local Mail = require("src.core.gen2.Mail")
|
||||
if Mail.isMail(newItem) then
|
||||
local prev = partyMailEntry(S, slot)
|
||||
local player = S.save.player or {}
|
||||
Mail.set(S.save, slot, Mail.entry(
|
||||
newItem,
|
||||
prev and prev.message or "",
|
||||
tostring(mon.otName or mon.ot or player.name or ""):sub(1, Mail.AUTHOR_LENGTH),
|
||||
mon.otId or player.id or 0,
|
||||
mon.species))
|
||||
return
|
||||
end
|
||||
if Mail.isMail(prevItem) or partyMailEntry(S, slot) then
|
||||
Mail.clear(S.save, slot)
|
||||
end
|
||||
end
|
||||
|
||||
local function now()
|
||||
if love and love.timer and love.timer.getTime then
|
||||
return love.timer.getTime()
|
||||
@@ -111,9 +168,7 @@ function Ops.partyAdd(S)
|
||||
return Ops.say(S, ("Party is full (%d/%d)"):format(#S.save.party, PartyMod.MAX))
|
||||
end
|
||||
local species = S.cat.species[1]
|
||||
local mon = MonOps.create(S.data, species, 5)
|
||||
mon.ot = S.save.player.name
|
||||
mon.otId = S.save.player.id
|
||||
local mon = createMon(S, species, 5)
|
||||
table.insert(S.save.party, mon)
|
||||
S.selectedParty = #S.save.party
|
||||
S.editingMon = mon
|
||||
@@ -129,6 +184,11 @@ function Ops.partyRemove(S)
|
||||
return false
|
||||
end
|
||||
table.remove(S.save.party, index)
|
||||
-- sPartyMail is keyed by party slot, not by mon: dropping a member without
|
||||
-- shifting letters hands the next mon someone else's mail.
|
||||
if Gen.ofState(S) == 2 then
|
||||
require("src.core.gen2.Mail").removeSlot(S.save, index)
|
||||
end
|
||||
if S.editingMon == mon then S.editingMon = nil end
|
||||
S.selectedParty = clamp(index, 1, math.max(#S.save.party, 1))
|
||||
S.editingMon = S.save.party[S.selectedParty]
|
||||
@@ -144,6 +204,9 @@ function Ops.partyMove(S, delta)
|
||||
return Ops.say(S, delta < 0 and "Already the lead mon" or "Already the last mon")
|
||||
end
|
||||
party[i], party[j] = party[j], party[i]
|
||||
if Gen.ofState(S) == 2 then
|
||||
require("src.core.gen2.Mail").swapSlots(S.save, i, j)
|
||||
end
|
||||
S.selectedParty = j
|
||||
return Ops.mark(S, ("Moved %s to slot %d"):format(party[j].species, j))
|
||||
end
|
||||
@@ -157,7 +220,7 @@ function Ops.setLevel(S, mon, level)
|
||||
if want == mon.level then
|
||||
return Ops.say(S, want == 1 and "Level is already 1" or "Level is already 100")
|
||||
end
|
||||
MonOps.setLevel(S.data, mon, want)
|
||||
MonOps.setLevel(S.data, mon, want, Gen.ofState(S))
|
||||
return Ops.mark(S, ("%s is now Lv%d"):format(mon.species, mon.level))
|
||||
end
|
||||
|
||||
@@ -172,15 +235,23 @@ end
|
||||
-- instead of trusting the list: without this, picking such a species walked
|
||||
-- Stats.calc into `speciesDef.baseStats[key]` on a nil and took the window
|
||||
-- down (#541).
|
||||
local BASE_STAT_KEYS = { "hp", "attack", "defense", "speed", "special" }
|
||||
local BASE_STAT_KEYS_G1 = { "hp", "attack", "defense", "speed", "special" }
|
||||
local BASE_STAT_KEYS_G2 = {
|
||||
"hp", "attack", "defense", "speed", "specialAttack", "specialDefense",
|
||||
}
|
||||
|
||||
local function baseStatsComplete(bs, keys)
|
||||
for _, key in ipairs(keys) do
|
||||
if type(bs[key]) ~= "number" then return false end
|
||||
end
|
||||
return true
|
||||
end
|
||||
|
||||
function Ops.speciesUsable(S, id)
|
||||
local def = id and S.data.pokemon[id]
|
||||
if type(def) ~= "table" or type(def.baseStats) ~= "table" then return false end
|
||||
for _, key in ipairs(BASE_STAT_KEYS) do
|
||||
if type(def.baseStats[key]) ~= "number" then return false end
|
||||
end
|
||||
return true
|
||||
return baseStatsComplete(def.baseStats, BASE_STAT_KEYS_G1)
|
||||
or baseStatsComplete(def.baseStats, BASE_STAT_KEYS_G2)
|
||||
end
|
||||
|
||||
-- The one funnel every species change goes through (the picker, the stepper,
|
||||
@@ -199,14 +270,19 @@ function Ops.setSpecies(S, mon, id)
|
||||
end
|
||||
-- MonOps.recalc replaces mon.stats with a fresh table rather than editing
|
||||
-- it in place, so holding the old reference is a real rollback.
|
||||
local wasSpecies, wasLevel, wasExp = mon.species, mon.level, mon.exp
|
||||
local wasStats, wasHp = mon.stats, mon.hp
|
||||
local ok, err = pcall(MonOps.setSpecies, S.data, mon, id)
|
||||
local wasSpecies, wasLevel, wasExp, wasExperience = mon.species, mon.level, mon.exp, mon.experience
|
||||
local wasStats, wasHp, wasName = mon.stats, mon.hp, mon.name
|
||||
local wasTypes, wasGender, wasShiny, wasUnown, wasMaxHp =
|
||||
mon.types, mon.gender, mon.shiny, mon.unownLetter, mon.maxHp
|
||||
local ok, err = pcall(MonOps.setSpecies, S.data, mon, id, Gen.ofState(S))
|
||||
if not ok then
|
||||
mon.species, mon.level, mon.exp = wasSpecies, wasLevel, wasExp
|
||||
mon.stats, mon.hp = wasStats, wasHp
|
||||
mon.species, mon.level, mon.exp, mon.experience = wasSpecies, wasLevel, wasExp, wasExperience
|
||||
mon.stats, mon.hp, mon.name = wasStats, wasHp, wasName
|
||||
mon.types, mon.gender, mon.shiny, mon.unownLetter, mon.maxHp =
|
||||
wasTypes, wasGender, wasShiny, wasUnown, wasMaxHp
|
||||
return Ops.say(S, ("Could not set %s: %s"):format(tostring(id), tostring(err)))
|
||||
end
|
||||
syncPartyMailSpecies(S, mon)
|
||||
return Ops.mark(S, ("Species set to %s"):format(id))
|
||||
end
|
||||
|
||||
@@ -313,9 +389,9 @@ end
|
||||
-- the target is the box, not a mon.
|
||||
function Ops.openBoxAddPicker(S, Kit)
|
||||
local box = Ops.boxes(S)[S.selectedBox]
|
||||
if #box >= BoxesMod.CAPACITY then
|
||||
if #box >= Ops.boxCapacity(S) then
|
||||
return Ops.say(S, ("Box %d is full (%d/%d)")
|
||||
:format(S.selectedBox, #box, BoxesMod.CAPACITY))
|
||||
:format(S.selectedBox, #box, Ops.boxCapacity(S)))
|
||||
end
|
||||
S.speciesPicker = { query = "", offset = 0, opened = true, mode = "box-add" }
|
||||
if Kit then Kit.focus = "species-picker" end -- soft keyboard rises (#529)
|
||||
@@ -327,17 +403,15 @@ end
|
||||
-- box mon and a party mon born in the editor are indistinguishable.
|
||||
function Ops.boxAddSpecies(S, id)
|
||||
local box = Ops.boxes(S)[S.selectedBox]
|
||||
if #box >= BoxesMod.CAPACITY then
|
||||
if #box >= Ops.boxCapacity(S) then
|
||||
return Ops.say(S, ("Box %d is full (%d/%d)")
|
||||
:format(S.selectedBox, #box, BoxesMod.CAPACITY))
|
||||
:format(S.selectedBox, #box, Ops.boxCapacity(S)))
|
||||
end
|
||||
if not Ops.speciesUsable(S, id) then
|
||||
return Ops.say(S, ("%s has no usable base stats, cannot add it")
|
||||
:format(tostring(id)))
|
||||
end
|
||||
local mon = MonOps.create(S.data, id, 5)
|
||||
mon.ot = S.save.player.name
|
||||
mon.otId = S.save.player.id
|
||||
local mon = createMon(S, id, 5)
|
||||
table.insert(box, mon)
|
||||
S.selectedBoxSlot = #box
|
||||
S.editingMon = mon
|
||||
@@ -351,7 +425,7 @@ function Ops.setDv(S, mon, key, value)
|
||||
if want == mon.dvs[key] then
|
||||
return Ops.say(S, ("%s DV is already %d"):format(key, want))
|
||||
end
|
||||
MonOps.setDv(S.data, mon, key, want)
|
||||
MonOps.setDv(S.data, mon, key, want, Gen.ofState(S))
|
||||
return Ops.mark(S, ("%s DV %d (HP DV now %d)"):format(key, mon.dvs[key], mon.dvs.hp))
|
||||
end
|
||||
|
||||
@@ -382,7 +456,17 @@ end
|
||||
function Ops.resetMoves(S, mon)
|
||||
if not mon then return false end
|
||||
local def = S.data.pokemon[mon.species]
|
||||
local learned = Pokemon.movesAtLevel(def, mon.level)
|
||||
local gen = Gen.ofState(S)
|
||||
local learned
|
||||
if gen == 2 then
|
||||
local Mon = require("src.battle.gen2.Mon")
|
||||
learned = {}
|
||||
for _, mv in ipairs(Mon.movesAtLevel(def, mon.level, S.data.moves)) do
|
||||
learned[#learned + 1] = mv.id
|
||||
end
|
||||
else
|
||||
learned = Pokemon.movesAtLevel(def, mon.level)
|
||||
end
|
||||
mon.moves = {}
|
||||
for slot, id in ipairs(learned) do
|
||||
MonOps.setMove(S.data, mon, slot, id)
|
||||
@@ -397,6 +481,7 @@ function Ops.healMon(S, mon)
|
||||
return Ops.say(S, ("%s is already at full HP"):format(mon.species))
|
||||
end
|
||||
mon.hp = mon.stats.hp
|
||||
if mon.maxHp then mon.maxHp = mon.stats.hp end
|
||||
mon.status = nil
|
||||
for _, mv in ipairs(mon.moves or {}) do
|
||||
local def = S.data.moves[mv.id]
|
||||
@@ -554,27 +639,35 @@ function Ops.clearNickname(S, mon)
|
||||
end
|
||||
|
||||
-- ------------------------------------------------------------------ boxes
|
||||
function Ops.boxCount(S)
|
||||
return Gen.boxCount(S.save)
|
||||
end
|
||||
|
||||
function Ops.boxCapacity(S)
|
||||
return Gen.boxCapacity(S.save)
|
||||
end
|
||||
|
||||
function Ops.boxes(S)
|
||||
return BoxesMod.ensure(S.save)
|
||||
return Gen.ensureBoxes(S.save)
|
||||
end
|
||||
|
||||
function Ops.selectBox(S, index)
|
||||
S.selectedBox = clamp(index, 1, BoxesMod.COUNT)
|
||||
S.selectedBox = clamp(index, 1, Ops.boxCount(S))
|
||||
S.selectedBoxSlot = 1
|
||||
S.save.currentBox = S.selectedBox
|
||||
local box = Ops.boxes(S)[S.selectedBox]
|
||||
S.status = ("Box %d (%d/%d)"):format(S.selectedBox, #box, BoxesMod.CAPACITY)
|
||||
S.status = ("Box %d (%d/%d)"):format(S.selectedBox, #box, Ops.boxCapacity(S))
|
||||
return true
|
||||
end
|
||||
|
||||
function Ops.stepBox(S, delta)
|
||||
local n = BoxesMod.COUNT
|
||||
local n = Ops.boxCount(S)
|
||||
return Ops.selectBox(S, ((S.selectedBox - 1 + delta) % n) + 1)
|
||||
end
|
||||
|
||||
function Ops.selectBoxSlot(S, index)
|
||||
local box = Ops.boxes(S)[S.selectedBox]
|
||||
S.selectedBoxSlot = clamp(index, 1, BoxesMod.CAPACITY)
|
||||
S.selectedBoxSlot = clamp(index, 1, Ops.boxCapacity(S))
|
||||
local mon = box[S.selectedBoxSlot]
|
||||
S.editingMon = mon
|
||||
S.status = mon
|
||||
@@ -589,14 +682,12 @@ end
|
||||
-- chooses what lands in the box instead of always getting catalog entry #1.
|
||||
function Ops.boxAdd(S)
|
||||
local box = Ops.boxes(S)[S.selectedBox]
|
||||
if #box >= BoxesMod.CAPACITY then
|
||||
if #box >= Ops.boxCapacity(S) then
|
||||
return Ops.say(S, ("Box %d is full (%d/%d)")
|
||||
:format(S.selectedBox, #box, BoxesMod.CAPACITY))
|
||||
:format(S.selectedBox, #box, Ops.boxCapacity(S)))
|
||||
end
|
||||
local species = S.cat.species[1]
|
||||
local mon = MonOps.create(S.data, species, 5)
|
||||
mon.ot = S.save.player.name
|
||||
mon.otId = S.save.player.id
|
||||
local mon = createMon(S, species, 5)
|
||||
table.insert(box, mon)
|
||||
S.selectedBoxSlot = #box
|
||||
S.editingMon = mon
|
||||
@@ -612,9 +703,16 @@ function Ops.withdraw(S)
|
||||
return Ops.say(S, ("Party is full (%d/%d), deposit one first")
|
||||
:format(#S.save.party, PartyMod.MAX))
|
||||
end
|
||||
table.remove(box, S.selectedBoxSlot)
|
||||
table.insert(S.save.party, mon)
|
||||
S.selectedBoxSlot = clamp(S.selectedBoxSlot, 1, math.max(#box, 1))
|
||||
if Gen.ofState(S) == 2 then
|
||||
local Boxes2 = require("src.core.gen2.Boxes")
|
||||
local ok, reason = Boxes2.canWithdraw(S.save, S.selectedBox, S.selectedBoxSlot)
|
||||
if not ok then return Ops.say(S, reason) end
|
||||
Boxes2.withdraw(S.save, S.selectedBox, S.selectedBoxSlot)
|
||||
else
|
||||
table.remove(box, S.selectedBoxSlot)
|
||||
table.insert(S.save.party, mon)
|
||||
end
|
||||
S.selectedBoxSlot = clamp(S.selectedBoxSlot, 1, math.max(#Ops.boxes(S)[S.selectedBox], 1))
|
||||
S.selectedParty = #S.save.party
|
||||
return Ops.mark(S, ("Withdrew %s to party slot %d"):format(mon.species, #S.save.party))
|
||||
end
|
||||
@@ -639,6 +737,17 @@ function Ops.deposit(S)
|
||||
local i = S.selectedParty
|
||||
local mon = S.save.party[i]
|
||||
if not mon then return Ops.say(S, "No party slot selected") end
|
||||
if Gen.ofState(S) == 2 then
|
||||
local Boxes2 = require("src.core.gen2.Boxes")
|
||||
local boxIndex = S.selectedBox or S.save.currentBox or 1
|
||||
local ok, reason = Boxes2.canDeposit(S.save, i, boxIndex)
|
||||
if not ok then return Ops.say(S, reason) end
|
||||
Boxes2.deposit(S.save, i, boxIndex)
|
||||
S.selectedParty = clamp(i, 1, math.max(#S.save.party, 1))
|
||||
S.selectedBox = boxIndex
|
||||
if S.editingMon == mon then S.editingMon = nil end
|
||||
return Ops.mark(S, ("Deposited %s into box %d"):format(mon.species, boxIndex))
|
||||
end
|
||||
local boxNum = BoxesMod.deposit(S.save, mon)
|
||||
if not boxNum then
|
||||
return Ops.say(S, "Every box is full, release something first")
|
||||
@@ -652,12 +761,13 @@ end
|
||||
|
||||
-- ------------------------------------------------------------------ items
|
||||
function Ops.addMoney(S, delta)
|
||||
local want = clamp((S.save.money or 0) + delta, 0, Ops.MONEY_MAX)
|
||||
if want == S.save.money then
|
||||
local have = Gen.money(S.save)
|
||||
local want = clamp(have + delta, 0, Ops.MONEY_MAX)
|
||||
if want == have then
|
||||
return Ops.say(S, delta < 0 and "Money is already $0"
|
||||
or ("Money is already capped at $%d"):format(Ops.MONEY_MAX))
|
||||
end
|
||||
S.save.money = want
|
||||
Gen.setMoney(S.save, want)
|
||||
return Ops.mark(S, ("Money set to $%d"):format(want))
|
||||
end
|
||||
|
||||
@@ -665,15 +775,29 @@ function Ops.maxMoney(S)
|
||||
return Ops.addMoney(S, Ops.MONEY_MAX)
|
||||
end
|
||||
|
||||
Ops.COIN_MAX = 9999
|
||||
|
||||
function Ops.addCoins(S, delta)
|
||||
local have = Gen.coins(S.save)
|
||||
local want = clamp(have + delta, 0, Ops.COIN_MAX)
|
||||
if want == have then
|
||||
return Ops.say(S, delta < 0 and "Coins are already 0"
|
||||
or ("Coins are already capped at %d"):format(Ops.COIN_MAX))
|
||||
end
|
||||
Gen.setCoins(S.save, want)
|
||||
return Ops.mark(S, ("Coins set to %d"):format(want))
|
||||
end
|
||||
|
||||
function Ops.addToBag(S, id)
|
||||
if not id then return Ops.say(S, "Pick an item first") end
|
||||
local capacity = Bag.capacity(S.data)
|
||||
local pocket = Bag.pocketOf(id, S.data)
|
||||
local capacity = Bag.capacity(S.data, pocket)
|
||||
if Bag.add(S.save, id, 1, S.data) then
|
||||
return Ops.mark(S, ("Added %s to the bag (%d/%d slots)")
|
||||
:format(id, Bag.slots(S.save), capacity))
|
||||
return Ops.mark(S, ("Added %s to the bag (%d/%d %s slots)")
|
||||
:format(id, Bag.slots(S.save, S.data, pocket), capacity, pocket))
|
||||
end
|
||||
return Ops.say(S, ("Bag is full (%d/%d slots)")
|
||||
:format(Bag.slots(S.save), capacity))
|
||||
return Ops.say(S, ("Bag is full (%d/%d %s slots)")
|
||||
:format(Bag.slots(S.save, S.data, pocket), capacity, pocket))
|
||||
end
|
||||
|
||||
function Ops.bagAdjust(S, id, delta)
|
||||
@@ -715,6 +839,12 @@ end
|
||||
function Ops.addToPc(S, id)
|
||||
if not id then return Ops.say(S, "Pick an item first") end
|
||||
local pc = Ops.pcItems(S)
|
||||
local n = 0
|
||||
for _ in pairs(pc) do n = n + 1 end
|
||||
if not pc[id] and Gen.ofState(S) == 2 and n >= 50 then
|
||||
return Ops.say(S, "PC item storage is full (50 stacks)")
|
||||
end
|
||||
local pc = Ops.pcItems(S)
|
||||
pc[id] = math.min(Ops.STACK_MAX, (pc[id] or 0) + 1)
|
||||
return Ops.mark(S, ("%s x%d in PC storage"):format(id, pc[id]))
|
||||
end
|
||||
@@ -749,27 +879,17 @@ function Ops.isBadgeId(id)
|
||||
end
|
||||
|
||||
function Ops.badgeIds(S)
|
||||
local ids = {}
|
||||
for _, id in ipairs(S.cat.items) do
|
||||
if Ops.isBadgeId(id) then ids[#ids + 1] = id end
|
||||
end
|
||||
return ids
|
||||
return Gen.badgeIds(S.save, S.cat)
|
||||
end
|
||||
|
||||
function Ops.toggleBadge(S, id)
|
||||
-- #515: badges are truthy inventory entries written as 1 by the in-game
|
||||
-- grant (checkVictoryRewards, src/world/OverworldController.lua) and by
|
||||
-- GenSave's .sav import; read and write that same shape here, or a badge
|
||||
-- earned in game reads as unowned and an editor-written boolean blows up
|
||||
-- Bag.add's `(inv[id] or 0) + qty` (src/inventory/Bag.lua).
|
||||
local on = S.save.inventory[id] and true or false
|
||||
S.save.inventory[id] = (not on) and 1 or nil
|
||||
return Ops.mark(S, ("%s %s"):format(id, on and "removed" or "earned"))
|
||||
local nowOn = Gen.toggleBadge(S.save, id)
|
||||
return Ops.mark(S, ("%s %s"):format(id, nowOn and "earned" or "removed"))
|
||||
end
|
||||
|
||||
-- ----------------------------------------------------------------- events
|
||||
function Ops.setFlag(S, name, on)
|
||||
S.save.flags[name] = on and true or nil
|
||||
Gen.setFlag(S.save, name, on)
|
||||
return Ops.mark(S, ("%s = %s"):format(name, tostring(on and true or false)))
|
||||
end
|
||||
|
||||
@@ -801,17 +921,19 @@ end
|
||||
|
||||
-- -------------------------------------------------------------------- dex
|
||||
function Ops.dex(S)
|
||||
S.save.pokedex = S.save.pokedex or { seen = {}, owned = {} }
|
||||
local key = Gen.dexOwnedKey(S.save)
|
||||
S.save.pokedex = S.save.pokedex or { seen = {}, [key] = {} }
|
||||
S.save.pokedex.seen = S.save.pokedex.seen or {}
|
||||
S.save.pokedex.owned = S.save.pokedex.owned or {}
|
||||
S.save.pokedex[key] = S.save.pokedex[key] or {}
|
||||
return S.save.pokedex
|
||||
end
|
||||
|
||||
function Ops.dexCounts(S)
|
||||
local dex = Ops.dex(S)
|
||||
local key = Gen.dexOwnedKey(S.save)
|
||||
local seen, owned = 0, 0
|
||||
for _ in pairs(dex.seen) do seen = seen + 1 end
|
||||
for _ in pairs(dex.owned) do owned = owned + 1 end
|
||||
for _ in pairs(dex[key] or {}) do owned = owned + 1 end
|
||||
return seen, owned, #S.cat.species
|
||||
end
|
||||
|
||||
@@ -819,25 +941,28 @@ end
|
||||
-- the game's own rule, enforced here so a hand-edited dex stays legal.
|
||||
function Ops.dexSeen(S, species, on)
|
||||
local dex = Ops.dex(S)
|
||||
local key = Gen.dexOwnedKey(S.save)
|
||||
dex.seen[species] = on and true or nil
|
||||
if not on then dex.owned[species] = nil end
|
||||
if not on then dex[key][species] = nil end
|
||||
return Ops.mark(S, ("%s %s"):format(species, on and "marked seen" or "cleared"))
|
||||
end
|
||||
|
||||
function Ops.dexOwned(S, species, on)
|
||||
local dex = Ops.dex(S)
|
||||
dex.owned[species] = on and true or nil
|
||||
local key = Gen.dexOwnedKey(S.save)
|
||||
dex[key][species] = on and true or nil
|
||||
if on then dex.seen[species] = true end
|
||||
return Ops.mark(S, ("%s %s"):format(species, on and "marked owned" or "un-owned"))
|
||||
end
|
||||
|
||||
function Ops.dexStamp(S)
|
||||
local dex = Ops.dex(S)
|
||||
local key = Gen.dexOwnedKey(S.save)
|
||||
local n = 0
|
||||
local function stamp(mon)
|
||||
if not dex.owned[mon.species] then n = n + 1 end
|
||||
if not dex[key][mon.species] then n = n + 1 end
|
||||
dex.seen[mon.species] = true
|
||||
dex.owned[mon.species] = true
|
||||
dex[key][mon.species] = true
|
||||
end
|
||||
for _, m in ipairs(S.save.party) do stamp(m) end
|
||||
for _, box in ipairs(S.save.boxes or {}) do
|
||||
@@ -855,9 +980,10 @@ end
|
||||
|
||||
function Ops.dexOwnAll(S)
|
||||
local dex = Ops.dex(S)
|
||||
local key = Gen.dexOwnedKey(S.save)
|
||||
for _, species in ipairs(S.cat.species) do
|
||||
dex.seen[species] = true
|
||||
dex.owned[species] = true
|
||||
dex[key][species] = true
|
||||
end
|
||||
return Ops.mark(S, ("Marked all %d species owned"):format(#S.cat.species))
|
||||
end
|
||||
@@ -866,7 +992,8 @@ function Ops.dexClear(S)
|
||||
if not Ops.arm(S, "dex-clear", "Wipe the whole Pokedex? Click again to confirm") then
|
||||
return false
|
||||
end
|
||||
S.save.pokedex = { seen = {}, owned = {} }
|
||||
local key = Gen.dexOwnedKey(S.save)
|
||||
S.save.pokedex = { seen = {}, [key] = {} }
|
||||
return Ops.mark(S, "Pokedex wiped")
|
||||
end
|
||||
|
||||
@@ -927,6 +1054,11 @@ end
|
||||
-- OVERWORLD/PLATEAU tilesets, maps with connections, or fly spots the save
|
||||
-- has already visited.
|
||||
function Ops.isOutdoor(S, map)
|
||||
if not map or not map.def then return false end
|
||||
local Map2 = require("src.world.gen2.Map")
|
||||
if Gen.ofState(S) == 2 and Map2.isOutdoor then
|
||||
return Map2.isOutdoor(map.def) and true or false
|
||||
end
|
||||
if map.def.tileset == "OVERWORLD" or map.def.tileset == "PLATEAU" then
|
||||
return true
|
||||
end
|
||||
@@ -937,15 +1069,17 @@ end
|
||||
function Ops.setPlayerHere(S)
|
||||
local cell = S.mapClickCell
|
||||
if not cell then return Ops.say(S, "Click a cell first") end
|
||||
S.save.player.map = S.mapId
|
||||
S.save.player.x = cell.cx
|
||||
S.save.player.y = cell.cy
|
||||
Gen.setPlayerHere(S.save, S.mapId, cell.cx, cell.cy)
|
||||
return Ops.mark(S, ("Player set to %s (%d,%d)"):format(S.mapId, cell.cx, cell.cy))
|
||||
end
|
||||
|
||||
function Ops.setLastOutdoor(S, map)
|
||||
local cell = S.mapClickCell
|
||||
if not cell then return Ops.say(S, "Click a cell first") end
|
||||
if Gen.ofState(S) == 2 then
|
||||
S.save.spawn = S.mapId
|
||||
return Ops.mark(S, ("spawn set to %s"):format(S.mapId))
|
||||
end
|
||||
if not Ops.isOutdoor(S, map) then
|
||||
return Ops.say(S, S.mapId .. " doesn't look outdoor (no connections, not visited)")
|
||||
end
|
||||
@@ -956,8 +1090,50 @@ end
|
||||
function Ops.setLastHeal(S)
|
||||
local cell = S.mapClickCell
|
||||
if not cell then return Ops.say(S, "Click a cell first") end
|
||||
if Gen.ofState(S) == 2 then
|
||||
S.save.spawn = S.mapId
|
||||
return Ops.mark(S, ("spawn set to %s"):format(S.mapId))
|
||||
end
|
||||
S.save.lastHeal = { map = S.mapId, x = cell.cx, y = cell.cy }
|
||||
return Ops.mark(S, ("lastHeal set to %s (%d,%d)"):format(S.mapId, cell.cx, cell.cy))
|
||||
end
|
||||
|
||||
function Ops.setHeldItem(S, mon, id)
|
||||
if not mon then return false end
|
||||
if id == "" or id == nil then
|
||||
if not mon.item then return Ops.say(S, "No held item to clear") end
|
||||
local was = mon.item
|
||||
mon.item = nil
|
||||
syncPartyMailHeldItem(S, mon, was, nil)
|
||||
return Ops.mark(S, ("Cleared held item (%s)"):format(was))
|
||||
end
|
||||
if not S.data.items[id] then
|
||||
return Ops.say(S, ("%s is not an item"):format(tostring(id)))
|
||||
end
|
||||
local was = mon.item
|
||||
mon.item = id
|
||||
syncPartyMailHeldItem(S, mon, was, id)
|
||||
return Ops.mark(S, ("%s now holds %s"):format(mon.species, id))
|
||||
end
|
||||
|
||||
function Ops.setHappiness(S, mon, value)
|
||||
if not mon then return false end
|
||||
local want = clamp(math.floor(value), 0, 255)
|
||||
if want == (mon.happiness or 0) then
|
||||
return Ops.say(S, ("Happiness is already %d"):format(want))
|
||||
end
|
||||
mon.happiness = want
|
||||
return Ops.mark(S, ("%s happiness %d"):format(mon.species, want))
|
||||
end
|
||||
|
||||
function Ops.setPokerus(S, mon, value)
|
||||
if not mon then return false end
|
||||
local want = clamp(math.floor(value), 0, 255)
|
||||
if want == (mon.pokerus or 0) then
|
||||
return Ops.say(S, ("Pokerus is already %d"):format(want))
|
||||
end
|
||||
mon.pokerus = want
|
||||
return Ops.mark(S, ("%s pokerus byte %d"):format(mon.species, want))
|
||||
end
|
||||
|
||||
return Ops
|
||||
|
||||
@@ -19,7 +19,6 @@
|
||||
-- in the selected box as a Lv5 mon built by the same MonOps path partyAdd
|
||||
-- uses, so its stats, exp and moves are consistent.
|
||||
|
||||
local BoxesMod = require("src.pokemon.Boxes")
|
||||
local PartyMod = require("src.pokemon.Party")
|
||||
local Theme = require("Theme")
|
||||
local Ops = require("Ops")
|
||||
@@ -33,12 +32,12 @@ local function drawStrip(S, Kit, boxes, x, y, stripW, h)
|
||||
local s = Kit.scale
|
||||
local pad = 16 * s
|
||||
Kit.card(x, y, stripW, h)
|
||||
Kit.caption(x + pad, y + pad, ("BOXES . %d"):format(BoxesMod.COUNT))
|
||||
Kit.caption(x + pad, y + pad, ("BOXES . %d"):format(Ops.boxCount(S)))
|
||||
local stripTop = y + pad + Kit.textHeight("caption") + 10 * s
|
||||
local stripInner = stripW - 2 * pad
|
||||
local bRowH = math.min(30 * s, math.max(22 * s,
|
||||
(h - (stripTop - y) - pad - (BoxesMod.COUNT - 1) * 6 * s) / BoxesMod.COUNT))
|
||||
for i = 1, BoxesMod.COUNT do
|
||||
(h - (stripTop - y) - pad - (Ops.boxCount(S) - 1) * 6 * s) / Ops.boxCount(S)))
|
||||
for i = 1, Ops.boxCount(S) do
|
||||
local ry = stripTop + (i - 1) * (bRowH + 6 * s)
|
||||
if ry + bRowH > y + h - pad then break end
|
||||
if Kit.row(x + pad, ry, stripInner, bRowH, i == S.selectedBox, PAL.blue, 9 * s) then
|
||||
@@ -52,7 +51,7 @@ local function drawStrip(S, Kit, boxes, x, y, stripW, h)
|
||||
ry + (bRowH - Kit.textHeight("tiny")) / 2, PAL.caption)
|
||||
local mx = x + pad + stripInner - 10 * s - countW - 8 * s - 44 * s
|
||||
Kit.meter(mx, ry + (bRowH - 5 * s) / 2, 44 * s, 5 * s,
|
||||
fill / BoxesMod.CAPACITY * 100, fill >= BoxesMod.CAPACITY and PAL.yellow or PAL.blue)
|
||||
fill / Ops.boxCapacity(S) * 100, fill >= Ops.boxCapacity(S) and PAL.yellow or PAL.blue)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -66,7 +65,7 @@ local function drawGrid(S, Kit, box, gridX, y, gridW, h)
|
||||
Kit.text("tab", ("Box %d"):format(S.selectedBox), gx,
|
||||
y + gpad + (headH - Kit.textHeight("tab")) / 2, PAL.heading)
|
||||
local titleW = Kit.textWidth("tab", ("Box %d"):format(S.selectedBox))
|
||||
Kit.text("mono", ("%d/%d"):format(#box, BoxesMod.CAPACITY),
|
||||
Kit.text("mono", ("%d/%d"):format(#box, Ops.boxCapacity(S)),
|
||||
gx + titleW + 14 * s, y + gpad + (headH - Kit.textHeight("mono")) / 2, PAL.caption)
|
||||
local navW = 34 * s
|
||||
if Kit.stepper(gx + ginner - 2 * navW - 8 * s, y + gpad, navW, headH, "<",
|
||||
@@ -102,7 +101,7 @@ local function drawGrid(S, Kit, box, gridX, y, gridW, h)
|
||||
end
|
||||
if Kit.button(gx + wdW + 10 * s, actY, addW, actH, addLabel,
|
||||
{ font = "small", radius = 9 * s,
|
||||
enabled = #box < BoxesMod.CAPACITY }) then
|
||||
enabled = #box < Ops.boxCapacity(S) }) then
|
||||
Ops.openBoxAddPicker(S, Kit)
|
||||
end
|
||||
if Kit.button(gx + ginner - relW, actY, relW, actH, relLabel,
|
||||
@@ -119,7 +118,7 @@ local function drawGrid(S, Kit, box, gridX, y, gridW, h)
|
||||
-- of five slivers.
|
||||
local cols = math.max(2, math.min(COLS,
|
||||
math.floor((ginner + cellGap) / (86 * s + cellGap))))
|
||||
local rows = math.ceil(BoxesMod.CAPACITY / cols)
|
||||
local rows = math.ceil(Ops.boxCapacity(S) / cols)
|
||||
local cellW = math.max(0, (ginner - cellGap * (cols - 1)) / cols)
|
||||
-- floor at Kit's 26px tap target so a short window shrinks the cells but
|
||||
-- never inverts them (#715); overflow clips inside the grid body rather
|
||||
@@ -128,7 +127,7 @@ local function drawGrid(S, Kit, box, gridX, y, gridW, h)
|
||||
math.min((gridH - cellGap * (rows - 1)) / rows, 110 * s))
|
||||
|
||||
Kit.pushClip(gx, gridTop, ginner, gridH)
|
||||
for i = 1, BoxesMod.CAPACITY do
|
||||
for i = 1, Ops.boxCapacity(S) do
|
||||
local cc = (i - 1) % cols
|
||||
local cr = math.floor((i - 1) / cols)
|
||||
local bx = gx + cc * (cellW + cellGap)
|
||||
@@ -220,7 +219,7 @@ function M.draw(S, Kit, x, y, w, h)
|
||||
local s = Kit.scale
|
||||
local gap = 20 * s
|
||||
|
||||
S.selectedBox = Ops.clamp(S.selectedBox or 1, 1, BoxesMod.COUNT)
|
||||
S.selectedBox = Ops.clamp(S.selectedBox or 1, 1, Ops.boxCount(S))
|
||||
S.save.currentBox = S.selectedBox
|
||||
local boxes = Ops.boxes(S)
|
||||
local box = boxes[S.selectedBox]
|
||||
|
||||
@@ -145,7 +145,8 @@ function M.draw(S, Kit, x, y, w, h)
|
||||
local rx = cx + ci * (colW + colGap)
|
||||
local ry = gridTop + ri * (rowH + rowGap)
|
||||
local isSeen = dex.seen[id] == true
|
||||
local isOwned = dex.owned[id] == true
|
||||
local ownedKey = require("Gen").dexOwnedKey(S.save)
|
||||
local isOwned = dex[ownedKey] and dex[ownedKey][id] == true
|
||||
|
||||
Theme.row(rx, ry, colW, rowH, 9 * s, 0.6)
|
||||
local def = S.data.pokemon[id]
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
|
||||
local Theme = require("Theme")
|
||||
local Ops = require("Ops")
|
||||
local Gen = require("Gen")
|
||||
local PAL = Theme.PAL
|
||||
|
||||
local M = {}
|
||||
@@ -50,7 +51,7 @@ local function buildRows(S)
|
||||
if contains(name, filter) then
|
||||
rows[#rows + 1] = {
|
||||
label = name,
|
||||
checked = S.save.flags[name] == true,
|
||||
checked = Gen.getFlag(S.save, name),
|
||||
set = function(on) Ops.setFlag(S, name, on) end,
|
||||
}
|
||||
end
|
||||
@@ -107,7 +108,12 @@ function M.draw(S, Kit, x, y, w, h)
|
||||
-- past the card edge.
|
||||
local pillH = 32 * s
|
||||
local px, py = cx, y + pad
|
||||
for _, t in ipairs(SUB_TABS) do
|
||||
local pills = SUB_TABS
|
||||
if Gen.of(S.save) == 2 then
|
||||
pills = { SUB_TABS[1] }
|
||||
if S.eventsTab ~= "flags" then S.eventsTab = "flags" end
|
||||
end
|
||||
for _, t in ipairs(pills) do
|
||||
local pw = Kit.textWidth("small", t.label) + 32 * s
|
||||
if px > cx and px + pw > cx + inner then
|
||||
px = cx
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
local Bag = require("src.inventory.Bag")
|
||||
local Theme = require("Theme")
|
||||
local Ops = require("Ops")
|
||||
local Gen = require("Gen")
|
||||
local PAL = Theme.PAL
|
||||
|
||||
local M = {}
|
||||
@@ -46,7 +47,11 @@ local function quantityRow(S, Kit, x, y, w, h, id, qty, selected, onMinus, onPlu
|
||||
local qtyW = Kit.textWidth("monoRow", qtyText)
|
||||
Kit.textRight("monoRow", qtyText, bx - 10 * s,
|
||||
y + (h - Kit.textHeight("monoRow")) / 2, PAL.heading)
|
||||
Kit.text("mono", Kit.ellipsize("mono", id, bx - qtyW - 30 * s - (x + 10 * s)),
|
||||
local label = id
|
||||
if Gen.of(S.save) == 2 then
|
||||
label = (Bag.pocketOf(id, S.data) or "ITEM") .. " " .. id
|
||||
end
|
||||
Kit.text("mono", Kit.ellipsize("mono", label, bx - qtyW - 30 * s - (x + 10 * s)),
|
||||
x + 10 * s, y + (h - Kit.textHeight("mono")) / 2, PAL.text)
|
||||
return clicked
|
||||
end
|
||||
@@ -55,9 +60,13 @@ end
|
||||
-- Each card is a function of its own rect so the wide (three column) and the
|
||||
-- stacked (#715) layouts are the same drawing code with different geometry.
|
||||
|
||||
local function moneyHeight(Kit, s, pad)
|
||||
return pad * 2 + Kit.textHeight("caption") + 8 * s
|
||||
local function moneyHeight(Kit, s, pad, S)
|
||||
local h = pad * 2 + Kit.textHeight("caption") + 8 * s
|
||||
+ Kit.textHeight("headline") + 10 * s + 30 * s
|
||||
if S and Gen.of(S.save) == 2 then
|
||||
h = h + 28 * s
|
||||
end
|
||||
return h
|
||||
end
|
||||
|
||||
local function drawMoney(S, Kit, x, y, w, h)
|
||||
@@ -68,11 +77,19 @@ local function drawMoney(S, Kit, x, y, w, h)
|
||||
local maxW = 74 * s
|
||||
if Kit.button(x + w - pad - maxW, y + pad - 4 * s, maxW, 26 * s, "Max out",
|
||||
{ kind = "accent", font = "tiny", radius = 7 * s,
|
||||
enabled = (S.save.money or 0) < Ops.MONEY_MAX }) then
|
||||
enabled = Gen.money(S.save) < Ops.MONEY_MAX }) then
|
||||
Ops.maxMoney(S)
|
||||
end
|
||||
Kit.text("headline", ("$%d"):format(S.save.money or 0), x + pad,
|
||||
Kit.text("headline", ("$%d"):format(Gen.money(S.save)), x + pad,
|
||||
y + pad + Kit.textHeight("caption") + 8 * s, PAL.yellow)
|
||||
if Gen.of(S.save) == 2 then
|
||||
Kit.text("mono", ("COINS %d"):format(Gen.coins(S.save)), x + pad + 160 * s,
|
||||
y + pad + Kit.textHeight("caption") + 8 * s, PAL.muted)
|
||||
if Kit.button(x + w - pad - 74 * s, y + pad + 26 * s, 74 * s, 22 * s, "+100 coins",
|
||||
{ kind = "ghost", font = "tiny", radius = 6 * s }) then
|
||||
Ops.addCoins(S, 100)
|
||||
end
|
||||
end
|
||||
local mbY = y + h - pad - 30 * s
|
||||
local mbW = (w - 2 * pad - 3 * 8 * s) / 4
|
||||
for i, delta in ipairs(MONEY_STEPS) do
|
||||
@@ -99,10 +116,7 @@ local function drawBadges(S, Kit, x, y, w, h)
|
||||
Kit.card(x, y, w, h)
|
||||
local earned = 0
|
||||
for _, id in ipairs(badgeIds) do
|
||||
-- #515: truthy check, not `== true` -- the in-game grant path stores a
|
||||
-- number (see OverworldController.lua checkVictoryRewards), matching
|
||||
-- src/inventory/Badges.lua's own truthy read.
|
||||
if S.save.inventory[id] then earned = earned + 1 end
|
||||
if Gen.hasBadge(S.save, id) then earned = earned + 1 end
|
||||
end
|
||||
Kit.caption(x + pad, y + pad, "BADGES")
|
||||
Kit.textRight("mono", ("%d/%d"):format(earned, #badgeIds), x + w - pad,
|
||||
@@ -112,7 +126,7 @@ local function drawBadges(S, Kit, x, y, w, h)
|
||||
for i, id in ipairs(badgeIds) do
|
||||
local bc = (i - 1) % BADGE_COLS
|
||||
local br = math.floor((i - 1) / BADGE_COLS)
|
||||
local on = S.save.inventory[id]
|
||||
local on = Gen.hasBadge(S.save, id)
|
||||
local short = id:gsub("BADGE$", "")
|
||||
if Kit.chip(x + pad + bc * (bW + 7 * s), bTop + br * (28 * s + 7 * s),
|
||||
bW, 28 * s, Kit.ellipsize("micro", short, bW - 8 * s), on,
|
||||
@@ -253,7 +267,7 @@ function M.draw(S, Kit, x, y, w, h)
|
||||
-- drag over their own bodies first.
|
||||
local off = Theme.clamp(S.itemsScroll or 0, 0,
|
||||
math.max(0, (S._itemsContentH or 0) - h))
|
||||
local moneyH = moneyHeight(Kit, s, pad)
|
||||
local moneyH = moneyHeight(Kit, s, pad, S)
|
||||
local badgeH = badgeHeight(S, Kit, s, pad)
|
||||
local pickH = 280 * s
|
||||
local listH = 300 * s
|
||||
@@ -278,7 +292,7 @@ function M.draw(S, Kit, x, y, w, h)
|
||||
-- Money and badges are fixed-height so the picker gets every pixel left
|
||||
-- over: cycling through ~250 item ids in a two-row list was the thing that
|
||||
-- made the old panel unusable.
|
||||
local moneyH = moneyHeight(Kit, s, pad)
|
||||
local moneyH = moneyHeight(Kit, s, pad, S)
|
||||
local badgeH = badgeHeight(S, Kit, s, pad)
|
||||
drawMoney(S, Kit, x, y, leftW, moneyH)
|
||||
drawPicker(S, Kit, x, y + moneyH + gap, leftW, h - moneyH - badgeH - 2 * gap)
|
||||
|
||||
@@ -14,12 +14,18 @@ local MapLoader = require("src.world.MapLoader")
|
||||
local Warp = require("src.world.Warp")
|
||||
local Theme = require("Theme")
|
||||
local Ops = require("Ops")
|
||||
local Gen = require("Gen")
|
||||
local PAL = Theme.PAL
|
||||
|
||||
local MapBrowser = {}
|
||||
|
||||
local CELL = 16 -- the walk grid; a cell is 16px of map art
|
||||
|
||||
local function playerPos(S)
|
||||
local map, x, y = Gen.playerMap(S.save)
|
||||
return map, x or 0, y or 0
|
||||
end
|
||||
|
||||
local function clampZoom(z)
|
||||
if z < 1 then return 1 end
|
||||
if z > 4 then return 4 end
|
||||
@@ -38,7 +44,7 @@ MapBrowser.centerOn = centerOn
|
||||
|
||||
local function sortedMapIds(data)
|
||||
local ids = {}
|
||||
for id in pairs(data.maps) do ids[#ids + 1] = id end
|
||||
for id in pairs(Gen.maps(data)) do ids[#ids + 1] = id end
|
||||
table.sort(ids)
|
||||
return ids
|
||||
end
|
||||
@@ -52,7 +58,19 @@ local OUTSIDE_TILESETS = { OVERWORLD = true, PLATEAU = true }
|
||||
|
||||
local function goToWarp(S, warp)
|
||||
local def = warp.def
|
||||
local fromMap = S.data.maps[S.mapId]
|
||||
if Gen.of(S.save) == 2 then
|
||||
local dest = def.destMap or def.map
|
||||
if dest then
|
||||
S.mapId = dest
|
||||
S.mapClickCell = nil
|
||||
S._mapCenteredFor = dest
|
||||
S.status = "Followed warp to " .. tostring(dest)
|
||||
else
|
||||
S.status = "Warp has no destination map"
|
||||
end
|
||||
return
|
||||
end
|
||||
local fromMap = Gen.maps(S.data)[S.mapId]
|
||||
if fromMap and OUTSIDE_TILESETS[fromMap.tileset]
|
||||
and def.destMap ~= "LAST_MAP" and def.destMap ~= S.mapId then
|
||||
S.save.lastOutdoor = { id = S.mapId, x = def.x, y = def.y }
|
||||
@@ -125,22 +143,25 @@ local function drawOverlays(S, map)
|
||||
return cx * CELL - S.mapCamX, cy * CELL - S.mapCamY, CELL, CELL
|
||||
end
|
||||
love.graphics.setColor(0.27, 0.59, 1, 0.55)
|
||||
for _, wdef in ipairs(map.def.warps) do
|
||||
for _, wdef in ipairs(map.def.warps or {}) do
|
||||
love.graphics.rectangle("line", cellRect(wdef.x, wdef.y))
|
||||
end
|
||||
if S.save.player.map == S.mapId then
|
||||
local playerMap, px, py = playerPos(S)
|
||||
if playerMap == S.mapId then
|
||||
love.graphics.setColor(1, 0.36, 0.4, 0.9)
|
||||
love.graphics.rectangle("fill", cellRect(S.save.player.x, S.save.player.y))
|
||||
love.graphics.rectangle("fill", cellRect(px, py))
|
||||
end
|
||||
local heal = S.save.lastHeal
|
||||
if heal and heal.map == S.mapId then
|
||||
love.graphics.setColor(0.24, 0.88, 0.54, 0.9)
|
||||
love.graphics.rectangle("line", cellRect(heal.x, heal.y))
|
||||
end
|
||||
local out = S.save.lastOutdoor
|
||||
if out and out.id == S.mapId then
|
||||
love.graphics.setColor(1, 0.8, 0.02, 0.9)
|
||||
love.graphics.rectangle("line", cellRect(out.x, out.y))
|
||||
if Gen.of(S.save) ~= 2 then
|
||||
local heal = S.save.lastHeal
|
||||
if heal and heal.map == S.mapId then
|
||||
love.graphics.setColor(0.24, 0.88, 0.54, 0.9)
|
||||
love.graphics.rectangle("line", cellRect(heal.x, heal.y))
|
||||
end
|
||||
local out = S.save.lastOutdoor
|
||||
if out and out.id == S.mapId then
|
||||
love.graphics.setColor(1, 0.8, 0.02, 0.9)
|
||||
love.graphics.rectangle("line", cellRect(out.x, out.y))
|
||||
end
|
||||
end
|
||||
if S.mapClickCell then
|
||||
love.graphics.setColor(1, 1, 0.35, 0.95)
|
||||
@@ -239,9 +260,13 @@ function MapBrowser.draw(S, Kit, x, y, w, h)
|
||||
#ids, perPage)
|
||||
if Kit.button(lr.x + pad, gotoY, listInner, gotoH, "Go to save location",
|
||||
{ font = "small", radius = 9 * s }) then
|
||||
MapBrowser.select(S, S.save.player.map)
|
||||
Ops.say(S, ("Jumped to %s (%d,%d)"):format(S.save.player.map,
|
||||
S.save.player.x, S.save.player.y))
|
||||
local pmap, px, py = playerPos(S)
|
||||
if pmap then
|
||||
MapBrowser.select(S, pmap)
|
||||
Ops.say(S, ("Jumped to %s (%d,%d)"):format(pmap, px, py))
|
||||
else
|
||||
Ops.say(S, "No player location on this save")
|
||||
end
|
||||
end
|
||||
|
||||
-- ---------------------------------------------------------- the viewport
|
||||
@@ -253,7 +278,32 @@ function MapBrowser.draw(S, Kit, x, y, w, h)
|
||||
Kit.text("monoBig", tostring(S.mapId), vx0,
|
||||
vr.y + vpad + (headH - Kit.textHeight("monoBig")) / 2, PAL.heading)
|
||||
|
||||
local ok, map = pcall(MapLoader.load, S.data, S.mapId)
|
||||
local ok, map
|
||||
if Gen.of(S.save) == 2 then
|
||||
local def = Gen.maps(S.data)[S.mapId]
|
||||
if def then
|
||||
local Map2 = require("src.world.gen2.Map")
|
||||
if type(def.width) ~= "number" or type(def.height) ~= "number" then
|
||||
ok, map = false, "incomplete map record (missing width/height)"
|
||||
else
|
||||
local tileset = Gen.tilesets(S.data)[def.tileset]
|
||||
ok, map = pcall(Map2.new, def, tileset or {})
|
||||
if ok and map and not map.renderer then
|
||||
local MapPreview = require("src.world.gen2.MapPreview")
|
||||
S._g2MapBaker = S._g2MapBaker or MapPreview.baker({
|
||||
tilesets = Gen.tilesets(S.data),
|
||||
gen2Roofs = S.data.gen2Roofs, roofs = S.data.roofs,
|
||||
gen2Palettes = S.data.gen2Palettes, palettes = S.data.palettes,
|
||||
})
|
||||
map.renderer = MapPreview.renderer(S._g2MapBaker, map)
|
||||
end
|
||||
end
|
||||
else
|
||||
ok, map = false, "unknown map"
|
||||
end
|
||||
else
|
||||
ok, map = pcall(MapLoader.load, S.data, S.mapId)
|
||||
end
|
||||
if not ok then
|
||||
Kit.text("mono", "Failed to load map: " .. tostring(map), vx0,
|
||||
vr.y + vpad + headH + 20 * s, PAL.red)
|
||||
@@ -279,12 +329,13 @@ function MapBrowser.draw(S, Kit, x, y, w, h)
|
||||
local zBtn = 32 * s
|
||||
local rightEdge = vx0 + vinner
|
||||
local zoomW = 2 * zBtn + 56 * s + 12 * s
|
||||
local pmap, px, py = playerPos(S)
|
||||
local showCenter = vinner >= zoomW + 10 * s + centerW + 160 * s
|
||||
if showCenter then
|
||||
if Kit.button(rightEdge - centerW, vr.y + vpad, centerW, headH, "Center on player",
|
||||
{ kind = "accent", font = "small", radius = 7 * s }) then
|
||||
if S.save.player.map == S.mapId then
|
||||
centerOn(S, S.save.player.x, S.save.player.y)
|
||||
if pmap == S.mapId then
|
||||
centerOn(S, px, py)
|
||||
Ops.say(S, "Centred on the player")
|
||||
else
|
||||
Ops.say(S, "Player isn't on this map")
|
||||
@@ -312,10 +363,11 @@ function MapBrowser.draw(S, Kit, x, y, w, h)
|
||||
-- the panel has laid itself out.
|
||||
if S._mapCenteredFor ~= S.mapId then
|
||||
S._mapCenteredFor = S.mapId
|
||||
if S.save.player.map == S.mapId then
|
||||
centerOn(S, S.save.player.x, S.save.player.y)
|
||||
if pmap == S.mapId then
|
||||
centerOn(S, px, py)
|
||||
else
|
||||
centerOn(S, map.widthCells / 2, map.heightCells / 2)
|
||||
centerOn(S, (map.widthCells or map.width or 10) / 2,
|
||||
(map.heightCells or map.height or 10) / 2)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -334,7 +386,24 @@ function MapBrowser.draw(S, Kit, x, y, w, h)
|
||||
love.graphics.push()
|
||||
love.graphics.translate(vx0, vy0)
|
||||
love.graphics.scale(S.mapZoom, S.mapZoom)
|
||||
map.renderer:draw(S.mapCamX, S.mapCamY)
|
||||
if map.renderer and map.renderer.draw then
|
||||
map.renderer:draw(S.mapCamX, S.mapCamY)
|
||||
else
|
||||
local wc = map.widthCells or ((map.width or 8) * 2)
|
||||
local hc = map.heightCells or ((map.height or 8) * 2)
|
||||
for cy = 0, hc - 1 do
|
||||
for cx = 0, wc - 1 do
|
||||
if (cx + cy) % 2 == 0 then
|
||||
love.graphics.setColor(0.18, 0.22, 0.32, 1)
|
||||
else
|
||||
love.graphics.setColor(0.14, 0.17, 0.26, 1)
|
||||
end
|
||||
love.graphics.rectangle("fill",
|
||||
cx * CELL - S.mapCamX, cy * CELL - S.mapCamY, CELL, CELL)
|
||||
end
|
||||
end
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
end
|
||||
drawOverlays(S, map)
|
||||
love.graphics.pop()
|
||||
love.graphics.setScissor()
|
||||
@@ -402,20 +471,31 @@ function MapBrowser.draw(S, Kit, x, y, w, h)
|
||||
Kit.caption(sx0 + pad, sr.y + pad, "SPAWN POINTS")
|
||||
local sTop = sr.y + pad + Kit.textHeight("caption") + 12 * s
|
||||
local sInner = sr.w - 2 * pad
|
||||
local player = S.save.player
|
||||
local out = S.save.lastOutdoor
|
||||
local heal = S.save.lastHeal
|
||||
local spawns = {
|
||||
{ key = "PLAYER", color = PAL.red,
|
||||
value = ("%s (%d,%d)"):format(player.map, player.x, player.y),
|
||||
set = function() Ops.setPlayerHere(S) end },
|
||||
{ key = "LAST HEAL", color = PAL.green,
|
||||
value = heal and ("%s (%d,%d)"):format(heal.map, heal.x, heal.y) or "unset",
|
||||
set = function() Ops.setLastHeal(S) end },
|
||||
{ key = "LAST OUTDOOR", color = PAL.yellow,
|
||||
value = out and ("%s (%d,%d)"):format(out.id, out.x, out.y) or "unset",
|
||||
set = function() Ops.setLastOutdoor(S, map) end },
|
||||
}
|
||||
local pmap2, px2, py2 = playerPos(S)
|
||||
local playerValue = pmap2 and ("%s (%d,%d)"):format(pmap2, px2, py2) or "unset"
|
||||
local spawns
|
||||
if Gen.of(S.save) == 2 then
|
||||
spawns = {
|
||||
{ key = "PLAYER", color = PAL.red, value = playerValue,
|
||||
set = function() Ops.setPlayerHere(S) end },
|
||||
{ key = "SPAWN", color = PAL.green,
|
||||
value = tostring(S.save.spawn or "SPAWN_HOME"),
|
||||
set = function() Ops.setLastHeal(S) end },
|
||||
}
|
||||
else
|
||||
local out = S.save.lastOutdoor
|
||||
local heal = S.save.lastHeal
|
||||
spawns = {
|
||||
{ key = "PLAYER", color = PAL.red, value = playerValue,
|
||||
set = function() Ops.setPlayerHere(S) end },
|
||||
{ key = "LAST HEAL", color = PAL.green,
|
||||
value = heal and ("%s (%d,%d)"):format(heal.map, heal.x, heal.y) or "unset",
|
||||
set = function() Ops.setLastHeal(S) end },
|
||||
{ key = "LAST OUTDOOR", color = PAL.yellow,
|
||||
value = out and ("%s (%d,%d)"):format(out.id, out.x, out.y) or "unset",
|
||||
set = function() Ops.setLastOutdoor(S, map) end },
|
||||
}
|
||||
end
|
||||
local spawnH = 62 * s
|
||||
for i, sp in ipairs(spawns) do
|
||||
local ry = sTop + (i - 1) * (spawnH + 8 * s)
|
||||
@@ -431,7 +511,7 @@ function MapBrowser.draw(S, Kit, x, y, w, h)
|
||||
sx0 + pad + 12 * s, ry + spawnH - 10 * s - Kit.textHeight("mono"), PAL.muted)
|
||||
end
|
||||
|
||||
local noteY = sTop + 3 * (spawnH + 8 * s) + 6 * s
|
||||
local noteY = sTop + #spawns * (spawnH + 8 * s) + 6 * s
|
||||
Kit.textCenter("tiny",
|
||||
"Click a cell first. Warp cells follow the warp instead of selecting. " ..
|
||||
"Arrow keys / WASD pan, the wheel zooms.",
|
||||
|
||||
@@ -19,17 +19,26 @@
|
||||
local Theme = require("Theme")
|
||||
local Ops = require("Ops")
|
||||
local PAL = Theme.PAL
|
||||
local Gen = require("Gen")
|
||||
|
||||
local MonEditor = {}
|
||||
|
||||
local DV_KEYS = { "attack", "defense", "speed", "special" }
|
||||
local STAT_KEYS = {
|
||||
local STAT_KEYS_G1 = {
|
||||
{ key = "HP", field = "hp" },
|
||||
{ key = "ATK", field = "attack" },
|
||||
{ key = "DEF", field = "defense" },
|
||||
{ key = "SPD", field = "speed" },
|
||||
{ key = "SPC", field = "special" },
|
||||
}
|
||||
local STAT_KEYS_G2 = {
|
||||
{ key = "HP", field = "hp" },
|
||||
{ key = "ATK", field = "attack" },
|
||||
{ key = "DEF", field = "defense" },
|
||||
{ key = "SPA", field = "specialAttack" },
|
||||
{ key = "SPD", field = "specialDefense" },
|
||||
{ key = "SPE", field = "speed" },
|
||||
}
|
||||
|
||||
-- Front sprites are read straight off the generated cache. One image per
|
||||
-- species, cached for the process: the old panel called newImage every frame,
|
||||
@@ -96,7 +105,7 @@ local function drawLevelRow(S, Kit, mon, lx0, ly)
|
||||
end
|
||||
lx = lx + bw + 8 * s
|
||||
end
|
||||
Kit.text("mono", ("EXP %d"):format(mon.exp or 0), lx + 6 * s,
|
||||
Kit.text("mono", ("EXP %d"):format(Gen.exp(mon)), lx + 6 * s,
|
||||
ly + (lh - Kit.textHeight("mono")) / 2, PAL.muted)
|
||||
return lh
|
||||
end
|
||||
@@ -106,7 +115,7 @@ end
|
||||
local function levelRowWidth(Kit, mon)
|
||||
local s = Kit.scale
|
||||
return 52 * s + 2 * (40 * s + 8 * s) + 58 * s + 8 * s + 2 * (40 * s + 8 * s)
|
||||
+ 6 * s + Kit.textWidth("mono", ("EXP %d"):format(mon.exp or 0))
|
||||
+ 6 * s + Kit.textWidth("mono", ("EXP %d"):format(Gen.exp(mon)))
|
||||
end
|
||||
|
||||
local function drawDvRows(S, Kit, mon, cx, rowY, colW, rowH, rowGap)
|
||||
@@ -179,7 +188,7 @@ function MonEditor.draw(S, Kit, x, y, w, h)
|
||||
local tw = math.min(w - 40 * s, 340 * s)
|
||||
Kit.textCenter("button",
|
||||
"Pick a slot on the left to inspect it. Every change here re-runs the " ..
|
||||
"Gen1 stat formulas, so HP and stats stay legal.",
|
||||
"stat formulas, so HP and stats stay legal.",
|
||||
x + (w - tw) / 2, y + h / 2 - Kit.textHeight("button"), tw, PAL.muted)
|
||||
return
|
||||
end
|
||||
@@ -220,10 +229,13 @@ function MonEditor.draw(S, Kit, x, y, w, h)
|
||||
end
|
||||
-- the nickname section: a caption line (with the Clear button on it) plus
|
||||
-- the field + Set row
|
||||
local extraH = 0
|
||||
if Gen.ofState(S) == 2 then extraH = 88 * s end
|
||||
local nickFieldH = 30 * s
|
||||
local contentH = pad + headerH + 18 * s
|
||||
+ capH + 10 * s + nickFieldH + 18 * s
|
||||
+ capH + 10 * s + cellH + 18 * s
|
||||
+ extraH
|
||||
+ colsH + pad
|
||||
|
||||
-- Called before the widgets so this frame already draws at the updated
|
||||
@@ -311,8 +323,9 @@ function MonEditor.draw(S, Kit, x, y, w, h)
|
||||
local statsY = nickY + capH + 10 * s + nickFieldH + 18 * s
|
||||
Kit.caption(cx, statsY, "STATS . recalculated from level + DVs")
|
||||
statsY = statsY + capH + 10 * s
|
||||
local STAT_KEYS = Gen.ofState(S) == 2 and STAT_KEYS_G2 or STAT_KEYS_G1
|
||||
local gap = 12 * s
|
||||
local cellW = (inner - gap * 4) / 5
|
||||
local cellW = (inner - gap * (#STAT_KEYS - 1)) / #STAT_KEYS
|
||||
for i, st in ipairs(STAT_KEYS) do
|
||||
local bx = cx + (i - 1) * (cellW + gap)
|
||||
Theme.row(bx, statsY, cellW, cellH, 10 * s, 0.6)
|
||||
@@ -326,6 +339,40 @@ function MonEditor.draw(S, Kit, x, y, w, h)
|
||||
|
||||
-- --------------------------------------------------- DVs | moves split
|
||||
local colY = statsY + cellH + 18 * s
|
||||
if Gen.ofState(S) == 2 then
|
||||
local extraY = colY
|
||||
Kit.caption(cx, extraY, "GOLD")
|
||||
extraY = extraY + capH + 8 * s
|
||||
local row = 28 * s
|
||||
Kit.text("tiny", "HELD " .. tostring(mon.item or "none"), cx, extraY, PAL.text)
|
||||
if Kit.button(cx + inner - 70 * s, extraY, 70 * s, row, "Clear item",
|
||||
{ kind = "danger", font = "tiny", radius = 6 * s }) then
|
||||
Ops.setHeldItem(S, mon, nil)
|
||||
end
|
||||
extraY = extraY + row + 6 * s
|
||||
Kit.text("tiny", ("HAPPINESS %d"):format(mon.happiness or 0), cx, extraY, PAL.text)
|
||||
if Kit.stepper(cx + 140 * s, extraY, 28 * s, row, "-", { font = "small" }) then
|
||||
Ops.setHappiness(S, mon, (mon.happiness or 0) - 10)
|
||||
end
|
||||
if Kit.stepper(cx + 174 * s, extraY, 28 * s, row, "+", { font = "small" }) then
|
||||
Ops.setHappiness(S, mon, (mon.happiness or 0) + 10)
|
||||
end
|
||||
Kit.text("tiny", ("PKRS %d"):format(mon.pokerus or 0), cx + 220 * s, extraY, PAL.text)
|
||||
if Kit.stepper(cx + 300 * s, extraY, 28 * s, row, "-", { font = "small" }) then
|
||||
Ops.setPokerus(S, mon, (mon.pokerus or 0) - 1)
|
||||
end
|
||||
if Kit.stepper(cx + 334 * s, extraY, 28 * s, row, "+", { font = "small" }) then
|
||||
Ops.setPokerus(S, mon, (mon.pokerus or 0) + 1)
|
||||
end
|
||||
extraY = extraY + row + 4 * s
|
||||
local bits = {}
|
||||
if mon.gender then bits[#bits + 1] = mon.gender end
|
||||
if mon.shiny then bits[#bits + 1] = "shiny" end
|
||||
if mon.unownLetter then bits[#bits + 1] = "Unown " .. tostring(mon.unownLetter) end
|
||||
Kit.text("tiny", table.concat(bits, " ") ~= "" and table.concat(bits, " ")
|
||||
or "gender/shiny follow DVs", cx, extraY, PAL.caption)
|
||||
colY = extraY + 22 * s
|
||||
end
|
||||
if narrow then
|
||||
-- stacked: DVs first, then moves, then the two actions side by side at
|
||||
-- full width (#715)
|
||||
|
||||
Reference in New Issue
Block a user