mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-15 07:41:21 +02:00
Compare commits
46 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1cfd91a503 | |||
| fb738fa1ce | |||
| c0ad6d0328 | |||
| 84de8c9cb1 | |||
| 6307bc92f6 | |||
| 052dd26b3e | |||
| c8f6c7241b | |||
| b3928388ef | |||
| a38fae5a97 | |||
| 9fab992d42 | |||
| dcc388a942 | |||
| 797a6bebfe | |||
| 97a9c0f58f | |||
| 407f649e9d | |||
| 84635cdfdf | |||
| 3de45b671c | |||
| 4395792226 | |||
| a77210799f | |||
| 6f67292b0e | |||
| 78e8a31ead | |||
| b6388013ec | |||
| ba7cd8fabf | |||
| c8f7f396ce | |||
| a94fecfec8 | |||
| 2010ba71a6 | |||
| e90dff6764 | |||
| f4658b89ae | |||
| da63163216 | |||
| fceaa2b54c | |||
| be2ef46ebc | |||
| d8e86e6878 | |||
| 7e57e3174e | |||
| 5192106730 | |||
| dfeacc36b0 | |||
| 1586aec9f6 | |||
| 35d44efb8b | |||
| 500d8c2c07 | |||
| 09204daa28 | |||
| 01329758ff | |||
| 7b8cb127a8 | |||
| e4786a78d9 | |||
| bde606f966 | |||
| c0f654f8c2 | |||
| e44769a48a | |||
| d38faab03c | |||
| 83682f011d |
@@ -252,8 +252,40 @@ jobs:
|
||||
Remove-Item $env:UWP_PFX -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
|
||||
# Windows Native AOT TLS dialer. The Mac release runner fuses the win64 zip
|
||||
# from LÖVE's prebuilt binaries and cannot cross-compile this DLL, so build
|
||||
# it here and inject it in the release job before scripts/build.sh win.
|
||||
native-tls-win:
|
||||
name: build Windows gen1tls.dll
|
||||
needs: version
|
||||
runs-on: windows-2022
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- name: Setup .NET 8
|
||||
uses: actions/setup-dotnet@v4
|
||||
with:
|
||||
dotnet-version: "8.0.x"
|
||||
- name: Publish gen1tls (win-x64 Native AOT)
|
||||
shell: pwsh
|
||||
run: |
|
||||
$out = "dist/native/win-x64"
|
||||
New-Item -ItemType Directory -Force -Path $out | Out-Null
|
||||
dotnet publish native/tls_dial/Gen1Tls.csproj `
|
||||
-c Release -r win-x64 -o $out
|
||||
if (-not (Test-Path "$out/gen1tls.dll")) {
|
||||
throw "gen1tls.dll missing after publish"
|
||||
}
|
||||
Get-Item "$out/gen1tls.dll" | Format-List Name, Length, LastWriteTime
|
||||
- name: Upload gen1tls.dll
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: gen1tls-win-x64
|
||||
path: dist/native/win-x64/gen1tls.dll
|
||||
if-no-files-found: error
|
||||
retention-days: 1
|
||||
|
||||
release:
|
||||
needs: [version, xbox-uwp, linux-arm64]
|
||||
needs: [version, xbox-uwp, linux-arm64, native-tls-win]
|
||||
runs-on: ${{ fromJSON(github.repository == 'bryanthaboi/gen1recomp' && '["self-hosted", "macOS"]' || '"macos-latest"') }}
|
||||
|
||||
steps:
|
||||
@@ -269,6 +301,12 @@ jobs:
|
||||
fetch-depth: 0
|
||||
fetch-tags: true
|
||||
|
||||
- name: Download Windows gen1tls dialer
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: gen1tls-win-x64
|
||||
path: dist/native/win-x64
|
||||
|
||||
- name: Import signing certificate into a temporary keychain
|
||||
if: github.repository == 'bryanthaboi/gen1recomp'
|
||||
run: |
|
||||
@@ -307,13 +345,21 @@ jobs:
|
||||
security find-identity -v -p codesigning "$KEYCHAIN_PATH"
|
||||
|
||||
- name: Build macOS + Windows + Linux
|
||||
env:
|
||||
GEN1TLS_DLL: ${{ github.workspace }}/dist/native/win-x64/gen1tls.dll
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# Sign in-build (identity auto-detected from the temp keychain);
|
||||
# notarize separately below so it uses secret credentials, not a
|
||||
# login-keychain profile. "all" also builds the Linux AppImage,
|
||||
# which needs no signing/notarization.
|
||||
if [ ! -f "$GEN1TLS_DLL" ]; then
|
||||
echo "::error::gen1tls.dll missing at $GEN1TLS_DLL (native-tls-win job)"
|
||||
exit 1
|
||||
fi
|
||||
scripts/build.sh all --version "${{ needs.version.outputs.version }}" --no-notarize
|
||||
unzip -l dist/win/gen1recomp-win64.zip | grep -F gen1tls.dll \
|
||||
|| { echo "::error::Windows zip is missing gen1tls.dll"; exit 1; }
|
||||
|
||||
- name: Build Android
|
||||
run: |
|
||||
|
||||
+5
-1
@@ -3,8 +3,9 @@
|
||||
data/generated/
|
||||
assets/generated/
|
||||
|
||||
# LÖVE packages
|
||||
# LÖVE packages & archives
|
||||
*.love
|
||||
*.zip
|
||||
|
||||
# Local saves (LÖVE writes to its save dir, but keep the repo clean anyway)
|
||||
save/
|
||||
@@ -75,3 +76,6 @@ mobile/ios/bundle_id.local
|
||||
/dist/native/
|
||||
/dist/win/
|
||||
/.bazinga/
|
||||
|
||||
# Local options / preferences
|
||||
/options.lua*
|
||||
|
||||
+48
-3
@@ -242,10 +242,55 @@ with a `MK4xx` finding per site and an `unresolved:` note, with a file and a
|
||||
line, for every reach a static scan could not follow. Neither substitutes for a
|
||||
real Gold boot.
|
||||
|
||||
### 5. `mod.card`
|
||||
### 5. What a mod's code can reach
|
||||
|
||||
Your code runs in a sandbox (`src/mods/Sandbox.lua`), not against the
|
||||
engine's globals. Every chunk you author gets it: `main.lua`, your
|
||||
`options_schema`, and anything you `load()` yourself. What is absent:
|
||||
|
||||
| Absent | Use instead |
|
||||
| --- | --- |
|
||||
| `io`, and `require("io")` | `mod:read` for your own files, `mod.storage` to persist |
|
||||
| `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), `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 |
|
||||
|
||||
The rest of `love` passes through unchanged, so graphics, audio, timers and
|
||||
input work as they always have.
|
||||
|
||||
Three consequences worth knowing before you write against it:
|
||||
|
||||
- **Your globals are yours.** `_G` inside a mod is that mod's own table. Two
|
||||
mods no longer share a namespace, and neither can reach the engine's. To
|
||||
publish something to another mod, put it on `mod.exports` and let them
|
||||
`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: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
|
||||
player, and `network` now gates `require("socket")` and friends. There is no
|
||||
permission that grants raw filesystem access, because no mod needs one:
|
||||
everything a mod legitimately writes is already scoped by
|
||||
`mod.storage` or the asset-transform derived root.
|
||||
|
||||
If your mod used one of the absent globals, the fix is almost always
|
||||
`mod.storage`. Open an issue if you have a case it does not cover.
|
||||
|
||||
### 6. `mod.card`
|
||||
|
||||
The manifest is the *engine's* contract: identity, load order, dependencies,
|
||||
permissions, profile. The card is the *human-facing* one: who made this,
|
||||
permissions, profile (see [Manifest specification](docs/modding.md#manifest-specification-manifestjson)).
|
||||
The card is the *human-facing* one: who made this,
|
||||
what it changes, what it does not do yet. It is never read by the loader's
|
||||
merge — only by tooling and the manager's detail pane — so an absent or
|
||||
malformed card can never break a load.
|
||||
@@ -263,7 +308,7 @@ Two fields deserve their own note:
|
||||
distributed mod never carries ROM-derived bytes, not even in its preview
|
||||
images.
|
||||
|
||||
### 6. Tags
|
||||
### 7. Tags
|
||||
|
||||
Lowercase kebab strings, open vocabulary. The showcase generator
|
||||
lowercases and de-dupes. A recommended starting set: `beginner`,
|
||||
|
||||
@@ -92,7 +92,7 @@ return {
|
||||
{ "set_flag", "EVENT_GOT_EEVEE" }, -- 7
|
||||
{ "hide_object", "CELADON_MANSION_ROOF_HOUSE",
|
||||
"CELADONMANSION_ROOF_HOUSE_EEVEE_POKEBALL" }, -- 8
|
||||
{ "play_sound", "Get_Item1" }, -- 9 (GotMonText jingle)
|
||||
{ "text_sound", "Get_Item1" }, -- 9 (GotMonText jingle)
|
||||
{ "show_text", "_GotMonText", { RAM = "EEVEE" } }, -- 10
|
||||
{ "jump", 13 }, -- 11
|
||||
{ "show_text", "_BoxIsFullText" }, -- 12
|
||||
|
||||
@@ -10,10 +10,10 @@
|
||||
local function coinGiver(opts)
|
||||
return function(game, ow, npc, done)
|
||||
local TextBox = require("src.render.TextBox")
|
||||
local Sound = require("src.core.Sound")
|
||||
local t = game.data.text
|
||||
local function push(label, fallback, onDone)
|
||||
game.stack:push(TextBox.new(game, t[label] or fallback, onDone or done))
|
||||
local function push(label, fallback, onDone, popts)
|
||||
game.stack:push(TextBox.new(game, t[label] or fallback, onDone or done,
|
||||
popts))
|
||||
end
|
||||
if game.save.flags[opts.event] then
|
||||
push(opts.alreadyGotLabel, opts.alreadyGotFallback)
|
||||
@@ -30,9 +30,10 @@ local function coinGiver(opts)
|
||||
end
|
||||
game.save.coins = math.min(9999, (game.save.coins or 0) + opts.amount)
|
||||
game.save.flags[opts.event] = true
|
||||
Sound.play(game.data, "Get_Item1")
|
||||
-- the ReceivedNCoinsText strings carry sound_get_item_1
|
||||
push(opts.receivedLabel,
|
||||
("{PLAYER} received\n%d coins!"):format(opts.amount))
|
||||
("{PLAYER} received\n%d coins!"):format(opts.amount), nil,
|
||||
TextBox.soundOpts(game, "Get_Item1"))
|
||||
end)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -22,8 +22,8 @@ return {
|
||||
local TextBox = require("src.render.TextBox")
|
||||
local Commands = require("src.script.Commands")
|
||||
local t = game.data.text
|
||||
local function say(label, cb)
|
||||
game.stack:push(TextBox.new(game, t[label] or label, cb))
|
||||
local function say(label, cb, sopts)
|
||||
game.stack:push(TextBox.new(game, t[label] or label, cb, sopts))
|
||||
end
|
||||
|
||||
if game.save.flags.EVENT_GOT_OLD_AMBER then
|
||||
@@ -39,8 +39,9 @@ return {
|
||||
game.save.flags.EVENT_GOT_OLD_AMBER = true
|
||||
Commands.hide_object({ save = game.save, overworld = ow, game = game },
|
||||
"MUSEUM_1F", "MUSEUM1F_OLD_AMBER")
|
||||
require("src.core.Sound").play(game.data, "Get_Item1")
|
||||
say("_Museum1FScientist2ReceivedOldAmberText", done)
|
||||
-- .ReceivedOldAmberText carries sound_get_item_1
|
||||
say("_Museum1FScientist2ReceivedOldAmberText", done,
|
||||
TextBox.soundOpts(game, "Get_Item1"))
|
||||
end)
|
||||
end,
|
||||
|
||||
|
||||
@@ -38,9 +38,9 @@ local function starterBall(askText, species, choseFlag, ownBall,
|
||||
-- inside give_pokemon). Show the received text first so the
|
||||
-- nickname prompt follows "you got X", matching Gen1.
|
||||
-- The received text carries sound_get_key_item (OaksLab.asm
|
||||
-- OaksLabReceivedMonText); the jingle plays as the box opens
|
||||
-- (same beat as the Yellow port's starter, #668).
|
||||
{ "play_sound", "Get_Key_Item" }, -- 8
|
||||
-- OaksLabReceivedMonText), so the jingle fires once the box has
|
||||
-- typed out and holds it (same beat as the Yellow port's starter, #668).
|
||||
{ "text_sound", "Get_Key_Item" }, -- 8
|
||||
{ "show_text", "_OaksLabReceivedMonText", { RAM = species } }, -- 9
|
||||
{ "give_pokemon", species, 5 }, -- 10
|
||||
{ "set_flag", "EVENT_GOT_STARTER" }, -- 11
|
||||
@@ -54,7 +54,7 @@ local function starterBall(askText, species, choseFlag, ownBall,
|
||||
{ "face_object", 1, "up" }, -- 15
|
||||
{ "show_text", "_OaksLabRivalIllTakeThisOneText" }, -- 16
|
||||
{ "hide_object", "OAKS_LAB", rivalBall }, -- 17
|
||||
{ "play_sound", "Get_Key_Item" }, -- 18 (sound_get_key_item)
|
||||
{ "text_sound", "Get_Key_Item" }, -- 18 (sound_get_key_item)
|
||||
{ "show_text", "_OaksLabRivalReceivedMonText",
|
||||
{ RAM = rivalBall == "OAKSLAB_CHARMANDER_POKE_BALL" and "CHARMANDER"
|
||||
or rivalBall == "OAKSLAB_SQUIRTLE_POKE_BALL" and "SQUIRTLE"
|
||||
@@ -106,8 +106,8 @@ return {
|
||||
{ "check_item", "OAKS_PARCEL" },
|
||||
{ "jump_if_false", "raise_young" },
|
||||
-- OaksLabOak1Text.got_parcel → RivalArrives + OakGivesPokedex
|
||||
{ "text_sound", "Get_Key_Item" },
|
||||
{ "show_text", "_OaksLabOak1DeliverParcelText" },
|
||||
{ "play_sound", "Get_Key_Item" },
|
||||
{ "show_text", "_OaksLabOak1ParcelThanksText" },
|
||||
{ "take_item", "OAKS_PARCEL", 1 },
|
||||
{ "stop_music" },
|
||||
@@ -128,8 +128,8 @@ return {
|
||||
{ "face_object", 1, "up" },
|
||||
{ "face_object", 5, "down" },
|
||||
{ "show_text", "_OaksLabOakMyInventionPokedexText" },
|
||||
{ "text_sound", "Get_Key_Item" },
|
||||
{ "show_text", "_OaksLabOakGotPokedexText" },
|
||||
{ "play_sound", "Get_Key_Item" },
|
||||
{ "hide_object", "OAKS_LAB", "OAKSLAB_POKEDEX1" },
|
||||
{ "hide_object", "OAKS_LAB", "OAKSLAB_POKEDEX2" },
|
||||
{ "face_object", 1, "up" },
|
||||
|
||||
@@ -38,8 +38,8 @@ return {
|
||||
{ "jump_if_false", "raise_young" },
|
||||
-- .DeliverParcelText: parcel handover, then the Pokédex scene
|
||||
-- (OaksLabRivalArrivesAtOaksRequestScript -> OakGivesPokedexScript)
|
||||
{ "text_sound", "Get_Key_Item" },
|
||||
{ "show_text", "_OaksLabOak1DeliverParcelText" },
|
||||
{ "play_sound", "Get_Key_Item" },
|
||||
{ "show_text", "_OaksLabOak1ParcelThanksText" },
|
||||
{ "take_item", "OAKS_PARCEL", 1 },
|
||||
{ "stop_music" },
|
||||
@@ -62,8 +62,8 @@ return {
|
||||
{ "face_object", RIVAL, "up" },
|
||||
{ "face_object", OAK1, "down" },
|
||||
{ "show_text", "_OaksLabOakMyInventionPokedexText" },
|
||||
{ "text_sound", "Get_Key_Item" },
|
||||
{ "show_text", "_OaksLabOakGotPokedexText" },
|
||||
{ "play_sound", "Get_Key_Item" },
|
||||
{ "hide_object", "OAKS_LAB", "OAKSLAB_POKEDEX1" },
|
||||
{ "hide_object", "OAKS_LAB", "OAKSLAB_POKEDEX2" },
|
||||
{ "face_object", RIVAL, "up" },
|
||||
@@ -113,8 +113,8 @@ return {
|
||||
{ "jump_if_true", "come_see" },
|
||||
{ "set_flag", "EVENT_GOT_POKEBALLS_FROM_OAK" },
|
||||
{ "give_item", "POKE_BALL", 5, false },
|
||||
{ "text_sound", "Get_Key_Item" },
|
||||
{ "show_text", "_OaksLabOak1ReceivedPokeballsText" },
|
||||
{ "play_sound", "Get_Key_Item" },
|
||||
{ "show_text", "_OaksLabGivePokeballsExplanationText" },
|
||||
{ "jump", "end" },
|
||||
|
||||
@@ -176,7 +176,7 @@ return {
|
||||
-- rival starter baseline (RIVAL_STARTER_JOLTEON) at snatch time
|
||||
rows[#rows + 1] = { "set_field", "rivalStarter", 1 }
|
||||
rows[#rows + 1] = { "show_text", "_OaksLabRivalTakesText1" }
|
||||
rows[#rows + 1] = { "play_sound", "Get_Key_Item" }
|
||||
rows[#rows + 1] = { "text_sound", "Get_Key_Item" }
|
||||
rows[#rows + 1] = { "show_text", "_OaksLabRivalTakesText2" }
|
||||
rows[#rows + 1] = { "show_text", "_OaksLabRivalTakesText3" }
|
||||
rows[#rows + 1] = { "show_text", "_OaksLabRivalTakesText4" }
|
||||
@@ -196,7 +196,7 @@ return {
|
||||
rows[#rows + 1] = { "face_object", OAK1, "down" }
|
||||
-- OaksLabPlayerReceivedMonText clears wMonDataLocation, so AskName runs (#1013)
|
||||
rows[#rows + 1] = { "show_text", "_OaksLabOakGivesText" }
|
||||
rows[#rows + 1] = { "play_sound", "Get_Key_Item" }
|
||||
rows[#rows + 1] = { "text_sound", "Get_Key_Item" }
|
||||
rows[#rows + 1] = { "show_text", "_OaksLabReceivedText", { RAM = "PIKACHU" } }
|
||||
rows[#rows + 1] = { "give_pokemon", "PIKACHU", 5 }
|
||||
-- DisablePikachuOverworldSpriteDrawing keeps it in the ball (#1009)
|
||||
|
||||
@@ -297,6 +297,7 @@ M.BILLS_HOUSE = {
|
||||
|
||||
M.ROUTE_25 = {
|
||||
onEnter = function(game, ow)
|
||||
game.save.pikachuMapScriptActive = nil
|
||||
local flags = game.save.flags
|
||||
if flags.EVENT_LEFT_BILLS_HOUSE_AFTER_HELPING then return end
|
||||
local Commands = require("src.script.Commands")
|
||||
@@ -329,6 +330,7 @@ M.VERMILION_CITY = {
|
||||
-- only read while EVENT_1ST_LOCK_OPENED is unset (the gym is only
|
||||
-- reachable through this map, so a fresh visit always re-rolls).
|
||||
onEnter = function(game, ow)
|
||||
game.save.pikachuMapScriptActive = nil
|
||||
local puz = game.save.trashPuzzle or {}
|
||||
game.save.trashPuzzle = puz
|
||||
puz.first = love.math.random(0, 7) * 2
|
||||
|
||||
+13
-5
@@ -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
|
||||
@@ -648,8 +654,10 @@ local function mtMoonFossil(itemId, otherName, gotFlag)
|
||||
end
|
||||
local idef = game.data.items[itemId]
|
||||
game.stringBuffer = idef and idef.name or itemId
|
||||
require("src.core.Sound").play(game.data, "Get_Key_Item")
|
||||
local dirs = mtMoonNerdWalk(ow.player.cellX, ow.player.cellY, itemId)
|
||||
-- MtMoonB2FReceivedFossilText: text_far, sound_get_key_item,
|
||||
-- text_waitbutton -- the jingle plays after the box has typed and
|
||||
-- the button wait comes after it
|
||||
game.stack:push(TextBox.new(game,
|
||||
t._MtMoonB2FReceivedFossilText
|
||||
or ("{PLAYER} got the\n" .. game.stringBuffer .. "!"),
|
||||
@@ -662,11 +670,11 @@ local function mtMoonFossil(itemId, otherName, gotFlag)
|
||||
ow.runner:run({
|
||||
{ "walk_npc", 1, dirs },
|
||||
{ "text_opts", { auto = true } },
|
||||
{ "text_sound", "Get_Key_Item" },
|
||||
{ "show_text", "_MtMoonB2FSuperNerdThenThisIsMineText" },
|
||||
{ "play_sound", "Get_Key_Item" },
|
||||
{ "hide_object", "MT_MOON_B2F", otherName },
|
||||
}, { onDone = done })
|
||||
end))
|
||||
end, TextBox.soundOpts(game, "Get_Key_Item")))
|
||||
end }))
|
||||
end
|
||||
end
|
||||
|
||||
+7
-11
@@ -8,9 +8,9 @@ local M = {}
|
||||
|
||||
local function text(game) return game.data.text end
|
||||
|
||||
local function push(game, s, done)
|
||||
local function push(game, s, done, opts)
|
||||
local TextBox = require("src.render.TextBox")
|
||||
game.stack:push(TextBox.new(game, s, done))
|
||||
game.stack:push(TextBox.new(game, s, done, opts))
|
||||
end
|
||||
|
||||
-- The question stays on screen under the YES/NO menu. The dojo prize
|
||||
@@ -263,7 +263,7 @@ M.SILPH_CO_7F = {
|
||||
{ "jump_if_false", "box_full" },
|
||||
-- flag ahead of the jingle, like the Celadon EEVEE (#426)
|
||||
{ "set_flag", "EVENT_GOT_LAPRAS" },
|
||||
{ "play_sound", "Get_Item1" },
|
||||
{ "text_sound", "Get_Item1" },
|
||||
{ "show_text", "_GotMonText", { RAM = "LAPRAS" } },
|
||||
{ "show_text", "_SilphCo7FSilphWorkerM1LaprasDescriptionText" },
|
||||
{ "jump", "end" },
|
||||
@@ -307,12 +307,11 @@ M.COPYCATS_HOUSE_2F = {
|
||||
return
|
||||
end
|
||||
game.stringBuffer = game.data.items.TM_MIMIC.name
|
||||
require("src.core.Sound").play(game.data, "Get_Item1")
|
||||
Bag.remove(game.save, "POKE_DOLL", 1)
|
||||
game.save.flags.EVENT_GOT_TM31 = true
|
||||
push(game, t._CopycatsHouse2FCopycatReceivedTM31Text, function()
|
||||
push(game, t._CopycatsHouse2FCopycatTM31Explanation1Text, done)
|
||||
end)
|
||||
end, require("src.render.TextBox").soundOpts(game, "Get_Item1"))
|
||||
end)
|
||||
end)
|
||||
end,
|
||||
@@ -478,7 +477,6 @@ M.CELADON_MART_ROOF = {
|
||||
return
|
||||
end
|
||||
game.save.flags[g.flag] = true
|
||||
require("src.core.Sound").play(game.data, "Get_Item1")
|
||||
local subs = { player = game.save.player.name,
|
||||
ram = game.data.items[g.tm].name }
|
||||
local explain = fill(t[g.explain] or "", subs)
|
||||
@@ -490,7 +488,7 @@ M.CELADON_MART_ROOF = {
|
||||
else
|
||||
done()
|
||||
end
|
||||
end)
|
||||
end, require("src.render.TextBox").soundOpts(game, "Get_Item1"))
|
||||
end)
|
||||
end,
|
||||
onCancel = done,
|
||||
@@ -522,7 +520,6 @@ M.ROUTE_24 = {
|
||||
local t = text(game)
|
||||
push(game, t._Route24CooltrainerM1YouBeatOurContestText .. "\f"
|
||||
.. t._Route24CooltrainerM1YouJustEarnedAPrizeText, function()
|
||||
require("src.core.Sound").play(game.data, "Get_Item1")
|
||||
if not require("src.inventory.Bag").add(game.save, "NUGGET", 1,
|
||||
game.data) then
|
||||
push(game, t._Route24CooltrainerM1NoRoomText, done)
|
||||
@@ -531,11 +528,10 @@ M.ROUTE_24 = {
|
||||
flags.EVENT_GOT_NUGGET = true
|
||||
game.stringBuffer = game.data.items.NUGGET.name
|
||||
push(game, t._Route24CooltrainerM1ReceivedNuggetText, function()
|
||||
require("src.core.Sound").play(game.data, "Get_Item1")
|
||||
push(game, t._Route24CooltrainerM1JoinTeamRocketText,
|
||||
battleOrDone)
|
||||
end)
|
||||
end)
|
||||
end, require("src.render.TextBox").soundOpts(game, "Get_Item1"))
|
||||
end, require("src.render.TextBox").soundOpts(game, "Get_Item1"))
|
||||
return
|
||||
end
|
||||
battleOrDone()
|
||||
|
||||
+14
-8
@@ -4,9 +4,9 @@ local M = {}
|
||||
|
||||
local function text(game) return game.data.text end
|
||||
|
||||
local function push(game, s, done)
|
||||
local function push(game, s, done, opts)
|
||||
local TextBox = require("src.render.TextBox")
|
||||
game.stack:push(TextBox.new(game, s, done))
|
||||
game.stack:push(TextBox.new(game, s, done, opts))
|
||||
end
|
||||
|
||||
-- fill the extracted text placeholders ({RAM:...}, {PLAYER})
|
||||
@@ -25,8 +25,8 @@ local function gift(opts)
|
||||
local t = text(game)
|
||||
local itemName = game.data.items[opts.item].name
|
||||
local subs = { ram = itemName, player = game.save.player.name }
|
||||
local function say(label, fallback, cb)
|
||||
push(game, fill(t[label] or fallback, subs), cb)
|
||||
local function say(label, fallback, cb, sopts)
|
||||
push(game, fill(t[label] or fallback, subs), cb, sopts)
|
||||
end
|
||||
if game.save.flags[opts.flag] then
|
||||
say(opts.already or opts.explain, "It's a useful\nitem, isn't it?", done)
|
||||
@@ -39,15 +39,16 @@ local function gift(opts)
|
||||
end
|
||||
game.save.flags[opts.flag] = true
|
||||
local idef = game.data.items[opts.item]
|
||||
require("src.core.Sound").play(game.data,
|
||||
(idef and idef.keyItem) and "Get_Key_Item" or "Get_Item1")
|
||||
-- the received texts carry sound_get_item_1 / sound_get_key_item, so
|
||||
-- the jingle only fires once that box has typed out
|
||||
say(opts.received, "{PLAYER} received\n{RAM:}!", function()
|
||||
if opts.explain then
|
||||
say(opts.explain, "", done)
|
||||
else
|
||||
done()
|
||||
end
|
||||
end)
|
||||
end, require("src.render.TextBox").soundOpts(game,
|
||||
(idef and idef.keyItem) and "Get_Key_Item" or "Get_Item1"))
|
||||
end
|
||||
if opts.pre then say(opts.pre, opts.preFallback or "", give) else give() end
|
||||
end
|
||||
@@ -445,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)
|
||||
@@ -468,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
|
||||
|
||||
@@ -28,6 +28,14 @@
|
||||
-- is full; `gotFlag` (pokered's EVENT_GOT_TM*) is set only on a
|
||||
-- successful give, which is what makes the leader's talk script retry
|
||||
-- later (gyms.lua).
|
||||
--
|
||||
-- `badgeSound` / `tmSound` are the text sound command each gym's reward
|
||||
-- text carries right after its FIRST label -- home/text.asm TextCommand_SOUND
|
||||
-- plays it once that page has typed out and then blocks on
|
||||
-- WaitForSoundToFinish, so the jingle sits between the pages rather than
|
||||
-- under them. macros/scripts/text.asm defines sound_level_up as
|
||||
-- sound_get_item_1, so Pewter's and Viridian's badge lines are Get_Item1
|
||||
-- too. Vermilion, Celadon and Fuchsia carry no sound on the badge text.
|
||||
|
||||
local function range(prefix, first, last)
|
||||
local t = {}
|
||||
@@ -50,6 +58,8 @@ return {
|
||||
{ "PEWTER_CITY", "PEWTERCITY_YOUNGSTER" },
|
||||
{ "ROUTE_22", "ROUTE22_RIVAL1" },
|
||||
},
|
||||
badgeSound = "Get_Item1", -- sound_level_up
|
||||
tmSound = "Get_Item1",
|
||||
dialogue = {
|
||||
"_PewterGymBrockReceivedBoulderBadgeText",
|
||||
"_PewterGymBrockBoulderBadgeInfoText",
|
||||
@@ -64,6 +74,8 @@ return {
|
||||
gotFlag = "EVENT_GOT_TM11",
|
||||
noRoom = "_CeruleanGymMistyTM11NoRoomText",
|
||||
deactivate = range("EVENT_BEAT_CERULEAN_GYM_TRAINER_", 0, 1),
|
||||
badgeSound = "Get_Key_Item",
|
||||
tmSound = "Get_Item1",
|
||||
dialogue = {
|
||||
"_CeruleanGymMistyReceivedCascadeBadgeText",
|
||||
},
|
||||
@@ -76,6 +88,7 @@ return {
|
||||
gotFlag = "EVENT_GOT_TM24",
|
||||
noRoom = "_VermilionGymLTSurgeTM24NoRoomText",
|
||||
deactivate = range("EVENT_BEAT_VERMILION_GYM_TRAINER_", 0, 2),
|
||||
tmSound = "Get_Key_Item",
|
||||
dialogue = {
|
||||
"_VermilionGymLTSurgeReceivedThunderBadgeText",
|
||||
},
|
||||
@@ -89,6 +102,7 @@ return {
|
||||
gotFlag = "EVENT_GOT_TM21",
|
||||
noRoom = "_CeladonGymTM21NoRoomText",
|
||||
deactivate = range("EVENT_BEAT_CELADON_GYM_TRAINER_", 0, 6),
|
||||
tmSound = "Get_Item1",
|
||||
dialogue = {
|
||||
"_CeladonGymErikaReceivedRainbowBadgeText",
|
||||
},
|
||||
@@ -102,6 +116,7 @@ return {
|
||||
gotFlag = "EVENT_GOT_TM06",
|
||||
noRoom = "_FuchsiaGymKogaTM06NoRoomText",
|
||||
deactivate = range("EVENT_BEAT_FUCHSIA_GYM_TRAINER_", 0, 5),
|
||||
tmSound = "Get_Key_Item",
|
||||
dialogue = {
|
||||
"_FuchsiaGymKogaReceivedSoulBadgeText",
|
||||
},
|
||||
@@ -115,6 +130,8 @@ return {
|
||||
gotFlag = "EVENT_GOT_TM46",
|
||||
noRoom = "_SaffronGymSabrinaTM46NoRoomText",
|
||||
deactivate = range("EVENT_BEAT_SAFFRON_GYM_TRAINER_", 0, 6),
|
||||
badgeSound = "Get_Key_Item",
|
||||
tmSound = "Get_Item1",
|
||||
dialogue = {
|
||||
"_SaffronGymSabrinaReceivedMarshBadgeText",
|
||||
},
|
||||
@@ -128,6 +145,8 @@ return {
|
||||
gotFlag = "EVENT_GOT_TM38",
|
||||
noRoom = "_CinnabarGymBlaineTM38NoRoomText",
|
||||
deactivate = range("EVENT_BEAT_CINNABAR_GYM_TRAINER_", 0, 6),
|
||||
badgeSound = "Get_Key_Item",
|
||||
tmSound = "Get_Item1",
|
||||
dialogue = {
|
||||
"_CinnabarGymBlaineReceivedVolcanoBadgeText",
|
||||
},
|
||||
@@ -141,6 +160,8 @@ return {
|
||||
gotFlag = "EVENT_GOT_TM27",
|
||||
noRoom = "_ViridianGymGiovanniTM27NoRoomText",
|
||||
deactivate = range("EVENT_BEAT_VIRIDIAN_GYM_TRAINER_", 0, 7),
|
||||
badgeSound = "Get_Item1", -- sound_level_up
|
||||
tmSound = "Get_Item1",
|
||||
dialogue = {
|
||||
"_ViridianGymGiovanniReceivedEarthBadgeText",
|
||||
},
|
||||
|
||||
@@ -118,10 +118,11 @@ Gen 2 games no longer loads on Red, Blue or Yellow. Say `["all"]` or list both
|
||||
generations if you want both.
|
||||
|
||||
Two riders. **A hard dependency that does not run here takes the dependent down
|
||||
with it**, as a skip rather than a failure and carrying the dependency's own
|
||||
wording (`depends on X, which does not run here (For Blue, not Red)`), so the
|
||||
whole chain has to cover the same games. And **the claim is yours, not the last
|
||||
word**: it is the manager's `TRY HERE ANYWAY` row that lets a player run a mod
|
||||
with it** (unless scoped to specific games, e.g.
|
||||
`dependencies: [{ id = "x", games = ["gen2"] }]`), as a skip rather than a
|
||||
failure and carrying the dependency's own wording (`depends on X, which does not
|
||||
run here (For Blue, not Red)`), so the whole chain has to cover the same games.
|
||||
And **the claim is yours, not the last word**: it is the manager's `TRY HERE ANYWAY` row that lets a player run a mod
|
||||
whose author never opted in, which is the only route for a mod written before
|
||||
the field existed. The override is per game -- `options.modsGen2[id]` is a
|
||||
`{ [version] = true }` table, so forcing a mod onto Red does not force it onto
|
||||
@@ -239,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
|
||||
@@ -485,6 +486,9 @@ 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
|
||||
bicycle and fishing records in both games. Each engine keeps ownership of its
|
||||
inventory, terrain, surfing, bike, and fishing 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
|
||||
@@ -533,8 +537,9 @@ gains a field instead of the name gaining a prefix.
|
||||
`pokemon.level_up`, `pokemon.move_learned`; hooks `battle.damage`,
|
||||
`battle.crit`, `battle.accuracy`, `battle.turn_order`,
|
||||
`battle.enemy_action`, `battle.run`, `battle.exp_award`, `exp.gain`,
|
||||
`catch.rate`, `trainer.party`, `battle.overlay`, `battle.low_health_alarm`
|
||||
and `battle.catch_exp`. One payload difference: Gen 1's vanilla
|
||||
`catch.rate`, `trainer.party`, `battle.overlay`, `battle.low_health_alarm`,
|
||||
`battle.catch_exp`, `battle.bottom_ui_visible` and
|
||||
`battle.status_hud_visible`. One payload difference: Gen 1's vanilla
|
||||
`battle.low_health_alarm` link reads `ctx.battle.data`, and Gold's battle
|
||||
screen has no `.data` field, so the Gen 2 site **adds** `ctx.data` beside the
|
||||
Gen 1 keys. A mod that calls `nextFn` is unaffected; one that reaches through
|
||||
@@ -546,11 +551,13 @@ gains a field instead of the name gaining a prefix.
|
||||
each row's decision in `evolution.check`. The hook passes `data` where Gen 1
|
||||
passes `game`; positions 2-4 (mon, row, trigger) match.
|
||||
- *The frame (`src/core/Game2.lua`):* hooks `input.step`, `input.pointer`,
|
||||
`render.zones`, `render.compose`, `render.letterbox`, `render.hud`. Each sits
|
||||
`render.zones`, `render.compose`, `render.output_enabled`, `render.output`,
|
||||
`render.letterbox`, `render.hud`. Each sits
|
||||
at the same moment `src/core/Game.lua` and `src/render/Renderer.lua` raise it
|
||||
-- the logic tick before the pad is read, a pointer the touch overlay gets
|
||||
first refusal on, the palette zone list handed to the present pass, the
|
||||
letterbox, and the finished playfield rect -- and carries the same payload.
|
||||
composed frame before GBCFX, the letterbox, and the finished playfield rect
|
||||
-- and carries the same payload.
|
||||
`render.hud`'s `gameX` / `gameY` really is where Gold's dialogue boxes and
|
||||
menus land, because `Chrome.fitScale` / `fitOrigin` and `World:fitScale`
|
||||
compute the same number. `render.zones` is handed `nil` in GBC mode (Gold
|
||||
@@ -759,6 +766,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
|
||||
|
||||
+207
-11
@@ -21,6 +21,76 @@ luajit tools/gen_registry_docs.lua
|
||||
luajit tools/gen_registry_docs.lua ../gen1recomp.wiki
|
||||
```
|
||||
|
||||
## Manifest specification (`manifest.json`)
|
||||
|
||||
Every mod contains a root `manifest.json` defining its metadata, supported games, and dependencies for the engine loader.
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "my_mod",
|
||||
"name": "My Cool Mod",
|
||||
"version": "1.0.0",
|
||||
"api": 2,
|
||||
"entry": "main.lua",
|
||||
"profile": "content",
|
||||
"category": "GAMEPLAY",
|
||||
"games": ["gen1", "gen2"],
|
||||
"game_version": ">=0.0.0-dev <2.0.0",
|
||||
"priority": 100,
|
||||
"dependencies": [
|
||||
"helper_lib@^1.0.0",
|
||||
{ "id": "pokegear_cards", "games": ["gen2"], "range": "^1.0.0", "github": "1jamie/pokegear_cards" }
|
||||
],
|
||||
"optional_dependencies": [
|
||||
"gen1_modern_ui"
|
||||
],
|
||||
"conflicts": [],
|
||||
"permissions": ["engine_internals"],
|
||||
"description": "A brief description of the mod.",
|
||||
"github": "author/my_mod"
|
||||
}
|
||||
```
|
||||
|
||||
### Manifest Fields
|
||||
|
||||
| Field | Type | Description |
|
||||
| --- | --- | --- |
|
||||
| `id` | `string` | Unique identifier (lowercase alphanumeric, underscores, hyphens). |
|
||||
| `name` | `string` | Human-readable title shown in launcher and manager. |
|
||||
| `version` | `string` | Semantic version string (e.g. `"1.0.0"`). |
|
||||
| `api` | `integer` | Mod API level (`2` for current standard, `1` for legacy). |
|
||||
| `entry` | `string` | Entry Lua file path relative to mod root (usually `"main.lua"`). |
|
||||
| `profile` | `string` | Mod profile: `"content"`, `"overhaul"`, or `"total_conversion"`. |
|
||||
| `category` | `string` | Categorization chip (e.g. `"GAMEPLAY"`, `"CONTENT"`, `"UI"`, `"AUDIO"`). |
|
||||
| `games` | `array` | Supported game versions: `["gen1"]`, `["gen2"]`, `["red"]`, `["blue"]`, `["yellow"]`, `["gold"]`, or `["all"]`. |
|
||||
| `game_version`| `string` | Semver range of required engine version (e.g. `">=0.0.0-dev <2.0.0"`). |
|
||||
| `priority` | `integer` | Load priority order (lower numbers load earlier; dependencies always precede dependents regardless of priority). |
|
||||
| `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. |
|
||||
| `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. |
|
||||
|
||||
### Declaring Dependencies & Scoping
|
||||
|
||||
Dependencies in `dependencies` and `optional_dependencies` can be declared in several formats:
|
||||
|
||||
1. **Simple string**: `"mod_id"`
|
||||
2. **Version-pinned string**: `"mod_id@^1.2.0"`
|
||||
3. **Repository-hinted string**: `"mod_id#owner/repo"` or `"mod_id@^1.2.0#owner/repo"`
|
||||
4. **Structured object**:
|
||||
```json
|
||||
{
|
||||
"id": "mod_id",
|
||||
"range": "^1.2.0",
|
||||
"games": ["gen2"],
|
||||
"github": "owner/repo"
|
||||
}
|
||||
```
|
||||
|
||||
#### 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.
|
||||
|
||||
## Mods and Gold (Gen 2)
|
||||
|
||||
The mod API is one API across both generations, but Gold runs its own battle
|
||||
@@ -77,6 +147,20 @@ 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 items
|
||||
|
||||
`mod.world:availableFieldActions()` returns the field items that can start at
|
||||
the player's current position. Red and Gold currently expose `bicycle` and
|
||||
`fish`; fishing rows include the owned rods that are valid choices. The list
|
||||
is empty while the world is busy, while riding states or terrain forbid an
|
||||
action, or when the required item is not owned.
|
||||
|
||||
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 bike, collision, or fishing logic.
|
||||
|
||||
## Rendering pipelines
|
||||
|
||||
Most registries hand the engine *content*. `render_pipelines` hands it
|
||||
@@ -238,6 +322,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
|
||||
@@ -246,19 +349,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:
|
||||
|
||||
@@ -353,6 +459,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
|
||||
@@ -440,6 +583,20 @@ oldest queued event as `"action,x,y"` in submitted-frame coordinates, or `nil`.
|
||||
This is what lets a mod lay the two passes out as two stacked Game Boy screens,
|
||||
or push one onto a second screen, without the engine knowing the layout.
|
||||
|
||||
`render.output_enabled` and `render.output` are the later, whole-window seam
|
||||
for mods that need the engine's normal composite rather than its separate
|
||||
layers. It runs after registered present pipelines and before GBCFX,
|
||||
`render.hud`, and touch controls. A mod wraps both hooks: the first returns
|
||||
`true` only while output ownership is needed, and the second receives
|
||||
`(next, ctx)` with `canvas`, `width`, `height`, `gameX`, `gameY`, `gameWidth`,
|
||||
`gameHeight`, `scale`, `dpiX`,
|
||||
`dpiY`, and `generation`. Returning `true` from `render.output` takes over the
|
||||
window; calling `next(ctx)` keeps the normal presentation. Both hooks default
|
||||
to `false`. Enabling the seam requires a full-window canvas for that frame.
|
||||
With no `render.output` subscriber, or while `render.output_enabled` is false,
|
||||
the existing presentation path is unchanged. `render.compose` takes precedence
|
||||
when it owns the frame.
|
||||
|
||||
`screen.render_visible` receives `(next, state)` while the main screen is being
|
||||
composed. Return `false` to omit that state from drawing, opacity selection and
|
||||
palette-zone ownership. The state remains on the stack and keeps its normal
|
||||
@@ -455,6 +612,7 @@ identifiers: `pc_box_withdraw`, `pc_box_deposit`, `pc_box_release`,
|
||||
`battle.bottom_ui_visible` and `battle.status_hud_visible` independently
|
||||
control the battle text/menu layer and the HP/status panels. Both receive
|
||||
`(next, state)` and default to `true`, so vanilla rendering is unchanged.
|
||||
Both hooks apply to Gen 1 and Gen 2 battles.
|
||||
Text boxes and YES/NO prompts pushed above a battle inherit a `false` result
|
||||
for that battle, so hiding the bottom layer cannot leave their white backing
|
||||
behind under another overlay. Text boxes also pass through the hook as their
|
||||
@@ -545,3 +703,41 @@ local both = mod.datetime:dateTime(game, createdAt)
|
||||
The live `game` supplies only the current option context. Formatting never
|
||||
mutates the save, options, or timestamp, and invalid timestamps return
|
||||
`"----"`.
|
||||
|
||||
## Device power information
|
||||
|
||||
Sandboxed mods can read the host's battery state without receiving the rest
|
||||
of `love.system`:
|
||||
|
||||
```lua
|
||||
local state, percent = mod.device:powerInfo()
|
||||
```
|
||||
|
||||
`state` follows LÖVE's values: `"unknown"`, `"battery"`, `"nobattery"`,
|
||||
`"charging"`, or `"charged"`. `percent` is `0` through `100`, or `nil` when
|
||||
the platform cannot report it. The facade is read-only and does not expose
|
||||
URL launching, clipboard access, or other system operations.
|
||||
|
||||
## Real-world steps
|
||||
|
||||
On iOS and Android the game counts the player's real-world steps natively
|
||||
(HealthKit / the hardware step counter). A mod reaches that bridge through
|
||||
the `steps` permission in `manifest.json`, which the player sees in the
|
||||
mod manager like every other permission:
|
||||
|
||||
```lua
|
||||
if mod.steps:available() then
|
||||
mod.steps:sync() -- async; OS consent sheet on first use
|
||||
end
|
||||
-- later, at a quiet moment:
|
||||
local walk = mod.steps:poll() -- { steps = n, from = ?, to = ? } or nil
|
||||
```
|
||||
|
||||
`available()` is `false` on builds without the bridge (desktop) and for
|
||||
mods without the permission, so a probe is always safe. `sync()` asks the
|
||||
platform to refresh its count and returns whether there was a bridge to
|
||||
ask. `poll()` returns the next delivery for this mod — the engine consumes
|
||||
the native side's pending file itself, each permissioned mod receives its
|
||||
own copy of a delivery, and steps are anchored natively so the same walk
|
||||
is never delivered twice. Without the permission, `sync` and `poll` raise
|
||||
an error naming it.
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -19,13 +19,14 @@ Features intentionally added beyond the original Pokémon Red, Blue, and Yellow
|
||||
* **Soft reset button combination**
|
||||
* **Keyboard and controller rebinding**
|
||||
* **Mod profiles** with separate mod settings and save slots
|
||||
* **Sandboxed mods**: an installed mod can read only its own folder and write only its own storage, so it cannot reach the rest of your device
|
||||
* **Improved launcher and save editor UI**, including background downloads and update checks
|
||||
* **Direct-launch options** for shortcuts, Steam entries, and handheld frontends
|
||||
* **Custom boot branding**
|
||||
|
||||
## Pokémon Gold (Gen 2)
|
||||
|
||||
A fourth game the launcher can import and play, built from pret/pokegold the same way Red/Blue/Yellow are built from pokered. Port extras beyond the cartridge:
|
||||
A fourth game the launcher can import and play, built from pret/pokegold the same way Red/Blue/Yellow are built from pokered (and pokeyellow). Port extras beyond the cartridge:
|
||||
|
||||
* **COLOR, zoom, tilt, GBC FX, and quick save/load**
|
||||
* **UI that stays fixed while the overworld zooms**
|
||||
|
||||
@@ -258,6 +258,25 @@ row on the detail screen. The launcher's dependency verdict asks the same
|
||||
question of your dependencies: a mod whose hard dependency does not run on the
|
||||
selected game reads `Needs <id> (not for Gold)` rather than `Ready`.
|
||||
|
||||
### Scoping dependencies per game / generation
|
||||
|
||||
For mods targeting multiple generations (`"games": ["gen1", "gen2"]`), a hard
|
||||
dependency can be scoped to specific games so that it is only enforced when
|
||||
booting those games:
|
||||
|
||||
```json
|
||||
"dependencies": [
|
||||
{ "id": "pokegear_cards", "games": ["gen2"], "range": "^1.0.0", "github": "1jamie/pokegear_cards" }
|
||||
]
|
||||
```
|
||||
|
||||
When booting a Gen 1 game (Red, Blue, Yellow), the engine loader sees that
|
||||
`pokegear_cards` is scoped to `"gen2"` and will not skip or block the parent mod
|
||||
on Gen 1. When booting Gen 2 (Gold), `pokegear_cards` is strictly required.
|
||||
|
||||
For conditional integrations where the dependency is optional across the board,
|
||||
`optional_dependencies` remains the standard pattern.
|
||||
|
||||
### One limit worth knowing
|
||||
|
||||
**Enablement is per game.** The overlay
|
||||
@@ -306,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 |
|
||||
@@ -755,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,49 @@
|
||||
# RFC 0008 — Read-only device power information for sandboxed mods
|
||||
|
||||
## Status
|
||||
|
||||
Proposed. Engine: `Loader.lua`, `Sandbox.lua`. Test:
|
||||
`tests/modkit/cases/device_power_info.lua`.
|
||||
|
||||
## Motivation
|
||||
|
||||
A handheld UI mod can show the player's battery state and warn before power
|
||||
loss. The sandbox correctly removes `love.system` because that module also
|
||||
launches URLs and exposes other host operations, but it leaves no scoped way
|
||||
to read the harmless power values that LÖVE already provides.
|
||||
|
||||
## The decision it extends
|
||||
|
||||
Extends the mod sandbox in `src/mods/Sandbox.lua`: blocked host modules stay
|
||||
blocked while legitimate operations receive narrow engine-owned facades.
|
||||
|
||||
## The exact API delta
|
||||
|
||||
Add `mod.device:powerInfo() -> state, percent`.
|
||||
|
||||
The engine calls `love.system.getPowerInfo()` outside the mod sandbox and
|
||||
returns only its first two values. `state` is one of LÖVE's standard power
|
||||
states. `percent` is `0` through `100` or `nil`. When the platform has no
|
||||
power-information backend, the result is `"unknown", nil`.
|
||||
|
||||
No permission grants access to `love.system`; URL launching, clipboard access,
|
||||
OS identification, and the module table itself remain unavailable.
|
||||
|
||||
## Migration note for existing mods
|
||||
|
||||
Mods that used `love.system.getPowerInfo()` replace that call with
|
||||
`mod.device:powerInfo()`. No other mod changes.
|
||||
|
||||
## Parity tests
|
||||
|
||||
- **No mod:** loading no mods does not call the platform power backend.
|
||||
- **Mod API:** a fixture mod loaded through the public loader receives state
|
||||
and percentage through `mod.device`, while the existing sandbox suite keeps
|
||||
proving that direct `love.system` access is refused.
|
||||
- **Unavailable backend:** the public facade returns `"unknown", nil` rather
|
||||
than inventing battery data or failing mod load.
|
||||
|
||||
## Deprecation etiquette
|
||||
|
||||
Nothing deprecated. The facade is additive; the sandbox's `love.system` block
|
||||
remains in force.
|
||||
@@ -0,0 +1,68 @@
|
||||
# RFC 0009 — Permission-gated step bridge for sandboxed mods
|
||||
|
||||
## Status
|
||||
|
||||
Proposed. Engine: `Steps.lua` (new), `Loader.lua`, `Manifest.lua`,
|
||||
`Sandbox.lua`. Test: `tests/modkit/cases/steps_bridge.lua`. Issue: #1186.
|
||||
|
||||
## Motivation
|
||||
|
||||
The iOS and Android builds count the player's real-world steps natively
|
||||
(#452, #489), exposed to Lua as `love.system.syncHealthSteps()` and
|
||||
delivered as `steps_pending.json` in the save-directory root. The sandbox
|
||||
correctly blocks both — `love.system` also launches URLs, and the file API
|
||||
names paths — but that leaves the bridge with no consumer: the mod that
|
||||
step counting was built for (Pokéwalker, steps→EXP) can no longer be
|
||||
written.
|
||||
|
||||
## The decision it extends
|
||||
|
||||
Extends the mod sandbox in `src/mods/Sandbox.lua` (blocked host modules
|
||||
stay blocked; legitimate operations receive narrow engine-owned facades)
|
||||
and the permission model `network` established: a `manifest.json`
|
||||
permission the player sees in the mod manager that genuinely gates a
|
||||
capability.
|
||||
|
||||
## The exact API delta
|
||||
|
||||
A new manifest permission token, `steps`, and a `mod.steps` facade:
|
||||
|
||||
- `mod.steps:available() -> boolean` — whether this build carries the
|
||||
native bridge. Answers `false` without the permission, so a probe stays
|
||||
quiet.
|
||||
- `mod.steps:sync() -> boolean` — asks the platform to refresh its count
|
||||
(async; the OS consent sheet still appears on first use, exactly as
|
||||
before the sandbox). `false` when there is no bridge.
|
||||
- `mod.steps:poll() -> { steps = n, from = iso?, to = iso? } | nil` — the
|
||||
next delivery for this mod, engine-consumed from the pending file. Each
|
||||
permissioned mod receives its own copy of a delivery.
|
||||
|
||||
Without the permission, `sync` and `poll` raise an error naming the
|
||||
missing permission, the way the network gate does. The engine owns the
|
||||
pending file: mods never learn its name or location, and only the three
|
||||
contract fields travel. No new event, hook, or registry names.
|
||||
|
||||
## Migration note for existing mods
|
||||
|
||||
Mods that called `love.system.syncHealthSteps()` and read
|
||||
`steps_pending.json` themselves add `"steps"` to `permissions` and switch
|
||||
to `mod.steps:sync()` / `mod.steps:poll()`. No other mod changes.
|
||||
|
||||
## Parity tests
|
||||
|
||||
- **No mod:** with nothing installed the bridge is never called and a
|
||||
pending file on disk is left untouched.
|
||||
- **Mod API:** a fixture mod with the permission syncs and receives a
|
||||
delivery through the public loader; two permissioned mods both receive
|
||||
the same walk; a second poll returns nil.
|
||||
- **No permission:** `available()` is false and the acting calls name the
|
||||
missing permission; the sandbox suite keeps proving direct
|
||||
`love.system` access is refused.
|
||||
- **Malformed delivery:** a bad or empty pending file is dropped whole
|
||||
rather than crashing a poll (the native anchor only advances on a
|
||||
successful sync, so nothing is lost).
|
||||
|
||||
## Deprecation etiquette
|
||||
|
||||
Nothing deprecated. The facade is additive; the sandbox's `love.system`
|
||||
and `love.filesystem` blocks remain in force.
|
||||
@@ -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.
|
||||
@@ -12,6 +12,41 @@
|
||||
"tintColor": "3b5ca8",
|
||||
"category": "games",
|
||||
"versions": [
|
||||
{
|
||||
"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",
|
||||
"size": 11282300,
|
||||
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.1.83/gen1recomp++-0.1.83-ios.ipa",
|
||||
"localizedDescription": "Download the correct version for your computer below.\n\n## Contributors\n\n- @AverageConsumer\n- @bryanthaboi"
|
||||
},
|
||||
{
|
||||
"version": "0.1.82",
|
||||
"date": "2026-08-13",
|
||||
|
||||
Symlink
+1
@@ -0,0 +1 @@
|
||||
/Users/bryanbassett/Documents/development/pokemon-gen1-recomp-project/.bazinga/mods/timekeepers_hut
|
||||
@@ -23,6 +23,10 @@ Ship `gen1tls.dll` (or `libgen1tls.so` / `libgen1tls.dylib`) **next to** the
|
||||
fused executable. Mods load it through LuaJIT FFI; no .NET runtime is
|
||||
required on the player's machine.
|
||||
|
||||
Official Windows release zips get `gen1tls.dll` from CI: a `windows-2022`
|
||||
job publishes this project and `scripts/build.sh`'s Windows packaging copies
|
||||
it beside `gen1recomp.exe` (see `GEN1TLS_DLL` / `dist/native/win-x64`).
|
||||
|
||||
## Android
|
||||
|
||||
On Android, the matching API is exposed as `love.system.tlsOpen` /
|
||||
|
||||
@@ -261,6 +261,21 @@ build_win() {
|
||||
cp "$love_dir"/*.dll "$out_dir"/
|
||||
cp "$love_dir"/license.txt "$out_dir"/ 2>/dev/null || true
|
||||
|
||||
# Native AOT TLS dialer for outbound wss:// (e.g. Archipelago hosted rooms).
|
||||
# Release CI builds this on windows-2022 (Native AOT can't cross-compile
|
||||
# win-x64 from the Mac runner) and either exports GEN1TLS_DLL or drops the
|
||||
# file at dist/native/win-x64/gen1tls.dll before calling build.sh.
|
||||
local tls_dll="${GEN1TLS_DLL:-}"
|
||||
if [ -z "$tls_dll" ] && [ -f "$DIST/native/win-x64/gen1tls.dll" ]; then
|
||||
tls_dll="$DIST/native/win-x64/gen1tls.dll"
|
||||
fi
|
||||
if [ -n "$tls_dll" ] && [ -f "$tls_dll" ]; then
|
||||
cp "$tls_dll" "$out_dir/gen1tls.dll"
|
||||
say "bundled gen1tls.dll for Windows TLS (wss://)"
|
||||
else
|
||||
warn "gen1tls.dll not found — Windows zip will not support wss:// (set GEN1TLS_DLL or build native/tls_dial)"
|
||||
fi
|
||||
|
||||
# The exe's icon lives in love.exe's PE resources, so it must be patched
|
||||
# BEFORE the .love is appended: peresed rewrites the whole file and would
|
||||
# drop the fused bytes. peresed (pipx install pe_tools) has no .ico input,
|
||||
@@ -400,6 +415,9 @@ EOF
|
||||
grep -q '^FUSE_PATH="\$APPDIR/game.love"$' "$appdir/AppRun" \
|
||||
|| fail "failed to enable FUSE_PATH in AppRun (upstream AppRun changed?)"
|
||||
|
||||
sed -i '' 's|^exec "\$APPDIR/bin/love"|if [ -n "$WAYLAND_DISPLAY" ] \&\& [ -z "$SDL_VIDEODRIVER" ]; then export SDL_VIDEODRIVER=x11; fi\
|
||||
exec "$APPDIR/bin/love"|' "$appdir/AppRun"
|
||||
|
||||
# Match the upstream image's compression (gzip, 128K blocks) so the
|
||||
# bundled runtime can read it.
|
||||
local sfs_out="$WORK/game.squashfs"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -405,6 +411,12 @@ if [ -z "\$LUA_CPATH" ]; then
|
||||
fi
|
||||
export LUA_CPATH="\$APPDIR/lib/lua/5.1/?.so;\$LUA_CPATH"
|
||||
|
||||
# SDL2 on Wayland crashes during desktop drag-and-drop in certain compositors;
|
||||
# default to X11/XWayland when available to ensure rock-solid drag-drop stability.
|
||||
if [ -n "\$WAYLAND_DISPLAY" ] && [ -z "\$SDL_VIDEODRIVER" ]; then
|
||||
export SDL_VIDEODRIVER=x11
|
||||
fi
|
||||
|
||||
exec "\$APPDIR/bin/love" --fused "\$APPDIR/game.love" "\$@"
|
||||
EOF
|
||||
chmod +x "$APPDIR/AppRun"
|
||||
|
||||
@@ -35,4 +35,10 @@ find_love() {
|
||||
LOVE_BIN="$(find_love)" \
|
||||
|| fail "LÖVE not found, run scripts/setup.sh (or install from https://love2d.org)"
|
||||
|
||||
# SDL2 on Wayland crashes during desktop drag-and-drop in certain compositors;
|
||||
# default to X11/XWayland when available to ensure rock-solid drag-drop stability.
|
||||
if [ -n "${WAYLAND_DISPLAY:-}" ] && [ -z "${SDL_VIDEODRIVER:-}" ]; then
|
||||
export SDL_VIDEODRIVER=x11
|
||||
fi
|
||||
|
||||
exec "$LOVE_BIN" "$ROOT" "$@"
|
||||
|
||||
+197
-83
@@ -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
|
||||
@@ -501,6 +522,9 @@ local function makeBattler(data, mon, isPlayer, save)
|
||||
badgeBoosts = badgeBoosts,
|
||||
statuses = data.statuses,
|
||||
shownHP = mon.hp, -- the HP the bar displays (UpdateHPBar drain)
|
||||
-- the bar's own length in GetHPBarLength pixels; it trails shownHP
|
||||
-- because UpdateHPBar_AnimateHPBar slides it one pixel at a time
|
||||
shownPx = Timing.hpBarPixels(mon.hp, math.max(1, mon.stats.hp)),
|
||||
-- HUD status label (DrawHUDsAndHPBars); mon.status can land mid-move
|
||||
-- while the tilemap still shows the prior condition until the next
|
||||
-- post-action HUD refresh (core.asm after Execute*Move)
|
||||
@@ -563,10 +587,6 @@ local function markOwned(game, species)
|
||||
local dex = game.save.pokedex
|
||||
if dex then
|
||||
dex.seen[species] = true
|
||||
if not dex.owned[species] then
|
||||
-- new dex page registered (SFX_DEX_PAGE_ADDED)
|
||||
require("src.core.Sound").play(game.data, "Dex_Page_Added")
|
||||
end
|
||||
dex.owned[species] = true
|
||||
end
|
||||
end
|
||||
@@ -618,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)
|
||||
@@ -693,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
|
||||
@@ -743,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
|
||||
@@ -757,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
|
||||
@@ -935,6 +994,17 @@ function BattleState:sayNextWaitSfx(text, sfx)
|
||||
table.insert(self.queue, self.nextInsert, { text = text, waitForLearningSfx = sfx })
|
||||
end
|
||||
|
||||
-- WaitForSoundToFinish for a sound an act() has already started: PlayCry
|
||||
-- ends in `jp WaitForSoundToFinish` (home/pokemon.asm), so every cry in
|
||||
-- Gen 1 holds whatever the ROM does next. `src` is the audio source, or a
|
||||
-- getter the row calls at execution time when the source is only known then.
|
||||
function BattleState:waitSfxNext(src)
|
||||
self.nextInsert = (self.nextInsert or 0) + 1
|
||||
table.insert(self.queue, self.nextInsert,
|
||||
{ waitSound = type(src) == "function" and src
|
||||
or function() return src end })
|
||||
end
|
||||
|
||||
-- sayNext for a page that ends in `text_end` (see sayAuto) (#765)
|
||||
function BattleState:sayNextAuto(text, delay)
|
||||
self.nextInsert = (self.nextInsert or 0) + 1
|
||||
@@ -1022,22 +1092,39 @@ function BattleState:stepHPDrain()
|
||||
and b.shownHP >= b.drainFloor then
|
||||
goal = b.drainFloor
|
||||
end
|
||||
local maxHP = math.max(1, b.mon.stats.hp)
|
||||
local playerSide = (b == self.player)
|
||||
local targetPx = Timing.hpBarPixels(b.shownHP, maxHP)
|
||||
if not b.shownPx then b.shownPx = targetPx end
|
||||
if (b.drainHold or 0) > 0 then
|
||||
b.drainHold = b.drainHold - 1
|
||||
busy = true
|
||||
elseif b.shownPx ~= targetPx then
|
||||
-- .barAnimationLoop redraws the bar one pixel at a time, `ld c, 2 /
|
||||
-- call DelayFrames` apiece (:141-148), so a single HP point that
|
||||
-- spans several pixels still slides instead of jumping
|
||||
b.shownPx = b.shownPx + ((b.shownPx > targetPx) and -1 or 1)
|
||||
b.drainHold = Timing.HP_BAR_PIXEL_STEP - 1
|
||||
busy = true
|
||||
elseif b.shownHP ~= goal then
|
||||
local maxHP = math.max(1, b.mon.stats.hp)
|
||||
local playerSide = (b == self.player)
|
||||
local cost = 0
|
||||
local spent = 0
|
||||
-- consume whole HP steps until this frame's budget is spent; on the
|
||||
-- enemy HUD several free steps can land in the same frame
|
||||
while b.shownHP ~= goal and cost < 1 do
|
||||
local nextHP = b.shownHP + ((b.shownHP > goal) and -1 or 1)
|
||||
cost = cost + Timing.hpDrainStepFrames(b.shownHP, nextHP,
|
||||
maxHP, playerSide)
|
||||
b.shownHP = nextHP
|
||||
repeat
|
||||
b.shownHP = b.shownHP + ((b.shownHP > goal) and -1 or 1)
|
||||
spent = spent + (playerSide and Timing.HP_BAR_HP_STEP or 0)
|
||||
targetPx = Timing.hpBarPixels(b.shownHP, maxHP)
|
||||
until b.shownHP == goal or targetPx ~= b.shownPx or spent >= 1
|
||||
if spent > 0 then
|
||||
b.drainHold = spent - 1
|
||||
elseif targetPx ~= b.shownPx then
|
||||
-- the enemy HUD printed no number, so this frame is already the
|
||||
-- first of the pixel step the crossing just asked for
|
||||
b.shownPx = b.shownPx + ((b.shownPx > targetPx) and -1 or 1)
|
||||
b.drainHold = Timing.HP_BAR_PIXEL_STEP - 1
|
||||
else
|
||||
b.drainHold = 0
|
||||
end
|
||||
b.drainHold = math.max(0, cost - 1)
|
||||
b.draining = true
|
||||
busy = true
|
||||
elseif b.draining then
|
||||
@@ -1431,7 +1518,7 @@ end
|
||||
function BattleState:playEntranceCry(battler)
|
||||
local mon = battler and battler.mon
|
||||
if not mon then return end
|
||||
require("src.core.Sound").playCry(self.data, mon.species,
|
||||
return require("src.core.Sound").playCry(self.data, mon.species,
|
||||
mon.status == "SLP" and 37 or 11)
|
||||
end
|
||||
|
||||
@@ -1555,7 +1642,23 @@ function BattleState:enter()
|
||||
-- default stays opaque for every other battle and for older saves.
|
||||
self.isOpaque = self:bgMode() ~= "world"
|
||||
self.introSlide = Timing.BATTLE_SLIDE_IN_FRAMES
|
||||
self.showEnemyTrainer = self.kind == "trainer" and self.trainerPic ~= nil
|
||||
-- GetTrainerInformation .linkBattle (home/trainers2.asm:26-31): the link
|
||||
-- foe's pic is RedPicFront whatever either save looks like, and
|
||||
-- InitBattleCommon loads and tilemaps it at hlcoord 12,0 for every
|
||||
-- wIsInBattle == 2 battle (core.asm:6681-6688), the link one included.
|
||||
-- wEnemyMonSpecies2 is zeroed under it, so the pic palette is PAL_MEWMON
|
||||
-- exactly as it is for a trainer.
|
||||
if self.kind == "link" and not self.trainerPic then
|
||||
local frontPath, frontTrueColor =
|
||||
require("src.pokemon.Sprites").playerPath(self.data, "front",
|
||||
{ kind = "battle", battle = self })
|
||||
if frontPath and require("src.render.Assets").exists(frontPath) then
|
||||
self.trainerPic = getImage(frontPath, namedPalette(self.data, "MEWMON"),
|
||||
frontTrueColor)
|
||||
end
|
||||
end
|
||||
self.showEnemyTrainer = (self.kind == "trainer" or self.kind == "link")
|
||||
and self.trainerPic ~= nil
|
||||
-- DrawAllPokeballs (common_text.asm:27) puts the party ball rows AND the
|
||||
-- HUD corner/underline tiles under them (PlacePlayerHUDTiles /
|
||||
-- PlaceEnemyHUDTiles, draw_hud_pokeball_gfx.asm:119-165) on screen with
|
||||
@@ -1587,13 +1690,13 @@ function BattleState:enter()
|
||||
-- a different point in each battle kind, so queue it per branch
|
||||
local function queueEnemyCry()
|
||||
self:act(function()
|
||||
self:playEntranceCry(self.enemy)
|
||||
self:waitSfxNext(self:playEntranceCry(self.enemy))
|
||||
end)
|
||||
end
|
||||
-- PrintBeginningBattleText (engine/battle/common_text.asm:10-19): a wild
|
||||
-- battle calls PlayCry BEFORE PrintText WildMonAppearedText, so the cry
|
||||
-- sounds with the "Wild X appeared!" box instead of waiting on the A
|
||||
-- press that clears its `prompt` (#303). The Silph-Scope-less tower
|
||||
-- battle calls PlayCry BEFORE PrintText WildMonAppearedText, and PlayCry
|
||||
-- ends in WaitForSoundToFinish, so the cry runs to its end and only then
|
||||
-- does the "Wild X appeared!" box open (#303). The Silph-Scope-less tower
|
||||
-- ghost gets no cry at all (common_text.asm:43-48), and neither does the
|
||||
-- unveiled MAROWAK: .isMarowak never reaches PlayCry (#492).
|
||||
if self.kind ~= "trainer" and self.kind ~= "link"
|
||||
@@ -1611,7 +1714,10 @@ function BattleState:enter()
|
||||
-- The sfx is extracted as "Trainer_Appeared" (tools/rom_manifest.json
|
||||
-- sfxHeaders, bank 8 / $42bb -- the same header pokered names
|
||||
-- SFX_Silph_Scope); nothing had ever played it.
|
||||
if self.kind == "trainer" then
|
||||
--
|
||||
-- A link battle is wIsInBattle == 2, so PrintBeginningBattleText takes the
|
||||
-- same .trainerBattle arm and owes the sfx too (common_text.asm:20-23).
|
||||
if self.kind == "trainer" or self.kind == "link" then
|
||||
self:act(function()
|
||||
self.introSfx = require("src.core.Sound").play(self.data,
|
||||
"Trainer_Appeared")
|
||||
@@ -1629,13 +1735,17 @@ function BattleState:enter()
|
||||
-- battle -- not on a switch, and not when the beaten trainer's pic
|
||||
-- scrolls back in (#317, #282)
|
||||
self:act(function() self.introBalls = nil end)
|
||||
if self.kind == "trainer" then
|
||||
-- EnemySendOutFirstMon (core.asm:1308-1310): SlideTrainerPicOffScreen
|
||||
-- walks the foe's pic off the RIGHT edge (hlcoord 18,0, a = 8 tiles,
|
||||
-- one tile every 2 frames) BEFORE TrainerSentOutText -- the pic does
|
||||
-- not blink out under the text (#317)
|
||||
self:act(function() self:slidePic("foe", 0, 64, 4) end)
|
||||
table.insert(self.queue, { wait = 16 })
|
||||
if self.kind == "trainer" or self.kind == "link" then
|
||||
local foeName = self.trainer and self.trainer.name
|
||||
or self.opponentName or Strings("FOE")
|
||||
if self.showEnemyTrainer then
|
||||
-- EnemySendOutFirstMon (core.asm:1308-1310): SlideTrainerPicOffScreen
|
||||
-- walks the foe's pic off the RIGHT edge (hlcoord 18,0, a = 8 tiles,
|
||||
-- one tile every 2 frames) BEFORE TrainerSentOutText -- the pic does
|
||||
-- not blink out under the text (#317)
|
||||
self:act(function() self:slidePic("foe", 0, 64, 4) end)
|
||||
table.insert(self.queue, { wait = 16 })
|
||||
end
|
||||
self:act(function()
|
||||
self.showEnemyTrainer = false
|
||||
-- the slot is EMPTY from here until AnimateSendingOutMon runs below:
|
||||
@@ -1648,7 +1758,7 @@ function BattleState:enter()
|
||||
self.enemySendingOut = true
|
||||
self:slidePic("foe")
|
||||
end)
|
||||
self:say(Strings("%s sent\nout %s!", self.trainer.name, self.enemy.name))
|
||||
self:say(Strings("%s sent\nout %s!", foeName, self.enemy.name))
|
||||
self:act(function()
|
||||
-- EnemySendOutFirstMon (core.asm:1421-1434): after the text the
|
||||
-- pic grows out of the ball (AnimateSendingOutMon), then the cry
|
||||
@@ -1656,18 +1766,6 @@ function BattleState:enter()
|
||||
self:startGrowIn(self.enemy)
|
||||
end)
|
||||
queueEnemyCry()
|
||||
elseif self.kind == "link" then
|
||||
-- Colosseum has no foe trainer pic, but the enemy mon still grows
|
||||
-- out of the ball after "X sent out Y!" (not the wild "already there"
|
||||
-- intro that LinkBattle previously inherited from newWild).
|
||||
self.enemySendingOut = true
|
||||
self:say(Strings("%s sent\nout %s!", self.opponentName or Strings("FOE"),
|
||||
self.enemy.name))
|
||||
self:act(function()
|
||||
self.enemySendingOut = false
|
||||
self:startGrowIn(self.enemy)
|
||||
end)
|
||||
queueEnemyCry()
|
||||
end
|
||||
-- StartBattle .foundFirstAliveEnemyMon (core.asm:152-156): the `call nz`
|
||||
-- gates only EnemySendOutFirstMon -- the `ld c, 40 / call DelayFrames`
|
||||
@@ -1698,7 +1796,7 @@ function BattleState:enter()
|
||||
-- SendOutMon (core.asm:1757-1762): after the poof the mon grows
|
||||
-- out of the ball (AnimateSendingOutMon at hlcoord 4,11)
|
||||
self:startGrowIn(self.player)
|
||||
self:playEntranceCry(self.player)
|
||||
self:waitSfxNext(self:playEntranceCry(self.player))
|
||||
end)
|
||||
self:markParticipant()
|
||||
end
|
||||
@@ -1876,7 +1974,10 @@ function BattleState:update(dt)
|
||||
if self.phase == "menu" then
|
||||
for _, b in ipairs({ self.player, self.enemy }) do
|
||||
if b then
|
||||
if b.shownHP then b.shownHP = b.mon.hp end
|
||||
if b.shownHP then
|
||||
b.shownHP = b.mon.hp
|
||||
b.shownPx = Timing.hpBarPixels(b.mon.hp, math.max(1, b.mon.stats.hp))
|
||||
end
|
||||
b.drainFloor = nil
|
||||
b.shownStatus = b.mon.status
|
||||
end
|
||||
@@ -1961,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
|
||||
@@ -2294,10 +2395,10 @@ function BattleState:oldManThrow()
|
||||
return
|
||||
end
|
||||
self:ballChain("TOSS_ANIM", true, 3, "POKE_BALL")
|
||||
self:actNext(function()
|
||||
require("src.core.Sound").play(self.data, "Caught_Mon")
|
||||
end)
|
||||
self:sayNext(Strings("All right!\n%s was\ncaught!", self.enemy.name))
|
||||
-- ItemUseBallText05: text_far, sound_caught_mon, text_promptbutton --
|
||||
-- the fanfare follows the caught text and holds the prompt
|
||||
self:sayNextWaitSfx(Strings("All right!\n%s was\ncaught!", self.enemy.name),
|
||||
function() return require("src.core.Sound").play(self.data, "Caught_Mon") end)
|
||||
end)
|
||||
end
|
||||
|
||||
@@ -2481,7 +2582,7 @@ function BattleState:resolveSwitch(newMon)
|
||||
self.sendingOut = false
|
||||
-- SendOutMon (core.asm:1757-1762): poof, then the grow-in
|
||||
self:startGrowIn(self.player)
|
||||
self:playEntranceCry(self.player)
|
||||
self:waitSfxNext(self:playEntranceCry(self.player))
|
||||
end)
|
||||
end)
|
||||
self:act(function()
|
||||
@@ -3793,7 +3894,7 @@ function BattleState:onFaint(battler)
|
||||
if battler.isPlayer then
|
||||
-- RemoveFaintedPlayerMon (core.asm:1040-1042): the player mon's
|
||||
-- faint plays its ordinary species cry -- no Faint_Fall
|
||||
Sound.playCry(self.data, battler.mon.species)
|
||||
self.faintCry = Sound.playCry(self.data, battler.mon.species)
|
||||
elseif self.kind ~= "wild" then
|
||||
-- FaintEnemyPokemon (core.asm:732-771): the enemy faint plays no
|
||||
-- species cry; trainer battles get SFX_FAINT_FALL, then SFX_FAINT_THUD
|
||||
@@ -3808,6 +3909,11 @@ function BattleState:onFaint(battler)
|
||||
end)
|
||||
self.nextInsert = (self.nextInsert or 0) + 1
|
||||
table.insert(self.queue, self.nextInsert, { wait = Timing.FAINT_SLIDE })
|
||||
if battler.isPlayer then
|
||||
-- RemoveFaintedPlayerMon ends `call PlayCry / jp PrintText`, and PlayCry
|
||||
-- is `jp WaitForSoundToFinish`, so "X fainted!" waits out the cry
|
||||
self:waitSfxNext(function() return self.faintCry end)
|
||||
end
|
||||
if not battler.isPlayer and self.kind ~= "wild" then
|
||||
-- FaintEnemyPokemon's SFX_FAINT_THUD lands as the slide does (after
|
||||
-- Faint_Fall, before EnemyMonFaintedText)
|
||||
@@ -3844,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
|
||||
@@ -3895,9 +4002,11 @@ function BattleState:awardExp()
|
||||
-- experience.asm:248 fires per grew-level text
|
||||
require("src.world.PikachuFollower")
|
||||
.modifyHappiness(game.save, "LEVELUP", mon)
|
||||
self:sayNext(Strings("%s grew\nto level %d!", name, lv))
|
||||
-- GrewLevelText: text_far, sound_level_up, text_end (experience.asm:
|
||||
-- 369-372); PrintStatsBox only runs once PrintText has returned
|
||||
self:sayNextWaitSfx(Strings("%s grew\nto level %d!", name, lv),
|
||||
function() return require("src.core.Sound").play(game.data, "Level_Up") end)
|
||||
self:uiNext(function()
|
||||
require("src.core.Sound").play(game.data, "Level_Up")
|
||||
return StatBox.new(game, mon)
|
||||
end)
|
||||
-- After PrintStatsBox, experience.asm reloads the active battler's
|
||||
@@ -3936,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
|
||||
@@ -3979,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
|
||||
@@ -4006,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
|
||||
@@ -4042,7 +4152,7 @@ function BattleState:enemyMonFainted()
|
||||
self.enemySendingOut = false
|
||||
self:startGrowIn(self.enemy)
|
||||
self:actNext(function()
|
||||
self:playEntranceCry(self.enemy)
|
||||
self:waitSfxNext(self:playEntranceCry(self.enemy))
|
||||
end)
|
||||
end)
|
||||
end)
|
||||
@@ -4081,7 +4191,7 @@ function BattleState:enemyMonFainted()
|
||||
self:actNext(function()
|
||||
self.sendingOut = false
|
||||
self:startGrowIn(self.player)
|
||||
self:playEntranceCry(self.player)
|
||||
self:waitSfxNext(self:playEntranceCry(self.player))
|
||||
end)
|
||||
end)
|
||||
return
|
||||
@@ -4178,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
|
||||
@@ -4251,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)
|
||||
@@ -4277,7 +4388,7 @@ function BattleState:openReplacementMenu()
|
||||
self.sendingOut = false
|
||||
-- SendOutMon (core.asm:1757-1762): poof, then the grow-in
|
||||
self:startGrowIn(self.player)
|
||||
self:playEntranceCry(self.player)
|
||||
self:waitSfxNext(self:playEntranceCry(self.player))
|
||||
end)
|
||||
end,
|
||||
})
|
||||
@@ -4321,11 +4432,10 @@ function BattleState:safariAction(choice)
|
||||
-- above DoBallTossSpecialEffects's <= ULTRA_BALL check)
|
||||
self:ballChain(self:tossAnimFor("SAFARI_BALL"), caught, shakes, "SAFARI_BALL")
|
||||
if caught then
|
||||
-- ItemUseBallText05's sound_caught_mon: fanfare with the text
|
||||
self:actNext(function()
|
||||
require("src.core.Sound").play(self.data, "Caught_Mon")
|
||||
end)
|
||||
self:sayNext(Strings("All right!\n%s was\ncaught!", self.enemy.name))
|
||||
-- ItemUseBallText05: text_far, sound_caught_mon, text_promptbutton --
|
||||
-- the fanfare follows the caught text and holds the prompt
|
||||
self:sayNextWaitSfx(Strings("All right!\n%s was\ncaught!", self.enemy.name),
|
||||
function() return require("src.core.Sound").play(self.data, "Caught_Mon") end)
|
||||
-- same ItemUseBall .captured flow as a regular ball
|
||||
self:act(function() self:storeCaughtMon() end)
|
||||
else
|
||||
@@ -4553,8 +4663,12 @@ function BattleState:storeCaughtMon()
|
||||
markOwned(game, species)
|
||||
stampOT(game.save, self.enemy.mon)
|
||||
if isNew then
|
||||
-- _ItemUseBallText06 + ShowPokedexData
|
||||
self:sayNext(Strings("New POKéDEX data\nwill be added for\n%s!", self.enemy.name))
|
||||
-- _ItemUseBallText06 + ShowPokedexData: text_far, sound_dex_page_added,
|
||||
-- text_promptbutton (item_effects.asm:624-629), so the fanfare follows
|
||||
-- the box rather than firing when the dex bit is set
|
||||
self:sayNextWaitSfx(
|
||||
Strings("New POKéDEX data\nwill be added for\n%s!", self.enemy.name),
|
||||
function() return require("src.core.Sound").play(self.data, "Dex_Page_Added") end)
|
||||
self:uiNext(function()
|
||||
return self:buildScreen("DexEntryMenu", species)
|
||||
end)
|
||||
@@ -4704,12 +4818,10 @@ function BattleState:throwBall(ball)
|
||||
self:ballChain(self:tossAnimFor(ball), caught, shakes, ball)
|
||||
if caught then
|
||||
-- ItemUseBallText05 carries sound_caught_mon (item_effects.asm:
|
||||
-- 608-614): the fanfare sounds with the caught message, before
|
||||
-- the prompt, not after the text is dismissed
|
||||
self:actNext(function()
|
||||
require("src.core.Sound").play(self.data, "Caught_Mon")
|
||||
end)
|
||||
self:sayNext(Strings("All right!\n%s was\ncaught!", self.enemy.name))
|
||||
-- 608-614): text_far, sound_caught_mon, text_promptbutton -- the
|
||||
-- fanfare follows the caught message and holds the prompt
|
||||
self:sayNextWaitSfx(Strings("All right!\n%s was\ncaught!", self.enemy.name),
|
||||
function() return require("src.core.Sound").play(self.data, "Caught_Mon") end)
|
||||
self:act(function() self:storeCaughtMon() end)
|
||||
else
|
||||
self:sayNext(self:ballMissMessage(shakes))
|
||||
@@ -4730,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))
|
||||
@@ -4776,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"
|
||||
@@ -5196,7 +5309,8 @@ function BattleState:sgbBattlePals()
|
||||
local function bar(b)
|
||||
if not b then return pals.GREENBAR end
|
||||
local hp = b.shownHP or b.mon.hp
|
||||
return pals[PaletteFX.barPalName(hp, b.mon.stats.hp)] or pals.GREENBAR
|
||||
return pals[PaletteFX.barPalName(hp, b.mon.stats.hp, b.shownPx)]
|
||||
or pals.GREENBAR
|
||||
end
|
||||
local function mon(b, placeholder)
|
||||
if placeholder or not b then return pals.MEWMON or pals.GREENBAR end
|
||||
@@ -5594,7 +5708,7 @@ function BattleState:drawHUDs(slide)
|
||||
hudTile(0x73, 8, 16)
|
||||
drawHPBar(barData, 2, 2,
|
||||
{ hp = shownHP(self.enemy), stats = self.enemy.mon.stats },
|
||||
nil, grayFill)
|
||||
nil, grayFill, nil, self.enemy.shownPx)
|
||||
hudTile(0x74, 8, 24)
|
||||
for i = 2, 9 do hudTile(0x76, i * 8, 24) end
|
||||
hudTile(0x78, 80, 24)
|
||||
@@ -5657,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
|
||||
@@ -5675,7 +5789,7 @@ function BattleState:drawHUDs(slide)
|
||||
end
|
||||
drawHPBar(barData, 10, 9,
|
||||
{ hp = shownHP(self.player), stats = self.player.mon.stats },
|
||||
1, grayFill) -- wHPBarType 1: the $6D cap
|
||||
1, grayFill, nil, self.player.shownPx) -- wHPBarType 1: the $6D cap
|
||||
Font.draw(("%3d/%3d"):format(shownHP(self.player), self.player.mon.stats.hp), 88, 80)
|
||||
hudTile(0x73, 144, 80)
|
||||
hudTile(0x77, 144, 88)
|
||||
|
||||
@@ -108,7 +108,7 @@ local function drawStatusPanel(battle, battler, x, y, player)
|
||||
HudTiles.drawHPBar(battle.data, tx + 1, ty + 2, {
|
||||
hp = shownHP(battler),
|
||||
stats = battler.mon.stats,
|
||||
}, nil, monoMode(), tw - 5)
|
||||
}, nil, monoMode(), tw - 5, battler.shownPx)
|
||||
|
||||
if player then
|
||||
Font.draw(("%3d/%3d"):format(shownHP(battler), battler.mon.stats.hp),
|
||||
|
||||
@@ -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
|
||||
@@ -334,6 +367,12 @@ function BattleCheckpoint.restore(game, checkpoint, copy)
|
||||
battle.player.mon.hp, battle.player.mon.status
|
||||
battle.enemy.shownHP, battle.enemy.shownStatus =
|
||||
battle.enemy.mon.hp, battle.enemy.mon.status
|
||||
-- the bar's own pixel length is derived HUD state, not captured: it
|
||||
-- settles with shownHP above (UpdateHPBar_AnimateHPBar's end position)
|
||||
local Timing = require("src.core.Timing")
|
||||
for _, b in ipairs({ battle.player, battle.enemy }) do
|
||||
b.shownPx = Timing.hpBarPixels(b.mon.hp, math.max(1, b.mon.stats.hp))
|
||||
end
|
||||
|
||||
local ow = game.overworld
|
||||
if not ow or type(ow.restoreBattleContinuation) ~= "function"
|
||||
|
||||
+26
-13
@@ -73,7 +73,7 @@ end
|
||||
--
|
||||
-- Gold composites its own frame (Game2:draw / drawScene) and pumps its own pad
|
||||
-- (the FixedStep callback in Game2:load), so none of it goes through
|
||||
-- src/render/Renderer.lua or src/core/Game.lua. That explains why the six
|
||||
-- src/render/Renderer.lua or src/core/Game.lua. That explains why the eight
|
||||
-- hooks below never used to fire here; it is not a reason they should not. A
|
||||
-- hook is a contract about a MOMENT in the frame, and Gold has every one of
|
||||
-- these moments -- so each is raised under the Gen 1 NAME with the Gen 1
|
||||
@@ -83,6 +83,7 @@ end
|
||||
-- input.pointer uncaptured pointer events (src/core/Game.lua:887)
|
||||
-- render.zones the palette pass, pre-blit (src/core/Game.lua:505)
|
||||
-- render.compose the whole-window composite (Renderer.lua:759)
|
||||
-- render.output* the normal composed frame (Renderer.lua:1063)
|
||||
-- render.letterbox the void around the 160x144 blit (Renderer.lua:840)
|
||||
-- render.hud screen-space UI over the frame (src/core/Game.lua:521)
|
||||
--
|
||||
@@ -1382,12 +1383,14 @@ function Game2:draw()
|
||||
local zoned = type(zones) == "table" and zones[1] ~= nil
|
||||
|
||||
-- A present canvas is paid for only when something reads it: the zone pass,
|
||||
-- GBC FX, a mod post-process, or a render.compose subscriber about to be
|
||||
-- handed the finished frame. With none of them the frame draws straight to
|
||||
-- the screen exactly as it always did.
|
||||
-- GBC FX, a mod post-process, render.compose, or an enabled render.output
|
||||
-- subscriber. With none of them the frame draws straight to the screen
|
||||
-- exactly as it always did.
|
||||
local composing = ModRuntime.wantsHook("render.compose")
|
||||
local hasOutputHook = ModRuntime.wantsHook("render.output")
|
||||
and ModRuntime.call("render.output_enabled", function() return false end) == true
|
||||
local scene = nil
|
||||
if zoned or fx or composing or Pipelines.wantsPresent() then
|
||||
if zoned or fx or composing or Pipelines.wantsPresent() or hasOutputHook then
|
||||
scene = self:presentCanvas(1, w, h)
|
||||
end
|
||||
if not scene then
|
||||
@@ -1419,7 +1422,7 @@ function Game2:draw()
|
||||
-- untinted one. On its own the tint rides the final blit and no second
|
||||
-- canvas is paid for.
|
||||
local source = scene
|
||||
local reread = fx or Pipelines.wantsPresent()
|
||||
local reread = fx or Pipelines.wantsPresent() or hasOutputHook
|
||||
if zoned and reread then
|
||||
local tinted = self:presentCanvas(2, w, h)
|
||||
if tinted then
|
||||
@@ -1439,15 +1442,25 @@ function Game2:draw()
|
||||
-- Post-process pipelines run over the finished composite and before GBC
|
||||
-- FX. Each hands back a canvas; with none registered this returns `source`
|
||||
-- unchanged and the frame is byte-identical (Renderer.lua:1058).
|
||||
local scale, _, _, dpi = self:frameFit(w, h)
|
||||
local scale, ox, oy, dpi = self:frameFit(w, h)
|
||||
source = Pipelines.present(source, { width = w, height = h, scale = scale,
|
||||
dpi = dpi, dpiX = dpi, dpiY = dpi }) or source
|
||||
if fx then
|
||||
GBCFX.present(source, self:pixelScale(w, h))
|
||||
else
|
||||
G.setColor(1, 1, 1, 1)
|
||||
G.draw(source, 0, 0)
|
||||
G.setShader()
|
||||
local outputHandled = hasOutputHook
|
||||
and ModRuntime.call("render.output", function() return false end, {
|
||||
canvas = source, width = w, height = h,
|
||||
gameX = ox, gameY = oy,
|
||||
gameWidth = 160 * scale, gameHeight = 144 * scale,
|
||||
scale = scale, dpiX = dpi, dpiY = dpi,
|
||||
generation = 2,
|
||||
}) == true
|
||||
if not outputHandled then
|
||||
if fx then
|
||||
GBCFX.present(source, self:pixelScale(w, h))
|
||||
else
|
||||
G.setColor(1, 1, 1, 1)
|
||||
G.draw(source, 0, 0)
|
||||
G.setShader()
|
||||
end
|
||||
end
|
||||
end
|
||||
G.pop()
|
||||
|
||||
@@ -116,6 +116,8 @@ local physfsMountFn = nil
|
||||
local function resolveMount()
|
||||
if physfsMountFn ~= nil then return physfsMountFn end
|
||||
physfsMountFn = false
|
||||
if Platform.isUWP() then return physfsMountFn end
|
||||
if love and love.filesystem and love.filesystem._mounts then return physfsMountFn end
|
||||
local ok, ffi = pcall(require, "ffi")
|
||||
if not ok then return physfsMountFn end
|
||||
pcall(ffi.cdef,
|
||||
@@ -159,6 +161,8 @@ local physfsUnmountFn = nil
|
||||
local function resolveUnmount()
|
||||
if physfsUnmountFn ~= nil then return physfsUnmountFn end
|
||||
physfsUnmountFn = false
|
||||
if Platform.isUWP() then return physfsUnmountFn end
|
||||
if love and love.filesystem and love.filesystem._mounts then return physfsUnmountFn end
|
||||
local ok, ffi = pcall(require, "ffi")
|
||||
if not ok then return physfsUnmountFn end
|
||||
pcall(ffi.cdef, "int PHYSFS_unmount(const char *oldDir);")
|
||||
|
||||
+798
-156
File diff suppressed because it is too large
Load Diff
+141
-1
@@ -1649,7 +1649,7 @@ end
|
||||
function RomImporter:_installMod(source)
|
||||
if self.workState == "working" then return end
|
||||
self.tab = "mods"
|
||||
local ok, installed, res = pcall(function()
|
||||
local ok, installed, res, manifest = pcall(function()
|
||||
local LauncherMods = require("src.mods.LauncherMods")
|
||||
return LauncherMods.installZip(source)
|
||||
end)
|
||||
@@ -1661,6 +1661,17 @@ function RomImporter:_installMod(source)
|
||||
if installed then
|
||||
pcall(self._refreshMods, self)
|
||||
self.modNotice = { ok = true, text = "Installed " .. tostring(res) }
|
||||
local LauncherMods = require("src.mods.LauncherMods")
|
||||
local checkTarget = manifest
|
||||
if not checkTarget and type(res) == "string" then
|
||||
checkTarget = { id = res }
|
||||
end
|
||||
if checkTarget and LauncherMods.checkDependencies then
|
||||
local depCheck = LauncherMods.checkDependencies(checkTarget)
|
||||
if depCheck and depCheck.hasIssues then
|
||||
self._modDepResolver = depCheck
|
||||
end
|
||||
end
|
||||
else
|
||||
self.modNotice = { ok = false, text = tostring(res) }
|
||||
end
|
||||
@@ -2345,6 +2356,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
|
||||
@@ -2716,6 +2741,43 @@ function RomImporter:fileUrl(path)
|
||||
end
|
||||
|
||||
function RomImporter:keypressed(key)
|
||||
if self._profileSavePrompt then
|
||||
if key == "backspace" then
|
||||
self._profileSavePrompt.text = utf8Back(self._profileSavePrompt.text or "")
|
||||
elseif key == "return" or key == "kpenter" then
|
||||
local txt = self._profileSavePrompt and self._profileSavePrompt.text
|
||||
if txt and txt ~= "" then
|
||||
local LauncherMods = require("src.mods.LauncherMods")
|
||||
LauncherMods.saveProfile(txt)
|
||||
self._profileSavePrompt = nil
|
||||
self:_disarmTextInput()
|
||||
if self._refreshMods then self:_refreshMods() end
|
||||
end
|
||||
elseif key == "escape" then
|
||||
self._profileSavePrompt = nil
|
||||
self:_disarmTextInput()
|
||||
end
|
||||
return
|
||||
end
|
||||
if self._profileRenamePrompt then
|
||||
if key == "backspace" then
|
||||
self._profileRenamePrompt.text = utf8Back(self._profileRenamePrompt.text or "")
|
||||
elseif key == "return" or key == "kpenter" then
|
||||
local txt = self._profileRenamePrompt and self._profileRenamePrompt.text
|
||||
local old = self._profileRenamePrompt and self._profileRenamePrompt.oldName
|
||||
if txt and txt ~= "" and old then
|
||||
local LauncherMods = require("src.mods.LauncherMods")
|
||||
LauncherMods.renameProfile(old, txt)
|
||||
self._profileRenamePrompt = nil
|
||||
self:_disarmTextInput()
|
||||
if self._refreshMods then self:_refreshMods() end
|
||||
end
|
||||
elseif key == "escape" then
|
||||
self._profileRenamePrompt = nil
|
||||
self:_disarmTextInput()
|
||||
end
|
||||
return
|
||||
end
|
||||
if self._settingsText then
|
||||
if key == "backspace" then
|
||||
self._settingsText.text = utf8Back(self._settingsText.text)
|
||||
@@ -2885,6 +2947,14 @@ function RomImporter:_commitRename()
|
||||
end
|
||||
|
||||
function RomImporter:textinput(text)
|
||||
if self._profileSavePrompt then
|
||||
self._profileSavePrompt.text = utf8Cap((self._profileSavePrompt.text or "") .. text, MAX_SLOT_LABEL)
|
||||
return
|
||||
end
|
||||
if self._profileRenamePrompt then
|
||||
self._profileRenamePrompt.text = utf8Cap((self._profileRenamePrompt.text or "") .. text, MAX_SLOT_LABEL)
|
||||
return
|
||||
end
|
||||
if self._settingsText then
|
||||
local st = self._settingsText
|
||||
st.text = utf8Cap(st.text .. text, st.maxLen or MAX_SLOT_LABEL)
|
||||
@@ -3371,6 +3441,76 @@ function RomImporter:_pumpModInstall()
|
||||
else
|
||||
self.modNotice = { ok = true, text = text }
|
||||
end
|
||||
local LauncherMods = require("src.mods.LauncherMods")
|
||||
local depCheck = LauncherMods.checkDependencies({ id = spec.modId })
|
||||
if depCheck and depCheck.hasIssues then
|
||||
self._modDepResolver = depCheck
|
||||
end
|
||||
end
|
||||
|
||||
-- Start an async pull for a single dependency
|
||||
function RomImporter:_startDepPull(dep)
|
||||
if not dep or not dep.github then return end
|
||||
self._depPullState = self._depPullState or {}
|
||||
local hFetch = require("src.mods.ModUpdate").beginFetchReleases(dep.github, dep.id, { force = true })
|
||||
self._depPullState[dep.id] = {
|
||||
dep = dep,
|
||||
stage = "fetching",
|
||||
fetchHandle = hFetch,
|
||||
}
|
||||
end
|
||||
|
||||
-- Pump all in-flight dependency pulls
|
||||
function RomImporter:_pumpDepPulls()
|
||||
if not self._depPullState then return end
|
||||
local ModUpdate = require("src.mods.ModUpdate")
|
||||
local LauncherMods = require("src.mods.LauncherMods")
|
||||
|
||||
for depId, state in pairs(self._depPullState) do
|
||||
if state.stage == "fetching" then
|
||||
local done, releases, err = ModUpdate.pumpFetchReleases(state.fetchHandle)
|
||||
if done then
|
||||
if err or not releases or #releases == 0 then
|
||||
state.stage = "error"
|
||||
state.err = err or "No downloadable releases found on GitHub"
|
||||
else
|
||||
local rel = releases[1]
|
||||
if not rel or not rel.zip or not rel.zip.url then
|
||||
state.stage = "error"
|
||||
state.err = "Latest release has no downloadable .zip asset"
|
||||
else
|
||||
local tmpName = ("dep_%s_%s.zip"):format(depId, tostring(rel.version or os.time()))
|
||||
state.dlHandle = ModUpdate.beginDownloadZip(rel.zip.url, tmpName, rel.zip.size)
|
||||
state.stage = "downloading"
|
||||
state.targetVersion = rel.version
|
||||
end
|
||||
end
|
||||
end
|
||||
elseif state.stage == "downloading" then
|
||||
local done, localPath, err, progress = ModUpdate.pumpDownloadZip(state.dlHandle)
|
||||
state.progress = progress
|
||||
if done then
|
||||
if err or not localPath then
|
||||
state.stage = "error"
|
||||
state.err = err or "Download failed"
|
||||
else
|
||||
state.stage = "installing"
|
||||
local okInst, versionRes = LauncherMods.installDownloadedZip(depId, localPath, state.targetVersion)
|
||||
if okInst then
|
||||
state.stage = "done"
|
||||
pcall(self._refreshMods, self)
|
||||
if self._modDepResolver and self._modDepResolver.targetMod then
|
||||
local updated = LauncherMods.checkDependencies(self._modDepResolver.targetMod)
|
||||
self._modDepResolver = updated
|
||||
end
|
||||
else
|
||||
state.stage = "error"
|
||||
state.err = tostring(versionRes or "Installation failed")
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
function RomImporter:_confirmModUpdate(modId, release)
|
||||
|
||||
+120
-11
@@ -9,15 +9,16 @@
|
||||
-- cable pull.
|
||||
--
|
||||
-- Cable rules: no experience, no money, no items; either side may RUN
|
||||
-- (a draw); a fainted mon is auto-replaced by the next healthy party
|
||||
-- member (the original prompts; documented divergence). Badge stat
|
||||
-- boosts don't apply on either side (divergence: Gen 1 famously kept
|
||||
-- them in link battles).
|
||||
-- (a draw); a fainted mon is replaced from the party menu and the chosen
|
||||
-- slot rides the wire, the way ChooseNextMon hands it to
|
||||
-- LinkBattleExchangeData. Badge stat boosts don't apply on either side
|
||||
-- (divergence: Gen 1 famously kept them in link battles).
|
||||
|
||||
local Fingerprint = require("src.link.Fingerprint")
|
||||
local Font = require("src.render.Font")
|
||||
local Handshake = require("src.link.Handshake")
|
||||
local Logger = require("src.core.Logger")
|
||||
local Party = require("src.pokemon.Party")
|
||||
local Protocol = require("src.link.Protocol")
|
||||
local Runtime = require("src.mods.Runtime")
|
||||
local TurnOrder = require("src.battle.TurnOrder")
|
||||
@@ -257,7 +258,9 @@ function LinkBattle.new(game, net, opts)
|
||||
self.enemyParty = theirParty
|
||||
self.playerParty = myParty -- intro ball row uses the clamped copies
|
||||
self.opponentName = theirName
|
||||
self.introText = Strings("%s wants\nto battle!", theirName)
|
||||
-- _TrainerWantsToFightText (data/text/text_2.asm:1257): wIsInBattle == 2
|
||||
-- takes PrintBeginningBattleText's .trainerBattle arm, link included
|
||||
self.introText = Strings("%s wants\nto fight!", theirName)
|
||||
self.remoteHashes = {}
|
||||
self.localHashes = {}
|
||||
self.remoteParts = {}
|
||||
@@ -327,6 +330,68 @@ function LinkBattle.new(game, net, opts)
|
||||
end)
|
||||
end
|
||||
|
||||
-- ChooseNextMon (engine/battle/core.asm:1086-1103): the replacement after
|
||||
-- a faint is a free party-menu pick in a link battle too -- DisplayPartyMenu
|
||||
-- runs first, a fainted pick or a cancel goes back to it
|
||||
-- (.goBackToPartyMenu), and only the chosen slot rides
|
||||
-- LinkBattleExchangeData.
|
||||
local function chooseReplacement(s)
|
||||
s.linkReplacement = nil
|
||||
s:uiNext(function()
|
||||
return s:buildScreen("PartyMenu", {
|
||||
battle = s,
|
||||
party = myParty,
|
||||
forceSwitch = true,
|
||||
onSwitch = function(mon)
|
||||
if mon.hp > 0 then s.linkReplacement = mon end
|
||||
end,
|
||||
})
|
||||
end)
|
||||
s:actNext(function()
|
||||
local mon = s.linkReplacement
|
||||
s.linkReplacement = nil
|
||||
if s.result then return end
|
||||
if not mon then
|
||||
chooseReplacement(s)
|
||||
return
|
||||
end
|
||||
for i, m in ipairs(myParty) do
|
||||
if m == mon then send({ type = "replace", index = i }) break end
|
||||
end
|
||||
sendOutPlayer(s, mon)
|
||||
end)
|
||||
end
|
||||
|
||||
-- ReplaceFaintedEnemyMon (core.asm:892-905) reads the peer's slot back out
|
||||
-- of the exchange, and EnemySendOutFirstMon (core.asm:1315-1320) decodes it
|
||||
-- as wSerialExchangeNybbleReceiveData - 4. HandlePlayerMonFainted
|
||||
-- (core.asm:989-996) always runs ChooseNextMon BEFORE that read, so on a
|
||||
-- double faint both machines commit their own slot before they block on the
|
||||
-- peer's; our queue can reach the enemy's handler first, so the wait
|
||||
-- pre-empts itself with the local pick rather than deadlocking on a message
|
||||
-- neither side is going to send.
|
||||
local function awaitReplacement(s)
|
||||
s:actNext(function()
|
||||
if s.result then return end
|
||||
if s.player.mon.hp <= 0 and not s.linkChoiceQueued
|
||||
and Party.firstHealthy(myParty) then
|
||||
s.linkChoiceQueued = true
|
||||
chooseReplacement(s)
|
||||
awaitReplacement(s)
|
||||
return
|
||||
end
|
||||
local idx = s.remoteReplace
|
||||
if not idx then
|
||||
awaitReplacement(s)
|
||||
return
|
||||
end
|
||||
s.remoteReplace = nil
|
||||
local mon = theirParty[idx]
|
||||
if not mon or mon.hp <= 0 then mon = Party.firstHealthy(theirParty) end
|
||||
sendOutEnemy(s, mon)
|
||||
end)
|
||||
end
|
||||
|
||||
-- decode a remote action message against the enemy battler
|
||||
local function decodeTheirAction(s, msg)
|
||||
return decodeWireAction(s, msg, s.enemy)
|
||||
@@ -540,12 +605,17 @@ function LinkBattle.new(game, net, opts)
|
||||
submit(s, { type = "action", kind = "run" }, nil)
|
||||
end
|
||||
|
||||
-- fainted mons auto-replace with the next healthy teammate, in party
|
||||
-- order, identically on both machines
|
||||
-- linkChoiceQueued: awaitReplacement already pre-empted itself with this
|
||||
-- side's ChooseNextMon and put the slot on the wire, so the handler the
|
||||
-- faint queued has nothing left to do but drop the flag
|
||||
self.playerMonFainted = function(s)
|
||||
if s.linkChoiceQueued then
|
||||
s.linkChoiceQueued = nil
|
||||
return
|
||||
end
|
||||
for _, mon in ipairs(myParty) do
|
||||
if mon.hp > 0 then
|
||||
s:act(function() sendOutPlayer(s, mon) end)
|
||||
if not s.result then chooseReplacement(s) end
|
||||
return
|
||||
end
|
||||
end
|
||||
@@ -558,7 +628,7 @@ function LinkBattle.new(game, net, opts)
|
||||
self.enemyMonFainted = function(s)
|
||||
for _, mon in ipairs(theirParty) do
|
||||
if mon.hp > 0 then
|
||||
s:act(function() sendOutEnemy(s, mon) end)
|
||||
if not s.result then awaitReplacement(s) end
|
||||
return
|
||||
end
|
||||
end
|
||||
@@ -579,6 +649,9 @@ function LinkBattle.new(game, net, opts)
|
||||
s.remoteHashes[msg.turn or 0] = msg.value
|
||||
s.remoteParts[msg.turn or 0] = msg.parts
|
||||
checkHashes(s)
|
||||
elseif msg.type == "replace" then
|
||||
local idx = math.floor(tonumber(msg.index) or 1)
|
||||
s.remoteReplace = math.max(1, math.min(#theirParty, idx))
|
||||
elseif msg.type == "bye" then
|
||||
-- only a draw if our own simulation hasn't already decided
|
||||
-- (the winner's bye can arrive while we're still animating)
|
||||
@@ -770,6 +843,34 @@ function LinkBattle.newSpectator(game, net, opts)
|
||||
end)
|
||||
end
|
||||
|
||||
local function awaitHostReplacement(s)
|
||||
s:actNext(function()
|
||||
if s.result then return end
|
||||
local idx = table.remove(s.hostReplace, 1)
|
||||
if not idx then
|
||||
awaitHostReplacement(s)
|
||||
return
|
||||
end
|
||||
local mon = hostParty[idx]
|
||||
if not mon or mon.hp <= 0 then mon = Party.firstHealthy(hostParty) end
|
||||
sendOutHost(s, mon)
|
||||
end)
|
||||
end
|
||||
|
||||
local function awaitGuestReplacement(s)
|
||||
s:actNext(function()
|
||||
if s.result then return end
|
||||
local idx = table.remove(s.guestReplace, 1)
|
||||
if not idx then
|
||||
awaitGuestReplacement(s)
|
||||
return
|
||||
end
|
||||
local mon = guestParty[idx]
|
||||
if not mon or mon.hp <= 0 then mon = Party.firstHealthy(guestParty) end
|
||||
sendOutGuest(s, mon)
|
||||
end)
|
||||
end
|
||||
|
||||
local function resolveSpecTurn(s, hostMsg, guestMsg)
|
||||
if hostMsg.kind == "run" or guestMsg.kind == "run" then
|
||||
endSpectate(s, "The match ended.")
|
||||
@@ -840,7 +941,7 @@ function LinkBattle.newSpectator(game, net, opts)
|
||||
self.playerMonFainted = function(s)
|
||||
for _, mon in ipairs(hostParty) do
|
||||
if mon.hp > 0 then
|
||||
s:act(function() sendOutHost(s, mon) end)
|
||||
if not s.result then awaitHostReplacement(s) end
|
||||
return
|
||||
end
|
||||
end
|
||||
@@ -852,7 +953,7 @@ function LinkBattle.newSpectator(game, net, opts)
|
||||
self.enemyMonFainted = function(s)
|
||||
for _, mon in ipairs(guestParty) do
|
||||
if mon.hp > 0 then
|
||||
s:act(function() sendOutGuest(s, mon) end)
|
||||
if not s.result then awaitGuestReplacement(s) end
|
||||
return
|
||||
end
|
||||
end
|
||||
@@ -862,6 +963,7 @@ function LinkBattle.newSpectator(game, net, opts)
|
||||
end
|
||||
|
||||
self.hostMsg, self.guestMsg = nil, nil
|
||||
self.hostReplace, self.guestReplace = {}, {}
|
||||
local baseUpdate = self.update
|
||||
self.update = function(s, dt)
|
||||
net:update()
|
||||
@@ -875,6 +977,13 @@ function LinkBattle.newSpectator(game, net, opts)
|
||||
s.hostMsg, s.guestMsg = nil, nil
|
||||
resolveSpecTurn(s, h, g)
|
||||
end
|
||||
elseif inner.type == "replace" then
|
||||
local idx = math.floor(tonumber(inner.index) or 1)
|
||||
if msg.side == "host" then
|
||||
table.insert(s.hostReplace, math.max(1, math.min(#hostParty, idx)))
|
||||
else
|
||||
table.insert(s.guestReplace, math.max(1, math.min(#guestParty, idx)))
|
||||
end
|
||||
elseif inner.type == "bye" or inner.type == "forfeit" then
|
||||
if not s.result then endSpectate(s, "The match ended.") end
|
||||
end
|
||||
|
||||
+87
-17
@@ -19,6 +19,7 @@ LinkState.__index = LinkState
|
||||
LinkState.isOpaque = true
|
||||
|
||||
local CURSOR = 0xED
|
||||
local CURSOR_HOLLOW = 0xEC
|
||||
local ANY = "ANY" -- sentinel: a leading nil array entry breaks ipairs under
|
||||
-- LuaJIT even though # still reports the full size, so
|
||||
-- the level picker cycles this string instead of nil,
|
||||
@@ -114,6 +115,7 @@ end
|
||||
function LinkState:exitWith(message, reason)
|
||||
DiscordPresence.setJoinCode(nil)
|
||||
self.game.linkSession = nil -- back to the player's own GAME SPEED
|
||||
if self.game.linkNet == self.net then self.game.linkNet = nil end
|
||||
Runtime.emit("link.ended", { reason = reason or (message and "error" or "bye") })
|
||||
if self.net then self.net:close() end
|
||||
self.game.stack:pop()
|
||||
@@ -519,6 +521,9 @@ function LinkState:startMode(mode, isHost)
|
||||
})
|
||||
self.net:send(self.trade:opening())
|
||||
self.index = 1
|
||||
self.theirIndex = 1
|
||||
self.side = "mine"
|
||||
self.pickChoice = nil
|
||||
else
|
||||
self.stage = "battleWait"
|
||||
-- the host deals the shared RNG seed for the lockstep simulation
|
||||
@@ -536,7 +541,14 @@ end
|
||||
-- trade flow
|
||||
-- -------------------------------------------------------------------
|
||||
|
||||
function LinkState:openStats(mon)
|
||||
if not mon then return end
|
||||
self.game.linkNet = self.net
|
||||
Screens.push(self.game, "SummaryMenu", mon)
|
||||
end
|
||||
|
||||
function LinkState:updateTrade(input)
|
||||
if self.game.linkNet == self.net then self.game.linkNet = nil end
|
||||
for _, msg in ipairs(self.net:poll()) do
|
||||
local reply = self.trade:handle(msg)
|
||||
if reply then self.net:send(reply) end
|
||||
@@ -591,10 +603,51 @@ function LinkState:updateTrade(input)
|
||||
return
|
||||
end
|
||||
|
||||
if t.stage == "picking" and input:wasPressed("up") then
|
||||
self.index = math.max(1, self.index - 1)
|
||||
-- pokered engine/link/cable_club.asm TradeCenter_SelectMon: A on one of
|
||||
-- your own mons opens the "STATS TRADE" row (.displayStatsTradeMenu)
|
||||
-- and only TRADE commits the pick, while the enemy list carries its own
|
||||
-- cursor whose A shows that mon's status pages (.displayEnemyMonStats).
|
||||
-- The cart's enemy path sets hl but never wMonDataLocation, the way the
|
||||
-- battle menu's STATS (engine/battle/core.asm) does, so LoadMonData_ reads
|
||||
-- the player's party and it draws YOUR mon at that slot -- an omission,
|
||||
-- not behaviour, so we show the peer's mon.
|
||||
if t.stage == "picking" and self.pickChoice then
|
||||
if input:wasPressed("left") then
|
||||
self.pickChoice = 1
|
||||
elseif input:wasPressed("right") then
|
||||
self.pickChoice = 2
|
||||
elseif input:wasPressed("b") then
|
||||
self.pickChoice = nil -- .cancelPlayerMonChoice: back to the list, not
|
||||
-- out of the trade
|
||||
elseif input:wasPressed("a") then
|
||||
if self.pickChoice == 1 then
|
||||
self.pickChoice = nil
|
||||
self:openStats(self.game.save.party[self.index])
|
||||
elseif t:canPick(self.index) then
|
||||
self.pickChoice = nil
|
||||
self.side = "mine"
|
||||
self.net:send(t:pick(self.index))
|
||||
end
|
||||
end
|
||||
elseif t.stage == "picking" and input:wasPressed("up") then
|
||||
if self.side == "theirs" then
|
||||
self.theirIndex = math.max(1, self.theirIndex - 1)
|
||||
else
|
||||
self.index = math.max(1, self.index - 1)
|
||||
end
|
||||
elseif t.stage == "picking" and input:wasPressed("down") then
|
||||
self.index = math.min(#self.game.save.party, self.index + 1)
|
||||
if self.side == "theirs" then
|
||||
self.theirIndex = math.min(#(t.theirParty or {}), self.theirIndex + 1)
|
||||
else
|
||||
self.index = math.min(#self.game.save.party, self.index + 1)
|
||||
end
|
||||
elseif t.stage == "picking" and input:wasPressed("right") then
|
||||
if t.theirParty and #t.theirParty > 0 then
|
||||
self.side = "theirs"
|
||||
self.theirIndex = math.min(self.theirIndex, #t.theirParty)
|
||||
end
|
||||
elseif t.stage == "picking" and input:wasPressed("left") then
|
||||
self.side = "mine"
|
||||
elseif self.confirmed == nil and input:wasPressed("b") then
|
||||
-- once confirm=true has been sent to the peer, backing out here
|
||||
-- would desync the two sides (the peer may already be committing
|
||||
@@ -603,8 +656,10 @@ function LinkState:updateTrade(input)
|
||||
self.net:send({ type = "bye" })
|
||||
self:exitWith(Strings("The trade was\ncancelled."))
|
||||
elseif t.stage == "picking" and input:wasPressed("a") then
|
||||
if t:canPick(self.index) then
|
||||
self.net:send(t:pick(self.index))
|
||||
if self.side == "theirs" then
|
||||
self:openStats((t.theirParty or {})[self.theirIndex])
|
||||
else
|
||||
self.pickChoice = 1
|
||||
end
|
||||
elseif t.stage == "confirming" and self.confirmed == nil then
|
||||
if input:wasPressed("a") then
|
||||
@@ -738,25 +793,40 @@ function LinkState:draw()
|
||||
local label = (mon.nickname or def.name):sub(1, 8)
|
||||
if not t:canPick(i) then label = label .. "X" end
|
||||
Font.draw(label, 16, 20 + i * 12)
|
||||
if i == self.index then Font.drawCode(CURSOR, 8, 20 + i * 12) end
|
||||
if i == self.index and self.side ~= "theirs" then
|
||||
Font.drawCode(CURSOR, 8, 20 + i * 12)
|
||||
end
|
||||
end
|
||||
Font.draw(Strings("THEIRS"), 84, 20)
|
||||
for i, mon in ipairs(t.theirParty or {}) do
|
||||
local def = self.game.data.pokemon[mon.species]
|
||||
Font.draw((mon.nickname or def.name):sub(1, 8), 92, 20 + i * 12)
|
||||
if t.theirPick == i then Font.drawCode(CURSOR, 84, 20 + i * 12) end
|
||||
if self.side == "theirs" and i == self.theirIndex then
|
||||
Font.drawCode(CURSOR, 84, 20 + i * 12)
|
||||
elseif t.theirPick == i then
|
||||
Font.drawCode(CURSOR_HOLLOW, 84, 20 + i * 12)
|
||||
end
|
||||
end
|
||||
local hint
|
||||
if t.stage == "waitRecords" then hint = "Comparing games..."
|
||||
elseif t.stage == "waitParty" then hint = "Exchanging data..."
|
||||
elseif t.stage == "picking" then
|
||||
hint = t:canPick(self.index) and "Pick one to trade"
|
||||
or Strings("X: not on theirs")
|
||||
elseif t.stage == "waitPick" then hint = "Waiting for them..."
|
||||
elseif t.stage == "confirming" then
|
||||
hint = self.confirmed and "Waiting..." or Strings("A: trade B: cancel")
|
||||
if self.pickChoice then
|
||||
Font.draw(Strings("STATS"), 16, 128)
|
||||
Font.draw(Strings("TRADE"), 96, 128)
|
||||
Font.drawCode(CURSOR, self.pickChoice == 1 and 8 or 88, 128)
|
||||
else
|
||||
local hint
|
||||
if t.stage == "waitRecords" then hint = "Comparing games..."
|
||||
elseif t.stage == "waitParty" then hint = "Exchanging data..."
|
||||
elseif t.stage == "picking" then
|
||||
if self.side == "theirs" then hint = Strings("A: stats")
|
||||
else
|
||||
hint = t:canPick(self.index) and "Pick one to trade"
|
||||
or Strings("X: not on theirs")
|
||||
end
|
||||
elseif t.stage == "waitPick" then hint = "Waiting for them..."
|
||||
elseif t.stage == "confirming" then
|
||||
hint = self.confirmed and "Waiting..." or Strings("A: trade B: cancel")
|
||||
end
|
||||
Font.draw(hint or "", 8, 132)
|
||||
end
|
||||
Font.draw(hint or "", 8, 132)
|
||||
|
||||
elseif self.stage == "battleWait" or self.stage == "battleRunning" then
|
||||
drawTitle("LINK BATTLE")
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
|
||||
local Logger = require("src.core.Logger")
|
||||
local Runtime = require("src.mods.Runtime")
|
||||
local SafePath = require("src.mods.SafePath")
|
||||
|
||||
local unpack = table.unpack or unpack
|
||||
local loadstring = loadstring or load
|
||||
@@ -31,25 +32,8 @@ AssetTransform.SOURCE_ROOT = SOURCE_ROOT
|
||||
AssetTransform.DERIVED_ROOT = DERIVED_ROOT
|
||||
|
||||
-- ------- path sandbox
|
||||
|
||||
-- a relative path that cannot climb out of the root it is joined to
|
||||
local function safeRelative(rel)
|
||||
if type(rel) ~= "string" or rel == "" then return nil end
|
||||
if rel:sub(1, 1) == "/" then return nil end
|
||||
if rel:find("\\", 1, true) then return nil end
|
||||
for segment in rel:gmatch("[^/]+") do
|
||||
if segment == ".." or segment == "." then return nil end
|
||||
end
|
||||
return rel
|
||||
end
|
||||
|
||||
local function requireRelative(rel, what)
|
||||
local safe = safeRelative(rel)
|
||||
if not safe then
|
||||
error(("%s must stay inside its root, got %q"):format(what, tostring(rel)), 0)
|
||||
end
|
||||
return safe
|
||||
end
|
||||
-- shared with mod:read and the manifest's own paths (src/mods/SafePath.lua)
|
||||
local requireRelative = SafePath.require
|
||||
|
||||
-- ------- the restricted context
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
+334
-19
@@ -41,6 +41,8 @@ local CacheFs = require("src.import.CacheFs")
|
||||
|
||||
local LauncherMods = {}
|
||||
|
||||
local discover -- forward declaration for helper functions above line 343
|
||||
|
||||
-- ------- pure status derivation
|
||||
|
||||
-- A hard-dependency / conflict / version verdict for one manifest. mods is
|
||||
@@ -85,26 +87,190 @@ local function statusFor(mods, id, enabledSet, enabled, version, forcedFor)
|
||||
-- resolveToggle would cascade-enable a merely-disabled dep rather than flag
|
||||
-- it, so the disabled case is judged straight off the manifest here.
|
||||
for _, spec in ipairs(m.dependencySpecs or {}) do
|
||||
local dep = mods[spec.id]
|
||||
if not dep then
|
||||
return "warn", "Needs " .. spec.id .. " (not installed)"
|
||||
elseif not enabledSet[spec.id] then
|
||||
return "warn", "Needs " .. spec.id .. " (disabled)"
|
||||
-- installed and on, but not for THIS game: the loader skips the
|
||||
-- dependency and the skip is contagious (Loader:_enforceDependencies),
|
||||
-- so a mod that runs everywhere still does not run here
|
||||
elseif version
|
||||
and not ModTargets.runsHere(dep, version, nil, forcedFor(spec.id)) then
|
||||
return "warn", "Needs " .. spec.id .. " (not for "
|
||||
.. ModTargets.gameLabel(version) .. ")"
|
||||
elseif spec.range
|
||||
and not Semver.satisfies(dep.version, spec.range) then
|
||||
return "warn", "Needs " .. spec.id .. " " .. spec.range
|
||||
if ModTargets.specApplies(spec, version) then
|
||||
local dep = mods[spec.id]
|
||||
if not dep then
|
||||
return "warn", "Needs " .. spec.id .. " (not installed)"
|
||||
elseif not enabledSet[spec.id] then
|
||||
return "warn", "Needs " .. spec.id .. " (disabled)"
|
||||
-- installed and on, but not for THIS game: the loader skips the
|
||||
-- dependency and the skip is contagious (Loader:_enforceDependencies),
|
||||
-- so a mod that runs everywhere still does not run here
|
||||
elseif version
|
||||
and not ModTargets.runsHere(dep, version, nil, forcedFor(spec.id)) then
|
||||
return "warn", "Needs " .. spec.id .. " (not for "
|
||||
.. ModTargets.gameLabel(version) .. ")"
|
||||
elseif spec.range
|
||||
and not Semver.satisfies(dep.version, spec.range) then
|
||||
return "warn", "Needs " .. spec.id .. " " .. spec.range
|
||||
end
|
||||
end
|
||||
end
|
||||
return "ok", "Ready"
|
||||
end
|
||||
|
||||
-- Resolves the best-known GitHub owner/repo string for a dependency spec, if any.
|
||||
function LauncherMods.resolveDependencyRepo(depId, parentManifest, installedDep)
|
||||
if not depId or depId == "" then return nil end
|
||||
-- 1. Check spec hint if parentManifest dependencySpecs carries it
|
||||
if parentManifest and parentManifest.dependencySpecs then
|
||||
for _, spec in ipairs(parentManifest.dependencySpecs) do
|
||||
if spec.id == depId and spec.github then
|
||||
return spec.github
|
||||
end
|
||||
end
|
||||
end
|
||||
-- 2. Check parentManifest raw dependency_sources
|
||||
if parentManifest and parentManifest.raw and type(parentManifest.raw.dependency_sources) == "table" then
|
||||
local src = parentManifest.raw.dependency_sources[depId]
|
||||
if src then
|
||||
local ok, clean = pcall(Manifest.parseGithub, src)
|
||||
if ok and clean then return clean end
|
||||
end
|
||||
end
|
||||
-- 3. Check installed dependency manifest
|
||||
if installedDep and installedDep.github then
|
||||
return installedDep.github
|
||||
end
|
||||
-- 4. Check ModIndex entries if available
|
||||
local okModIndex, ModIndex = pcall(require, "src.mods.ModIndex")
|
||||
if okModIndex and ModIndex and type(ModIndex.sources) == "function" then
|
||||
for _, src in ipairs(ModIndex.sources() or {}) do
|
||||
local cached = ModIndex.readCache and ModIndex.readCache(src.feed)
|
||||
if cached and type(cached.index) == "table" then
|
||||
for _, entry in ipairs(cached.index) do
|
||||
if type(entry) == "table" and entry.id == depId and entry.github then
|
||||
local ok, clean = pcall(Manifest.parseGithub, entry.github)
|
||||
if ok and clean then return clean end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
-- Inspect manifest dependencies and conflicts against installed mods.
|
||||
-- Returns: { hasIssues = bool, targetMod = {...}, deps = [ { id, name, range, status, kind, installedVersion, github, safeUrl }, ... ] }
|
||||
function LauncherMods.checkDependencies(manifest, options, version, installedManifests)
|
||||
if not manifest then
|
||||
return { hasIssues = false, targetMod = { name = "Unknown" }, deps = {} }
|
||||
end
|
||||
|
||||
local SaveData = require("src.core.SaveData")
|
||||
local manifests = installedManifests or discover()
|
||||
local installedMap = {}
|
||||
for _, m in ipairs(manifests) do
|
||||
installedMap[m.id] = m
|
||||
end
|
||||
|
||||
local depsResult = {}
|
||||
local hasIssues = false
|
||||
|
||||
-- 1. Hard Dependencies (dependencySpecs)
|
||||
if type(manifest.dependencySpecs) == "table" then
|
||||
for _, spec in ipairs(manifest.dependencySpecs) do
|
||||
if not version or ModTargets.specApplies(spec, version) then
|
||||
local depId = spec.id
|
||||
local range = spec.range
|
||||
local installedDep = installedMap[depId]
|
||||
local status = "satisfied"
|
||||
local installedVersion = installedDep and installedDep.version or nil
|
||||
|
||||
if not installedDep then
|
||||
status = "missing"
|
||||
hasIssues = true
|
||||
elseif range and not Semver.satisfies(installedDep.version, range) then
|
||||
status = "incompatible"
|
||||
hasIssues = true
|
||||
end
|
||||
|
||||
local ghRepo = LauncherMods.resolveDependencyRepo(depId, manifest, installedDep)
|
||||
local safeUrl = ghRepo and ("https://github.com/" .. ghRepo) or nil
|
||||
|
||||
depsResult[#depsResult + 1] = {
|
||||
id = depId,
|
||||
name = (installedDep and installedDep.name) or depId,
|
||||
range = range,
|
||||
status = status,
|
||||
kind = "dependency",
|
||||
installedVersion = installedVersion,
|
||||
github = ghRepo,
|
||||
safeUrl = safeUrl,
|
||||
}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- 2. Conflicts / Incompatible Mods (conflictSpecs)
|
||||
local conflictIdsSeen = {}
|
||||
local scope = SaveData.modScope and SaveData.modScope(version) or version
|
||||
|
||||
local isEnabled = function(modId)
|
||||
if not options then return true end
|
||||
local dec = SaveData.modEnabled(options, modId, scope)
|
||||
if dec ~= nil then return dec == true end
|
||||
local m = installedMap[modId]
|
||||
return m and not m.experimental
|
||||
end
|
||||
|
||||
-- (a) Conflicts declared by target manifest
|
||||
if type(manifest.conflictSpecs) == "table" then
|
||||
for _, spec in ipairs(manifest.conflictSpecs) do
|
||||
local conflictId = spec.id
|
||||
local installedOther = installedMap[conflictId]
|
||||
if installedOther and isEnabled(conflictId) and not conflictIdsSeen[conflictId] then
|
||||
conflictIdsSeen[conflictId] = true
|
||||
hasIssues = true
|
||||
depsResult[#depsResult + 1] = {
|
||||
id = conflictId,
|
||||
name = installedOther.name or conflictId,
|
||||
status = "conflict",
|
||||
kind = "conflict",
|
||||
installedVersion = installedOther.version or "?",
|
||||
github = nil,
|
||||
safeUrl = nil,
|
||||
}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- (b) Reverse conflicts declared by installed mods against target manifest
|
||||
if manifest.id then
|
||||
for _, other in ipairs(manifests) do
|
||||
if other.id ~= manifest.id and isEnabled(other.id) and not conflictIdsSeen[other.id] then
|
||||
local conflicts = other.conflictSpecs or {}
|
||||
for _, spec in ipairs(conflicts) do
|
||||
if spec.id == manifest.id then
|
||||
conflictIdsSeen[other.id] = true
|
||||
hasIssues = true
|
||||
depsResult[#depsResult + 1] = {
|
||||
id = other.id,
|
||||
name = other.name or other.id,
|
||||
status = "conflict",
|
||||
kind = "conflict",
|
||||
installedVersion = other.version or "?",
|
||||
github = nil,
|
||||
safeUrl = nil,
|
||||
}
|
||||
break
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
return {
|
||||
hasIssues = hasIssues,
|
||||
targetMod = {
|
||||
id = manifest.id,
|
||||
name = manifest.name or manifest.id,
|
||||
version = manifest.version or "?",
|
||||
},
|
||||
deps = depsResult,
|
||||
}
|
||||
end
|
||||
|
||||
-- deriveList(manifests, options [, version]) -> the panel row list, pure.
|
||||
-- manifests is an array of validated manifests (Manifest.validate output);
|
||||
-- options is the options table (options.mods, options.modsByVersion and
|
||||
@@ -165,6 +331,8 @@ function LauncherMods.deriveList(manifests, options, version)
|
||||
statusDetail = detail,
|
||||
github = m.github,
|
||||
experimental = m.experimental == true,
|
||||
dependencySpecs = m.dependencySpecs,
|
||||
manifest = m,
|
||||
-- what game this mod is for, and whether it will run on the one the
|
||||
-- panel is showing (src/mods/ModTargets.lua)
|
||||
targets = ModTargets.chip(m),
|
||||
@@ -252,7 +420,7 @@ end
|
||||
-- Scan "mods/" one level deep for valid manifests (mirrors Loader:_discover,
|
||||
-- but validates only -- no entry chunk is ever loaded). First id wins on a
|
||||
-- duplicate. Returns an array of validated manifests.
|
||||
local function discover()
|
||||
discover = function()
|
||||
local fs = love and love.filesystem
|
||||
local out = {}
|
||||
if not (fs and fs.getInfo and fs.getDirectoryItems) then return out end
|
||||
@@ -386,6 +554,7 @@ function LauncherMods.setEnabled(id, enabled, version)
|
||||
local options = SaveData.loadOptions()
|
||||
SaveData.setModEnabled(options, id, enabled, SaveData.modScope(version))
|
||||
SaveData.saveOptions(options)
|
||||
LauncherMods.syncActiveProfile(options)
|
||||
return true
|
||||
end
|
||||
|
||||
@@ -409,6 +578,7 @@ function LauncherMods.setAllEnabled(ids, enabled, version)
|
||||
end
|
||||
end
|
||||
SaveData.saveOptions(options)
|
||||
LauncherMods.syncActiveProfile(options)
|
||||
return true
|
||||
end
|
||||
|
||||
@@ -674,9 +844,9 @@ function LauncherMods.adoptStrays() return scanStrays(true) end
|
||||
-- opts.replace = true uninstalls an existing same-id mod first (updates /
|
||||
-- rollbacks). opts.expectId, when set, refuses a zip whose manifest id differs.
|
||||
function LauncherMods.installZip(source, opts)
|
||||
local ok, result, err = pcall(LauncherMods._installZipInner, source, opts)
|
||||
local ok, result, res2, res3 = pcall(LauncherMods._installZipInner, source, opts)
|
||||
if not ok then return nil, "import failed: " .. tostring(result) end
|
||||
return result, err
|
||||
return result, res2, res3
|
||||
end
|
||||
|
||||
function LauncherMods._installZipInner(source, opts)
|
||||
@@ -786,7 +956,7 @@ function LauncherMods._installZipInner(source, opts)
|
||||
return nil, copyErr or "could not copy the mod files"
|
||||
end
|
||||
cleanup()
|
||||
return true, manifest.id
|
||||
return true, manifest.id, manifest
|
||||
end
|
||||
|
||||
-- Install (or replace) a mod from a GitHub release zip URL.
|
||||
@@ -912,4 +1082,149 @@ function LauncherMods.uninstall(id)
|
||||
return true
|
||||
end
|
||||
|
||||
-- ----------------------------------------------------------- Mod Profiles (#593)
|
||||
local ModProfile = require("src.mods.ModProfile")
|
||||
|
||||
function LauncherMods.getProfiles(options)
|
||||
options = options or SaveData.loadOptions()
|
||||
local manifests = discover()
|
||||
ModProfile.ensureFirst(options, manifests, options.modOptions)
|
||||
return options.modProfiles or {}, options.activeProfile or "PROFILE 1"
|
||||
end
|
||||
|
||||
function LauncherMods.applyProfile(profileName, options)
|
||||
options = options or SaveData.loadOptions()
|
||||
local profiles = options.modProfiles or {}
|
||||
local targetProfile
|
||||
for _, p in ipairs(profiles) do
|
||||
if p.name == profileName then targetProfile = p; break end
|
||||
end
|
||||
if not targetProfile then return false end
|
||||
ModProfile.restoreVersions(targetProfile, options)
|
||||
options.activeProfile = profileName
|
||||
SaveData.saveOptions(options)
|
||||
return true
|
||||
end
|
||||
|
||||
function LauncherMods.saveProfile(profileName, options)
|
||||
options = options or SaveData.loadOptions()
|
||||
local manifests = discover()
|
||||
options.modProfiles = options.modProfiles or {}
|
||||
local snap = ModProfile.capture(manifests, options.modOptions, options.modsByVersion)
|
||||
snap.name = profileName
|
||||
local existingIdx
|
||||
for i, p in ipairs(options.modProfiles) do
|
||||
if p.name == profileName then existingIdx = i; break end
|
||||
end
|
||||
if existingIdx then
|
||||
options.modProfiles[existingIdx] = snap
|
||||
else
|
||||
options.modProfiles[#options.modProfiles + 1] = snap
|
||||
end
|
||||
options.activeProfile = profileName
|
||||
SaveData.saveOptions(options)
|
||||
return snap
|
||||
end
|
||||
|
||||
local function copyTable(tbl)
|
||||
if type(tbl) ~= "table" then return tbl end
|
||||
local copy = {}
|
||||
for k, v in pairs(tbl) do
|
||||
copy[k] = type(v) == "table" and copyTable(v) or v
|
||||
end
|
||||
return copy
|
||||
end
|
||||
|
||||
function LauncherMods.syncActiveProfile(options)
|
||||
options = options or SaveData.loadOptions()
|
||||
local activeName = options.activeProfile or "PROFILE 1"
|
||||
local profiles = options.modProfiles or {}
|
||||
local manifests = discover()
|
||||
local snap = ModProfile.capture(manifests, options.modOptions, options.modsByVersion)
|
||||
snap.name = activeName
|
||||
|
||||
local found = false
|
||||
for i, p in ipairs(profiles) do
|
||||
if p.name == activeName then
|
||||
profiles[i] = snap
|
||||
found = true
|
||||
break
|
||||
end
|
||||
end
|
||||
if not found then
|
||||
profiles[#profiles + 1] = snap
|
||||
end
|
||||
options.modProfiles = profiles
|
||||
options.activeProfile = activeName
|
||||
SaveData.saveOptions(options)
|
||||
return snap
|
||||
end
|
||||
|
||||
function LauncherMods.duplicateProfile(sourceName, options)
|
||||
options = options or SaveData.loadOptions()
|
||||
local profiles = options.modProfiles or {}
|
||||
local sourceProfile
|
||||
for _, p in ipairs(profiles) do
|
||||
if p.name == sourceName then sourceProfile = p; break end
|
||||
end
|
||||
if not sourceProfile then return nil end
|
||||
|
||||
local baseName = sourceName .. " (Copy)"
|
||||
local newName = baseName
|
||||
local n = 1
|
||||
local taken = {}
|
||||
for _, p in ipairs(profiles) do taken[p.name] = true end
|
||||
while taken[newName] do
|
||||
n = n + 1
|
||||
newName = sourceName .. " (" .. n .. ")"
|
||||
end
|
||||
|
||||
local snap = {
|
||||
name = newName,
|
||||
enabled = copyTable(sourceProfile.enabled),
|
||||
options = copyTable(sourceProfile.options),
|
||||
slots = copyTable(sourceProfile.slots),
|
||||
enabledByVersion = copyTable(sourceProfile.enabledByVersion),
|
||||
}
|
||||
profiles[#profiles + 1] = snap
|
||||
options.modProfiles = profiles
|
||||
options.activeProfile = newName
|
||||
SaveData.saveOptions(options)
|
||||
return snap
|
||||
end
|
||||
|
||||
function LauncherMods.renameProfile(oldName, newName, options)
|
||||
options = options or SaveData.loadOptions()
|
||||
if not newName or newName == "" then return false end
|
||||
local profiles = options.modProfiles or {}
|
||||
for i, p in ipairs(profiles) do
|
||||
if p.name == oldName then
|
||||
p.name = newName
|
||||
if options.activeProfile == oldName then
|
||||
options.activeProfile = newName
|
||||
end
|
||||
SaveData.saveOptions(options)
|
||||
return true
|
||||
end
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
function LauncherMods.deleteProfile(profileName, options)
|
||||
options = options or SaveData.loadOptions()
|
||||
options.modProfiles = options.modProfiles or {}
|
||||
local newProfiles = {}
|
||||
for _, p in ipairs(options.modProfiles) do
|
||||
if p.name ~= profileName then newProfiles[#newProfiles + 1] = p end
|
||||
end
|
||||
options.modProfiles = newProfiles
|
||||
if options.activeProfile == profileName then
|
||||
local fallback = newProfiles[1] and newProfiles[1].name or "PROFILE 1"
|
||||
LauncherMods.applyProfile(fallback, options)
|
||||
else
|
||||
SaveData.saveOptions(options)
|
||||
end
|
||||
return true
|
||||
end
|
||||
|
||||
return LauncherMods
|
||||
|
||||
+194
-63
@@ -12,12 +12,15 @@ local Manifest = require("src.mods.Manifest")
|
||||
local Merge = require("src.mods.Merge")
|
||||
local ModTargets = require("src.mods.ModTargets")
|
||||
local Registry = require("src.mods.Registry")
|
||||
local SafePath = require("src.mods.SafePath")
|
||||
local Sandbox = require("src.mods.Sandbox")
|
||||
local Schemas = require("src.mods.Schemas")
|
||||
local Semver = require("src.mods.Semver")
|
||||
local Events = require("src.mods.Events")
|
||||
local Gen2Compat = require("src.mods.Gen2Compat")
|
||||
local Hooks = require("src.mods.Hooks")
|
||||
local Runtime = require("src.mods.Runtime")
|
||||
local Steps = require("src.mods.Steps")
|
||||
|
||||
local Loader = {}
|
||||
Loader.__index = Loader
|
||||
@@ -72,10 +75,12 @@ local function orderedIds(mods, filter)
|
||||
return ids
|
||||
end
|
||||
|
||||
-- ------- dev-mode permissions tripwire
|
||||
-- Attribution only: the shim delegates unconditionally and blocks nothing.
|
||||
-- Installed once per process and only when the loader runs in dev mode, so a
|
||||
-- player build has zero interposition.
|
||||
-- ------- the require gate
|
||||
-- Two jobs in one interposition. The engine_internals/network scan is
|
||||
-- attribution only and stays dev-mode: it warns and delegates. The
|
||||
-- Sandbox.moduleDenial check is not -- require("io") would hand back
|
||||
-- package.loaded.io and undo the whole mod environment -- so it is installed
|
||||
-- in player builds too, for any boot that has mods on it.
|
||||
|
||||
local devShim = { installed = false, permissions = {}, warned = {}, depth = 0 }
|
||||
|
||||
@@ -135,7 +140,8 @@ end
|
||||
|
||||
local function scanRequire(name)
|
||||
local modId = Runtime.currentMod
|
||||
if not modId or type(name) ~= "string" then return end
|
||||
if type(modId) ~= "string" then modId = Runtime.modRequire end
|
||||
if type(modId) ~= "string" or type(name) ~= "string" then return end
|
||||
local granted = devShim.permissions[modId] or {}
|
||||
local function warnOnce(permission)
|
||||
local key = modId .. "|" .. permission .. "|" .. name
|
||||
@@ -188,6 +194,7 @@ function Loader:_installDevShim()
|
||||
for id, mod in pairs(self.mods) do
|
||||
devShim.permissions[id] = mod.manifest.permissionSet
|
||||
end
|
||||
devShim.dev = self.dev
|
||||
if devShim.installed then return end
|
||||
devShim.installed = true
|
||||
local delegate = require
|
||||
@@ -195,12 +202,22 @@ function Loader:_installDevShim()
|
||||
-- only the mod's own call is the mod's doing; whatever that module
|
||||
-- requires in turn is the engine wiring itself up
|
||||
if devShim.depth == 0 then
|
||||
scanRequire(name)
|
||||
-- Backstop for the deny list Sandbox.envFor's require already applies:
|
||||
-- an engine module requiring io is the engine wiring itself up, a mod
|
||||
-- doing it is the hole this closes, and any future path that runs mod
|
||||
-- code without a sandbox env still lands here.
|
||||
local owner = Runtime.currentMod or Runtime.modRequire
|
||||
if owner or callerIsMod(3) then
|
||||
local id = type(owner) == "string" and owner or nil
|
||||
local denial = Sandbox.moduleDenial(name, devShim.permissions[id])
|
||||
if denial then error(("[%s] %s"):format(id or "mod", denial), 0) end
|
||||
end
|
||||
if devShim.dev or devShim.generation ~= 1 then scanRequire(name) end
|
||||
-- The Gen 1 name a mod asked for, answered by the Gen 2 arm behind it.
|
||||
-- Engine code keeps the real module: src/render/PaletteFX.lua:776
|
||||
-- requires src.core.Game on both generations and means it.
|
||||
if devShim.generation ~= 1 and Gen2Compat.serves(name)
|
||||
and callerIsMod(3) then
|
||||
and (owner or callerIsMod(3)) then
|
||||
local adapter = Gen2Compat.resolve(name, Runtime.currentMod)
|
||||
if adapter then
|
||||
local key = "adapter|" .. name
|
||||
@@ -235,7 +252,7 @@ function Loader.new(opts)
|
||||
events = Events.new(), hooks = Hooks.new(), content = {}, assets = {},
|
||||
exports = {}, migrations = {}, order = {},
|
||||
modSave = {}, modOptions = {}, optionSchemas = {}, imageCache = {},
|
||||
modInput = {},
|
||||
modInput = {}, modEnv = {}, stepsQueues = {},
|
||||
fs = (opts and opts.fs) or (love and love.filesystem),
|
||||
dev = dev,
|
||||
-- Which generation this boot is (1 or 2). Fixed at construction: the
|
||||
@@ -357,11 +374,13 @@ function Loader:_writeOptionSchemas()
|
||||
-- demand; using it here means older mods do not need to migrate to
|
||||
-- mod.options:define just to appear in a launcher settings screen.
|
||||
if schema == nil and mod.manifest.options_schema and self.fs.load then
|
||||
local chunk = self.fs.load(mod.path .. "/" .. mod.manifest.options_schema)
|
||||
if chunk then
|
||||
local ok, rows = pcall(chunk)
|
||||
if ok and type(rows) == "table" then schema = rows end
|
||||
end
|
||||
local ok, rows = pcall(function()
|
||||
local path = SafePath.join(mod.path, mod.manifest.options_schema,
|
||||
"options_schema")
|
||||
local chunk = Sandbox.loadFile(self.fs, path, self:_modEnv(mod))
|
||||
return chunk and chunk()
|
||||
end)
|
||||
if ok and type(rows) == "table" then schema = rows end
|
||||
end
|
||||
if schema ~= nil then
|
||||
mods[id] = schema
|
||||
@@ -561,42 +580,46 @@ end
|
||||
-- hard dependencies must exist, be enabled, have survived, and satisfy their
|
||||
-- range; run to a fixpoint so failures propagate to dependents transitively
|
||||
function Loader:_enforceDependencies()
|
||||
local targetVersion = self:_targetVersion()
|
||||
local generation = self.generation
|
||||
local changed = true
|
||||
while changed do
|
||||
changed = false
|
||||
for _, id in ipairs(orderedIds(self.mods, isActive)) do
|
||||
local mod = self.mods[id]
|
||||
for _, spec in ipairs(mod.manifest.dependencySpecs) do
|
||||
local dep = self.mods[spec.id]
|
||||
local reason, skip
|
||||
if not dep then
|
||||
reason = "missing dependency: " .. spec.id
|
||||
elseif not dep.enabled then
|
||||
reason = ("dependency %s is disabled"):format(spec.id)
|
||||
elseif dep.state == "wrong_generation" then
|
||||
-- the gate's skip is contagious as a SKIP, not as a failure: the
|
||||
-- dependency has no bug to report and neither does this mod, so
|
||||
-- nothing here lands on the boot error list
|
||||
skip = true
|
||||
-- carry the dependency's own reason: it names the game or the
|
||||
-- missing gen2compat, and a guess here would name the wrong one
|
||||
reason = ("depends on %s, which does not run here (%s)")
|
||||
:format(spec.id, dep.skipReason or "not made for this game")
|
||||
elseif dep.failed then
|
||||
reason = ("dependency %s failed to load"):format(spec.id)
|
||||
elseif spec.range
|
||||
and not Semver.satisfies(dep.manifest.version, spec.range) then
|
||||
reason = ("needs %s@%s, found %s")
|
||||
:format(spec.id, spec.range, dep.manifest.version)
|
||||
end
|
||||
if reason then
|
||||
if skip then
|
||||
self:_skip(mod, "wrong_generation", reason)
|
||||
else
|
||||
self:_fail(mod, "blocked_dependency", reason)
|
||||
if ModTargets.specApplies(spec, targetVersion, generation) then
|
||||
local dep = self.mods[spec.id]
|
||||
local reason, skip
|
||||
if not dep then
|
||||
reason = "missing dependency: " .. spec.id
|
||||
elseif not dep.enabled then
|
||||
reason = ("dependency %s is disabled"):format(spec.id)
|
||||
elseif dep.state == "wrong_generation" then
|
||||
-- the gate's skip is contagious as a SKIP, not as a failure: the
|
||||
-- dependency has no bug to report and neither does this mod, so
|
||||
-- nothing here lands on the boot error list
|
||||
skip = true
|
||||
-- carry the dependency's own reason: it names the game or the
|
||||
-- missing gen2compat, and a guess here would name the wrong one
|
||||
reason = ("depends on %s, which does not run here (%s)")
|
||||
:format(spec.id, dep.skipReason or "not made for this game")
|
||||
elseif dep.failed then
|
||||
reason = ("dependency %s failed to load"):format(spec.id)
|
||||
elseif spec.range
|
||||
and not Semver.satisfies(dep.manifest.version, spec.range) then
|
||||
reason = ("needs %s@%s, found %s")
|
||||
:format(spec.id, spec.range, dep.manifest.version)
|
||||
end
|
||||
if reason then
|
||||
if skip then
|
||||
self:_skip(mod, "wrong_generation", reason)
|
||||
else
|
||||
self:_fail(mod, "blocked_dependency", reason)
|
||||
end
|
||||
changed = true
|
||||
break
|
||||
end
|
||||
changed = true
|
||||
break
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -606,6 +629,8 @@ end
|
||||
-- Tarjan SCC over the hard-dependency graph: only a cycle's own members
|
||||
-- fail, so an unrelated mod beside a cycle still loads
|
||||
function Loader:_failCycles()
|
||||
local targetVersion = self:_targetVersion()
|
||||
local generation = self.generation
|
||||
local mods = self.mods
|
||||
local counter, stack, onStack, index, low = 0, {}, {}, {}, {}
|
||||
local cycles = {}
|
||||
@@ -616,14 +641,16 @@ function Loader:_failCycles()
|
||||
onStack[id] = true
|
||||
local selfEdge = false
|
||||
for _, spec in ipairs(mods[id].manifest.dependencySpecs) do
|
||||
local dep = mods[spec.id]
|
||||
if spec.id == id then selfEdge = true end
|
||||
if dep and isActive(dep) and spec.id ~= id then
|
||||
if not index[spec.id] then
|
||||
connect(spec.id)
|
||||
if low[spec.id] < low[id] then low[id] = low[spec.id] end
|
||||
elseif onStack[spec.id] and index[spec.id] < low[id] then
|
||||
low[id] = index[spec.id]
|
||||
if ModTargets.specApplies(spec, targetVersion, generation) then
|
||||
local dep = mods[spec.id]
|
||||
if spec.id == id then selfEdge = true end
|
||||
if dep and isActive(dep) and spec.id ~= id then
|
||||
if not index[spec.id] then
|
||||
connect(spec.id)
|
||||
if low[spec.id] < low[id] then low[id] = low[spec.id] end
|
||||
elseif onStack[spec.id] and index[spec.id] < low[id] then
|
||||
low[id] = index[spec.id]
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -674,6 +701,8 @@ end
|
||||
-- Kahn over the surviving graph with the ready set kept in (priority, id)
|
||||
-- order, so dependencies come first and the rest matches the v1 contract
|
||||
function Loader:_order()
|
||||
local targetVersion = self:_targetVersion()
|
||||
local generation = self.generation
|
||||
local pending, indegree, dependents = {}, {}, {}
|
||||
for _, id in ipairs(orderedIds(self.mods, isActive)) do
|
||||
pending[id], indegree[id] = true, 0
|
||||
@@ -686,9 +715,17 @@ function Loader:_order()
|
||||
dependents[depId][#dependents[depId] + 1] = id
|
||||
indegree[id] = indegree[id] + 1
|
||||
end
|
||||
for _, spec in ipairs(manifest.dependencySpecs) do edge(spec.id) end
|
||||
for _, spec in ipairs(manifest.dependencySpecs) do
|
||||
if ModTargets.specApplies(spec, targetVersion, generation) then
|
||||
edge(spec.id)
|
||||
end
|
||||
end
|
||||
-- optional dependencies order without requiring anything
|
||||
for _, spec in ipairs(manifest.optionalSpecs) do edge(spec.id) end
|
||||
for _, spec in ipairs(manifest.optionalSpecs) do
|
||||
if ModTargets.specApplies(spec, targetVersion, generation) then
|
||||
edge(spec.id)
|
||||
end
|
||||
end
|
||||
end
|
||||
local ordered = {}
|
||||
local function nextId()
|
||||
@@ -980,6 +1017,40 @@ function Loader:_api(mod)
|
||||
return DateTime.dateTime(game, timestamp)
|
||||
end,
|
||||
},
|
||||
-- The read-only part of love.system that device UIs legitimately need.
|
||||
-- Do not expose the module: openURL and clipboard access stay sandboxed.
|
||||
device = {
|
||||
powerInfo = function()
|
||||
local getPowerInfo = love and love.system and love.system.getPowerInfo
|
||||
if not getPowerInfo then return "unknown", nil end
|
||||
local state, percent = getPowerInfo()
|
||||
return state, percent
|
||||
end,
|
||||
},
|
||||
-- The native step bridge (#1186), behind the "steps" permission the
|
||||
-- player sees in the mod manager: sync asks the platform to refresh
|
||||
-- its count, poll hands this mod its copy of what the bridge
|
||||
-- delivered. The engine owns the pending file -- a mod never names a
|
||||
-- path, it only receives { steps, from, to }. available() answers
|
||||
-- false without the permission (a probe stays quiet); the calls that
|
||||
-- would do something name the missing permission instead, the way the
|
||||
-- network gate does.
|
||||
steps = (function()
|
||||
if mod.manifest.permissionSet.steps then
|
||||
loader.stepsQueues[modId] = loader.stepsQueues[modId] or {}
|
||||
return {
|
||||
available = function() return Steps.available() end,
|
||||
sync = function() return Steps.sync() end,
|
||||
poll = function() return Steps.poll(loader, modId) end,
|
||||
}
|
||||
end
|
||||
local function refuse()
|
||||
error(('[%s] mod.steps needs the "steps" permission in '
|
||||
.. "manifest.json"):format(modId), 2)
|
||||
end
|
||||
return { available = function() return false end,
|
||||
sync = refuse, poll = refuse }
|
||||
end)(),
|
||||
-- namespaced per mod; M11 backs these with save.modData /
|
||||
-- options.modOptions, the shape mods compile against is already final
|
||||
save = {
|
||||
@@ -998,7 +1069,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 = {
|
||||
@@ -1006,6 +1078,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,
|
||||
},
|
||||
@@ -1087,12 +1163,47 @@ 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({
|
||||
path = function(_, relative) return mod.path .. "/" .. relative end,
|
||||
path = function(_, relative)
|
||||
return SafePath.join(mod.path, relative, "mod.assets:path")
|
||||
end,
|
||||
image = function(_, relative)
|
||||
local full = mod.path .. "/" .. relative
|
||||
local full = SafePath.join(mod.path, relative, "mod.assets:image")
|
||||
local cached = loader.imageCache[full]
|
||||
if cached then return cached end
|
||||
assert(love and love.graphics,
|
||||
@@ -1101,11 +1212,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)
|
||||
local path = self.path .. "/" .. relative
|
||||
return loader.fs.read(path)
|
||||
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
|
||||
@@ -1148,9 +1264,21 @@ function Loader:_game()
|
||||
return engineRequire("src.core.Game")
|
||||
end
|
||||
|
||||
-- The environment every chunk this mod authors runs in, built once per mod so
|
||||
-- its entry file and its options_schema share one globals table.
|
||||
function Loader:_modEnv(mod)
|
||||
local id = mod.manifest.id
|
||||
local env = self.modEnv[id]
|
||||
if not env then
|
||||
env = Sandbox.envFor({ modId = id, permissions = mod.manifest.permissionSet })
|
||||
self.modEnv[id] = env
|
||||
end
|
||||
return env
|
||||
end
|
||||
|
||||
function Loader:_loadMod(mod)
|
||||
local path = mod.path .. "/" .. mod.manifest.entry
|
||||
local chunk, err = self.fs.load(path)
|
||||
local path = SafePath.join(mod.path, mod.manifest.entry, "manifest entry")
|
||||
local chunk, err = Sandbox.loadFile(self.fs, path, self:_modEnv(mod))
|
||||
if not chunk then error(err or ("unable to load " .. path)) end
|
||||
local api = self:_api(mod)
|
||||
local result = chunk(api)
|
||||
@@ -1182,6 +1310,7 @@ function Loader:_rollback(modId)
|
||||
self.optionSchemas[modId] = nil
|
||||
self.migrations[modId] = nil
|
||||
self.modSave[modId] = nil
|
||||
self.stepsQueues[modId] = nil
|
||||
end
|
||||
|
||||
-- a mod that explicitly swears it stays link-compatible while writing into a
|
||||
@@ -1365,10 +1494,12 @@ function Loader:load(data)
|
||||
-- every touch: a mod captures the facade at file scope, before Game2 has a
|
||||
-- save or a world (src/mods/Gen2Compat.lua).
|
||||
Gen2Compat.bind(function() return self:_game() end)
|
||||
-- Dev mode wants the permissions tripwire; a Gold boot with mods on it wants
|
||||
-- the Gen 1-only require report, which is the difference between "the mod
|
||||
-- does nothing" and knowing why. A Gold boot with no mods pays nothing.
|
||||
if self.dev or (self.generation ~= 1 and next(self.mods) ~= nil) then
|
||||
-- Any boot with mods on it needs the gate, because require("io") is how a
|
||||
-- mod would walk out of Sandbox.envFor. Dev mode adds the permissions
|
||||
-- tripwire on top, and a Gold boot the Gen 1-only require report -- the
|
||||
-- difference between "the mod does nothing" and knowing why. A boot with no
|
||||
-- mods pays nothing.
|
||||
if self.dev or next(self.mods) ~= nil then
|
||||
self:_installDevShim()
|
||||
end
|
||||
for _, mod in ipairs(ordered) do
|
||||
|
||||
@@ -8,6 +8,8 @@ local Font = require("src.render.Font")
|
||||
local GameVersion = require("src.core.GameVersion")
|
||||
local ModTargets = require("src.mods.ModTargets")
|
||||
local Runtime = require("src.mods.Runtime")
|
||||
local SafePath = require("src.mods.SafePath")
|
||||
local Sandbox = require("src.mods.Sandbox")
|
||||
local SaveData = require("src.core.SaveData")
|
||||
local Semver = require("src.mods.Semver")
|
||||
local Version = require("src.core.Version")
|
||||
@@ -935,13 +937,18 @@ function ManagerState:schemaFor(m)
|
||||
local schema = loader.optionSchemas and loader.optionSchemas[m.id]
|
||||
if schema == nil and m.options_schema and m.path
|
||||
and loader.fs and loader.fs.load then
|
||||
local chunk = loader.fs.load(m.path .. "/" .. m.options_schema)
|
||||
if chunk then
|
||||
local ok, rows = pcall(chunk)
|
||||
if ok and type(rows) == "table" then
|
||||
schema = rows
|
||||
if loader.optionSchemas then loader.optionSchemas[m.id] = schema end
|
||||
end
|
||||
-- mod-authored code, so it runs in the same sandbox the entry chunk does
|
||||
local ok, rows = pcall(function()
|
||||
local path = SafePath.join(m.path, m.options_schema, "options_schema")
|
||||
local mod = loader.mods and loader.mods[m.id]
|
||||
local env = mod and loader._modEnv and loader:_modEnv(mod)
|
||||
or Sandbox.envFor()
|
||||
local chunk = Sandbox.loadFile(loader.fs, path, env)
|
||||
return chunk and chunk()
|
||||
end)
|
||||
if ok and type(rows) == "table" then
|
||||
schema = rows
|
||||
if loader.optionSchemas then loader.optionSchemas[m.id] = schema end
|
||||
end
|
||||
end
|
||||
return schema
|
||||
|
||||
+80
-26
@@ -3,13 +3,15 @@
|
||||
-- that need to stat a file, this owns shape, vocabulary and range grammar.
|
||||
local Logger = require("src.core.Logger")
|
||||
local ModTargets = require("src.mods.ModTargets")
|
||||
local SafePath = require("src.mods.SafePath")
|
||||
local Semver = require("src.mods.Semver")
|
||||
local Version = require("src.core.Version")
|
||||
|
||||
local Manifest = {}
|
||||
|
||||
Manifest.PROFILES = { content = true, overhaul = true, total_conversion = true }
|
||||
Manifest.PERMISSIONS = { network = true, filesystem = true, engine_internals = true }
|
||||
Manifest.PERMISSIONS = { network = true, filesystem = true,
|
||||
engine_internals = true, steps = true }
|
||||
|
||||
-- link-relevant registries; a mod that writes into one of these while
|
||||
-- declaring affects_link = false gets an attributed warning from the loader
|
||||
@@ -31,26 +33,6 @@ local function violation(strict, id, message)
|
||||
Logger.warn("[%s] %s", tostring(id), message)
|
||||
end
|
||||
|
||||
-- "id" or "id@<range>"; a malformed id or range fails for every api level
|
||||
-- because there is no sane fallback reading for it
|
||||
local function parseSpecs(list, field)
|
||||
local specs = {}
|
||||
for _, entry in ipairs(list) do
|
||||
assert(type(entry) == "string" and entry ~= "",
|
||||
field .. " entries must be non-empty strings")
|
||||
local id, range = entry:match("^([%w_%-]+)@(.+)$")
|
||||
if not id then
|
||||
id = entry:match("^([%w_%-]+)$")
|
||||
assert(id, ("malformed %s entry %q"):format(field, entry))
|
||||
range = nil
|
||||
end
|
||||
local ok, err = Semver.validRange(range)
|
||||
assert(ok, ("malformed %s range in %q: %s"):format(field, entry, tostring(err)))
|
||||
specs[#specs + 1] = { id = id, range = range }
|
||||
end
|
||||
return specs
|
||||
end
|
||||
|
||||
-- Optional GitHub repo for launcher auto-update / other-versions.
|
||||
-- Accepts "owner/repo" or a github.com URL; empty/absent means no updates.
|
||||
function Manifest.parseGithub(value)
|
||||
@@ -73,6 +55,69 @@ function Manifest.parseGithub(value)
|
||||
return owner .. "/" .. repo
|
||||
end
|
||||
|
||||
-- "id", "id@<range>", "id@<range>#<github>", "id#<github>", or table entry
|
||||
local function parseSpecs(list, field, sources)
|
||||
local specs = {}
|
||||
sources = type(sources) == "table" and sources or {}
|
||||
for _, entry in ipairs(list) do
|
||||
local id, range, ghHint, gamesRaw, gameVersion
|
||||
if type(entry) == "table" then
|
||||
id = entry.id
|
||||
range = entry.range or entry.version
|
||||
ghHint = entry.github or entry.repo
|
||||
gamesRaw = entry.games or entry.game
|
||||
gameVersion = entry.game_version
|
||||
elseif type(entry) == "string" and entry ~= "" then
|
||||
local main, hashRepo = entry:match("^([^#]+)#(.*)$")
|
||||
if main then
|
||||
entry = main
|
||||
ghHint = hashRepo
|
||||
end
|
||||
id, range = entry:match("^([%w_%-]+)@(.+)$")
|
||||
if not id then
|
||||
id = entry:match("^([%w_%-]+)$")
|
||||
assert(id, ("malformed %s entry %q"):format(field, entry))
|
||||
range = nil
|
||||
end
|
||||
else
|
||||
error(field .. " entries must be non-empty strings or tables")
|
||||
end
|
||||
assert(id, ("malformed %s entry"):format(field))
|
||||
local ok, err = Semver.validRange(range)
|
||||
assert(ok, ("malformed %s range in %q: %s"):format(field, tostring(entry), tostring(err)))
|
||||
|
||||
if gameVersion then
|
||||
local okV, errV = Semver.validRange(gameVersion)
|
||||
assert(okV, ("malformed %s game_version range in %q: %s"):format(field, tostring(entry), tostring(errV)))
|
||||
end
|
||||
|
||||
local parsedGames = nil
|
||||
if gamesRaw ~= nil then
|
||||
if type(gamesRaw) == "string" then gamesRaw = { gamesRaw } end
|
||||
assert(type(gamesRaw) == "table", "dependency games must be a string or table")
|
||||
local normalized, unknown = ModTargets.normalize(gamesRaw)
|
||||
assert(#unknown == 0, ("unknown game in %s: %s"):format(field, table.concat(unknown, ", ")))
|
||||
parsedGames = normalized
|
||||
end
|
||||
|
||||
local parsedGh = nil
|
||||
local rawGh = ghHint or sources[id]
|
||||
if rawGh then
|
||||
local okGh, cleanGh = pcall(Manifest.parseGithub, rawGh)
|
||||
if okGh and cleanGh then parsedGh = cleanGh end
|
||||
end
|
||||
|
||||
specs[#specs + 1] = {
|
||||
id = id,
|
||||
range = range,
|
||||
github = parsedGh,
|
||||
games = parsedGames,
|
||||
game_version = gameVersion,
|
||||
}
|
||||
end
|
||||
return specs
|
||||
end
|
||||
|
||||
-- conflicts + incompatible (alias) merged, first-wins on duplicate ids
|
||||
local function mergeConflictLists(conflicts, incompatible)
|
||||
local seen, out = {}, {}
|
||||
@@ -144,6 +189,9 @@ function Manifest.validate(raw, path)
|
||||
assert(type(raw.name) == "string" and raw.name ~= "", "manifest name is required")
|
||||
assert(type(raw.version) == "string" and raw.version ~= "", "manifest version is required")
|
||||
assert(type(raw.entry) == "string" and raw.entry ~= "", "manifest entry is required")
|
||||
-- every manifest path is joined to the mod's own directory, so none of them
|
||||
-- may climb out of it (src/mods/SafePath.lua)
|
||||
local entry = SafePath.require(raw.entry, "manifest entry")
|
||||
|
||||
-- absent means 1: full v1 compat, schema violations downgrade to warnings
|
||||
assert(raw.api == nil or tonumber(raw.api) ~= nil, "manifest api must be a number")
|
||||
@@ -229,27 +277,32 @@ function Manifest.validate(raw, path)
|
||||
local affectsLink = profile ~= "content" and not language
|
||||
if type(raw.affects_link) == "boolean" then affectsLink = raw.affects_link end
|
||||
|
||||
local function optionalFile(value, field)
|
||||
local function optionalString(value, field)
|
||||
if value == nil then return nil end
|
||||
assert(type(value) == "string" and value ~= "", field .. " must be a file path")
|
||||
return value
|
||||
end
|
||||
|
||||
local function optionalFile(value, field)
|
||||
local text = optionalString(value, field)
|
||||
return text and SafePath.require(text, field)
|
||||
end
|
||||
|
||||
local conflicts = mergeConflictLists(raw.conflicts, raw.incompatible)
|
||||
|
||||
return {
|
||||
id = raw.id,
|
||||
name = raw.name,
|
||||
version = raw.version,
|
||||
entry = raw.entry,
|
||||
entry = entry,
|
||||
api = api,
|
||||
priority = tonumber(raw.priority) or 0,
|
||||
dependencies = array(raw.dependencies),
|
||||
optional_dependencies = array(raw.optional_dependencies),
|
||||
conflicts = conflicts,
|
||||
incompatible = array(raw.incompatible),
|
||||
dependencySpecs = parseSpecs(array(raw.dependencies), "dependencies"),
|
||||
optionalSpecs = parseSpecs(array(raw.optional_dependencies), "optional_dependencies"),
|
||||
dependencySpecs = parseSpecs(array(raw.dependencies), "dependencies", raw.dependency_sources),
|
||||
optionalSpecs = parseSpecs(array(raw.optional_dependencies), "optional_dependencies", raw.dependency_sources),
|
||||
conflictSpecs = parseSpecs(conflicts, "conflicts"),
|
||||
category = raw.category or "OTHER",
|
||||
game_version = raw.game_version,
|
||||
@@ -265,7 +318,8 @@ function Manifest.validate(raw, path)
|
||||
permissionSet = permissionSet,
|
||||
options_schema = optionalFile(raw.options_schema, "options_schema"),
|
||||
assets_transforms = optionalFile(raw.assets_transforms, "assets_transforms"),
|
||||
force_enable_env = optionalFile(raw.force_enable_env, "force_enable_env"),
|
||||
-- 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,
|
||||
raw = raw,
|
||||
}
|
||||
|
||||
@@ -334,9 +334,15 @@ function ModIndex.compatIssues(entry, ctx)
|
||||
local function eachSpec(spec, fn)
|
||||
if type(spec) ~= "table" then return end
|
||||
for k, v in pairs(spec) do
|
||||
if type(k) == "number" and type(v) == "string" then
|
||||
local id, range = v:match("^([^@]+)@(.+)$")
|
||||
fn(id or v, range)
|
||||
if type(k) == "number" then
|
||||
if type(v) == "table" and type(v.id) == "string" then
|
||||
fn(v.id, v.range or v.version, v.github or v.repo)
|
||||
elseif type(v) == "string" then
|
||||
local main, hashRepo = v:match("^([^#]+)#(.*)$")
|
||||
if main then v = main end
|
||||
local id, range = v:match("^([^@]+)@(.+)$")
|
||||
fn(id or v, range, hashRepo)
|
||||
end
|
||||
elseif type(k) == "string" then
|
||||
fn(k, type(v) == "string" and v or nil)
|
||||
end
|
||||
|
||||
@@ -168,4 +168,39 @@ function ModTargets.detail(manifest, version)
|
||||
ModTargets.gameLabel(version))
|
||||
end
|
||||
|
||||
-- Does a dependency spec apply to this game / version?
|
||||
-- If spec.games is provided, it must match version / generation.
|
||||
-- If spec.game_version is provided, the engine version must satisfy it.
|
||||
function ModTargets.specApplies(spec, version, generation)
|
||||
if not spec then return false end
|
||||
if type(spec.games) == "table" and #spec.games > 0 then
|
||||
if version or generation then
|
||||
local match = false
|
||||
for _, id in ipairs(spec.games) do
|
||||
if version and id == version then
|
||||
match = true
|
||||
break
|
||||
end
|
||||
if generation and GameVersion.generation(id) == generation then
|
||||
match = true
|
||||
break
|
||||
end
|
||||
end
|
||||
if not match then return false end
|
||||
end
|
||||
end
|
||||
if spec.game_version then
|
||||
local ok = pcall(function()
|
||||
local Semver = require("src.mods.Semver")
|
||||
local Version = require("src.core.Version")
|
||||
if Version and Version.engine and Version.engine:match("^0%.0%.0%-") == nil then
|
||||
return Semver.satisfies(Version.engine, spec.game_version)
|
||||
end
|
||||
return true
|
||||
end)
|
||||
if not ok then return false end
|
||||
end
|
||||
return true
|
||||
end
|
||||
|
||||
return ModTargets
|
||||
|
||||
@@ -28,6 +28,11 @@ Runtime.errors = nil
|
||||
-- permissions tripwire knows there is nobody to attribute to
|
||||
Runtime.currentMod = nil
|
||||
|
||||
-- set by the sandbox's require for the duration of one mod-initiated require,
|
||||
-- so the loader's gate can still attribute a lazy one made long after
|
||||
-- currentMod went back to nil (src/mods/Sandbox.lua)
|
||||
Runtime.modRequire = nil
|
||||
|
||||
function Runtime.install(events, hooks, errors)
|
||||
Runtime.events, Runtime.hooks = events, hooks
|
||||
Runtime.errors = errors
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
-- One relative-path grammar for every path a mod supplies: a mod names files
|
||||
-- inside its own directory and nowhere else. love.filesystem (PhysFS) already
|
||||
-- refuses "..", absolute paths and backslashes, but Loader.new takes an
|
||||
-- injected fs that has no such floor, so the rule lives here and not in
|
||||
-- whichever filesystem happens to be underneath.
|
||||
local SafePath = {}
|
||||
|
||||
-- The normalized path, or nil when it could climb out of the root it is about
|
||||
-- to be joined to. "." segments are dropped rather than rejected so a
|
||||
-- manifest that says "./main.lua" still loads.
|
||||
function SafePath.safe(rel)
|
||||
if type(rel) ~= "string" or rel == "" then return nil end
|
||||
if rel:sub(1, 1) == "/" then return nil end
|
||||
if rel:find("\\", 1, true) then return nil end
|
||||
if rel:match("^%a:") then return nil end -- windows drive-relative
|
||||
local parts = {}
|
||||
for segment in rel:gmatch("[^/]+") do
|
||||
if segment == ".." then return nil end
|
||||
if segment ~= "." then parts[#parts + 1] = segment end
|
||||
end
|
||||
if #parts == 0 then return nil end
|
||||
return table.concat(parts, "/")
|
||||
end
|
||||
|
||||
function SafePath.require(rel, what)
|
||||
local safe = SafePath.safe(rel)
|
||||
if not safe then
|
||||
error(("%s must stay inside its root, got %q"):format(what, tostring(rel)), 0)
|
||||
end
|
||||
return safe
|
||||
end
|
||||
|
||||
-- root .. "/" .. rel, with the traversal check in between
|
||||
function SafePath.join(root, rel, what)
|
||||
return root .. "/" .. SafePath.require(rel, what or "path")
|
||||
end
|
||||
|
||||
return SafePath
|
||||
@@ -0,0 +1,228 @@
|
||||
-- The environment a mod's own code runs in. Every chunk a mod authors -- the
|
||||
-- entry file, an options_schema, anything it hands to load() -- runs against
|
||||
-- this table instead of _G, so the only paths it can name are the ones the
|
||||
-- engine hands it (mod:read, mod.storage, mod.assets).
|
||||
--
|
||||
-- What this is and is not: raw io/os/ffi are the only way to name a file
|
||||
-- outside the game tree at all, and they are absent here, so the reported
|
||||
-- "any mod can rewrite anything in your home directory" hole closes by
|
||||
-- construction. Inside the LÖVE tree this is defense in depth, not a security
|
||||
-- boundary: an engine module reached through require, or ImageData:encode,
|
||||
-- still writes in the save directory.
|
||||
--
|
||||
-- Lua 5.1/LuaJIT is the target, so setfenv is the mechanism; the 5.2+ arm
|
||||
-- exists because AssetTransform's sandbox needed it and getting this wrong
|
||||
-- silently hands the chunk the real globals.
|
||||
|
||||
local Runtime = require("src.mods.Runtime")
|
||||
local SafePath = require("src.mods.SafePath")
|
||||
|
||||
local Sandbox = {}
|
||||
|
||||
-- Modules that hand a mod the disk, a raw socket or a fresh Lua state no
|
||||
-- matter what this file removes from the environment. package.loaded.io is
|
||||
-- the one call that would undo every other rule here.
|
||||
local DENIED = {
|
||||
io = "the filesystem", os = "the filesystem", debug = "the debug library",
|
||||
package = "the module loader", ffi = "arbitrary C calls",
|
||||
}
|
||||
|
||||
-- Same idea one level up: love.filesystem is reachable by name, and
|
||||
-- love.thread starts a Lua state this sandbox has no say over.
|
||||
local DENIED_PREFIX = { ["love"] = true, ["ffi"] = true }
|
||||
|
||||
-- The wire, which is what the network permission governs.
|
||||
local NETWORK = { socket = true, enet = true, http = true, https = true,
|
||||
ssl = true, mime = true, ltn12 = true }
|
||||
|
||||
local function head(name)
|
||||
return (name:match("^([^%.]+)")) or name
|
||||
end
|
||||
|
||||
-- nil when the require is allowed, else the message to fail it with.
|
||||
function Sandbox.moduleDenial(name, permissionSet)
|
||||
if type(name) ~= "string" then return nil end
|
||||
local root = head(name)
|
||||
local reason = DENIED[root]
|
||||
if reason then
|
||||
return ("%s is not available to mods (it grants %s); use mod.storage, "
|
||||
.. "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, 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)
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
-- ------- the love facade
|
||||
|
||||
-- Dropped, not narrowed: filesystem writes anywhere in the save directory
|
||||
-- (including another mod's storage), thread opens a Lua state with a full
|
||||
-- standard library, system.openURL launches whatever it is handed, and event
|
||||
-- lets a mod quit the game out from under the player. Everything else LÖVE
|
||||
-- exposes passes through, so a new module in a future LÖVE is available
|
||||
-- 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, mod:read and mod:list", thread = true,
|
||||
system = "mod.device:powerInfo() for battery information, mod.steps for "
|
||||
.. "the step bridge", event = true,
|
||||
}
|
||||
|
||||
local loveProxy
|
||||
local function loveFacade()
|
||||
if loveProxy or not _G.love then return loveProxy end
|
||||
loveProxy = setmetatable({}, {
|
||||
__index = function(_, key)
|
||||
local hint = BLOCKED_LOVE[key]
|
||||
if hint then
|
||||
error(("love.%s is not available to mods%s"):format(key,
|
||||
type(hint) == "string" and (", use " .. hint) or ""), 2)
|
||||
end
|
||||
return _G.love[key]
|
||||
end,
|
||||
__newindex = function(_, key)
|
||||
error(("mods cannot assign love.%s"):format(tostring(key)), 2)
|
||||
end,
|
||||
})
|
||||
return loveProxy
|
||||
end
|
||||
|
||||
-- ------- the environment
|
||||
|
||||
-- Absent on purpose: io, package, dofile, loadfile, getfenv, setfenv, debug,
|
||||
-- newproxy, module. os keeps only the clock -- getenv is how the reported
|
||||
-- exploit found the user's home directory.
|
||||
local SAFE_OS = { time = true, date = true, clock = true, difftime = true }
|
||||
|
||||
-- Per-mod copies, not the shared tables: a mod that assigns string.trim or
|
||||
-- replaces table.insert changes its own view and nobody else's. The functions
|
||||
-- are the same objects, so state behind them (math.randomseed's RNG) is
|
||||
-- unaffected -- only the namespace is private.
|
||||
local function copy(source)
|
||||
if type(source) ~= "table" then return source end
|
||||
local out = {}
|
||||
for key, value in pairs(source) do out[key] = value end
|
||||
return out
|
||||
end
|
||||
|
||||
local function baseGlobals()
|
||||
local safeOs = {}
|
||||
for key in pairs(SAFE_OS) do safeOs[key] = os[key] end
|
||||
return {
|
||||
assert = assert, error = error, ipairs = ipairs, next = next,
|
||||
pairs = pairs, pcall = pcall, xpcall = xpcall, select = select,
|
||||
tonumber = tonumber, tostring = tostring, type = type, unpack = unpack,
|
||||
rawequal = rawequal, rawget = rawget, rawset = rawset, rawlen = rawlen,
|
||||
setmetatable = setmetatable, getmetatable = getmetatable, print = print,
|
||||
collectgarbage = collectgarbage, _VERSION = _VERSION,
|
||||
coroutine = copy(coroutine), math = copy(math), string = copy(string),
|
||||
table = copy(table), bit = copy(bit), jit = jit, os = safeOs,
|
||||
}
|
||||
end
|
||||
|
||||
-- setfenv on 5.1/LuaJIT; on 5.2+ the env has to be handed to load itself, so
|
||||
-- a caller there compiles through Sandbox.compile instead.
|
||||
function Sandbox.bind(chunk, env)
|
||||
if setfenv then setfenv(chunk, env) end
|
||||
return chunk
|
||||
end
|
||||
|
||||
-- Bytecode is unreviewable and, on LuaJIT, a way out of any sandbox built out
|
||||
-- of environments. Mods ship source.
|
||||
local function rejectBytecode(source, what)
|
||||
if type(source) == "string" and source:sub(1, 1) == "\27" then
|
||||
return nil, (what or "chunk") .. ": mods must ship Lua source, not bytecode"
|
||||
end
|
||||
return true
|
||||
end
|
||||
|
||||
function Sandbox.compile(source, chunkname, env)
|
||||
local ok, err = rejectBytecode(source, chunkname)
|
||||
if not ok then return nil, err end
|
||||
if setfenv then
|
||||
local chunk, compileErr = loadstring(source, chunkname)
|
||||
if not chunk then return nil, compileErr end
|
||||
return setfenv(chunk, env)
|
||||
end
|
||||
return load(source, chunkname, "t", env)
|
||||
end
|
||||
|
||||
-- The load() a mod sees. Lua 5.1 gives a loaded chunk the GLOBAL environment
|
||||
-- rather than the caller's, so without this every sandboxed mod is one
|
||||
-- load(mod:read(...)) away from the real _G -- which is exactly how the
|
||||
-- multi-file mods in mods/ are written.
|
||||
local function sandboxedLoad(env)
|
||||
return function(chunk, chunkname)
|
||||
if type(chunk) == "function" then
|
||||
local parts = {}
|
||||
while true do
|
||||
local piece = chunk()
|
||||
if piece == nil or piece == "" then break end
|
||||
parts[#parts + 1] = piece
|
||||
end
|
||||
chunk = table.concat(parts)
|
||||
end
|
||||
if type(chunk) ~= "string" then return nil, "load expects a string or reader" end
|
||||
return Sandbox.compile(chunk, chunkname or "=(load)", env)
|
||||
end
|
||||
end
|
||||
|
||||
-- The require a mod sees: the deny list lives here rather than on a stack
|
||||
-- walk, because pcall(require, "io") puts a C frame where the walk would look.
|
||||
-- Runtime.modRequire is how the loader's gate identifies the caller for the
|
||||
-- Gen 2 facade once Runtime.currentMod has gone back to nil (a mod requiring
|
||||
-- lazily from an event handler).
|
||||
local function sandboxedRequire(modId, permissionSet)
|
||||
return function(name, ...)
|
||||
local denial = Sandbox.moduleDenial(name, permissionSet)
|
||||
if denial then error(("[%s] %s"):format(modId or "mod", denial), 2) end
|
||||
local previous = Runtime.modRequire
|
||||
Runtime.modRequire = modId or true
|
||||
local ok, result = pcall(_G.require, name, ...)
|
||||
Runtime.modRequire = previous
|
||||
if not ok then error(result, 0) end
|
||||
return result
|
||||
end
|
||||
end
|
||||
|
||||
function Sandbox.envFor(opts)
|
||||
opts = opts or {}
|
||||
local env = baseGlobals()
|
||||
env.love = loveFacade()
|
||||
env.require = sandboxedRequire(opts.modId, opts.permissions)
|
||||
local loader = sandboxedLoad(env)
|
||||
env.load = loader
|
||||
env.loadstring = loader
|
||||
-- a mod's globals are its own: two mods no longer share a namespace, and
|
||||
-- neither can reach the engine's
|
||||
env._G = env
|
||||
return env
|
||||
end
|
||||
|
||||
-- fs.load keeps the real filesystem's handling of the file; the environment is
|
||||
-- swapped after the fact. The 5.2+ arm has to go back to source, which is the
|
||||
-- only reason fs.read is touched here.
|
||||
function Sandbox.loadFile(fs, path, env)
|
||||
if fs.read then
|
||||
local ok, err = rejectBytecode(fs.read(path), path)
|
||||
if not ok then return nil, err end
|
||||
end
|
||||
if setfenv then
|
||||
local chunk, err = fs.load(path)
|
||||
if not chunk then return nil, err end
|
||||
return setfenv(chunk, env)
|
||||
end
|
||||
local source = fs.read and fs.read(path)
|
||||
if not source then return nil, "unable to read " .. path end
|
||||
return Sandbox.compile(source, "@" .. path, env)
|
||||
end
|
||||
|
||||
Sandbox.safePath = SafePath.safe
|
||||
Sandbox.requirePath = SafePath.require
|
||||
|
||||
return Sandbox
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
-- The scoped seam for the native step bridge (#1186).
|
||||
--
|
||||
-- The iOS/Android builds count the player's real-world steps natively
|
||||
-- (#452, #489) and deliver them by writing steps_pending.json into the
|
||||
-- save-directory root. Before the sandbox, the Pokéwalker mod called
|
||||
-- love.system.syncHealthSteps() and consumed that file itself; the sandbox
|
||||
-- blocks both, which is correct -- love.system launches URLs and the file
|
||||
-- API names paths -- but it left the bridge with no consumer at all.
|
||||
--
|
||||
-- This module is the narrow replacement, gated by the "steps" permission
|
||||
-- in manifest.json (the network model: a permission the player sees that
|
||||
-- genuinely gates a capability). The engine owns the file: mods never
|
||||
-- learn its name or location, they receive only the three contract fields
|
||||
-- ({ steps, from, to }), each permissioned mod gets its own copy, and the
|
||||
-- merge-don't-overwrite anchor semantics stay on the native side where
|
||||
-- they always lived.
|
||||
--
|
||||
-- No frame pump: the file is looked for lazily when a mod polls, so a
|
||||
-- build with no permissioned mod installed never touches the bridge or
|
||||
-- the disk.
|
||||
|
||||
local Json = require("src.link.Json")
|
||||
|
||||
local Steps = {}
|
||||
|
||||
-- The native contract's drop point, in the save-directory root (see
|
||||
-- mobile/ios and mobile/android step bridges).
|
||||
Steps.PENDING = "steps_pending.json"
|
||||
|
||||
local function bridge()
|
||||
return _G.love and _G.love.system and _G.love.system.syncHealthSteps
|
||||
end
|
||||
|
||||
-- Whether this build carries the native bridge. Desktop builds do not;
|
||||
-- a mod uses this to stay dormant without probing love.system.
|
||||
function Steps.available()
|
||||
return bridge() ~= nil
|
||||
end
|
||||
|
||||
-- Ask the native side to refresh its count. Async: the result lands in
|
||||
-- the pending file and comes back through a later poll. The platform's
|
||||
-- own consent sheet (HealthKit / ACTIVITY_RECOGNITION) still appears on
|
||||
-- first use, exactly as it did pre-sandbox. false when there is no
|
||||
-- bridge to ask.
|
||||
function Steps.sync()
|
||||
local fn = bridge()
|
||||
if not fn then return false end
|
||||
fn()
|
||||
return true
|
||||
end
|
||||
|
||||
-- Consume the pending file, if one has appeared, and fan its payload out
|
||||
-- to every permissioned mod's queue. Only the contract fields travel;
|
||||
-- anything else in the file stays in the file's grave. A malformed or
|
||||
-- empty delivery is dropped whole -- the native anchor only advances on a
|
||||
-- successful sync, so nothing is lost to a bad write.
|
||||
function Steps.pump(loader)
|
||||
local fs = _G.love and _G.love.filesystem
|
||||
if not (fs and fs.getInfo(Steps.PENDING, "file")) then return end
|
||||
local raw = fs.read(Steps.PENDING)
|
||||
fs.remove(Steps.PENDING)
|
||||
if not raw then return end
|
||||
local ok, decoded = pcall(Json.decode, raw)
|
||||
if not ok or type(decoded) ~= "table" then return end
|
||||
local steps = tonumber(decoded.steps)
|
||||
if not steps or steps <= 0 then return end
|
||||
local payload = { steps = steps, from = decoded.from, to = decoded.to }
|
||||
for _, queue in pairs(loader.stepsQueues) do
|
||||
queue[#queue + 1] = { steps = payload.steps, from = payload.from,
|
||||
to = payload.to }
|
||||
end
|
||||
end
|
||||
|
||||
-- The next delivery for this mod, or nil. Each permissioned mod consumes
|
||||
-- its own queue, so two mods both see the same walk (pre-sandbox, whoever
|
||||
-- read the file first won).
|
||||
function Steps.poll(loader, modId)
|
||||
Steps.pump(loader)
|
||||
local queue = loader.stepsQueues[modId]
|
||||
if not queue then return nil end
|
||||
return table.remove(queue, 1)
|
||||
end
|
||||
|
||||
return Steps
|
||||
+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
|
||||
|
||||
|
||||
@@ -140,10 +140,12 @@ function Evolution.learnEvolutionMoves(game, mon, onDone)
|
||||
if #mon.moves < 4 then
|
||||
table.insert(mon.moves, { id = moveId, pp = mdef.pp })
|
||||
Runtime.emit("pokemon.move_learned", { mon = mon, moveId = moveId })
|
||||
require("src.core.Sound").play(game.data, "Get_Item1")
|
||||
-- LearnedMove1Text: text_far, sound_get_item_1, text_promptbutton
|
||||
-- (learn_move.asm), so the jingle rides the box
|
||||
game.stack:push(TextBox.new(game,
|
||||
romText(game.data, "_LearnedMove1Text",
|
||||
"%s learned\n%s!", name, mdef.name), nextStep))
|
||||
"%s learned\n%s!", name, mdef.name), nextStep,
|
||||
TextBox.soundOpts(game, "Get_Item1")))
|
||||
else
|
||||
-- LearnMoveFromLevelUp with a full moveset: the forget UI
|
||||
Screens.push(game, "MoveLearnMenu", mon, moveId, nextStep)
|
||||
|
||||
@@ -141,13 +141,20 @@ end
|
||||
-- Tinting first would double-apply the color: GREENBAR's fill {0,189,0} has
|
||||
-- red channel 0, so the tint zeroes the whole bar's red and the zone's
|
||||
-- red-channel-keyed shade shader then maps every pixel to color 3 = black.
|
||||
function HudTiles.drawHPBar(data, tx, ty, mon, barType, grayFill, segments)
|
||||
--
|
||||
-- pixels: an explicit 0..48 bar length on GetHPBarLength's scale, for a
|
||||
-- caller that is animating the bar between two HP values
|
||||
-- (UpdateHPBar_AnimateHPBar); it scales with `segments` like the color
|
||||
-- thresholds do. Without it the length comes from mon.hp as before.
|
||||
function HudTiles.drawHPBar(data, tx, ty, mon, barType, grayFill, segments, pixels)
|
||||
local x, y = tx * 8, ty * 8
|
||||
segments = math.max(1, math.floor(segments or 6))
|
||||
HudTiles.tile(0x71, x, y)
|
||||
HudTiles.tile(0x62, x + 8, y)
|
||||
local px = 0
|
||||
if mon.stats.hp > 0 and mon.hp > 0 then
|
||||
if pixels then
|
||||
px = math.max(0, math.floor(pixels * segments / 6))
|
||||
elseif mon.stats.hp > 0 and mon.hp > 0 then
|
||||
px = math.max(1, math.floor(mon.hp * segments * 8 / mon.stats.hp))
|
||||
end
|
||||
local tint
|
||||
|
||||
@@ -691,10 +691,12 @@ function PaletteFX.spriteObp(spriteDef, seed)
|
||||
return PaletteFX.darkObp(w.spritePalettes[group], group)
|
||||
end
|
||||
|
||||
-- GetHealthBarColor (home/palettes.asm) on the standard 48px bar
|
||||
function PaletteFX.barPalName(hp, maxHp)
|
||||
local px = maxHp > 0 and math.floor(hp * 48 / maxHp) or 0
|
||||
if hp > 0 and px < 1 then px = 1 end
|
||||
-- GetHealthBarColor (home/palettes.asm) on the standard 48px bar. It reads
|
||||
-- the bar's own length, so a caller mid-drain passes the animated `pixels`
|
||||
-- rather than let it be re-derived from hp.
|
||||
function PaletteFX.barPalName(hp, maxHp, pixels)
|
||||
local px = pixels or (maxHp > 0 and math.floor(hp * 48 / maxHp) or 0)
|
||||
if not pixels and hp > 0 and px < 1 then px = 1 end
|
||||
return px >= 27 and "GREENBAR" or px >= 10 and "YELLOWBAR" or "REDBAR"
|
||||
end
|
||||
|
||||
|
||||
+25
-13
@@ -768,11 +768,12 @@ function Renderer:endFrame(zones, worldZones)
|
||||
end
|
||||
end
|
||||
|
||||
-- A post-process pipeline needs the whole composite in a canvas for the
|
||||
-- same reason GBC FX does, so either one alone is enough to take the
|
||||
-- present path; with neither, the frame draws straight to the screen
|
||||
-- exactly as it always did.
|
||||
local needPresent = GBCFX.active() or Pipelines.wantsPresent()
|
||||
-- Post-process pipelines, GBC FX and an enabled final-output owner need the
|
||||
-- whole composite in a canvas. With none of them, the frame draws straight
|
||||
-- to the screen exactly as it always did.
|
||||
local hasOutputHook = Runtime.wantsHook("render.output")
|
||||
and Runtime.call("render.output_enabled", function() return false end) == true
|
||||
local needPresent = GBCFX.active() or Pipelines.wantsPresent() or hasOutputHook
|
||||
local present = nil
|
||||
if needPresent then
|
||||
if not self.presentCanvas or self.presentCanvas:getWidth() ~= ww
|
||||
@@ -1057,14 +1058,25 @@ function Renderer:endFrame(zones, worldZones)
|
||||
-- this returns `present` unchanged and the frame is byte-identical.
|
||||
local composed = Pipelines.present(present,
|
||||
{ width = ww, height = wh, scale = Sp, dpi = dpiY, dpiX = dpiX, dpiY = dpiY }) or present
|
||||
if GBCFX.active() then
|
||||
-- shader grid/shadow math is in framebuffer pixels
|
||||
GBCFX.present(composed, Sp)
|
||||
else
|
||||
-- the present canvas only existed for the post-process, so put the
|
||||
-- result on the screen at the same 1:1 unit mapping it was built at
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
love.graphics.draw(composed, 0, 0)
|
||||
local outputHandled = hasOutputHook
|
||||
and Runtime.call("render.output", function() return false end, {
|
||||
canvas = composed,
|
||||
width = ww, height = wh,
|
||||
gameX = ox, gameY = oy,
|
||||
gameWidth = vpw, gameHeight = vph,
|
||||
scale = Sp, dpiX = dpiX, dpiY = dpiY,
|
||||
generation = 1,
|
||||
}) == true
|
||||
if not outputHandled then
|
||||
if GBCFX.active() then
|
||||
-- shader grid/shadow math is in framebuffer pixels
|
||||
GBCFX.present(composed, Sp)
|
||||
else
|
||||
-- the present canvas only existed for the post-process, so put the
|
||||
-- result on the screen at the same 1:1 unit mapping it was built at
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
love.graphics.draw(composed, 0, 0)
|
||||
end
|
||||
end
|
||||
end
|
||||
self.worldActive = false
|
||||
|
||||
@@ -100,6 +100,26 @@ function TextBox.new(game, text, onDone, opts)
|
||||
return self
|
||||
end
|
||||
|
||||
-- soundOpts: a jingle carried at the end of the string as a trailing text
|
||||
-- command (sound_get_item_1 and friends -> home/text.asm TextCommand_SOUND),
|
||||
-- which runs PlaySound then WaitForSoundToFinish once the last page has
|
||||
-- typed, so the box holds until the fanfare is over. Merges into a caller's
|
||||
-- opts table; auto.wait keeps the trailing button press the plain A/B path
|
||||
-- gives every other box.
|
||||
function TextBox.soundOpts(game, sound, opts)
|
||||
opts = opts or {}
|
||||
local auto = opts.auto
|
||||
-- auto = true is the no-button-wait arming (text_opts); keep that choice
|
||||
if auto == true then auto = { wait = false } end
|
||||
auto = auto or {}
|
||||
if auto.wait == nil then auto.wait = true end
|
||||
auto.delay = auto.delay or 0
|
||||
auto.sound = type(sound) == "function" and sound
|
||||
or function() return require("src.core.Sound").play(game.data, sound) end
|
||||
opts.auto = auto
|
||||
return opts
|
||||
end
|
||||
|
||||
-- The runtime tokens substitute() knows, as handlers the tokens registry
|
||||
-- serves. Each is fn(game, arg) -> replacement, or nil to drop the token.
|
||||
-- RAM keeps pokered's stale-buffer semantics: give_item copies the item
|
||||
|
||||
+15
-2
@@ -592,6 +592,17 @@ function Commands.play_sound(ctx, soundId)
|
||||
require("src.core.Sound").play(ctx.game.data, soundId)
|
||||
end
|
||||
|
||||
-- text_sound <soundId>: the jingle the ROM parks at the END of a string as
|
||||
-- a trailing text command (sound_get_item_1, sound_get_key_item ->
|
||||
-- home/text.asm TextCommand_SOUND). It arms the NEXT show_text the same
|
||||
-- way play_cry arms ctx.pendingCry, so the fanfare fires once the last page
|
||||
-- has typed and the box holds on WaitForSoundToFinish before the button
|
||||
-- wait. play_sound stays the bare PlaySound used for the non-blocking
|
||||
-- beats (Bill's teleporter, the S.S. Anne horn).
|
||||
function Commands.text_sound(ctx, soundId)
|
||||
ctx.textOpts = TextBox.soundOpts(ctx.game, soundId, ctx.textOpts)
|
||||
end
|
||||
|
||||
-- play_once <songId>: one-shot jingle (Music_PkmnHealed, etc.); blocks
|
||||
-- until it finishes so heal-rest scripts (Mom, captain text_asm) match
|
||||
-- Gen1's wait-on-channel loop. The map theme resumes when it ends
|
||||
@@ -1050,8 +1061,10 @@ function Commands.trade(ctx, tradeIndex, doneFlag)
|
||||
onDone = function() runner:resume() end,
|
||||
})
|
||||
runner:yield()
|
||||
-- TradedForText (sound_get_key_item) then the dialogset's thanks
|
||||
require("src.core.Sound").play(data, "Get_Key_Item")
|
||||
-- TradedForText carries sound_get_key_item after the text, so the jingle
|
||||
-- rides the box and blocks it (home/text.asm TextCommand_SOUND), then the
|
||||
-- dialogset's thanks
|
||||
Commands.text_sound(ctx, "Get_Key_Item")
|
||||
say(texts.tradedFor or "_TradedForText")
|
||||
say(texts.thanks or "_Thanks" .. dialogset .. "Text")
|
||||
end
|
||||
|
||||
+8
-6
@@ -33,12 +33,12 @@ local function save_name(game)
|
||||
return game.save.player.name
|
||||
end
|
||||
|
||||
local function showMessages(game, msgs, onDone)
|
||||
local function showMessages(game, msgs, onDone, opts)
|
||||
if not msgs or #msgs == 0 then
|
||||
if onDone then onDone() end
|
||||
return
|
||||
end
|
||||
game.stack:push(TextBox.new(game, table.concat(msgs, "\f"), onDone))
|
||||
game.stack:push(TextBox.new(game, table.concat(msgs, "\f"), onDone, opts))
|
||||
end
|
||||
|
||||
-- run the use-flow for an item on a chosen target. `picker` is the party
|
||||
@@ -181,9 +181,11 @@ local function useOn(game, battle, id, target, list, moveIndex, picker)
|
||||
end
|
||||
if #target.moves < 4 then
|
||||
table.insert(target.moves, { id = moveId, pp = mdef.pp })
|
||||
require("src.core.Sound").play(game.data, "Get_Item1")
|
||||
-- LearnedMove1Text: text_far, sound_get_item_1, text_promptbutton
|
||||
-- (learn_move.asm), so the jingle rides the box
|
||||
showMessages(game, { Strings("%s learned\n%s!", target.nickname or
|
||||
game.data.pokemon[target.species].name, mdef.name) })
|
||||
game.data.pokemon[target.species].name, mdef.name) }, nil,
|
||||
TextBox.soundOpts(game, "Get_Item1"))
|
||||
if result == "learn" then consume(game, id) end
|
||||
list.items = buildItems(game)
|
||||
list.index = math.min(list.index, math.max(1, #list.items))
|
||||
@@ -333,10 +335,9 @@ local function useOn(game, battle, id, target, list, moveIndex, picker)
|
||||
local mdef = game.data.moves[moveId]
|
||||
if #target.moves < 4 then
|
||||
table.insert(target.moves, { id = moveId, pp = mdef.pp })
|
||||
require("src.core.Sound").play(game.data, "Get_Item1")
|
||||
local name = target.nickname or def.name
|
||||
showMessages(game, { Strings("%s learned\n%s!", name, mdef.name) },
|
||||
nextStep)
|
||||
nextStep, TextBox.soundOpts(game, "Get_Item1"))
|
||||
else
|
||||
require("src.ui.Screens").push(game, "MoveLearnMenu",
|
||||
target, moveId, nextStep)
|
||||
@@ -382,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.
|
||||
|
||||
@@ -46,9 +46,27 @@ function EvolutionState:sgbPalettes(game)
|
||||
return P.wholeNamed(game.data, "MEWMON")
|
||||
end
|
||||
|
||||
local FLASH_FRAMES = 220
|
||||
-- evolution.asm EvolveMon delays 80 frames before .animLoop, polling nothing (#968, #1031)
|
||||
local CANCEL_GRACE_FRAMES = 80
|
||||
-- .animLoop starts at `lb bc, $1, $10` and runs 8 iterations: iteration k holds
|
||||
-- the old pic for c = 18-2k frames inside Evolution_CheckForCancel (72 in all),
|
||||
-- then Evolution_BackAndForthAnim swaps b = k times, each swap a pair of
|
||||
-- Evolution_ChangeMonPic that end in Delay3 (6 frames a swap, 216 in all).
|
||||
local ANIM_LOOP_FRAMES = 288
|
||||
local FLASH_FRAMES = CANCEL_GRACE_FRAMES + ANIM_LOOP_FRAMES
|
||||
|
||||
-- which pic is on screen t frames into .animLoop
|
||||
local function evoShowsNew(t)
|
||||
for b = 1, 8 do
|
||||
local hold = 18 - 2 * b
|
||||
if t < hold then return false end
|
||||
t = t - hold
|
||||
local swap = b * 6
|
||||
if t < swap then return t % 6 < 3 end
|
||||
t = t - swap
|
||||
end
|
||||
return true
|
||||
end
|
||||
|
||||
local function frontSprite(game, species, mon)
|
||||
local path, trueColor = require("src.pokemon.Sprites").path(
|
||||
@@ -140,14 +158,10 @@ function EvolutionState:draw()
|
||||
else
|
||||
sprite, spriteTrueColor = self.newSprite, self.newSpriteTrueColor
|
||||
end
|
||||
elseif evoShowsNew(self.t - CANCEL_GRACE_FRAMES) then
|
||||
sprite, spriteTrueColor = self.newSprite, self.newSpriteTrueColor
|
||||
else
|
||||
local period = math.max(4, 28 - math.floor(self.t / 40) * 6)
|
||||
local showNew = math.floor(self.t / period) % 2 == 1
|
||||
if showNew then
|
||||
sprite, spriteTrueColor = self.newSprite, self.newSpriteTrueColor
|
||||
else
|
||||
sprite, spriteTrueColor = self.oldSprite, self.oldSpriteTrueColor
|
||||
end
|
||||
sprite, spriteTrueColor = self.oldSprite, self.oldSpriteTrueColor
|
||||
end
|
||||
if sprite then
|
||||
local x = math.floor((160 - sprite:getWidth()) / 2)
|
||||
|
||||
+16
-3
@@ -6,6 +6,7 @@ local GameVersion = require("src.core.GameVersion")
|
||||
local Music = require("src.core.Music")
|
||||
local Sound = require("src.core.Sound")
|
||||
local Strings = require("src.core.Strings")
|
||||
local Timing = require("src.core.Timing")
|
||||
|
||||
local IntroMovie = {}
|
||||
IntroMovie.__index = IntroMovie
|
||||
@@ -232,6 +233,10 @@ function IntroMovie:fightStep()
|
||||
self:finish()
|
||||
return
|
||||
end
|
||||
-- an op that consumed frames ends ON its last frame; only the instant
|
||||
-- ops (sfx / pose / frame, which are plain writes between DelayFrames
|
||||
-- calls in PlayIntroScene) chain into the next op the same frame
|
||||
local timed = false
|
||||
if op.sfx then
|
||||
Sound.play(self.game.data, op.sfx)
|
||||
elseif op.pose then
|
||||
@@ -250,6 +255,7 @@ function IntroMovie:fightStep()
|
||||
end
|
||||
self.opTimer = self.opTimer + 1
|
||||
if self.opTimer < (op.px or math.abs(op.dx)) then return end
|
||||
timed = true
|
||||
elseif op.anim then
|
||||
-- one {dy,dx} delta per 5 frames (AnimateIntroNidorino: DelayFrames 5)
|
||||
if self.opTimer % 5 == 0 then
|
||||
@@ -259,9 +265,11 @@ function IntroMovie:fightStep()
|
||||
end
|
||||
self.opTimer = self.opTimer + 1
|
||||
if self.opTimer < #ANIM[op.anim] * 5 then return end
|
||||
timed = true
|
||||
elseif op.wait then
|
||||
self.opTimer = self.opTimer + 1
|
||||
if self.opTimer < op.wait then return end
|
||||
timed = true
|
||||
elseif op.fade then
|
||||
self.opTimer = self.opTimer + 1
|
||||
self.fade = self.opTimer / op.fade
|
||||
@@ -270,6 +278,7 @@ function IntroMovie:fightStep()
|
||||
end
|
||||
self.opIndex = self.opIndex + 1
|
||||
self.opTimer = 0
|
||||
if timed then return end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -284,8 +293,9 @@ function IntroMovie:update(dt)
|
||||
return
|
||||
end
|
||||
local input = self.game.input
|
||||
if input:wasPressed("a") or input:wasPressed("b")
|
||||
or input:wasPressed("start") then
|
||||
if input:wasPressed("a") or input:wasPressed("start") then
|
||||
-- CheckForUserInterruption (home/overworld.asm:2395) returns carry only
|
||||
-- on a fresh START or A -- B alone never skips the intro.
|
||||
-- PlayIntro still GBFadeOutToWhite's after an interrupted scene; the
|
||||
-- white hold stands in for that beat before the title is built.
|
||||
self:exitToTitle()
|
||||
@@ -300,7 +310,10 @@ function IntroMovie:update(dt)
|
||||
end
|
||||
if self.timer >= SPLASH_FRAMES then self:startPhase(3) end
|
||||
else
|
||||
self:fightStep()
|
||||
-- PlayShootingStar ends `jp Delay3` once Music_IntroBattle is playing
|
||||
-- (intro.asm:337), so PlayIntroScene's first op is not on the music's
|
||||
-- own frame
|
||||
if self.timer > Timing.DELAY3 then self:fightStep() end
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
+91
-37
@@ -87,6 +87,15 @@ local function refuseUnavailable(self)
|
||||
t._SleepingPikachuText1 or Strings("There isn't any\nresponse...")))
|
||||
end
|
||||
|
||||
-- .newBadgeRequired (start_sub_menus.asm): every badge-gated arm of
|
||||
-- .outOfBattleMovePointers prints this and jumps back to the open submenu
|
||||
local function refuseBadge(self)
|
||||
local TextBox = require("src.render.TextBox")
|
||||
local t = self.game.data and self.game.data.text or {}
|
||||
self.game.stack:push(TextBox.new(self.game,
|
||||
t._NewBadgeRequiredText or Strings("No! A new BADGE\nis required.")))
|
||||
end
|
||||
|
||||
-- where DIG escapes work: escape_rope_tilesets.asm (Agatha's room is
|
||||
-- excluded by map id in ItemUseEscapeRope)
|
||||
local DIG_TILESETS = { FOREST = true, CEMETERY = true, CAVERN = true,
|
||||
@@ -280,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
|
||||
@@ -288,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
|
||||
@@ -305,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
|
||||
@@ -411,6 +421,23 @@ function PartyMenu:update(dt)
|
||||
-- flyTo (OverworldController) validates the fly-warp + runs the
|
||||
-- departure/warp, so we just hand it the chosen mapId (#195).
|
||||
local ow = self.game.overworld
|
||||
-- .fly checks THUNDERBADGE first, then CheckIfInOutsideMap
|
||||
-- (OVERWORLD + PLATEAU -- Route 23 / Indigo Plateau outdoor -- not
|
||||
-- OVERWORLD alone, #83); both refusals loop back to the submenu
|
||||
if ow and not ow:partyKnows("FLY") then
|
||||
refuseBadge(self)
|
||||
return
|
||||
end
|
||||
if ow and not Map.isOutside(ow.map.def,
|
||||
FieldDefaults.field(self.game.data, "outsideTilesets")) then
|
||||
local TextBox = require("src.render.TextBox")
|
||||
local def = self.game.data.pokemon[mon.species]
|
||||
local txt = (self.game.data.text._CannotFlyHereText
|
||||
or Strings("{RAM:wNameBuffer} can't\nFLY here."))
|
||||
:gsub("{RAM:wNameBuffer}", mon.nickname or def.name)
|
||||
self.game.stack:push(TextBox.new(self.game, txt))
|
||||
return -- .loop: submenu stays open behind the message
|
||||
end
|
||||
self.game.stack:pop() -- close the party menu
|
||||
Screens.push(self.game, "TownMap", { fly = true, onFly = function(mapId)
|
||||
if ow then ow:flyTo(mapId) end
|
||||
@@ -423,9 +450,17 @@ function PartyMenu:update(dt)
|
||||
-- over the menu, and the cave is lit when the blink hands the
|
||||
-- screen back, never under the text (#385).
|
||||
local ow = self.game.overworld
|
||||
if ow and not ow:partyKnows("FLASH") then
|
||||
refuseBadge(self)
|
||||
return
|
||||
end
|
||||
local TextBox = require("src.render.TextBox")
|
||||
local Transition = require("src.render.Transition")
|
||||
self.game.save.flashLit = true
|
||||
-- .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()
|
||||
@@ -440,13 +475,13 @@ function PartyMenu:update(dt)
|
||||
-- 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).
|
||||
ow:setDark(false)
|
||||
if wasDark then ow:setDark(false) end
|
||||
self.game.stack:push(Transition.whiteFlash(self.game))
|
||||
end))
|
||||
return
|
||||
elseif action == "surf" then
|
||||
-- start_sub_menus.asm .surf: SOULBADGE-gated (checked at list time
|
||||
-- above), then IsSurfingAllowed (the Cycling Road / Seafoam B4F
|
||||
-- start_sub_menus.asm .surf: SOULBADGE-gated (useSurfFieldMove),
|
||||
-- then IsSurfingAllowed (the Cycling Road / Seafoam B4F
|
||||
-- current refusals, both of which loop back to the submenu), then
|
||||
-- ItemUseSurfboard: while surfing it tries to dismount instead;
|
||||
-- otherwise it mounts only if the FACING tile is water, else
|
||||
@@ -503,8 +538,8 @@ function PartyMenu:update(dt)
|
||||
return -- .loop: submenu stays open behind the message
|
||||
elseif action == "cut" then
|
||||
-- start_sub_menus.asm .cut -> predef UsedCut (engine/overworld/cut.asm):
|
||||
-- CASCADEBADGE-gated (list time); _NothingToCutText loops back to the
|
||||
-- submenu when the FACING tile isn't a cuttable tree.
|
||||
-- CASCADEBADGE-gated (useCutFieldMove); _NothingToCutText loops back
|
||||
-- to the submenu when the FACING tile isn't a cuttable tree.
|
||||
local ow = self.game.overworld
|
||||
local reason = ow:useCutFieldMove()
|
||||
if reason == "ok" then
|
||||
@@ -522,7 +557,7 @@ function PartyMenu:update(dt)
|
||||
self.game.stack:push(TextBox.new(self.game, txt))
|
||||
return -- .loop: submenu stays open behind the message
|
||||
elseif action == "strength" then
|
||||
-- start_sub_menus.asm .strength: RAINBOWBADGE-gated (list time);
|
||||
-- start_sub_menus.asm .strength: RAINBOWBADGE-gated;
|
||||
-- predef PrintStrengthText (field_move_messages.asm) sets
|
||||
-- BIT_STRENGTH_ACTIVE of wStatusFlags1 -- the sole gate
|
||||
-- push_boulder.asm reads -- then prints _UsedStrengthText (no
|
||||
@@ -532,6 +567,10 @@ 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)
|
||||
return
|
||||
end
|
||||
local TextBox = require("src.render.TextBox")
|
||||
local Transition = require("src.render.Transition")
|
||||
local def = self.game.data.pokemon[mon.species]
|
||||
@@ -565,6 +604,33 @@ function PartyMenu:update(dt)
|
||||
-- centralizes the spin -> fade -> warp so BagMenu's ESCAPE ROPE shares
|
||||
-- the exact departure; the fade + warp fire when the spin ends.
|
||||
local ow = self.game.overworld
|
||||
if entry.move == "TELEPORT" then
|
||||
-- .teleport: TELEPORT works only OUTDOORS (CheckIfInOutsideMap --
|
||||
-- OVERWORLD + PLATEAU, #83); dark maps don't block it
|
||||
if ow and not Map.isOutside(ow.map.def,
|
||||
FieldDefaults.field(self.game.data, "outsideTilesets")) then
|
||||
local TextBox = require("src.render.TextBox")
|
||||
local def = self.game.data.pokemon[mon.species]
|
||||
local txt = (self.game.data.text._CannotUseTeleportNowText
|
||||
or Strings("{RAM:wNameBuffer} can't\nuse TELEPORT now."))
|
||||
:gsub("{RAM:wNameBuffer}", mon.nickname or def.name)
|
||||
self.game.stack:push(TextBox.new(self.game, txt))
|
||||
return -- .loop: submenu stays open behind the message
|
||||
end
|
||||
elseif ow and not (DIG_TILESETS[ow.map.def.tileset]
|
||||
and ow.map.id ~= "AGATHAS_ROOM") then
|
||||
-- .dig runs ItemUseEscapeRope (it sets wCurItem = ESCAPE_ROPE):
|
||||
-- usable in the dungeon tilesets of escape_rope_tilesets.asm minus
|
||||
-- Agatha's room, even in the dark (Rock Tunnel); anywhere else
|
||||
-- .notUsable -> ItemUseNotTime, the same line BagMenu prints for a
|
||||
-- bagged ESCAPE ROPE
|
||||
local TextBox = require("src.render.TextBox")
|
||||
self.game.stack:push(TextBox.new(self.game,
|
||||
self.game.data.text._ItemUseNotTimeText
|
||||
or Strings("OAK: %s!\nThis isn't the\ntime to use that!",
|
||||
self.game.save.player.name)))
|
||||
return -- .loop: submenu stays open behind the message
|
||||
end
|
||||
self.game.stack:pop()
|
||||
if ow then ow:beginTeleportOut() end
|
||||
return
|
||||
@@ -658,42 +724,30 @@ function PartyMenu:update(dt)
|
||||
-- Battle still excludes this list via `not self.battle`. Softboiled
|
||||
-- can appear for a fainted user; its heal transfer then no-ops.
|
||||
if not self.battle and ow then
|
||||
-- FLY/TELEPORT: CheckIfInOutsideMap (OVERWORLD + PLATEAU --
|
||||
-- Route 23 / Indigo Plateau outdoor), not OVERWORLD alone (#83)
|
||||
local outside = Map.isOutside(ow.map.def,
|
||||
FieldDefaults.field(self.game.data, "outsideTilesets"))
|
||||
-- GetMonFieldMoves (engine/menus/text_box.asm) matches the mon's
|
||||
-- four moves against FieldMoveDisplayData and nothing else -- no
|
||||
-- badge, no map, no tileset test. Every one of those lives in
|
||||
-- .outOfBattleMovePointers, i.e. on selection, where the refusal
|
||||
-- prints and .loop returns to this still-open submenu (#1022).
|
||||
for _, mv in ipairs(mon.moves) do
|
||||
if mv.id == "FLY" and outside
|
||||
and self.game.save.inventory.THUNDERBADGE then
|
||||
if mv.id == "FLY" then
|
||||
table.insert(items, { label = Strings("FLY"), action = "fly" })
|
||||
elseif mv.id == "FLASH" and ow.dark
|
||||
and self.game.save.inventory.BOULDERBADGE then
|
||||
elseif mv.id == "FLASH" then
|
||||
table.insert(items, { label = Strings("FLASH"), action = "flash" })
|
||||
elseif mv.id == "CUT" and self.game.save.inventory.CASCADEBADGE then
|
||||
-- CUT/SURF/STRENGTH are party-menu field moves too
|
||||
-- (start_sub_menus.asm .outOfBattleMovePointers); listed here
|
||||
-- with the same list-time badge filter this file already uses
|
||||
-- for FLY/FLASH. The facing-tile/activation check happens on
|
||||
-- selection (useCutFieldMove/useSurfFieldMove).
|
||||
elseif mv.id == "CUT" then
|
||||
table.insert(items, { label = Strings("CUT"), action = "cut" })
|
||||
elseif mv.id == "SURF" and self.game.save.inventory.SOULBADGE then
|
||||
elseif mv.id == "SURF" then
|
||||
table.insert(items, { label = Strings("SURF"), action = "surf" })
|
||||
elseif mv.id == "STRENGTH" and self.game.save.inventory.RAINBOWBADGE then
|
||||
elseif mv.id == "STRENGTH" then
|
||||
table.insert(items, { label = Strings("STRENGTH"), action = "strength" })
|
||||
elseif mv.id == "SOFTBOILED" then
|
||||
table.insert(items, { label = Strings("SOFTBOILED"), action = "softboiled" })
|
||||
elseif mv.id == "TELEPORT" and outside then
|
||||
-- TELEPORT works only OUTDOORS (start_sub_menus.asm
|
||||
-- .teleport -> CheckIfInOutsideMap); dark maps don't
|
||||
-- block it
|
||||
table.insert(items, { label = Strings("TELEPORT"), action = "escape" })
|
||||
elseif mv.id == "DIG" and DIG_TILESETS[ow.map.def.tileset]
|
||||
and ow.map.id ~= "AGATHAS_ROOM" then
|
||||
-- DIG runs ItemUseEscapeRope (.dig sets wCurItem =
|
||||
-- ESCAPE_ROPE): usable in the dungeon tilesets of
|
||||
-- escape_rope_tilesets.asm minus Agatha's room, even in
|
||||
-- the dark (Rock Tunnel)
|
||||
table.insert(items, { label = Strings("DIG"), action = "escape" })
|
||||
elseif mv.id == "TELEPORT" then
|
||||
table.insert(items, { label = Strings("TELEPORT"),
|
||||
action = "escape", move = "TELEPORT" })
|
||||
elseif mv.id == "DIG" then
|
||||
table.insert(items, { label = Strings("DIG"),
|
||||
action = "escape", move = "DIG" })
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -173,6 +173,31 @@ end
|
||||
function BattleState:wantsFillScale() return true end
|
||||
function BattleState:drawsWidescreen() return true end
|
||||
|
||||
function BattleState:bottomUIVisible()
|
||||
if not Runtime.wantsHook("battle.bottom_ui_visible") then return true end
|
||||
return Runtime.call("battle.bottom_ui_visible", function() return true end,
|
||||
self) ~= false
|
||||
end
|
||||
|
||||
function BattleState:statusHUDVisible()
|
||||
if not Runtime.wantsHook("battle.status_hud_visible") then return true end
|
||||
return Runtime.call("battle.status_hud_visible", function() return true end,
|
||||
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 {}
|
||||
@@ -296,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.
|
||||
@@ -305,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
|
||||
@@ -576,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)
|
||||
@@ -3079,6 +3110,7 @@ end
|
||||
function BattleState:drawHud()
|
||||
local wasBattle = Font.useBattleExtra(true)
|
||||
local enemy, player = self:activeMon("enemy"), self:activeMon("player")
|
||||
local showStatus = self:statusHUDVisible()
|
||||
|
||||
-- Enemy HUD (DrawEnemyHUD clears (1,0) 4 rows x 11 cols):
|
||||
-- name at (1,0); PrintLevel at (6,1) with the gender symbol at (9,1);
|
||||
@@ -3087,7 +3119,7 @@ function BattleState:drawHud()
|
||||
-- runs, so a shake or a slide does not drag the HP bar with it.
|
||||
-- And nothing at all before UpdateEnemyHUD has ever run: the intro bands
|
||||
-- slide in over a blanked tilemap (core.asm:8554/8564).
|
||||
if self.showEnemyHud and not self:hudCleared("enemy") then
|
||||
if showStatus and self.showEnemyHud and not self:hudCleared("enemy") then
|
||||
Chrome.print(self:name(enemy), 1, 0)
|
||||
-- PrintLevel writes <LV> at the coordinate it is given and then LEFT-aligns
|
||||
-- the digits after it, so the glyph is pinned to column 6 whether the level
|
||||
@@ -3127,7 +3159,8 @@ function BattleState:drawHud()
|
||||
-- BattleMenu's own tutorial arm skips UpdateBattleHuds as well. The DUDE's
|
||||
-- half of the screen is his back-pic and nothing more.
|
||||
-- Nor before SendOutPlayerMon's own UpdatePlayerHUD (core.asm:3838).
|
||||
if not player or not self.showPlayerHud or self:hudCleared("player") then
|
||||
if not showStatus or not player or not self.showPlayerHud
|
||||
or self:hudCleared("player") then
|
||||
Font.useBattleExtra(wasBattle)
|
||||
return
|
||||
end
|
||||
@@ -3226,6 +3259,11 @@ function BattleState:drawPanel()
|
||||
end
|
||||
self:drawHud()
|
||||
|
||||
if not self:bottomUIVisible() then
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
return
|
||||
end
|
||||
|
||||
-- Message box across the bottom, with the menu window over its right half --
|
||||
-- the cart draws the prompt into the full-width box and then opens the menu
|
||||
-- on top, so the tail of a long name is simply covered.
|
||||
|
||||
@@ -780,6 +780,11 @@ function MartMenu:offerToSell(itemId, count)
|
||||
end
|
||||
-- ScrollingMenu's .a_button copies the row's own quantity into
|
||||
-- wItemQuantity, so the selector's ceiling is how many you hold.
|
||||
-- The unit price is the item's own ItemAttributes price: GetItemPrice is
|
||||
-- what SelectQuantityToSell halves AND what the buy list's
|
||||
-- GetMartItemPrice charges, so a TM sells for exactly half of what the
|
||||
-- Goldenrod/Celadon TM shelves ask for it, and half of its hidden price
|
||||
-- (usually ¥1,500) anywhere else (issue #1243).
|
||||
self.qtyItem = { id = itemId, name = def.name or itemId,
|
||||
price = def.price or 0 }
|
||||
self.qty = 1
|
||||
|
||||
+15
-12
@@ -354,20 +354,23 @@ function PackMenu:useSelected()
|
||||
end
|
||||
return
|
||||
end
|
||||
-- engine/items/tmhm.asm:73
|
||||
local def = self.items and self.items[row.id]
|
||||
if def and def.teaches then
|
||||
self:openTeachParty(row)
|
||||
return
|
||||
end
|
||||
-- UseItem's jumptable runs off ITEMATTR's field-menu nibble, and the first
|
||||
-- four entries are all .Oak -- an X ATTACK or a POKé DOLL used from the
|
||||
-- field PACK prints OakThisIsntTheTimeText and goes nowhere. Only the
|
||||
-- FIELD pack owns that refusal: the battle pack returned above, and the
|
||||
-- catch tutorial's DUDE pack carries a stub world with no useFieldItem at
|
||||
-- all -- its POKE BALL is field-NOUSE and must still reach the throw.
|
||||
-- UseItem's FIELD-pack tail (engine/items/tmhm.asm:73): a TM/HM row opens
|
||||
-- the party to teach, and a field-NOUSE item -- an X ATTACK or a POKé DOLL
|
||||
-- used from the field PACK -- prints OakThisIsntTheTimeText and goes
|
||||
-- nowhere (UseItem's jumptable's first four entries are all .Oak). Both
|
||||
-- checks live behind `world.useFieldItem` on purpose: DepositSellPack (the
|
||||
-- mart's SELL, the item PC's DEPOSIT) is a chooser whose jumptable is four
|
||||
-- ScrollingMenus and never reaches tmhm.asm, so a TM picked there hands
|
||||
-- its row to onChoose like every other item instead of opening the teach
|
||||
-- party. The battle pack returned above, and the catch tutorial's DUDE
|
||||
-- pack carries a stub world with no useFieldItem at all -- its POKE BALL
|
||||
-- is field-NOUSE and must still reach the throw.
|
||||
if world and world.useFieldItem then
|
||||
local def = self.items and self.items[row.id]
|
||||
if def and def.teaches then
|
||||
self:openTeachParty(row)
|
||||
return
|
||||
end
|
||||
if def and def.fieldMenu == "ITEMMENU_NOUSE" then
|
||||
self.message = OAK_THIS_ISNT_THE_TIME
|
||||
return
|
||||
|
||||
+128
-78
@@ -368,6 +368,7 @@ Kit._navPrevN = 0
|
||||
Kit._navSeen = {}
|
||||
Kit._navQueue = nil
|
||||
Kit._activateId = nil
|
||||
Kit._ringShown = false
|
||||
|
||||
-- Register a focusable. Returns true when it currently holds the ring.
|
||||
-- Shielded widgets do not register: while a modal owns the frame the ring
|
||||
@@ -383,11 +384,12 @@ function Kit.focusable(id, x, y, w, h)
|
||||
-- First focusable ever drawn adopts the ring, so keyboard users start
|
||||
-- somewhere rather than nowhere.
|
||||
if Kit.focusId == nil then Kit.focusId = id end
|
||||
return Kit.focusId == id
|
||||
return Kit._ringShown and Kit.focusId == id
|
||||
end
|
||||
|
||||
function Kit.navigate(dir)
|
||||
Kit._navQueue = dir
|
||||
Kit._ringShown = true
|
||||
end
|
||||
|
||||
function Kit.activateFocused()
|
||||
@@ -396,6 +398,7 @@ end
|
||||
|
||||
function Kit.setFocus(id)
|
||||
Kit.focusId = id
|
||||
Kit._ringShown = id ~= nil
|
||||
end
|
||||
|
||||
-- Pick the nearest focusable in `dir` from the current one. Candidates must
|
||||
@@ -530,13 +533,10 @@ Kit._audit = audit
|
||||
function Kit.tapMin() return math.floor(30 * Kit.scale) end
|
||||
|
||||
-- ---------------------------------------------------------------- surfaces
|
||||
function Kit.card(x, y, w, h, emphasis)
|
||||
Theme.card(x, y, w, h, emphasis)
|
||||
function Kit.card(x, y, w, h, variant)
|
||||
Theme.card(x, y, w, h, variant)
|
||||
end
|
||||
|
||||
-- A list row. `id` opts it into the focus ring; pass nil for decorative
|
||||
-- rows. Returns (clicked, inkColor) -- a selected row fills white, so the
|
||||
-- caller must print with the returned ink or it will draw white on white.
|
||||
function Kit.row(x, y, w, h, selected, id)
|
||||
audit("row", x, y, w, h, id or "row")
|
||||
local focused = id and Kit.focusable(id, x, y, w, h) or false
|
||||
@@ -560,7 +560,7 @@ end
|
||||
-- hairline says the same thing for one rect.)
|
||||
function Kit.emptyBox(x, y, w, h, message)
|
||||
if not G then return end
|
||||
Theme.strokeRounded(x, y, w, h, PAL.line, 0.22, 1, Theme.radius())
|
||||
Theme.card(x, y, w, h, "empty")
|
||||
Kit.textCenter("button", Kit.ellipsize("button", message, w - 24 * Kit.scale),
|
||||
x, y + (h - Kit.textHeight("button")) / 2, w, PAL.muted)
|
||||
end
|
||||
@@ -598,61 +598,130 @@ local KINDS = {
|
||||
disabled = { fill = PAL.steel, ink = PAL.inverse, flat = true },
|
||||
}
|
||||
Kit.KINDS = KINDS
|
||||
local NO_OPTS = {}
|
||||
|
||||
-- opts: { kind, font, enabled, align, id, glow, fill, ink }
|
||||
-- id -- opts into the focus ring (give every real control one)
|
||||
-- glow -- a pulsing outline for "something is waiting for you" (the
|
||||
-- update button). No blend-mode change: the alpha of the
|
||||
-- existing outline is animated instead.
|
||||
-- fill/ink -- override the kind's colours. The ONE caller is the
|
||||
-- launcher's Play button, which wears its cartridge colour
|
||||
-- (red/blue/gold) rather than a semantic one: on that screen
|
||||
-- "which game am I launching" outranks "what kind of verb is
|
||||
-- this", and the colour is already the tab's identity.
|
||||
-- Returns true when activated, by click OR by the focus ring's Enter/A.
|
||||
function Kit.button(x, y, w, h, label, opts)
|
||||
opts = opts or {}
|
||||
opts = opts or NO_OPTS
|
||||
local enabled = opts.enabled ~= false
|
||||
-- Disabled buttons audit too: they stay visible, so they still must not
|
||||
-- paint over a neighbour.
|
||||
audit("control", x, y, w, h, label)
|
||||
local focused = enabled and opts.id
|
||||
and Kit.focusable(opts.id, x, y, w, h) or false
|
||||
local kind = KINDS[enabled and (opts.kind or "ghost") or "disabled"]
|
||||
if enabled and opts.fill then
|
||||
kind = { fill = opts.fill, ink = opts.ink or PAL.inverse }
|
||||
end
|
||||
local hot = enabled and Kit.hover(x, y, w, h)
|
||||
|
||||
if G then
|
||||
-- The fill IS the control: a rounded, embossed, colour-coded key. A
|
||||
-- disabled button keeps its shape in a dead grey rather than
|
||||
-- disappearing, so a layout never reflows on state.
|
||||
Theme.fillRounded(x, y, w, h, kind.fill, enabled and 1 or 0.45)
|
||||
Theme.emboss(x, y, w, h, enabled and (hot and 1.3 or 1) or 0.4)
|
||||
if hot or focused then
|
||||
-- White ring outside the fill: legible on green, blue, yellow, red and
|
||||
-- white alike, which one darker/lighter shade per colour would not be.
|
||||
Theme.strokeRounded(x - 2, y - 2, w + 4, h + 4, PAL.lineStrong,
|
||||
Theme.A.focus, 2, Theme.radius() + 2)
|
||||
elseif opts.glow and enabled then
|
||||
-- "Something is waiting for you" (the update button): a pulsing ring.
|
||||
-- Pure alpha on one existing stroke -- no extra draw calls, no blend
|
||||
-- mode change.
|
||||
local a = 0.25 + 0.75 * (0.5 + 0.5 * math.sin(Kit.time * 3))
|
||||
Theme.strokeRounded(x - 2, y - 2, w + 4, h + 4, PAL.lineStrong, a, 2,
|
||||
Theme.radius() + 2)
|
||||
local face = opts.face or "fill"
|
||||
local B = Theme.BUTTON
|
||||
local radius = opts.radius or B.radius
|
||||
local active = opts.active or opts.on
|
||||
local invert = false
|
||||
local fill, ink, stroke, strokeA, doEmboss, doRing, glowA
|
||||
if face == "invert" then
|
||||
invert = hot
|
||||
fill = invert and (opts.hotFill or PAL.ink) or (opts.fill or PAL.surface)
|
||||
ink = invert and (opts.hotInk or PAL.inverse) or (opts.ink or PAL.heading)
|
||||
stroke = opts.stroke or PAL.line
|
||||
strokeA = invert and Theme.A.focus or Theme.A.hairline
|
||||
doRing = focused and not hot
|
||||
elseif face == "tab" then
|
||||
invert = active or focused or hot
|
||||
local tint = opts.color or opts.fill or PAL.ink
|
||||
fill = invert and tint or PAL.surface
|
||||
ink = invert and PAL.inverse or (opts.color or PAL.text)
|
||||
if not invert then
|
||||
stroke = tint
|
||||
strokeA = opts.color and Theme.A.hover or Theme.A.hairline
|
||||
end
|
||||
local fname = opts.font or "button"
|
||||
local ink = enabled and kind.ink or PAL.inverse
|
||||
local ty = y + (h - Kit.textHeight(fname)) / 2
|
||||
local shown = Kit.ellipsize(fname, label, w - 16 * Kit.scale)
|
||||
-- Button labels are bold: they are the shortest, most-scanned text on
|
||||
-- screen and sit on a saturated fill.
|
||||
if opts.align == "left" then
|
||||
Kit.textBold(fname, shown, x + 10 * Kit.scale, ty, ink)
|
||||
elseif face == "chip" then
|
||||
local c = opts.color or PAL.line
|
||||
invert = active and true or false
|
||||
if active then
|
||||
fill = c
|
||||
ink = PAL.inverse
|
||||
doEmboss = true
|
||||
else
|
||||
Kit.textCenterBold(fname, shown, x, ty, w, ink)
|
||||
fill = PAL.bg
|
||||
ink = c
|
||||
stroke = c
|
||||
strokeA = (focused or hot) and Theme.A.focus or Theme.A.hover
|
||||
end
|
||||
doRing = focused or hot
|
||||
else
|
||||
local kind = KINDS[enabled and (opts.kind or "ghost") or "disabled"]
|
||||
fill = (enabled and opts.fill) or kind.fill
|
||||
ink = (enabled and opts.ink) or kind.ink
|
||||
doEmboss = true
|
||||
doRing = hot or focused
|
||||
if opts.glow and enabled and not doRing then
|
||||
glowA = B.glowBase + B.glowAmp * (0.5 + 0.5 * math.sin(Kit.time * B.glowHz))
|
||||
end
|
||||
end
|
||||
if opts.emboss ~= nil then doEmboss = opts.emboss end
|
||||
if opts.ring ~= nil then doRing = opts.ring end
|
||||
if G then
|
||||
Theme.fillRounded(x, y, w, h, fill, enabled and 1 or B.disabledA, radius)
|
||||
if doEmboss then
|
||||
local es = enabled and ((hot or focused) and B.embossHot or B.embossRest)
|
||||
or B.embossDisabled
|
||||
Theme.emboss(x, y, w, h, es)
|
||||
end
|
||||
if strokeA then
|
||||
Theme.strokeRounded(x, y, w, h, stroke, strokeA, 1, radius)
|
||||
end
|
||||
if doRing then
|
||||
Theme.strokeRounded(x - B.ringPad, y - B.ringPad,
|
||||
w + 2 * B.ringPad, h + 2 * B.ringPad, PAL.lineStrong,
|
||||
Theme.A.focus, B.ringWidth, radius + B.ringPad)
|
||||
elseif glowA then
|
||||
Theme.strokeRounded(x - B.ringPad, y - B.ringPad,
|
||||
w + 2 * B.ringPad, h + 2 * B.ringPad, PAL.lineStrong,
|
||||
glowA, B.ringWidth, radius + B.ringPad)
|
||||
end
|
||||
local fname = opts.font or ((face == "chip") and "micro" or "button")
|
||||
local ty = y + (h - Kit.textHeight(fname)) / 2
|
||||
local image = opts.image
|
||||
local drawFn = opts.drawFn
|
||||
local letter = opts.letter
|
||||
local hasLabel = label and label ~= ""
|
||||
local bold = opts.bold
|
||||
if bold == nil then bold = face ~= "tab" end
|
||||
if image then
|
||||
local box = h
|
||||
local boxX, boxY = x, y
|
||||
if not hasLabel then
|
||||
box = math.min(w, h)
|
||||
boxX = x + (w - box) / 2
|
||||
boxY = y + (h - box) / 2
|
||||
end
|
||||
local iw, ih = image:getDimensions()
|
||||
local pad = math.floor(box * (opts.iconPad or B.iconPad))
|
||||
local s = math.min((box - 2 * pad) / iw, (box - 2 * pad) / ih)
|
||||
if invert then Theme.col(PAL.inverse, 1)
|
||||
else Theme.col(PAL.ink, B.iconRestA) end
|
||||
G.draw(image, Theme.snap(boxX + (box - iw * s) / 2),
|
||||
Theme.snap(boxY + (box - ih * s) / 2), 0, s, s)
|
||||
if hasLabel then
|
||||
local lx = x + h + B.letterGap * Kit.scale
|
||||
if bold then Kit.textBold(fname, label, lx, ty, ink)
|
||||
else Kit.text(fname, label, lx, ty, ink) end
|
||||
end
|
||||
elseif drawFn then
|
||||
drawFn(x, y, w, h, invert or hot or focused)
|
||||
elseif letter then
|
||||
Kit.textCenter(fname, letter, x, ty, h, ink)
|
||||
if hasLabel then
|
||||
local lx = x + h + B.letterGap * Kit.scale
|
||||
if bold then Kit.textBold(fname, label, lx, ty, ink)
|
||||
else Kit.text(fname, label, lx, ty, ink) end
|
||||
end
|
||||
elseif hasLabel then
|
||||
local shown = Kit.ellipsize(fname, label, w - B.labelInset * Kit.scale)
|
||||
if opts.align == "left" then
|
||||
local lx = x + B.labelPad * Kit.scale
|
||||
if bold then Kit.textBold(fname, shown, lx, ty, ink)
|
||||
else Kit.text(fname, shown, lx, ty, ink) end
|
||||
elseif bold then
|
||||
Kit.textCenterBold(fname, shown, x, ty, w, ink)
|
||||
else
|
||||
Kit.textCenter(fname, shown, x, ty, w, ink)
|
||||
end
|
||||
end
|
||||
end
|
||||
if not enabled then return false end
|
||||
@@ -661,7 +730,6 @@ function Kit.button(x, y, w, h, label, opts)
|
||||
and Kit._activateId == opts.id)
|
||||
end
|
||||
|
||||
-- A small square control: +/- steppers, arrow cyclers, the row X.
|
||||
function Kit.stepper(x, y, w, h, glyph, opts)
|
||||
opts = opts or {}
|
||||
opts.kind = opts.kind or "ghost"
|
||||
@@ -669,31 +737,13 @@ function Kit.stepper(x, y, w, h, glyph, opts)
|
||||
return Kit.button(x, y, w, h, glyph, opts)
|
||||
end
|
||||
|
||||
-- A pill toggle (badges, dex SEEN/OWN, sub-tabs). `on` inverts it.
|
||||
local CHIP_OPTS = { face = "chip", font = "micro" }
|
||||
|
||||
function Kit.chip(x, y, w, h, label, on, color, id)
|
||||
audit("control", x, y, w, h, label)
|
||||
local focused = id and Kit.focusable(id, x, y, w, h) or false
|
||||
local c = color or PAL.line
|
||||
if G then
|
||||
local hot = focused or Kit.hover(x, y, w, h)
|
||||
if on then
|
||||
Theme.fillRounded(x, y, w, h, c, 1)
|
||||
Theme.emboss(x, y, w, h, 1)
|
||||
Kit.textCenterBold("micro", label, x,
|
||||
y + (h - Kit.textHeight("micro")) / 2, w, PAL.inverse)
|
||||
else
|
||||
Theme.fillRounded(x, y, w, h, PAL.bg, 1)
|
||||
Theme.strokeRounded(x, y, w, h, c,
|
||||
hot and Theme.A.focus or Theme.A.hover, 1)
|
||||
Kit.textCenterBold("micro", label, x,
|
||||
y + (h - Kit.textHeight("micro")) / 2, w, c)
|
||||
end
|
||||
if hot then
|
||||
Theme.strokeRounded(x - 2, y - 2, w + 4, h + 4, PAL.lineStrong,
|
||||
Theme.A.focus, 2, Theme.radius() + 2)
|
||||
end
|
||||
end
|
||||
return Kit.press(x, y, w, h) or (id ~= nil and Kit._activateId == id)
|
||||
CHIP_OPTS.active = on
|
||||
CHIP_OPTS.color = color
|
||||
CHIP_OPTS.id = id
|
||||
return Kit.button(x, y, w, h, label, CHIP_OPTS)
|
||||
end
|
||||
|
||||
-- A status label with no interaction: outlined text, the "INSTALLED"/"UPDATE"
|
||||
|
||||
+95
-20
@@ -35,8 +35,8 @@ local PAL = {
|
||||
-- it in the same hue, so a card reads as a raised object rather than as an
|
||||
-- outline drawn on the page. These are still flat fills -- the depth comes
|
||||
-- from the value step plus Theme.shadow, not from a gradient.
|
||||
field = { 16, 8, 10 }, -- the page BEHIND the cards
|
||||
bg = { 0, 0, 0 }, -- true black: button rests, field interiors
|
||||
field = { 20, 20, 20 }, -- the page BEHIND the cards
|
||||
bg = { 20, 20, 20 }, -- light black
|
||||
surface = { 28, 21, 24 }, -- card interiors
|
||||
rowBg = { 20, 14, 17 }, -- rows inside a card, one step below it
|
||||
raised = { 44, 34, 38 }, -- hover feedback
|
||||
@@ -82,6 +82,67 @@ Theme.A = {
|
||||
disabled = 0.30,
|
||||
}
|
||||
|
||||
Theme.BUTTON = {
|
||||
radius = 8,
|
||||
ringPad = 2,
|
||||
ringWidth = 2,
|
||||
labelInset = 16,
|
||||
labelPad = 10,
|
||||
iconPad = 0.24,
|
||||
letterGap = 4,
|
||||
disabledA = 0.45,
|
||||
embossHot = 1.3,
|
||||
embossRest = 1,
|
||||
embossDisabled = 0.4,
|
||||
glowHz = 3,
|
||||
glowBase = 0.25,
|
||||
glowAmp = 0.75,
|
||||
iconRestA = 0.85,
|
||||
}
|
||||
|
||||
Theme.CARD = {
|
||||
radius = 14,
|
||||
shadow = true,
|
||||
fill = PAL.surface,
|
||||
fillA = 1,
|
||||
stroke = PAL.line,
|
||||
strokeA = Theme.A.hairline,
|
||||
}
|
||||
|
||||
Theme.CARD_VARIANT = {
|
||||
emphasis = { strokeA = Theme.A.focus },
|
||||
muted = {
|
||||
fill = PAL.bg, fillA = 0.8, stroke = PAL.muted, strokeA = 0.25, shadow = false,
|
||||
},
|
||||
mutedHot = {
|
||||
fill = PAL.bg, fillA = 0.8, stroke = PAL.muted, strokeA = Theme.A.hover, shadow = false,
|
||||
},
|
||||
empty = { fillA = 0, strokeA = 0.22, shadow = false, radius = "ctl" },
|
||||
warn = {
|
||||
fill = PAL.rowBg, stroke = PAL.yellow, strokeA = Theme.A.hover,
|
||||
shadow = false, radius = "ctl",
|
||||
},
|
||||
row = {
|
||||
fill = PAL.rowBg, stroke = PAL.line, strokeA = Theme.A.hairline,
|
||||
shadow = false, radius = "ctl",
|
||||
},
|
||||
rowHover = {
|
||||
fill = PAL.raised, stroke = PAL.line, strokeA = Theme.A.hover,
|
||||
shadow = false, radius = "ctl",
|
||||
},
|
||||
rowSelected = {
|
||||
fill = PAL.ink, strokeA = 0, shadow = false, radius = "ctl",
|
||||
},
|
||||
hairline = {
|
||||
fillA = 0, stroke = PAL.line, strokeA = Theme.A.hairline,
|
||||
shadow = false, radius = "ctl",
|
||||
},
|
||||
badge = {
|
||||
fill = PAL.bg, stroke = PAL.yellow, strokeA = Theme.A.hover,
|
||||
shadow = false, radius = 0,
|
||||
},
|
||||
}
|
||||
|
||||
local G = love and love.graphics or nil
|
||||
|
||||
local has = {}
|
||||
@@ -123,11 +184,11 @@ end
|
||||
-- Controls get the smaller one, containers the larger, so a button never
|
||||
-- looks like a card and a card never looks like a button.
|
||||
function Theme.radius()
|
||||
return 8
|
||||
return Theme.BUTTON.radius
|
||||
end
|
||||
|
||||
function Theme.cardRadius()
|
||||
return 14
|
||||
return Theme.CARD.radius
|
||||
end
|
||||
|
||||
-- DROP SHADOW. Three stacked rounded rects at low alpha, each one step wider
|
||||
@@ -206,28 +267,42 @@ end
|
||||
-- The design's only container: a rounded surface a few values above the
|
||||
-- field, its own drop shadow, and a white hairline. `emphasis` raises the
|
||||
-- outline to full white (used for the focused/active card).
|
||||
function Theme.card(x, y, w, h, emphasis)
|
||||
local r = Theme.cardRadius()
|
||||
Theme.shadow(x, y, w, h, r)
|
||||
Theme.fillRounded(x, y, w, h, PAL.surface, 1, r)
|
||||
Theme.strokeRounded(x, y, w, h, PAL.line,
|
||||
emphasis and Theme.A.focus or Theme.A.hairline, 1, r)
|
||||
function Theme.card(x, y, w, h, variant)
|
||||
local spec = Theme.CARD
|
||||
local v
|
||||
if variant == true then
|
||||
v = Theme.CARD_VARIANT.emphasis
|
||||
elseif type(variant) == "string" then
|
||||
v = Theme.CARD_VARIANT[variant]
|
||||
elseif type(variant) == "table" then
|
||||
v = variant
|
||||
end
|
||||
local radius = (v and v.radius) or spec.radius
|
||||
if radius == "ctl" then radius = Theme.BUTTON.radius end
|
||||
local fill = (v and v.fill) or spec.fill
|
||||
local fillA = spec.fillA
|
||||
if v and v.fillA ~= nil then fillA = v.fillA end
|
||||
local stroke = (v and v.stroke) or spec.stroke
|
||||
local strokeA = spec.strokeA
|
||||
if v and v.strokeA ~= nil then strokeA = v.strokeA end
|
||||
local shadow = spec.shadow
|
||||
if v and v.shadow ~= nil then shadow = v.shadow end
|
||||
local strokeW = (v and v.strokeW) or 1
|
||||
if fillA > 0 and fill then
|
||||
if shadow then Theme.shadow(x, y, w, h, radius) end
|
||||
Theme.fillRounded(x, y, w, h, fill, fillA, radius)
|
||||
end
|
||||
if strokeA > 0 and stroke then
|
||||
Theme.strokeRounded(x, y, w, h, stroke, strokeA, strokeW, radius)
|
||||
end
|
||||
end
|
||||
|
||||
-- A list row. Three states, each one rect plus one outline:
|
||||
-- normal one value below the card it sits in, hairline
|
||||
-- hover lifted fill, brighter hairline
|
||||
-- selected WHITE fill (callers print ink = PAL.inverse over it)
|
||||
function Theme.row(x, y, w, h, state)
|
||||
local r = Theme.radius()
|
||||
if state == "selected" then
|
||||
Theme.fillRounded(x, y, w, h, PAL.ink, 1, r)
|
||||
Theme.card(x, y, w, h, "rowSelected")
|
||||
return PAL.inverse
|
||||
end
|
||||
Theme.fillRounded(x, y, w, h,
|
||||
state == "hover" and PAL.raised or PAL.rowBg, 1, r)
|
||||
Theme.strokeRounded(x, y, w, h, PAL.line,
|
||||
state == "hover" and Theme.A.hover or Theme.A.hairline, 1, r)
|
||||
Theme.card(x, y, w, h, state == "hover" and "rowHover" or "row")
|
||||
return PAL.text
|
||||
end
|
||||
|
||||
|
||||
+15
-5
@@ -8,7 +8,7 @@ local SpriteRenderer = require("src.render.SpriteRenderer")
|
||||
local NPC = {}
|
||||
NPC.__index = NPC
|
||||
|
||||
local STEP_FRAMES = 16
|
||||
local STEP_FRAMES = 32
|
||||
|
||||
local FACING_FROM_RANGE = {
|
||||
DOWN = "down", UP = "up", LEFT = "left", RIGHT = "right",
|
||||
@@ -52,7 +52,16 @@ function NPC:facePlayer(player)
|
||||
end
|
||||
|
||||
function NPC:update(map, entities)
|
||||
-- self.stepFrames overrides the shared 16-frame walk for an object whose
|
||||
-- An NPC tile is 32 frames, half the player's rate: TryWalking loads
|
||||
-- WALKANIMATIONCOUNTER with $10 and UpdateSpriteInWalkingAnimation adds
|
||||
-- the 1px step vector once per call (engine/overworld/movement.asm), but
|
||||
-- UpdateSprites runs once per OverworldLoop pass and every pass opens
|
||||
-- with two DelayFrame calls (home/overworld.asm) -- so those 16 ticks
|
||||
-- cost 32 frames for one 16px cell, against AdvancePlayerSprite's 8
|
||||
-- ticks of 2px. That halving is why pokeyellow's NormalPikachuFollow
|
||||
-- needs TryDoubleAddPikachuStepVectorToScreenPixelCoords to keep up.
|
||||
--
|
||||
-- self.stepFrames overrides the shared walk for an object whose
|
||||
-- step has to stay in phase with something else: Yellow's follower
|
||||
-- Pikachu takes the player's own step length, halved while it is more
|
||||
-- than a cell behind (FastPikachuFollow, engine/pikachu/
|
||||
@@ -78,7 +87,7 @@ function NPC:update(map, entities)
|
||||
return
|
||||
end
|
||||
local d = Collision.DELTA[self.facing]
|
||||
-- 1px per frame at the default length; a shortened step scales instead,
|
||||
-- 1px per 2 frames at the default length; a shortened step scales instead,
|
||||
-- so the cell still lands on a 16px boundary (Player:update does the
|
||||
-- same for the bicycle)
|
||||
local moved = math.floor(self.progress * 16 * span / stepLen)
|
||||
@@ -113,8 +122,9 @@ end
|
||||
|
||||
function NPC:walkPhase()
|
||||
if not self.moving then return 0 end
|
||||
local p = self.progress % 16
|
||||
return (p >= 4 and p < 12) and 1 or 0
|
||||
local stepLen = self.stepFrames or STEP_FRAMES
|
||||
local p = self.progress % stepLen
|
||||
return (p >= stepLen / 4 and p < stepLen * 3 / 4) and 1 or 0
|
||||
end
|
||||
|
||||
-- Same contract as Player:pose -- the sheet, position, facing and step
|
||||
|
||||
@@ -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
|
||||
@@ -384,12 +385,21 @@ function OverworldState:setMap(mapId, x, y, facing, opts)
|
||||
-- NPC instances persist across connection crossings in self.npcPool
|
||||
-- (keyed by NPC.id): a neighbor map's wandering ghosts ARE the
|
||||
-- objects that become the real NPCs when the player crosses the
|
||||
-- seam, so nothing snaps back to its spawn point in view of the
|
||||
-- survey zoom. Warps rebuild from scratch, like the original's
|
||||
-- per-entry sprite init (home/overworld.asm LoadMapHeader
|
||||
-- .loadSpriteData).
|
||||
-- seam, so a map the player is not entering keeps its ghosts alive
|
||||
-- in view of the survey zoom. The map being entered does not --
|
||||
-- crossing a seam runs .loadNewMap -> LoadMapHeader, whose
|
||||
-- .loadSpriteData zeroes the sprite state data and re-seeds every
|
||||
-- SPRITESTATEDATA2_MAPY/MAPX from the map header's object data
|
||||
-- (home/overworld.asm), so an NPC who walked up to the player stands
|
||||
-- on her spawn cell again the next time that map loads (#1028). Only
|
||||
-- the save-side spawn flags survive, and those live in Game.save, not
|
||||
-- here. Warps rebuild the whole pool from scratch.
|
||||
if not (opts and opts.seamless and self.npcPool) then
|
||||
self.npcPool = {}
|
||||
elseif fromMapId ~= mapId then
|
||||
for _, obj in ipairs(self.map.def.objects or {}) do
|
||||
self.npcPool[mapId .. "_obj_" .. obj.index] = nil
|
||||
end
|
||||
end
|
||||
self.npcs = {}
|
||||
for _, obj in ipairs(self.map.def.objects or {}) do
|
||||
@@ -753,6 +763,27 @@ 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
|
||||
|
||||
-- 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
|
||||
@@ -1716,6 +1747,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]
|
||||
@@ -1986,10 +2023,12 @@ function OverworldState:tryHiddenObject(fx, fy)
|
||||
end
|
||||
save.hiddenTaken[key] = true
|
||||
local name = Game.data.items[h.item] and Game.data.items[h.item].name or h.item
|
||||
-- hidden items always play SFX_GET_ITEM_2 (hidden_items.asm)
|
||||
require("src.core.Sound").play(Game.data, "Get_Item2")
|
||||
-- hidden items always play SFX_GET_ITEM_2, and FoundHiddenItemText's
|
||||
-- text_asm tail runs it as PlaySoundWaitForCurrent +
|
||||
-- WaitForSoundToFinish once the box has printed (hidden_items.asm)
|
||||
Game.stack:push(TextBox.new(Game,
|
||||
Strings("%s found\n%s!", save.player.name, name)))
|
||||
Strings("%s found\n%s!", save.player.name, name),
|
||||
nil, TextBox.soundOpts(Game, "Get_Item2")))
|
||||
return true
|
||||
end
|
||||
end
|
||||
@@ -2001,9 +2040,9 @@ function OverworldState:tryHiddenObject(fx, fy)
|
||||
if not save.inventory.COIN_CASE then return false end
|
||||
save.hiddenTaken[key] = true
|
||||
save.coins = math.min(9999, (save.coins or 0) + h.coins)
|
||||
require("src.core.Sound").play(Game.data, "Get_Item2")
|
||||
Game.stack:push(TextBox.new(Game,
|
||||
Strings("%s found\n%d coins!", save.player.name, h.coins)))
|
||||
Strings("%s found\n%d coins!", save.player.name, h.coins),
|
||||
nil, TextBox.soundOpts(Game, "Get_Item2")))
|
||||
return true
|
||||
end
|
||||
end
|
||||
@@ -2650,10 +2689,11 @@ function OverworldState:talkTo(npc)
|
||||
end
|
||||
local name = Game.data.items[d.item] and Game.data.items[d.item].name or d.item
|
||||
local ddef = Game.data.items[d.item]
|
||||
require("src.core.Sound").play(Game.data,
|
||||
(ddef and ddef.keyItem) and "Get_Key_Item" or "Get_Item1")
|
||||
-- FoundItemText: text_far, sound_get_item_1, text_end (pick_up_item.asm)
|
||||
Game.stack:push(TextBox.new(Game,
|
||||
Strings("%s found\n%s!", Game.save.player.name, name)))
|
||||
Strings("%s found\n%s!", Game.save.player.name, name), nil,
|
||||
TextBox.soundOpts(Game,
|
||||
(ddef and ddef.keyItem) and "Get_Key_Item" or "Get_Item1")))
|
||||
return
|
||||
end
|
||||
|
||||
@@ -3080,6 +3120,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
|
||||
@@ -3107,7 +3173,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
|
||||
@@ -3122,7 +3189,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,
|
||||
@@ -3159,10 +3227,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
|
||||
|
||||
@@ -3182,6 +3271,58 @@ local function giveVictoryItem(reward)
|
||||
return true
|
||||
end
|
||||
|
||||
-- A gym's reward text is not one box. Every gym script carries a sound
|
||||
-- command right after the FIRST label of each reward group
|
||||
-- (scripts/PewterGym.asm PewterGymBrockReceivedBoulderBadgeText's
|
||||
-- sound_level_up, PewterGymReceivedTM34Text's sound_get_item_1, and the
|
||||
-- equivalents in the other seven), and home/text.asm TextCommand_SOUND
|
||||
-- plays it only once that page has typed out, then blocks on
|
||||
-- WaitForSoundToFinish before the next page prints. So the pages
|
||||
-- accumulate and split into separate boxes at the sound points, each box
|
||||
-- chained off the previous one's button press.
|
||||
local function rewardChain()
|
||||
local chain = { boxes = {}, pending = {} }
|
||||
function chain.flush(sound)
|
||||
if #chain.pending > 0 then
|
||||
table.insert(chain.boxes,
|
||||
{ text = table.concat(chain.pending, "\f"), sound = sound })
|
||||
chain.pending = {}
|
||||
end
|
||||
end
|
||||
-- one reward group; `sound` rides its first page, where the scripts put it
|
||||
function chain.add(labels, sound)
|
||||
local text = Game.data.text or {}
|
||||
local n = 0
|
||||
for _, label in ipairs(labels or {}) do
|
||||
if text[label] and text[label] ~= "" then
|
||||
table.insert(chain.pending, text[label])
|
||||
n = n + 1
|
||||
if n == 1 and sound then chain.flush(sound) end
|
||||
end
|
||||
end
|
||||
end
|
||||
-- the synthetic stand-in line a reward with no `dialogue` shows
|
||||
function chain.line(str, sound)
|
||||
table.insert(chain.pending, str)
|
||||
if sound then chain.flush(sound) end
|
||||
end
|
||||
function chain.push(done)
|
||||
chain.flush()
|
||||
local function step(i)
|
||||
local box = chain.boxes[i]
|
||||
if not box then
|
||||
if done then done() end
|
||||
return
|
||||
end
|
||||
local opts = box.sound and TextBox.soundOpts(Game, box.sound) or nil
|
||||
Game.stack:push(TextBox.new(Game, box.text,
|
||||
function() step(i + 1) end, opts))
|
||||
end
|
||||
step(1)
|
||||
end
|
||||
return chain
|
||||
end
|
||||
|
||||
-- Badges/items awarded after specific battles (data/scripts/victories.lua).
|
||||
-- `deactivate` retires unfought gym/dojo trainers the way the originals'
|
||||
-- SetEvent / SetEventRange do after the leader victory.
|
||||
@@ -3218,44 +3359,31 @@ function OverworldState:checkVictoryRewards(trainerClass, partyIndex)
|
||||
-- retries the hand-over later (offerGymTm via gyms.lua)
|
||||
tmGiven = giveVictoryItem(reward)
|
||||
end
|
||||
local lines = {}
|
||||
local chain = rewardChain()
|
||||
if reward.dialogue then
|
||||
local text = Game.data.text or {}
|
||||
for _, label in ipairs(reward.dialogue) do
|
||||
if text[label] and text[label] ~= "" then
|
||||
table.insert(lines, text[label])
|
||||
end
|
||||
end
|
||||
chain.add(reward.dialogue, reward.badgeSound)
|
||||
if reward.item then
|
||||
for _, label in ipairs(reward.tmPre or {}) do
|
||||
if text[label] and text[label] ~= "" then
|
||||
table.insert(lines, text[label])
|
||||
end
|
||||
end
|
||||
chain.add(reward.tmPre)
|
||||
if tmGiven then
|
||||
for _, label in ipairs(reward.tmDialogue or {}) do
|
||||
if text[label] and text[label] ~= "" then
|
||||
table.insert(lines, text[label])
|
||||
end
|
||||
end
|
||||
elseif reward.noRoom and text[reward.noRoom] and text[reward.noRoom] ~= "" then
|
||||
table.insert(lines, text[reward.noRoom])
|
||||
chain.add(reward.tmDialogue, reward.tmSound)
|
||||
else
|
||||
chain.add({ reward.noRoom })
|
||||
end
|
||||
end
|
||||
elseif reward.badge or reward.item then
|
||||
if reward.badge then
|
||||
local name = Game.data.items[reward.badge] and Game.data.items[reward.badge].name
|
||||
or reward.badge
|
||||
table.insert(lines, Strings("%s received\nthe %s!", Game.save.player.name, name))
|
||||
chain.line(Strings("%s received\nthe %s!", Game.save.player.name, name),
|
||||
reward.badgeSound)
|
||||
end
|
||||
if tmGiven then
|
||||
local name = Game.stringBuffer or reward.item
|
||||
table.insert(lines, Strings("%s received\n%s!", Game.save.player.name, name))
|
||||
chain.line(Strings("%s received\n%s!", Game.save.player.name, name),
|
||||
reward.tmSound)
|
||||
end
|
||||
end
|
||||
if #lines > 0 then
|
||||
Game.stack:push(TextBox.new(Game, table.concat(lines, "\f")))
|
||||
end
|
||||
chain.push()
|
||||
self:runVictoryHook()
|
||||
end
|
||||
|
||||
@@ -3266,24 +3394,14 @@ end
|
||||
-- show again, then the same GiveItem check decides between the received
|
||||
-- lines and the "make room" text.
|
||||
function OverworldState:offerGymTm(reward, done)
|
||||
local text = Game.data.text or {}
|
||||
local lines = {}
|
||||
local function addLine(label)
|
||||
if label and text[label] and text[label] ~= "" then
|
||||
table.insert(lines, text[label])
|
||||
end
|
||||
end
|
||||
for _, label in ipairs(reward.tmPre or {}) do addLine(label) end
|
||||
local chain = rewardChain()
|
||||
chain.add(reward.tmPre)
|
||||
if giveVictoryItem(reward) then
|
||||
for _, label in ipairs(reward.tmDialogue or {}) do addLine(label) end
|
||||
chain.add(reward.tmDialogue, reward.tmSound)
|
||||
else
|
||||
addLine(reward.noRoom)
|
||||
end
|
||||
if #lines > 0 then
|
||||
Game.stack:push(TextBox.new(Game, table.concat(lines, "\f"), done))
|
||||
elseif done then
|
||||
done()
|
||||
chain.add({ reward.noRoom })
|
||||
end
|
||||
chain.push(done)
|
||||
end
|
||||
|
||||
-- pokered reloads the map after every battle, re-running the map
|
||||
@@ -3331,11 +3449,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
|
||||
@@ -3661,8 +3786,9 @@ function OverworldState:onStepComplete()
|
||||
end
|
||||
end
|
||||
|
||||
-- wild encounters in grass, on water while surfing, or -- on indoor
|
||||
-- maps whose tileset is not FOREST -- on EVERY tile
|
||||
-- wild encounters in grass, on water while surfing (at the map's water
|
||||
-- rate, which is 0 on every indoor map), or -- on indoor maps whose
|
||||
-- tileset is not FOREST -- on every other tile
|
||||
-- (wild_encounters.asm: caves, towers, the Mansion, Power Plant)
|
||||
-- The cooldown is checked after all other step processing so repel and
|
||||
-- movement systems continue to advance during the protected steps.
|
||||
@@ -3670,10 +3796,10 @@ function OverworldState:onStepComplete()
|
||||
local encDef = Game.data.encounters[self.map.id]
|
||||
local enc
|
||||
local indoor = Game.data.field.indoorEncounters
|
||||
if p.surfing and encDef and encDef.water and self.map:isWaterCell(p.cellX, p.cellY) then
|
||||
enc = self:rollEncounter({ grass = encDef.water }, "water")
|
||||
elseif self.map:isGrassCell(p.cellX, p.cellY) then
|
||||
if self.map:isGrassCell(p.cellX, p.cellY) then
|
||||
enc = self:rollEncounter(encDef, "grass")
|
||||
elseif p.surfing and self.map:isWaterCell(p.cellX, p.cellY) then
|
||||
enc = self:rollEncounter({ grass = encDef and encDef.water }, "water")
|
||||
elseif indoor and self.map.def.index >= indoor.firstIndoorMap
|
||||
and self.map.def.tileset ~= indoor.excludedTileset then
|
||||
enc = self:rollEncounter(encDef, "indoor")
|
||||
@@ -3794,10 +3920,10 @@ function OverworldState:checkBadgeGate()
|
||||
if Game.save.inventory[g.badge] then
|
||||
if not Game.save.flags[passedFlag] then
|
||||
Game.save.flags[passedFlag] = true
|
||||
-- Route22GateGuardGoRightAheadText plays sound_get_item_1
|
||||
require("src.core.Sound").play(Game.data, "Get_Item1")
|
||||
-- Route22GateGuardGoRightAheadText carries sound_get_item_1
|
||||
Game.stack:push(TextBox.new(Game,
|
||||
t["_" .. g.passText] or Strings("Go right ahead!")))
|
||||
t["_" .. g.passText] or Strings("Go right ahead!"),
|
||||
nil, TextBox.soundOpts(Game, "Get_Item1")))
|
||||
end
|
||||
return false
|
||||
end
|
||||
@@ -3822,11 +3948,11 @@ function OverworldState:checkBadgeGate()
|
||||
and Game.data.items[guard.badge].name or guard.badge
|
||||
if Game.save.inventory[guard.badge] then
|
||||
Game.save.flags[guard.event] = true
|
||||
-- Route23OhThatIsTheBadgeText plays sound_get_item_1
|
||||
require("src.core.Sound").play(Game.data, "Get_Item1")
|
||||
-- Route23OhThatIsTheBadgeText carries sound_get_item_1
|
||||
local text = (t["_" .. g.passText] or
|
||||
Strings("Oh! That is the\n{RAM}!")):gsub("{RAM:wNameBuffer}", badgeName)
|
||||
Game.stack:push(TextBox.new(Game, text))
|
||||
Game.stack:push(TextBox.new(Game, text,
|
||||
nil, TextBox.soundOpts(Game, "Get_Item1")))
|
||||
return false
|
||||
end
|
||||
-- Route23YouDontHaveTheBadgeYetText plays SFX_DENIED
|
||||
@@ -4470,7 +4596,7 @@ function OverworldState:scriptMove(entity, dir, tiles, onDone)
|
||||
})
|
||||
end
|
||||
|
||||
-- A step-in-place beat: the entity plays one walk-cycle animation (16
|
||||
-- A step-in-place beat: the entity plays one walk-cycle animation (32
|
||||
-- frames) without translating, keeping its current facing. Ports the
|
||||
-- NPC_CHANGE_FACING movement byte (engine/overworld/movement.asm
|
||||
-- ChangeFacingDirection -> zero-delta TryWalking), used for Oak marching
|
||||
@@ -4483,7 +4609,7 @@ end
|
||||
|
||||
-- Advance scripted moves in two phases so a chained step (a new move
|
||||
-- queued by a completing move's onDone) begins the SAME frame the
|
||||
-- previous one ends -- back-to-back 16-frame tiles like the GB's
|
||||
-- previous one ends -- back-to-back 32-frame tiles like the GB's
|
||||
-- simulated-joypad / NPC scripted movement, with no idle frame between
|
||||
-- tiles. Phase 1 retires finished moves (which may chain new ones);
|
||||
-- phase 2 then starts every not-yet-moving move.
|
||||
|
||||
@@ -821,11 +821,13 @@ end
|
||||
function PikachuFollower.onFanClubEntered(game, ow)
|
||||
if not (GameVersion.isYellow() and ow.map
|
||||
and ow.map.id == "POKEMON_FAN_CLUB") then return end
|
||||
local active = game.save.pikachuMapScriptActive
|
||||
game.save.pikachuMapScriptActive = true
|
||||
if active then return end
|
||||
local starter = PikachuFollower.starterInParty(game.save)
|
||||
local npc = findFollower(ow)
|
||||
if not npc or (starter and starter.status) then return end
|
||||
ow.pikachuFanClubScene = true
|
||||
ow.pikachuMapScriptActive = true
|
||||
ow.player.facing = "down"
|
||||
for _, other in ipairs(ow.npcs or {}) do
|
||||
if other.def and other.def.name == "POKEMONFANCLUB_SEEL" then
|
||||
@@ -844,6 +846,13 @@ function PikachuFollower.onBillsHouseEnter(game, ow)
|
||||
if not (GameVersion.isYellow() and ow.map and ow.map.id == "BILLS_HOUSE") then
|
||||
return
|
||||
end
|
||||
-- BillsHouse_CheckMetBill (scripts/BillsHouse.asm:22-40) sets
|
||||
-- BIT_PIKACHU_MAP_SCRIPT_ACTIVE and rets nz before it looks at
|
||||
-- EVENT_MET_BILL_2; the bit rides sMainData, so a reload inside the house
|
||||
-- resumes at BillsHouseScript1's bare ret (#919).
|
||||
local active = game.save.pikachuMapScriptActive
|
||||
game.save.pikachuMapScriptActive = true
|
||||
if active then return end
|
||||
if game.save.flags.EVENT_MET_BILL_2 then return end
|
||||
-- BillsHouseScript0 (scripts/BillsHouse.asm:41-47) only runs the confused
|
||||
-- walk while CheckPikachuStatusCondition comes back clear
|
||||
|
||||
@@ -15,6 +15,7 @@ local WorldAPI = {}
|
||||
WorldAPI.__index = WorldAPI
|
||||
|
||||
local NO_OVERWORLD = "no overworld"
|
||||
local RODS = { "OLD_ROD", "GOOD_ROD", "SUPER_ROD" }
|
||||
|
||||
local function acceptsMenuInput(game, ow)
|
||||
local stack = game and game.stack
|
||||
@@ -86,6 +87,60 @@ 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
|
||||
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 == "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"
|
||||
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).
|
||||
|
||||
@@ -27,11 +27,15 @@ 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" }
|
||||
|
||||
function WorldAPI.new(game, modId)
|
||||
return setmetatable({ game = game, modId = modId }, WorldAPI)
|
||||
@@ -52,6 +56,77 @@ 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
|
||||
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"
|
||||
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
|
||||
@@ -191,12 +191,12 @@ return function(game)
|
||||
return cond()
|
||||
end
|
||||
|
||||
-- Jump the flash clock to just under FLASH_FRAMES (220) once the shot is
|
||||
-- Jump the flash clock to just under FLASH_FRAMES (368) once the shot is
|
||||
-- taken. EvolutionState:update only compares self.t against that constant,
|
||||
-- so this ends the animation early without touching the palette logic.
|
||||
local function skipToEnd()
|
||||
local st = evoTop()
|
||||
if st then st.t = 214 end
|
||||
if st then st.t = 362 end
|
||||
end
|
||||
|
||||
local function startEvo(species, into)
|
||||
@@ -212,7 +212,7 @@ return function(game)
|
||||
|
||||
-- ---- case 1: the reported case, SGB, mid-flash --------------------------
|
||||
local weedle = startEvo("WEEDLE", "KAKUNA")
|
||||
U.wait(24) -- into the flash, far short of FLASH_FRAMES = 220
|
||||
U.wait(24) -- into the flash, far short of FLASH_FRAMES = 368
|
||||
U.shot(game, DIR .. "/evo279_1_flash_sgb.png")
|
||||
local c1 = probe("flash/SGB", {
|
||||
monYellow1 = YELLOW[2], monYellow2 = YELLOW[3],
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
-- LINK_STATE_TRADING) skip that poll and cannot be cancelled.
|
||||
--
|
||||
-- Case 1 (level path, cancelable): open EvolutionState directly, wait past
|
||||
-- the 80-frame pre-animLoop delay (still well under FLASH_FRAMES=220),
|
||||
-- the 80-frame pre-animLoop delay (still well under FLASH_FRAMES=368),
|
||||
-- press B, and assert the mon stays CATERPIE with the "stopped evolving"
|
||||
-- text on screen.
|
||||
-- Case 1b: after cancel, checkParty with no level-ups must not re-offer;
|
||||
@@ -81,7 +81,7 @@ return function(game)
|
||||
Evolution.evolve(game, mon, "METAPOD", function() done1 = true end)
|
||||
|
||||
if not waitFor(evoTop, 300) then error("EvolutionState never opened (case1)") end
|
||||
U.wait(100) -- past the 80-frame pre-animLoop delay, still under 220
|
||||
U.wait(100) -- past the 80-frame pre-animLoop delay, still under 368
|
||||
U.log("case1 flash", "t=", top().t, "species=", mon.species)
|
||||
U.shot(game, DIR .. "/evo213_1_evolving.png")
|
||||
|
||||
@@ -133,8 +133,8 @@ return function(game)
|
||||
local done2 = false
|
||||
Evolution.evolve(game, mon2, "METAPOD", function() done2 = true end)
|
||||
if not waitFor(evoTop, 300) then error("EvolutionState never opened (case2)") end
|
||||
-- let the full flash run (FLASH_FRAMES=220) without pressing B
|
||||
waitFor(function() return not evoTop() end, 400)
|
||||
-- let the full flash run (FLASH_FRAMES=368) without pressing B
|
||||
waitFor(function() return not evoTop() end, 500)
|
||||
if not waitFor(function() return findText("evolved into") ~= nil end, 120) then
|
||||
error("Congratulations text not shown (case2)")
|
||||
end
|
||||
|
||||
@@ -89,8 +89,8 @@ return function(game)
|
||||
Evolution.evolve(game, mon, "GYARADOS", function() done1 = true end)
|
||||
|
||||
if not waitFor(evoTop, 300) then error("EvolutionState never opened (case1)") end
|
||||
-- let the full flash run (FLASH_FRAMES=220) with no input, then apply
|
||||
waitFor(function() return not evoTop() end, 400)
|
||||
-- let the full flash run (FLASH_FRAMES=368) with no input, then apply
|
||||
waitFor(function() return not evoTop() end, 500)
|
||||
if not waitFor(function() return findText("evolved into") ~= nil end, 120) then
|
||||
error("Congratulations text not shown (case1)")
|
||||
end
|
||||
@@ -124,7 +124,7 @@ return function(game)
|
||||
local done2 = false
|
||||
Evolution.evolve(game, mon2, "GYARADOS", function() done2 = true end)
|
||||
if not waitFor(evoTop, 300) then error("EvolutionState never opened (case2)") end
|
||||
waitFor(function() return not evoTop() end, 400)
|
||||
waitFor(function() return not evoTop() end, 500)
|
||||
if not waitFor(function() return findText("evolved into") ~= nil end, 120) then
|
||||
error("Congratulations text not shown (case2)")
|
||||
end
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
-- Driver: a trainer who walked up to the player must be back on her spawn
|
||||
-- cell the next time her map loads, connection crossings included (#1028).
|
||||
-- .loadNewMap calls LoadMapHeader for a seam crossing exactly as a warp does,
|
||||
-- and .loadSpriteData zeroes the sprite state data and re-seeds every
|
||||
-- SPRITESTATEDATA2_MAPY/MAPX from the map header's object data
|
||||
-- (pokered home/overworld.asm), so only the save-side defeat flag survives.
|
||||
--
|
||||
-- Route 3's Lass (data/maps/objects/Route3.asm object_event 23, 4,
|
||||
-- SPRITE_COOLTRAINER_F, STAY, LEFT, ..., OPP_LASS) has sight range 4, so
|
||||
-- standing above the third Bug Catcher at (19,4) is inside her line and she
|
||||
-- walks dist-1 = 3 cells west to (20,4) -- the reporter's exact setup.
|
||||
--
|
||||
-- POKEPORT_DRIVER=tests/drivers/trainer_reset_bug1028_test.lua \
|
||||
-- POKEPORT_IDENTITY=bug1028 POKEPORT_TOUCH=0 POKEPORT_VERSION=red love .
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
local Pokemon = require("src.pokemon.Pokemon")
|
||||
local Zoom = require("src.render.Zoom")
|
||||
local DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
|
||||
|
||||
local MAP = "ROUTE_3"
|
||||
local TARGET = 6
|
||||
local SPAWN = { x = 23, y = 4 }
|
||||
local STAND = { x = 19, y = 4 }
|
||||
|
||||
local pass = true
|
||||
local function check(label, ok)
|
||||
if not ok then pass = false end
|
||||
U.log(ok and "PASS" or "FAIL", label)
|
||||
return ok
|
||||
end
|
||||
|
||||
local function lass()
|
||||
local ow = game.overworld
|
||||
for _, n in ipairs(ow and ow.npcs or {}) do
|
||||
if n.def and n.def.index == TARGET then return n end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
local function at(n) return n and (n.cellX .. "," .. n.cellY) or "absent" end
|
||||
|
||||
-- hold a direction until cond() or the budget runs out, then let the
|
||||
-- half-finished step land
|
||||
local function holdUntil(btn, cond, budget)
|
||||
local first = true
|
||||
for _ = 1, budget or 900 do
|
||||
if cond() then break end
|
||||
if first then table.insert(game.input.pressQueue, btn); first = false end
|
||||
game.input.state[btn] = true
|
||||
coroutine.yield()
|
||||
end
|
||||
game.input.state[btn] = false
|
||||
for _ = 1, 40 do
|
||||
local ow = game.overworld
|
||||
if ow and not ow.player.moving then break end
|
||||
coroutine.yield()
|
||||
end
|
||||
U.wait(4)
|
||||
return cond()
|
||||
end
|
||||
|
||||
local function walkTo(btn, axis, want, budget)
|
||||
return holdUntil(btn, function()
|
||||
local p = game.overworld and game.overworld.player
|
||||
return p and p[axis] == want and not p.moving
|
||||
end, budget)
|
||||
end
|
||||
|
||||
local function onMap(id)
|
||||
return game.overworld and game.overworld.map and game.overworld.map.id == id
|
||||
end
|
||||
|
||||
-- mash A until the battle stack unwinds back to the overworld
|
||||
local function mashToOverworld(budget)
|
||||
for _ = 1, budget or 4000 do
|
||||
if game.stack:top() == game.overworld and not game.overworld.engaging then
|
||||
return true
|
||||
end
|
||||
U.tap(game, "a")
|
||||
U.wait(3)
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
-- absolute zoom: reset first so every shot frames the same amount of world
|
||||
local function survey(n)
|
||||
Zoom.reset()
|
||||
U.wait(2)
|
||||
for _ = 1, n do game:zoomStep(-1) end
|
||||
U.wait(30)
|
||||
end
|
||||
|
||||
-- back up to the sighting cell so the before/after shots share a camera
|
||||
local function returnToStand()
|
||||
walkTo("right", "cellX", 11)
|
||||
walkTo("up", "cellY", 4)
|
||||
walkTo("right", "cellX", STAND.x)
|
||||
end
|
||||
|
||||
game.save.party = {
|
||||
Pokemon.new(game.data, "CHARIZARD", 80),
|
||||
Pokemon.new(game.data, "SNORLAX", 80),
|
||||
}
|
||||
game.save.player.name = "MATT"
|
||||
|
||||
U.teleport(game, MAP, STAND.x, STAND.y, "right")
|
||||
U.wait(20)
|
||||
|
||||
-- only the target Lass may engage: the reporter has already cleared the
|
||||
-- Bug Catchers and Youngsters ahead of her
|
||||
local ow = game.overworld
|
||||
for _, n in ipairs(ow.npcs) do
|
||||
if n.def.trainerClass and n.def.index ~= TARGET then
|
||||
game.save.defeatedTrainers[n.id] = true
|
||||
end
|
||||
end
|
||||
|
||||
local l = lass()
|
||||
check("Lass starts on her spawn cell " .. SPAWN.x .. "," .. SPAWN.y,
|
||||
l ~= nil and l.cellX == SPAWN.x and l.cellY == SPAWN.y)
|
||||
U.log("spawn:", at(l))
|
||||
|
||||
-- she sights the player and walks up, then the battle runs
|
||||
for _ = 1, 600 do
|
||||
if ow.engaging then break end
|
||||
coroutine.yield()
|
||||
end
|
||||
check("the Lass sighted the player at " .. STAND.x .. "," .. STAND.y, ow.engaging == true)
|
||||
check("the battle ran to completion", mashToOverworld())
|
||||
U.wait(30)
|
||||
|
||||
l = lass()
|
||||
local movedX, movedY = l and l.cellX, l and l.cellY
|
||||
U.log("after the walk-up she stands at", at(l))
|
||||
check("she is off her spawn cell after walking up",
|
||||
l ~= nil and not (movedX == SPAWN.x and movedY == SPAWN.y))
|
||||
check("she is recorded as defeated", game.save.defeatedTrainers[l.id] == true)
|
||||
|
||||
survey(4)
|
||||
U.shot(game, DIR .. "/bug1028_1_walked_up.png")
|
||||
Zoom.reset()
|
||||
U.wait(5)
|
||||
|
||||
-- west along row 4, down the single gap at column 11, then west on row 9
|
||||
-- to the Pewter City seam
|
||||
walkTo("left", "cellX", 11)
|
||||
walkTo("down", "cellY", 9)
|
||||
check("reached the descent column", game.overworld.player.cellX == 11
|
||||
and game.overworld.player.cellY == 9)
|
||||
|
||||
-- crossing x=0 westward is the seam: crossConnection, not a warp
|
||||
holdUntil("left", function() return onMap("PEWTER_CITY") end, 1200)
|
||||
check("crossed the seam into Pewter City", onMap("PEWTER_CITY"))
|
||||
|
||||
survey(12)
|
||||
U.shot(game, DIR .. "/bug1028_2_pewter_survey.png")
|
||||
Zoom.reset()
|
||||
U.wait(5)
|
||||
|
||||
-- and back east into Route 3, the map load that must re-seed her
|
||||
holdUntil("right", function() return onMap(MAP) end, 1200)
|
||||
check("crossed back into Route 3", onMap(MAP))
|
||||
U.wait(30)
|
||||
|
||||
l = lass()
|
||||
U.log("after the return crossing she stands at", at(l))
|
||||
check("she is back on her spawn cell " .. SPAWN.x .. "," .. SPAWN.y,
|
||||
l ~= nil and l.cellX == SPAWN.x and l.cellY == SPAWN.y)
|
||||
check("she is still recorded as defeated",
|
||||
l ~= nil and game.save.defeatedTrainers[l.id] == true)
|
||||
|
||||
-- same cell, same zoom as shot 1: she should now be three cells further
|
||||
-- east, and being defeated she must not re-engage on the way back in
|
||||
returnToStand()
|
||||
check("walked back to the sighting cell",
|
||||
game.overworld.player.cellX == STAND.x
|
||||
and game.overworld.player.cellY == STAND.y)
|
||||
check("a defeated trainer does not re-engage", game.overworld.engaging ~= true)
|
||||
|
||||
survey(4)
|
||||
U.shot(game, DIR .. "/bug1028_3_reset.png")
|
||||
|
||||
Zoom.reset()
|
||||
U.log(pass and "RESULT PASS" or "RESULT FAIL")
|
||||
love.event.quit(pass and 0 or 1)
|
||||
while true do coroutine.yield() end
|
||||
end
|
||||
@@ -420,6 +420,7 @@ local GEN2_HOOKS = {
|
||||
-- that a Gen 1 mod reaching through ctx.battle.data instead of calling
|
||||
-- nextFn gets nil there).
|
||||
"battle.catch_exp", "battle.low_health_alarm", "battle.overlay",
|
||||
"battle.bottom_ui_visible", "battle.status_hud_visible",
|
||||
-- One pic path resolver for both games: the Gen 1 site is the SHARED
|
||||
-- src/pokemon/Sprites.lua and Gold's own battle screen calls the same hook
|
||||
-- with the Gen 1 ctx keys plus `letter` and `shiny`, which Red has no
|
||||
@@ -434,7 +435,8 @@ local GEN2_HOOKS = {
|
||||
-- pointer with the touch overlay given first refusal, the palette zone list
|
||||
-- handed to the present pass, the letterbox and the HUD rect.
|
||||
"input.step", "input.pointer",
|
||||
"render.zones", "render.compose", "render.letterbox", "render.hud",
|
||||
"render.zones", "render.compose", "render.output_enabled", "render.output",
|
||||
"render.letterbox", "render.hud",
|
||||
}
|
||||
|
||||
local function assertShared(name, sites, kind)
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
|
||||
local T = require("tests.modkit")
|
||||
local Game2 = require("src.core.Game2")
|
||||
local Runtime = require("src.mods.Runtime")
|
||||
local Hooks = require("src.mods.Hooks")
|
||||
|
||||
local savedHooks = Runtime.hooks
|
||||
local hooks = Hooks.new()
|
||||
Runtime.hooks = hooks
|
||||
|
||||
local received
|
||||
hooks:wrap("render.output", function(_, context)
|
||||
received = context
|
||||
return true
|
||||
end, 0, "test")
|
||||
hooks:wrap("render.output_enabled", function() return false end, 0, "test")
|
||||
|
||||
local canvas = love.graphics.newCanvas(640, 480)
|
||||
local presentCalls = 0
|
||||
local game = setmetatable({
|
||||
frameFit = function() return 3, 80, 24, 1, 480, 432 end,
|
||||
presentCanvas = function() presentCalls = presentCalls + 1 return canvas end,
|
||||
drawScene = function() end,
|
||||
drawHud = function() end,
|
||||
}, Game2)
|
||||
|
||||
Game2.draw(game)
|
||||
T.eq(received, nil, "a disabled Gold output hook does not run")
|
||||
T.eq(presentCalls, 0, "a disabled Gold output hook keeps the direct path")
|
||||
|
||||
hooks:wrap("render.output_enabled", function() return true end, 10, "test")
|
||||
Game2.draw(game)
|
||||
T.check(received ~= nil, "Gold raises render.output for an enabled subscriber")
|
||||
T.eq(received and received.canvas, canvas,
|
||||
"Gold hands render.output the finished present canvas")
|
||||
T.eq(received and received.generation, 2,
|
||||
"Gold identifies the output context without changing Gen 1")
|
||||
T.eq(received and received.gameX, 80, "Gold output carries the fitted game X")
|
||||
T.eq(received and received.gameY, 24, "Gold output carries the fitted game Y")
|
||||
T.eq(received and received.gameWidth, 480,
|
||||
"Gold output carries the fitted game width")
|
||||
T.eq(received and received.gameHeight, 432,
|
||||
"Gold output carries the fitted game height")
|
||||
|
||||
Runtime.hooks = savedHooks
|
||||
T.finish("gen2 render output seam")
|
||||
@@ -20,7 +20,7 @@ local PartyMenu = require("src.ui.PartyMenu")
|
||||
|
||||
-- minimal stack/input doubles matching the StateStack and Input surfaces,
|
||||
-- plus an overworld stub: PALLET_TOWN's OVERWORLD tileset passes
|
||||
-- CheckIfInOutsideMap, and the badges cover the list-time HM gates
|
||||
-- CheckIfInOutsideMap
|
||||
local function newGame(moves, inventory)
|
||||
local game = {
|
||||
data = { pokemon = { LAPRAS = { name = "LAPRAS" },
|
||||
@@ -87,14 +87,15 @@ openSubmenu(pm2)
|
||||
same(actions(pm2.subItems), { "stats", "switch" },
|
||||
"no field moves: the submenu is just STATS/SWITCH")
|
||||
|
||||
-- the badge gates still filter the list: the same Lapras without the
|
||||
-- badges keeps its moves but shows none of them
|
||||
-- GetMonFieldMoves is badge-blind: the same Lapras without the badges
|
||||
-- still lists both HMs, and .outOfBattleMovePointers refuses on
|
||||
-- selection instead (#1022)
|
||||
local noBadges = newGame(
|
||||
{ { id = "STRENGTH", pp = 15 }, { id = "SURF", pp = 15 } })
|
||||
local pm3 = PartyMenu.new(noBadges, {})
|
||||
noBadges.stack:push(pm3)
|
||||
openSubmenu(pm3)
|
||||
same(actions(pm3.subItems), { "stats", "switch" },
|
||||
"ungated badges keep the HM moves out of the submenu")
|
||||
same(actions(pm3.subItems), { "strength", "surf", "stats", "switch" },
|
||||
"the HM moves are listed without the badges (#1022)")
|
||||
|
||||
T.finish("party_fieldmove_order_bug792")
|
||||
|
||||
@@ -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")
|
||||
@@ -16,6 +16,7 @@ local T = require("tests.modkit")
|
||||
local boxes = {}
|
||||
package.loaded["src.render.TextBox"] = {
|
||||
new = function(_, s, done) return { text = s, onDone = done } end,
|
||||
soundOpts = function() return {} end,
|
||||
}
|
||||
package.loaded["src.core.Sound"] = { play = function() end }
|
||||
package.loaded["src.inventory.Bag"] = {
|
||||
|
||||
@@ -549,6 +549,27 @@ do
|
||||
dudePack:useSelected()
|
||||
eq(thrown, "POKE_BALL", "the tutorial pack still throws")
|
||||
eq(dudePack.message, nil, "with no Oak line in the way")
|
||||
|
||||
-- DepositSellPack passes the same empty world (the mart's SELL and the
|
||||
-- item PC's DEPOSIT both do): a TM row must hand its id back rather than
|
||||
-- open the teach party, which is issue #1243's "Teach which PKMN?" in
|
||||
-- the middle of a sale.
|
||||
local sellGame = { input = newInput(), save = {
|
||||
player = { name = "GOLD" },
|
||||
inventory = { TM01 = 1 },
|
||||
options = {},
|
||||
}, data = DATA, stack = newStack() }
|
||||
local sold
|
||||
local sellPack = PackMenu.new(sellGame, {
|
||||
save = sellGame.save, items = DATA.items, world = {},
|
||||
onChoose = function(id) sold = id end,
|
||||
})
|
||||
sellPack.pocketIndex = 4 -- TM_HM
|
||||
sellPack:rebuild()
|
||||
sellPack:useSelected()
|
||||
eq(sold, "TM01", "a chooser pack hands a TM to its caller")
|
||||
eq(sellPack.message, nil, "with no teach refusal in the way")
|
||||
eq(#sellGame.stack._items, 0, "and nothing pushed over the pack")
|
||||
end
|
||||
|
||||
-- ------------------------------------------------ say(): the teardown rule
|
||||
|
||||
@@ -1481,6 +1481,11 @@ local martItems = {
|
||||
price = 100, canToss = true },
|
||||
NUGGET = { id = "NUGGET", name = "NUGGET", pocket = "ITEM", index = 36,
|
||||
price = 10000, canToss = true },
|
||||
-- A TM carries the move it teaches; the SELL pack must hand it to the
|
||||
-- mart rather than opening the teach party (issue #1243).
|
||||
TM_HEADBUTT = { id = "TM_HEADBUTT", name = "TM02", pocket = "TM_HM",
|
||||
index = 234, price = 2000, canToss = true, teaches = "HEADBUTT",
|
||||
tmNumber = 2 },
|
||||
-- Every KEY ITEM carries CANT_TOSS, which is the flag SellMenu's
|
||||
-- _CheckTossableItem actually refuses on.
|
||||
BICYCLE = { id = "BICYCLE", name = "BICYCLE", pocket = "KEY_ITEM", index = 7,
|
||||
@@ -1732,6 +1737,35 @@ sellInput:press("a") sell:update(0) -- YES
|
||||
check("the wallet clamps at MAX_MONEY", sellSave.player.money, 999999)
|
||||
check("and the nugget is gone", sellSave.inventory.NUGGET, nil)
|
||||
|
||||
-- A TM is not a key item: DepositSellPack's jumptable is four ScrollingMenus
|
||||
-- and has no teach arm, so picking a TM from the SELL pack must ask "How
|
||||
-- many?" and price it at half of its ItemAttributes price -- never open the
|
||||
-- teach party (issue #1243).
|
||||
local tmSave = Save.newGame()
|
||||
tmSave.inventory = { TM_HEADBUTT = 1 }
|
||||
tmSave.player.money = 0
|
||||
local tm, tmInput = newMart(tmSave)
|
||||
tmInput:press("down") tm:update(0)
|
||||
tmInput:press("a") tm:update(0) -- SELL
|
||||
check("SELL builds the pack", tm.pack ~= nil, true)
|
||||
-- The PACK opens on the ITEM pocket; cross to TM/HM the way the player would.
|
||||
tm.pack.pocketIndex = 4
|
||||
tm.pack:rebuild()
|
||||
check("the TM is the TM pocket's row", tm.pack.rows[1].id, "TM_HEADBUTT")
|
||||
tmInput:press("a") tm:update(0)
|
||||
check("picking a TM asks how many", tm.phase, "sellQuantity")
|
||||
check("with no teach screen opened", #tm.game.stack._items, 0)
|
||||
check("and no pack refusal printed", tm.pack.message, nil)
|
||||
check("at the TM's own price", tm.qtyItem.price, 2000)
|
||||
check("and the ceiling is what you hold", tm.qtyMax, 1)
|
||||
tmInput:press("a") tm:update(0)
|
||||
check("the offer prices half of the TM", tm.confirm.pages[1][2],
|
||||
"\xc2\xa51000.")
|
||||
tmInput:press("a") tm:update(0) -- page 2
|
||||
tmInput:press("a") tm:update(0) -- YES
|
||||
check("the TM leaves the bag", tmSave.inventory.TM_HEADBUTT, nil)
|
||||
check("and the money arrives", tmSave.player.money, 1000)
|
||||
|
||||
-- ------------------------------------------------- PlayTransactionSound
|
||||
--
|
||||
-- engine/items/mart.asm rings SFX_TRANSACTION in exactly two places, both of
|
||||
|
||||
@@ -94,6 +94,8 @@ local ITEMS = {
|
||||
canToss = false },
|
||||
HM_CUT = { id = "HM_CUT", name = "HM01", pocket = "TM_HM", index = 0xf3,
|
||||
canToss = false },
|
||||
TM_HEADBUTT = { id = "TM_HEADBUTT", name = "TM02", pocket = "TM_HM",
|
||||
index = 0xea, canToss = true, teaches = "HEADBUTT" },
|
||||
}
|
||||
|
||||
local function newGame(save, items)
|
||||
@@ -291,6 +293,23 @@ do
|
||||
eq(pc.phase, "menu", "B drops back to the item PC menu")
|
||||
end
|
||||
|
||||
-- The same DepositSellPack chooser holds a TM: it must reach the quantity
|
||||
-- selector like everything tossable, never the teach party (issue #1243).
|
||||
do
|
||||
local save = newSave(1)
|
||||
local game, input = newGame(save)
|
||||
Bag.add(save, "TM_HEADBUTT", 1, game.data)
|
||||
local pc = ItemPcMenu.new(game, { save = save, items = ITEMS })
|
||||
press(pc, input, "down", "a") -- DEPOSIT ITEM
|
||||
press(pc, input, "right", "right", "right") -- ITEM -> BALL -> KEY_ITEM -> TM_HM
|
||||
press(pc, input, "a") -- choose the TM
|
||||
check(pc.qtyState ~= nil, "a TM asks how many to deposit")
|
||||
eq(#game.stack._items, 0, "with no teach screen pushed over the pack")
|
||||
press(pc, input, "a") -- deposit x1
|
||||
eq(save.pcItems.TM_HEADBUTT, 1, "the TM lands in the PC")
|
||||
eq(save.inventory.TM_HEADBUTT, nil, "and leaves the bag")
|
||||
end
|
||||
|
||||
-- .TryDepositItem's .no_toss: a KEY ITEM stays in the bag, silently.
|
||||
do
|
||||
local save = newSave(1)
|
||||
|
||||
@@ -8,11 +8,16 @@ local function quote(value)
|
||||
return "'" .. tostring(value):gsub("'", "'\\''") .. "'"
|
||||
end
|
||||
|
||||
local function execOk(cmd)
|
||||
local status = os.execute(cmd)
|
||||
return status == 0 or status == true
|
||||
end
|
||||
|
||||
local function full(path) return root .. "/" .. path end
|
||||
|
||||
local fs = {}
|
||||
function fs.createDirectory(path)
|
||||
return os.execute("mkdir -p " .. quote(full(path))) == 0
|
||||
return execOk("mkdir -p " .. quote(full(path)))
|
||||
end
|
||||
function fs.write(path, body)
|
||||
local parent = path:match("^(.*)/[^/]+$")
|
||||
@@ -34,7 +39,7 @@ function fs.remove(path)
|
||||
return true
|
||||
end
|
||||
function fs.getInfo(path)
|
||||
if os.execute("test -d " .. quote(full(path))) == 0 then
|
||||
if execOk("test -d " .. quote(full(path))) then
|
||||
return { type = "directory" }
|
||||
end
|
||||
local handle = io.open(full(path), "rb")
|
||||
@@ -77,10 +82,11 @@ local function writeProbe()
|
||||
fs.write("mods/cold_start_probe/manifest.json",
|
||||
'{"id":"cold_start_probe","name":"cold start probe","version":"1.0.0",'
|
||||
.. '"entry":"main.lua","api":2,"profile":"content"}')
|
||||
-- mod.exports, not _G: a mod's globals are its own (src/mods/Sandbox.lua)
|
||||
fs.write("mods/cold_start_probe/main.lua", [[
|
||||
return function(mod)
|
||||
_G.COLD_STORAGE = mod.storage
|
||||
_G.COLD_CHECKPOINTS = mod.checkpoints
|
||||
mod.exports.storage = mod.storage
|
||||
mod.exports.checkpoints = mod.checkpoints
|
||||
end
|
||||
]])
|
||||
end
|
||||
@@ -130,11 +136,12 @@ if phase == "capture" then
|
||||
local game = runtime(SaveData.newGame({ version = "red" }), false)
|
||||
loader.game = game
|
||||
assert(loader:load({}) == true)
|
||||
assert(_G.COLD_STORAGE:write(game, "history/index", { newest = "q0001" }))
|
||||
local checkpoint = assert(_G.COLD_CHECKPOINTS:capture(game))
|
||||
assert(_G.COLD_STORAGE:write(game, "history/q0001", checkpoint))
|
||||
local probe = assert(loader.exports.cold_start_probe)
|
||||
assert(probe.storage:write(game, "history/index", { newest = "q0001" }))
|
||||
local checkpoint = assert(probe.checkpoints:capture(game))
|
||||
assert(probe.storage:write(game, "history/q0001", checkpoint))
|
||||
local id = assert(game.save.meta.playthroughId)
|
||||
assert(_G.COLD_CHECKPOINTS:ensureNormalSave(game, checkpoint))
|
||||
assert(probe.checkpoints:ensureNormalSave(game, checkpoint))
|
||||
local normal = assert(SaveData.load("red"))
|
||||
assert(normal.meta.playthroughId == id)
|
||||
fs.write("cold-start-witness.lua", SaveSerializer.encode({ playthroughId = id }))
|
||||
@@ -144,15 +151,16 @@ else
|
||||
title.save.options = { volume = 7, bindings = {} }
|
||||
loader.game = title
|
||||
assert(loader:load({}) == true)
|
||||
local selected = assert(_G.COLD_STORAGE:selected(title))
|
||||
local probe = assert(loader.exports.cold_start_probe)
|
||||
local selected = assert(probe.storage:selected(title))
|
||||
local witness = assert(SaveSerializer.decode(assert(fs.read("cold-start-witness.lua"))))
|
||||
assert(selected:context().playthroughId == witness.playthroughId)
|
||||
assert(selected:read("history/index").newest == "q0001")
|
||||
local checkpoint = assert(selected:read("history/q0001"))
|
||||
assert(_G.COLD_CHECKPOINTS:resume(title, checkpoint))
|
||||
assert(probe.checkpoints:resume(title, checkpoint))
|
||||
assert(title.save.meta.playthroughId == witness.playthroughId)
|
||||
assert(title.save.options.volume == 7)
|
||||
assert(SaveSerializer.encode(_G.COLD_CHECKPOINTS:capture(title))
|
||||
assert(SaveSerializer.encode(probe.checkpoints:capture(title))
|
||||
== SaveSerializer.encode(checkpoint))
|
||||
local normal = assert(SaveData.load("red"))
|
||||
assert(normal.meta.playthroughId == witness.playthroughId)
|
||||
|
||||
@@ -187,6 +187,13 @@ local function runOne(seed)
|
||||
local function drive(side)
|
||||
local bt = side.bt
|
||||
if bt.result then return end
|
||||
local top = side.game.stack:top()
|
||||
if top and top.forceSwitch and top.party then
|
||||
for i, mon in ipairs(top.party) do
|
||||
if mon.hp > 0 then top.index = i break end
|
||||
end
|
||||
return
|
||||
end
|
||||
if bt.phase ~= "menu" then side.menuFrames = 0 end
|
||||
if bt.phase == "moveSelect" then
|
||||
local usable = {}
|
||||
|
||||
@@ -181,6 +181,13 @@ local function playMatch(hostEntry, guestEntry, rnd, label)
|
||||
local function drive(side)
|
||||
local bt = side.bt
|
||||
if bt.result then return end
|
||||
local top = side.game.stack:top()
|
||||
if top and top.forceSwitch and top.party then
|
||||
for i, mon in ipairs(top.party) do
|
||||
if mon.hp > 0 then top.index = i break end
|
||||
end
|
||||
return
|
||||
end
|
||||
if bt.phase ~= "menu" then side.menuFrames = 0 end
|
||||
if bt.phase == "moveSelect" then
|
||||
local usable = {}
|
||||
|
||||
@@ -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
|
||||
@@ -604,7 +619,7 @@ do
|
||||
local game = uiGame({ mon })
|
||||
Bag.add(game.save, "RARE_CANDY", 1)
|
||||
game.stack:push(BagMenu.new(game))
|
||||
for _ = 1, 600 do
|
||||
for _ = 1, 800 do
|
||||
local top = game.stack:top()
|
||||
if not top then break end
|
||||
pressed = { a = true }
|
||||
|
||||
@@ -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")
|
||||
@@ -987,6 +1013,33 @@ check(fallback.style == "doublecircle",
|
||||
"a hook naming an unregistered style falls back to the vanilla bits")
|
||||
Runtime.install(Events.new(), Hooks.new(), {})
|
||||
|
||||
-- ------- gated final-output ownership
|
||||
|
||||
local outputHooks = Hooks.new()
|
||||
Runtime.install(Events.new(), outputHooks, {})
|
||||
local outputCalls, outputContext = 0, nil
|
||||
outputHooks:wrap("render.output", function(nextLink, context)
|
||||
outputCalls, outputContext = outputCalls + 1, context
|
||||
return true
|
||||
end, 0, "test")
|
||||
outputHooks:wrap("render.output_enabled", function() return false end, 0, "test")
|
||||
Renderer:init()
|
||||
Renderer.presentCanvas = nil
|
||||
Renderer:beginFrame(false)
|
||||
Renderer:endFrame(nil, nil)
|
||||
check(outputCalls == 0 and Renderer.presentCanvas == nil,
|
||||
"a disabled output hook leaves the direct render path untouched")
|
||||
|
||||
outputHooks:wrap("render.output_enabled", function() return true end, 10, "test")
|
||||
Renderer:init()
|
||||
Renderer.presentCanvas = nil
|
||||
Renderer:beginFrame(false)
|
||||
Renderer:endFrame(nil, nil)
|
||||
check(outputCalls == 1 and outputContext and outputContext.canvas,
|
||||
"an enabled output hook receives the finished frame")
|
||||
check(outputContext and outputContext.generation == 1,
|
||||
"the output context identifies the active generation")
|
||||
|
||||
-- ------- asset transforms
|
||||
|
||||
local function seedTransform(id, source)
|
||||
|
||||
@@ -102,20 +102,19 @@ if not headlessOk then error(headlessErr) end
|
||||
love = love or require("tests.love_stub")
|
||||
|
||||
-- ------- discovery, dependency order, merge
|
||||
-- "addon" sorts before "base" so only the dependency edge can order them
|
||||
_G.MOD_TEST_ORDER = {}
|
||||
-- "addon" sorts before "base" so only the dependency edge can order them.
|
||||
-- loader.order is the engine's own record of what ran when; a mod cannot
|
||||
-- append to a shared global any more (src/mods/Sandbox.lua).
|
||||
local files = {
|
||||
["mods/addon/manifest.json"] = manifestJson("addon", '["base"]'),
|
||||
["mods/addon/main.lua"] = [[
|
||||
return function(mod)
|
||||
_G.MOD_TEST_ORDER[#_G.MOD_TEST_ORDER + 1] = "addon"
|
||||
mod.content.pokemon:override("MODMON", { name = "ADDONMON" })
|
||||
end
|
||||
]],
|
||||
["mods/base/manifest.json"] = manifestJson("base"),
|
||||
["mods/base/main.lua"] = [[
|
||||
return function(mod)
|
||||
_G.MOD_TEST_ORDER[#_G.MOD_TEST_ORDER + 1] = "base"
|
||||
mod.content.pokemon:register("MODMON", { name = "BASEMON" })
|
||||
mod.content.music:register("MOD_SONG", { file = "song.ogg" })
|
||||
end
|
||||
@@ -126,7 +125,7 @@ local loader = Loader.new({ fs = memfs(files) })
|
||||
check(loader:load(data) == true, "headless load succeeds with injected fs")
|
||||
check(loader.mods.addon ~= nil and loader.mods.base ~= nil,
|
||||
"discovery finds both mods")
|
||||
check(_G.MOD_TEST_ORDER[1] == "base" and _G.MOD_TEST_ORDER[2] == "addon",
|
||||
check(loader.order[1] == "base" and loader.order[2] == "addon",
|
||||
"topo-sort runs the dependency before its dependent")
|
||||
check(data.pokemon.MODMON ~= nil and data.pokemon.MODMON.name == "ADDONMON",
|
||||
"registered content merges into data")
|
||||
@@ -411,6 +410,5 @@ local StateStack = require("src.core.StateStack")
|
||||
while StateStack:top() do StateStack:pop() end
|
||||
require("src.core.Music").stop()
|
||||
Runtime.install(savedEvents, savedHooks)
|
||||
_G.MOD_TEST_ORDER = nil
|
||||
|
||||
S.finish()
|
||||
|
||||
@@ -138,7 +138,25 @@ check(not pcall(Manifest.parseGithub, "not a repo"),
|
||||
check(not pcall(Manifest.validate, {
|
||||
id = "badgh", name = "Bad", version = "1.0.0", entry = "main.lua",
|
||||
github = "ftp://example.com/x",
|
||||
}), "a bad github field fails manifest validation")
|
||||
}), "an unsupported github URL fails validation")
|
||||
|
||||
-- ------- dependency github repo spec hints & dependency resolver
|
||||
local depGh = Manifest.validate({
|
||||
id = "depgh", name = "DepGH", version = "1.0.0", entry = "main.lua",
|
||||
dependencies = { "colorlib@^1.2.0#Acme/ColorLib", "soundpack#Acme/SoundPack" },
|
||||
dependency_sources = { helper = "Acme/Helper" },
|
||||
})
|
||||
check(depGh.dependencySpecs[1].id == "colorlib" and depGh.dependencySpecs[1].github == "Acme/ColorLib",
|
||||
"dependency spec hash hint parses github owner/repo")
|
||||
check(depGh.dependencySpecs[2].id == "soundpack" and depGh.dependencySpecs[2].github == "Acme/SoundPack",
|
||||
"dependency spec hash hint without range parses github owner/repo")
|
||||
|
||||
local LauncherMods = require("src.mods.LauncherMods")
|
||||
local depCheck = LauncherMods.checkDependencies(depGh)
|
||||
check(depCheck.hasIssues == true, "missing dependencies trigger issues verdict")
|
||||
check(#depCheck.deps == 2, "dependency check lists all specs")
|
||||
check(depCheck.deps[1].status == "missing", "absent dependency reports missing")
|
||||
check(depCheck.deps[1].safeUrl == "https://github.com/Acme/ColorLib", "safeUrl built from validated github repo")
|
||||
|
||||
local v1 = Manifest.validate({
|
||||
id = "v1", name = "V1", version = "1.0.0", entry = "main.lua",
|
||||
@@ -341,7 +359,8 @@ check(cycleStatus.innocent.state == "loaded" and cycleData.items.FINE ~= nil,
|
||||
"a mod beside the cycle loads normally")
|
||||
|
||||
-- ------- inter-mod exports and find
|
||||
_G.MOD_FIND_RESULTS = {}
|
||||
-- probes report through mod.exports: a mod's globals are its own
|
||||
-- (src/mods/Sandbox.lua)
|
||||
local exportLoader = Loader.new({ fs = memfs({
|
||||
["mods/colorlib/manifest.json"] = manifestJson("colorlib"),
|
||||
["mods/colorlib/main.lua"] = [[
|
||||
@@ -356,7 +375,7 @@ end
|
||||
}),
|
||||
["mods/daynight/main.lua"] = [[
|
||||
return function(mod)
|
||||
local results = _G.MOD_FIND_RESULTS
|
||||
local results = mod.exports
|
||||
local color = mod.find("colorlib")
|
||||
results.depVersion = color.version
|
||||
results.tint = color.exports.tint("dusk")
|
||||
@@ -371,7 +390,7 @@ end
|
||||
["options.lua"] = "return { mods = { shelved = false } }",
|
||||
}) })
|
||||
check(exportLoader:load({}) == true, "the export fixture loads clean")
|
||||
local found = _G.MOD_FIND_RESULTS
|
||||
local found = exportLoader.exports.daynight
|
||||
check(found.tint == "tinted:dusk", "find returns the other mod's live export table")
|
||||
check(found.depVersion == "1.0.0", "the handle carries the other mod's version")
|
||||
check(found.optional == true, "an enabled optional dependency is findable")
|
||||
@@ -380,10 +399,8 @@ check(found.disabled == nil, "find returns nil for a disabled mod")
|
||||
check(found.method == true, "mod:find is tolerated alongside mod.find")
|
||||
check(exportLoader.order[1] == "colorlib",
|
||||
"a hard dependency executes before its dependent")
|
||||
_G.MOD_FIND_RESULTS = nil
|
||||
|
||||
-- ------- the rest of the v2 mod object
|
||||
_G.MOD_OBJECT_PROBE = {}
|
||||
local objectLoader = Loader.new({ fs = memfs({
|
||||
["mods/probe/manifest.json"] = manifestJson("probe", {
|
||||
api = "2", description = '"probing"', priority = "3",
|
||||
@@ -391,7 +408,7 @@ local objectLoader = Loader.new({ fs = memfs({
|
||||
["mods/probe/data.txt"] = "hello from the mod dir",
|
||||
["mods/probe/main.lua"] = [[
|
||||
return function(mod)
|
||||
local probe = _G.MOD_OBJECT_PROBE
|
||||
local probe = mod.exports
|
||||
probe.id, probe.version, probe.path = mod.id, mod.version, mod.path
|
||||
probe.manifestApi = mod.manifest.api
|
||||
mod.manifest.api = 99
|
||||
@@ -423,7 +440,7 @@ end
|
||||
["options.lua"] = "return { modOptions = { probe = { volume = 4 } } }",
|
||||
}) })
|
||||
check(objectLoader:load({ pokemon = {} }) == true, "the mod object fixture loads clean")
|
||||
local probe = _G.MOD_OBJECT_PROBE
|
||||
local probe = objectLoader.exports.probe
|
||||
check(probe.id == "probe" and probe.version == "1.0.0" and probe.path == "mods/probe",
|
||||
"identity fields are present")
|
||||
check(probe.manifestApi == 2 and objectLoader.mods.probe.manifest.api == 2,
|
||||
@@ -449,7 +466,6 @@ check(probe.onceCount == 1 and probe.stillHeard == true,
|
||||
"events:once fires once and does not skip the listener behind it")
|
||||
check(tostring(probe.forgery):find("may only emit", 1, true) ~= nil,
|
||||
"a mod cannot emit outside its own event namespace")
|
||||
_G.MOD_OBJECT_PROBE = nil
|
||||
|
||||
-- a failing entry chunk takes its exports, commands and migrations with it
|
||||
local residueLoader = Loader.new({ fs = memfs({
|
||||
@@ -519,8 +535,61 @@ local emptyLoader = Loader.new({ fs = memfs({}) })
|
||||
check(emptyLoader:load(pristine) == true, "an empty mods dir still loads clean")
|
||||
check(#emptyLoader:status().errors == 0, "no mods means no diagnostics")
|
||||
check(#emptyLoader.order == 0, "no mods means an empty load order")
|
||||
check(pristine.pokemon.A.hp == 1 and next(pristine.items) == nil,
|
||||
"no-mod load leaves data untouched")
|
||||
-- ------- dependency resolver conflict detection test
|
||||
local LauncherMods = require("src.mods.LauncherMods")
|
||||
local testTargetManifest = Manifest.validate({
|
||||
id = "new_mod",
|
||||
name = "New Mod",
|
||||
version = "1.0.0",
|
||||
entry = "main.lua",
|
||||
incompatible = { "colorlib" },
|
||||
}, "mods/new_mod")
|
||||
local installedColorlib = Manifest.validate({
|
||||
id = "colorlib",
|
||||
name = "Color Lib",
|
||||
version = "1.0.0",
|
||||
entry = "main.lua",
|
||||
}, "mods/colorlib")
|
||||
-- ------- scoped dependency tests
|
||||
local Json = require("src.link.Json")
|
||||
local scopedDepManifest = Manifest.validate({
|
||||
id = "dual_gen_mod",
|
||||
name = "Dual Gen Mod",
|
||||
version = "1.0.0",
|
||||
entry = "main.lua",
|
||||
games = { "gen1", "gen2" },
|
||||
dependencies = {
|
||||
{ id = "gen2_only_dep", games = { "gen2" }, version = "^1.0.0" }
|
||||
},
|
||||
}, "mods/dual_gen_mod")
|
||||
check(#scopedDepManifest.dependencySpecs == 1, "scoped dependency parsed")
|
||||
check(scopedDepManifest.dependencySpecs[1].games ~= nil, "dependency carries games list")
|
||||
|
||||
local dualGenFiles = {
|
||||
["mods/dual_gen_mod/manifest.json"] = Json.encode({
|
||||
id = "dual_gen_mod",
|
||||
name = "Dual Gen Mod",
|
||||
version = "1.0.0",
|
||||
entry = "main.lua",
|
||||
games = { "gen1", "gen2" },
|
||||
dependencies = {
|
||||
{ id = "gen2_only_dep", games = { "gen2" } }
|
||||
},
|
||||
}),
|
||||
["mods/dual_gen_mod/main.lua"] = [[
|
||||
return function(mod)
|
||||
mod.content.pokemon:register("DUAL_MON", { hp = 100 })
|
||||
end
|
||||
]],
|
||||
}
|
||||
local gen1Loader = Loader.new({ fs = memfs(dualGenFiles), generation = 1 })
|
||||
check(gen1Loader:load({}) == true, "dual gen mod loads on Gen 1 when Gen 2 dep is absent")
|
||||
check(gen1Loader.content.pokemon:get("DUAL_MON") ~= nil, "dual gen mod executed on Gen 1")
|
||||
|
||||
local gen2Loader = Loader.new({ fs = memfs(dualGenFiles), generation = 2 })
|
||||
check(gen2Loader:load({}) == false, "loader returns false on Gen 2 when missing required Gen 2 dep")
|
||||
check(gen2Loader.content.pokemon:get("DUAL_MON") == nil, "dual gen mod is blocked on Gen 2 when missing required Gen 2 dep")
|
||||
check(#gen2Loader:status().errors > 0, "missing dependency error logged on Gen 2")
|
||||
|
||||
Runtime.install(savedEvents, savedHooks)
|
||||
|
||||
|
||||
@@ -168,8 +168,11 @@ end
|
||||
|
||||
do
|
||||
local BattleState = require("src.battle.BattleState")
|
||||
local Gen2BattleState = require("src.ui.gen2.BattleState")
|
||||
check(BattleState.bottomUIVisible({ phase = "menu" }),
|
||||
"battle bottom UI is visible without a mod")
|
||||
check(Gen2BattleState.bottomUIVisible({ phase = "menu" }),
|
||||
"Gold battle bottom UI is visible without a mod")
|
||||
local seen
|
||||
local unsub = wrap("battle.bottom_ui_visible", function(_, state)
|
||||
seen = state
|
||||
@@ -177,6 +180,8 @@ do
|
||||
end)
|
||||
check(not BattleState.bottomUIVisible({ phase = "messages" }),
|
||||
"a mod can hide the battle text and menu layer")
|
||||
check(not Gen2BattleState.bottomUIVisible({ phase = "menu" }),
|
||||
"the same hook hides Gold's battle text and menu layer")
|
||||
local text = setmetatable({}, TextBox)
|
||||
text:draw()
|
||||
check(seen == text, "pushed text boxes use the same visibility hook")
|
||||
@@ -200,15 +205,41 @@ do
|
||||
|
||||
check(BattleState.bottomUIVisible({ phase = "moveSelect" }),
|
||||
"battle bottom UI returns when the hook is removed")
|
||||
check(Gen2BattleState.bottomUIVisible({ phase = "moves" }),
|
||||
"Gold battle bottom UI returns when the hook is removed")
|
||||
|
||||
local Chrome = require("src.ui.gen2.Chrome")
|
||||
local clear, box, print = Chrome.clear, Chrome.box, Chrome.print
|
||||
local boxes = 0
|
||||
Chrome.clear = function() end
|
||||
Chrome.box = function() boxes = boxes + 1 end
|
||||
Chrome.print = function() end
|
||||
local panel = setmetatable({
|
||||
battle = { player = {}, enemy = {} }, phase = "resolving",
|
||||
drawHud = function() end,
|
||||
}, { __index = Gen2BattleState })
|
||||
unsub = wrap("battle.bottom_ui_visible", function() return false end)
|
||||
panel:drawPanel()
|
||||
check(boxes == 0, "Gold skips its in-state battle box when a mod owns it")
|
||||
unsub()
|
||||
panel:drawPanel()
|
||||
check(boxes == 1, "Gold draws its battle box again without the hook")
|
||||
Chrome.clear, Chrome.box, Chrome.print = clear, box, print
|
||||
|
||||
check(BattleState.statusHUDVisible({}),
|
||||
"battle status HUD is visible without a mod")
|
||||
check(Gen2BattleState.statusHUDVisible({}),
|
||||
"Gold battle status HUD is visible without a mod")
|
||||
unsub = wrap("battle.status_hud_visible", function() return false end)
|
||||
check(not BattleState.statusHUDVisible({}),
|
||||
"a mod can hide the battle status HUD")
|
||||
check(not Gen2BattleState.statusHUDVisible({}),
|
||||
"the same hook hides Gold's battle status HUD")
|
||||
unsub()
|
||||
check(BattleState.statusHUDVisible({}),
|
||||
"battle status HUD returns when the hook is removed")
|
||||
check(Gen2BattleState.statusHUDVisible({}),
|
||||
"Gold battle status HUD returns when the hook is removed")
|
||||
end
|
||||
|
||||
-- ------- battle.caught_marker_visible (caught wild marker)
|
||||
|
||||
+21
-23
@@ -87,13 +87,19 @@ end
|
||||
|
||||
-- ------- a mod registers two pipelines and the engine dispatches them
|
||||
|
||||
local trace = {}
|
||||
|
||||
-- The probe table is the mod's, published through mod.exports: a mod's
|
||||
-- globals are its own now (src/mods/Sandbox.lua). The world/present folds
|
||||
-- accept only a real Canvas, so the mod makes concrete ones to return and the
|
||||
-- test pins identity through the dispatch.
|
||||
local FILES = {
|
||||
["mods/painter/manifest.json"] = manifest("painter", ',"priority":10'),
|
||||
["mods/painter/main.lua"] = [[
|
||||
local mod = ...
|
||||
local T = _G.__RENDER_TEST
|
||||
local T = mod.exports
|
||||
T.trace, T.available = {}, true
|
||||
T.worldOut = love.graphics.newCanvas(2, 2)
|
||||
T.blurOut = love.graphics.newCanvas(2, 2)
|
||||
T.gradeOut = love.graphics.newCanvas(2, 2)
|
||||
mod.content.render_pipelines:register("diorama", {
|
||||
label = "DIORAMA",
|
||||
levels = { "OFF", "LOW", "HIGH" },
|
||||
@@ -101,8 +107,6 @@ local FILES = {
|
||||
priority = 20,
|
||||
available = function() return T.available end,
|
||||
update = function(dt, level) T.trace[#T.trace + 1] = "update:" .. level end,
|
||||
-- the folds composite only a real Canvas, so the mod hands back the
|
||||
-- canvases the test pre-created (see T.worldOut / T.blurOut / T.gradeOut)
|
||||
drawWorld = function(ctx)
|
||||
T.trace[#T.trace + 1] = "world:" .. tostring(ctx.tag)
|
||||
return T.worldOut
|
||||
@@ -123,17 +127,12 @@ local FILES = {
|
||||
]],
|
||||
}
|
||||
|
||||
_G.__RENDER_TEST = { trace = trace, available = true }
|
||||
-- the world/present folds accept only a real Canvas, so give the mod concrete
|
||||
-- ones to return and pin identity through the dispatch
|
||||
_G.__RENDER_TEST.worldOut = love.graphics.newCanvas(2, 2)
|
||||
_G.__RENDER_TEST.blurOut = love.graphics.newCanvas(2, 2)
|
||||
_G.__RENDER_TEST.gradeOut = love.graphics.newCanvas(2, 2)
|
||||
|
||||
local data = {}
|
||||
local loader = Loader.new({ fs = memfs(FILES) })
|
||||
local okLoad = loader:load(data)
|
||||
check(okLoad, "the pipeline mod loads clean: " .. table.concat(loader.errors, "; "))
|
||||
local RT = loader.exports.painter
|
||||
local trace = RT.trace
|
||||
Pipelines.install(data)
|
||||
|
||||
check(type(data.render_pipelines) == "table",
|
||||
@@ -169,24 +168,24 @@ Pipelines.setLevel("grade", 1)
|
||||
|
||||
eq(Pipelines.worldPipeline(), "diorama",
|
||||
"the eligible world pipeline claims the world pass")
|
||||
eq(Pipelines.drawWorld("diorama", { tag = "ctx" }), _G.__RENDER_TEST.worldOut,
|
||||
eq(Pipelines.drawWorld("diorama", { tag = "ctx" }), RT.worldOut,
|
||||
"drawWorld returns the mod's canvas")
|
||||
eq(trace[#trace], "world:ctx", "drawWorld received the frame context")
|
||||
|
||||
eq(Pipelines.worldPresent(_G.__RENDER_TEST.worldOut), _G.__RENDER_TEST.blurOut,
|
||||
eq(Pipelines.worldPresent(RT.worldOut), RT.blurOut,
|
||||
"worldPresent folds its canvas over the world image")
|
||||
eq(Pipelines.wantsPresent(), true, "a live present pass asks for the canvas")
|
||||
eq(Pipelines.present(_G.__RENDER_TEST.gradeOut), _G.__RENDER_TEST.gradeOut,
|
||||
eq(Pipelines.present(RT.gradeOut), RT.gradeOut,
|
||||
"present folds its canvas over the finished composite")
|
||||
|
||||
-- ------- the hardware gate
|
||||
|
||||
_G.__RENDER_TEST.available = false
|
||||
RT.available = false
|
||||
eq(Pipelines.worldPipeline(), nil,
|
||||
"an unavailable pipeline never takes the world pass")
|
||||
eq(Pipelines.worldPresent("world-canvas"), "world-canvas",
|
||||
"an unavailable pipeline's worldPresent is skipped")
|
||||
_G.__RENDER_TEST.available = true
|
||||
RT.available = true
|
||||
eq(Pipelines.worldPipeline(), "diorama", "availability is re-read each frame")
|
||||
|
||||
-- ------- the gate governs input, never the draw
|
||||
@@ -282,7 +281,8 @@ local SLOPPY = {
|
||||
["mods/sloppy/manifest.json"] = manifest("sloppy"),
|
||||
["mods/sloppy/main.lua"] = [[
|
||||
local mod = ...
|
||||
local T = _G.__SLOPPY
|
||||
local T = mod.exports
|
||||
T.ran = 0
|
||||
mod.content.render_pipelines:register("sloppy", {
|
||||
label = "SLOPPY",
|
||||
present = function(canvas)
|
||||
@@ -302,20 +302,20 @@ local SLOPPY = {
|
||||
})
|
||||
]],
|
||||
}
|
||||
_G.__SLOPPY = { ran = 0 }
|
||||
local sloppyData = {}
|
||||
local sloppyLoader = Loader.new({ fs = memfs(SLOPPY) })
|
||||
sloppyLoader:load(sloppyData)
|
||||
local SL = sloppyLoader.exports.sloppy
|
||||
Pipelines.install(sloppyData)
|
||||
|
||||
local composite = love.graphics.newCanvas(4, 4)
|
||||
Pipelines.setLevel("sloppy", 1)
|
||||
for _, bad in ipairs({ "just-a-string", true, 42 }) do
|
||||
_G.__SLOPPY.ret = bad
|
||||
SL.ret = bad
|
||||
eq(Pipelines.present(composite), composite,
|
||||
"a present returning a " .. type(bad) .. " leaves the composite untouched")
|
||||
end
|
||||
check(_G.__SLOPPY.ran == 3, "the present callback still ran each frame")
|
||||
check(SL.ran == 3, "the present callback still ran each frame")
|
||||
check(Pipelines.eligible("sloppy") == true,
|
||||
"a non-canvas return does not retire the pipeline as broken")
|
||||
Pipelines.setLevel("sloppy", 0)
|
||||
@@ -334,11 +334,9 @@ eq(love.graphics.getCanvas(), "engine-canvas",
|
||||
eq(love.graphics.getBlendMode(), "alpha",
|
||||
"a present that changed blend mode cannot leak it past the fold")
|
||||
Pipelines.setLevel("dirty", 0)
|
||||
_G.__SLOPPY = nil
|
||||
|
||||
Pipelines.reset()
|
||||
Pipelines.install(nil)
|
||||
_G.__RENDER_TEST = nil
|
||||
|
||||
-- ------- and with no mods at all, the whole subsystem is inert
|
||||
|
||||
|
||||
+15
-1
@@ -584,7 +584,9 @@ check(not fpm.submenu and forced == fgame.save.party[1],
|
||||
-- ------- issues #320/#385: the STRENGTH texts print over the party menu
|
||||
do
|
||||
local owStub = { strengthActive = false,
|
||||
map = { def = { tileset = "OVERWORLD" } }, dark = false }
|
||||
map = { def = { tileset = "OVERWORLD" } }, dark = false,
|
||||
partyKnows = function(self, id) return self.knows == id end,
|
||||
knows = "STRENGTH" }
|
||||
local sgame = partyGame()
|
||||
sgame.overworld = owStub
|
||||
sgame.data.text = {} -- the strength texts fall back to Strings sources
|
||||
@@ -1162,6 +1164,18 @@ seedOpts.modProfiles = {}
|
||||
ModProfile.ensureFirst(seedOpts, ms.status.available, {})
|
||||
check(#seedOpts.modProfiles == 0, "seeding never runs twice")
|
||||
|
||||
local LauncherMods = require("src.mods.LauncherMods")
|
||||
local testProfOpts = { activeProfile = "P1", modProfiles = { { name = "P1", enabled = { a = true } } } }
|
||||
local dupSnap = LauncherMods.duplicateProfile("P1", testProfOpts)
|
||||
check(dupSnap and dupSnap.name == "P1 (Copy)" and testProfOpts.activeProfile == "P1 (Copy)",
|
||||
"duplicateProfile creates P1 (Copy) and activates it")
|
||||
check(LauncherMods.renameProfile("P1 (Copy)", "RenamedP", testProfOpts) == true,
|
||||
"renameProfile renames active profile")
|
||||
check(testProfOpts.activeProfile == "RenamedP", "activeProfile updates on rename")
|
||||
check(LauncherMods.deleteProfile("RenamedP", testProfOpts) == true, "deleteProfile removes profile")
|
||||
check(#testProfOpts.modProfiles == 1 and testProfOpts.modProfiles[1].name == "P1", "only original profile remains")
|
||||
check(testProfOpts.activeProfile == "P1", "activeProfile falls back to remaining profile")
|
||||
|
||||
-- permissions rows
|
||||
local permy = manifest("permy", { permissions = { "network" } })
|
||||
local msP = ManagerState.new(managerGame(fakeLoader({ permy })))
|
||||
|
||||
@@ -139,10 +139,11 @@ local files = {
|
||||
["mods/probe/manifest.json"] =
|
||||
'{"id":"probe","name":"probe","version":"1.0.0",'
|
||||
.. '"entry":"main.lua","api":2,"profile":"content"}',
|
||||
-- mod.exports, not _G: a mod's globals are its own (src/mods/Sandbox.lua)
|
||||
["mods/probe/main.lua"] = [[
|
||||
return function(mod)
|
||||
_G.MOD_CHECKPOINTS = mod.checkpoints
|
||||
_G.MOD_HOOKS = mod.hooks
|
||||
mod.exports.checkpoints = mod.checkpoints
|
||||
mod.exports.hooks = mod.hooks
|
||||
end
|
||||
]],
|
||||
}
|
||||
@@ -150,12 +151,12 @@ local game, ow = makeGame()
|
||||
local loader = Loader.new({ fs = memfs(files) })
|
||||
loader.game = game
|
||||
T.check(loader:load({}) == true, "checkpoint fixture mod loads")
|
||||
local checkpoints = _G.MOD_CHECKPOINTS
|
||||
local checkpoints = (loader.exports.probe or {}).checkpoints
|
||||
local modHooks = (loader.exports.probe or {}).hooks
|
||||
T.check(type(checkpoints) == "table",
|
||||
"Loader exposes mod.checkpoints through the public mod object")
|
||||
if type(checkpoints) ~= "table" then
|
||||
Runtime.events, Runtime.hooks = savedEvents, savedHooks
|
||||
_G.MOD_CHECKPOINTS = nil
|
||||
T.finish()
|
||||
end
|
||||
|
||||
@@ -357,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)
|
||||
@@ -386,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
|
||||
@@ -396,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
|
||||
@@ -436,11 +450,67 @@ 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.
|
||||
local auxiliaryCalls = 0
|
||||
_G.MOD_HOOKS:wrap("battle.menu_auxiliary", function(nextFn, liveGame, context)
|
||||
modHooks:wrap("battle.menu_auxiliary", function(nextFn, liveGame, context)
|
||||
auxiliaryCalls = auxiliaryCalls + 1
|
||||
T.check(liveGame == battleGame, "public battle auxiliary action receives the game")
|
||||
T.same(context, { kind = "wild" }, "public auxiliary context is data-only")
|
||||
@@ -456,8 +526,6 @@ T.eq(boundary.menuIndex, originalMenuIndex, "public auxiliary hook preserves cur
|
||||
|
||||
Runtime.events, Runtime.hooks = savedEvents, savedHooks
|
||||
Runtime.currentMod = nil
|
||||
_G.MOD_CHECKPOINTS = nil
|
||||
_G.MOD_HOOKS = nil
|
||||
love.math.getRandomState = oldGetRandomState
|
||||
love.math.setRandomState = oldSetRandomState
|
||||
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
-- A sandboxed mod may read battery state without receiving love.system and
|
||||
-- its process-launching surface.
|
||||
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
|
||||
local T = require("tests.modkit")
|
||||
|
||||
local FIXTURE = {
|
||||
["mods/power_probe/manifest.json"] = [[{
|
||||
"id": "power_probe",
|
||||
"name": "Power Probe",
|
||||
"version": "1.0.0",
|
||||
"entry": "main.lua",
|
||||
"api": 2
|
||||
}]],
|
||||
["mods/power_probe/main.lua"] = [[
|
||||
local mod = ...
|
||||
mod.exports.state, mod.exports.percent = mod.device:powerInfo()
|
||||
]],
|
||||
}
|
||||
|
||||
local saved = T.love.system.getPowerInfo
|
||||
local calls = 0
|
||||
T.love.system.getPowerInfo = function()
|
||||
calls = calls + 1
|
||||
return "charging", 42, 900
|
||||
end
|
||||
|
||||
local vanilla = T.sdk.loadNone({})
|
||||
T.eq(calls, 0, "no mod leaves the device power backend cold")
|
||||
vanilla.release()
|
||||
|
||||
local run = T.sdk.loadMods({ "mods/power_probe" },
|
||||
{ fs = T.sdk.memfs(FIXTURE) })
|
||||
T.eq(#run.errors, 0,
|
||||
"the sandboxed power probe loads clean (" .. tostring(run.errors[1]) .. ")")
|
||||
local out = run.loader.exports.power_probe or {}
|
||||
T.eq(out.state, "charging", "the public facade reports battery state")
|
||||
T.eq(out.percent, 42, "the public facade reports battery percentage")
|
||||
T.eq(calls, 1, "one facade read makes one platform call")
|
||||
run.release()
|
||||
|
||||
T.love.system.getPowerInfo = nil
|
||||
local unavailable = T.sdk.loadMods({ "mods/power_probe" },
|
||||
{ fs = T.sdk.memfs(FIXTURE) })
|
||||
out = unavailable.loader.exports.power_probe or {}
|
||||
T.eq(out.state, "unknown", "a missing platform backend has a stable state")
|
||||
T.eq(out.percent, nil, "a missing platform backend has no invented percentage")
|
||||
unavailable.release()
|
||||
|
||||
T.love.system.getPowerInfo = saved
|
||||
T.finish("device_power_info")
|
||||
@@ -23,16 +23,16 @@ local FIXTURE = {
|
||||
extraPolls = extraPolls + 1
|
||||
if not paused then nextFn(game, dt) end
|
||||
end)
|
||||
local vetoQuit = false
|
||||
mod.hooks:wrap("core.quit_to_launcher", function(nextFn)
|
||||
if os.getenv("FIXTURE_VETO_QUIT") == "1" then return false end
|
||||
if vetoQuit then return false end
|
||||
return nextFn()
|
||||
end)
|
||||
-- test-only knobs, read back through mod.storage-free globals since
|
||||
-- this fixture never leaves the process
|
||||
_G.__fixturePlatformBridge = {
|
||||
setPaused = function(v) paused = v end,
|
||||
extraPolls = function() return extraPolls end,
|
||||
}
|
||||
-- test-only knobs on mod.exports: a sandboxed mod has no shared _G to
|
||||
-- smuggle them through (src/mods/Sandbox.lua)
|
||||
mod.exports.setPaused = function(v) paused = v end
|
||||
mod.exports.extraPolls = function() return extraPolls end
|
||||
mod.exports.setVetoQuit = function(v) vetoQuit = v end
|
||||
]],
|
||||
}
|
||||
|
||||
@@ -43,23 +43,23 @@ do
|
||||
T.eq(#run.errors, 0,
|
||||
"the fixture mod loads clean (" .. tostring(run.errors[1]) .. ")")
|
||||
|
||||
local bridge = run.loader.exports.fix_platform_bridge
|
||||
local calls = 0
|
||||
local fakeGame = { update = function(self, dt) calls = calls + 1 end }
|
||||
|
||||
_G.__fixturePlatformBridge.setPaused(false)
|
||||
bridge.setPaused(false)
|
||||
PlatformHooks.update(fakeGame, 1 / 60)
|
||||
T.eq(calls, 1, "unpaused: vanilla Game:update runs")
|
||||
T.eq(_G.__fixturePlatformBridge.extraPolls(), 1,
|
||||
T.eq(bridge.extraPolls(), 1,
|
||||
"the subscriber's wrapper runs every frame")
|
||||
|
||||
_G.__fixturePlatformBridge.setPaused(true)
|
||||
bridge.setPaused(true)
|
||||
PlatformHooks.update(fakeGame, 1 / 60)
|
||||
T.eq(calls, 1, "paused: vanilla Game:update is skipped")
|
||||
T.eq(_G.__fixturePlatformBridge.extraPolls(), 2,
|
||||
T.eq(bridge.extraPolls(), 2,
|
||||
"the subscriber keeps polling every frame while paused")
|
||||
|
||||
run.release()
|
||||
_G.__fixturePlatformBridge = nil
|
||||
end
|
||||
|
||||
-- core.quit_to_launcher: a subscriber can veto without the vanilla
|
||||
@@ -70,11 +70,8 @@ do
|
||||
T.eq(#run.errors, 0,
|
||||
"the fixture mod loads clean (" .. tostring(run.errors[1]) .. ")")
|
||||
|
||||
local realGetenv = os.getenv
|
||||
os.getenv = function(name)
|
||||
if name == "FIXTURE_VETO_QUIT" then return "1" end
|
||||
return realGetenv(name)
|
||||
end
|
||||
local bridge = run.loader.exports.fix_platform_bridge
|
||||
bridge.setVetoQuit(true)
|
||||
local vanillaCalls = 0
|
||||
local vetoed = PlatformHooks.quitToLauncher(function()
|
||||
vanillaCalls = vanillaCalls + 1
|
||||
@@ -82,7 +79,7 @@ do
|
||||
end)
|
||||
T.eq(vetoed, false, "a subscriber can veto the return-to-launcher decision")
|
||||
T.eq(vanillaCalls, 0, "a veto never evaluates the vanilla condition")
|
||||
os.getenv = realGetenv
|
||||
bridge.setVetoQuit(false)
|
||||
|
||||
local passed = PlatformHooks.quitToLauncher(function() return true end)
|
||||
T.eq(passed, true, "with no veto, the vanilla decision passes through unchanged")
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user