mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-20 04:31:09 +02:00
more bugs squashed + portable mode
CLOSES #28: PC in the beginning of the game isn't interactable (The one in your house) CLOSES #34: Bug when calculating exp after one or many fainted team members. CLOSES #37: No Grass Cutting CLOSES #38: Blind TMs CLOSES #53: Portable Mode CLOSES #55: changing palletes with hot key CLOSES #62: Poison status damage issue.
This commit is contained in:
@@ -45,6 +45,27 @@ then `love .` for later launches. Windows PowerShell scripts, the optional
|
|||||||
developer data build, test suites, and cache management are covered in
|
developer data build, test suites, and cache management are covered in
|
||||||
[Developer Setup](https://github.com/bryanthaboi/pokemon-gen1-recomp-project/wiki/Guide-Developer-Setup).
|
[Developer Setup](https://github.com/bryanthaboi/pokemon-gen1-recomp-project/wiki/Guide-Developer-Setup).
|
||||||
|
|
||||||
|
## Portable Mode
|
||||||
|
|
||||||
|
By default the game keeps your save, options, and the private ROM-derived
|
||||||
|
data cache in your OS's normal per-user app data folder. To keep everything
|
||||||
|
next to the game instead (handy for a USB stick or portable drive you carry
|
||||||
|
between computers), drop an empty file named `portable.txt` next to the
|
||||||
|
executable (or next to `main.lua`/`conf.lua` when running from source), then
|
||||||
|
launch the game.
|
||||||
|
|
||||||
|
With `portable.txt` present:
|
||||||
|
|
||||||
|
- `save.lua`, `save.lua.bak`, and `options.lua` are read from and written to
|
||||||
|
that same folder instead of the OS save directory.
|
||||||
|
- After a ROM import, the generated `data/generated` and `assets/generated`
|
||||||
|
cache is copied into that folder too, so a later launch (even on a
|
||||||
|
different computer, as long as the same folder comes along) reuses it
|
||||||
|
without asking for the ROM again.
|
||||||
|
- Deleting `portable.txt` switches back to the normal OS save directory; nothing
|
||||||
|
already written to either location is touched automatically, so copy files
|
||||||
|
over yourself if you want to carry existing progress across the switch.
|
||||||
|
|
||||||
## Modding
|
## Modding
|
||||||
|
|
||||||
The game ships a native mod platform: content registries, events and hooks,
|
The game ships a native mod platform: content registries, events and hooks,
|
||||||
|
|||||||
@@ -1532,10 +1532,16 @@ function BattleState:endOfTurn()
|
|||||||
-- that sets the flag also zeroes the counter, so a stale value is
|
-- that sets the flag also zeroes the counter, so a stale value is
|
||||||
-- unobservable (a switch or cure downgrades Toxic to plain poison).
|
-- unobservable (a switch or cure downgrades Toxic to plain poison).
|
||||||
self.sideToxic = self.sideToxic or {}
|
self.sideToxic = self.sideToxic or {}
|
||||||
for _, pair in ipairs({ { self.player, self.enemy, "player" },
|
-- a battler whose opponent was already knocked out by a move this turn
|
||||||
{ self.enemy, self.player, "enemy" } }) do
|
-- skips its own residual (HandlePoisonBurnLeechSeed is bypassed when the
|
||||||
local b, opp, side = pair[1], pair[2], pair[3]
|
-- move faints the target); snapshot before residual so one side's
|
||||||
if b.mon.hp > 0 then
|
-- residual faint can't suppress the other's
|
||||||
|
local playerAlive = self.player.mon.hp > 0
|
||||||
|
local enemyAlive = self.enemy.mon.hp > 0
|
||||||
|
for _, pair in ipairs({ { self.player, self.enemy, "player", enemyAlive },
|
||||||
|
{ self.enemy, self.player, "enemy", playerAlive } }) do
|
||||||
|
local b, opp, side, oppAlive = pair[1], pair[2], pair[3], pair[4]
|
||||||
|
if b.mon.hp > 0 and oppAlive then
|
||||||
local msgs = Status.residual(b, opp, self)
|
local msgs = Status.residual(b, opp, self)
|
||||||
for _, m in ipairs(msgs) do self:sayNext(prefixEnemy(m, b)) end
|
for _, m in ipairs(msgs) do self:sayNext(prefixEnemy(m, b)) end
|
||||||
if #msgs > 0 then self:drainNext() end -- poison/burn/seed HP moved
|
if #msgs > 0 then self:drainNext() end -- poison/burn/seed HP moved
|
||||||
@@ -2442,6 +2448,9 @@ end
|
|||||||
function BattleState:onFaint(battler)
|
function BattleState:onFaint(battler)
|
||||||
if battler.faintQueued then return end
|
if battler.faintQueued then return end
|
||||||
battler.faintQueued = true
|
battler.faintQueued = true
|
||||||
|
if battler.isPlayer and self.participants then
|
||||||
|
self.participants[battler.mon] = nil
|
||||||
|
end
|
||||||
Runtime.emit("battle.fainted", { battle = self, battler = battler })
|
Runtime.emit("battle.fainted", { battle = self, battler = battler })
|
||||||
-- the faint slide + cry ride the queue (after the move animation and
|
-- the faint slide + cry ride the queue (after the move animation and
|
||||||
-- the HP-bar drain, pokered's order); the slide finishes before the
|
-- the HP-bar drain, pokered's order); the slide finishes before the
|
||||||
@@ -2478,9 +2487,9 @@ function BattleState:enemyMonFainted()
|
|||||||
-- exp is split among the mons that fought this enemy
|
-- exp is split among the mons that fought this enemy
|
||||||
-- (engine/battle/experience.asm); traded mons earn x1.5; each
|
-- (engine/battle/experience.asm); traded mons earn x1.5; each
|
||||||
-- participant gets the full stat exp
|
-- participant gets the full stat exp
|
||||||
-- the divisor counts EVERY participant, fainted ones included
|
-- a mon that fainted mid-fight has had its gain-exp flag cleared
|
||||||
-- (DivideExpDataByNumMonsGainingExp keeps their flag bits); only the
|
-- (RemoveFaintedPlayerMon), so it drops out of the divisor and only
|
||||||
-- living ones are actually paid
|
-- the surviving participants are counted and paid
|
||||||
local participants, alive = 0, {}
|
local participants, alive = 0, {}
|
||||||
for _, mon in ipairs(self.game.save.party) do
|
for _, mon in ipairs(self.game.save.party) do
|
||||||
if self.participants and self.participants[mon] then
|
if self.participants and self.participants[mon] then
|
||||||
|
|||||||
+16
-4
@@ -283,10 +283,22 @@ function Game:keypressed(key)
|
|||||||
self:zoomStep(1)
|
self:zoomStep(1)
|
||||||
return
|
return
|
||||||
elseif key == "2" then
|
elseif key == "2" then
|
||||||
-- cycle COLORS (GBC / OG / OG INV / GBC INV / CLASSIC); always on
|
-- cycle COLORS (GBC / OG / OG INV / GBC INV / CLASSIC); the pack change
|
||||||
local PaletteFX = require("src.render.PaletteFX")
|
-- forces Game.overworld:reloadMap, which rebuilds the live NPC array, so
|
||||||
self.save.options.colors = PaletteFX.cycleMode()
|
-- hold it while a warp/transition or an on-screen scripted cutscene is
|
||||||
self:writeOptions()
|
-- driving the overworld rather than tear the escort's NPCs out mid-move
|
||||||
|
local ow = self.overworld
|
||||||
|
local top = self.stack:top()
|
||||||
|
local busy = ow and (ow.transitioning
|
||||||
|
or (top == ow and (
|
||||||
|
(ow.runner and ow.runner.isRunning and ow.runner:isRunning())
|
||||||
|
or (ow.scriptMoves and #ow.scriptMoves > 0)
|
||||||
|
or ow.engaging or ow.emote)))
|
||||||
|
if not busy then
|
||||||
|
local PaletteFX = require("src.render.PaletteFX")
|
||||||
|
self.save.options.colors = PaletteFX.cycleMode()
|
||||||
|
self:writeOptions()
|
||||||
|
end
|
||||||
return
|
return
|
||||||
elseif key == "3" then
|
elseif key == "3" then
|
||||||
-- cycle TILT OFF → 15 → 35 → 50 → OFF (mnemonic: 3D), free-roam only
|
-- cycle TILT OFF → 15 → 35 → 50 → OFF (mnemonic: 3D), free-roam only
|
||||||
|
|||||||
+106
-5
@@ -27,6 +27,107 @@ local OPTIONS_FILENAME = "options.lua"
|
|||||||
local BACKUP_FILENAME = FILENAME .. ".bak"
|
local BACKUP_FILENAME = FILENAME .. ".bak"
|
||||||
local TMP_FILENAME = FILENAME .. ".tmp"
|
local TMP_FILENAME = FILENAME .. ".tmp"
|
||||||
|
|
||||||
|
-- ------- portable mode
|
||||||
|
-- LÖVE's save directory is always the OS per-user path derived from the
|
||||||
|
-- identity (conf.lua), so it can't be relocated at runtime. Portable mode
|
||||||
|
-- instead drops a plain-Lua io.* filesystem next to the game whenever a
|
||||||
|
-- `portable.txt` marker sits beside the executable/source, letting a USB
|
||||||
|
-- copy carry its own save.lua/options.lua (and, through options.lua, the
|
||||||
|
-- mod enable-state) rather than leaving them on the host machine.
|
||||||
|
local PORTABLE_MARKER = "portable.txt"
|
||||||
|
local SEP = package.config:sub(1, 1)
|
||||||
|
|
||||||
|
local portableChecked = false
|
||||||
|
local portableBase = false -- resolved base dir when active, else false
|
||||||
|
local portableFsCache = nil
|
||||||
|
|
||||||
|
local function pathExists(path)
|
||||||
|
local f = io.open(path, "rb")
|
||||||
|
if not f then return false end
|
||||||
|
f:close()
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
|
||||||
|
-- an io.* filesystem exposing the love.filesystem subset the save/options
|
||||||
|
-- round-trip needs (getInfo/read/write/remove), rooted at `dir`
|
||||||
|
local function makePortableFs(dir)
|
||||||
|
local function full(name) return dir .. SEP .. name end
|
||||||
|
return {
|
||||||
|
getInfo = function(name)
|
||||||
|
if not pathExists(full(name)) then return nil end
|
||||||
|
return { type = "file" }
|
||||||
|
end,
|
||||||
|
read = function(name)
|
||||||
|
local f = io.open(full(name), "rb")
|
||||||
|
if not f then return nil, "no file: " .. name end
|
||||||
|
local data = f:read("*a")
|
||||||
|
f:close()
|
||||||
|
return data
|
||||||
|
end,
|
||||||
|
write = function(name, data)
|
||||||
|
local f, err = io.open(full(name), "wb")
|
||||||
|
if not f then return false, err end
|
||||||
|
f:write(data)
|
||||||
|
f:close()
|
||||||
|
return true
|
||||||
|
end,
|
||||||
|
remove = function(name)
|
||||||
|
os.remove(full(name))
|
||||||
|
return true
|
||||||
|
end,
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
local function detectPortable()
|
||||||
|
if portableChecked then return portableBase end
|
||||||
|
portableChecked = true
|
||||||
|
portableBase = false
|
||||||
|
if not (love and love.filesystem) then return false end
|
||||||
|
local candidates = {}
|
||||||
|
if love.filesystem.getSourceBaseDirectory then
|
||||||
|
candidates[#candidates + 1] = love.filesystem.getSourceBaseDirectory()
|
||||||
|
end
|
||||||
|
if love.filesystem.getSource then
|
||||||
|
candidates[#candidates + 1] = love.filesystem.getSource()
|
||||||
|
end
|
||||||
|
for _, base in ipairs(candidates) do
|
||||||
|
if base and base ~= "" and pathExists(base .. SEP .. PORTABLE_MARKER) then
|
||||||
|
portableBase = base
|
||||||
|
break
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return portableBase
|
||||||
|
end
|
||||||
|
|
||||||
|
function SaveData.isPortable()
|
||||||
|
return detectPortable() ~= false
|
||||||
|
end
|
||||||
|
|
||||||
|
-- the raw portable-folder path (for callers building their own nested
|
||||||
|
-- paths, e.g. the ROM-derived asset cache), or nil when portable mode
|
||||||
|
-- is off
|
||||||
|
function SaveData.portableBaseDir()
|
||||||
|
return detectPortable() or nil
|
||||||
|
end
|
||||||
|
|
||||||
|
-- the io.* filesystem for the active portable folder, or nil when off
|
||||||
|
function SaveData.portableFs()
|
||||||
|
local base = detectPortable()
|
||||||
|
if not base then return nil end
|
||||||
|
if not portableFsCache then portableFsCache = makePortableFs(base) end
|
||||||
|
return portableFsCache
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Resolve the filesystem a persistent read/write should land on: an
|
||||||
|
-- explicitly injected non-love fs (headless tests, the mod loader's stub)
|
||||||
|
-- always wins; otherwise portable mode reroutes off the OS save directory.
|
||||||
|
local function persistFs(fs)
|
||||||
|
if fs and love and love.filesystem and fs ~= love.filesystem then
|
||||||
|
return fs
|
||||||
|
end
|
||||||
|
return SaveData.portableFs() or fs or (love and love.filesystem)
|
||||||
|
end
|
||||||
|
|
||||||
-- Port + original Options menu defaults. Missing keys on load are filled
|
-- Port + original Options menu defaults. Missing keys on load are filled
|
||||||
-- from this table so old options.lua files stay compatible.
|
-- from this table so old options.lua files stay compatible.
|
||||||
function SaveData.defaultOptions()
|
function SaveData.defaultOptions()
|
||||||
@@ -94,7 +195,7 @@ end
|
|||||||
-- love.filesystem, so the mod loader's injected filesystem can carry the
|
-- love.filesystem, so the mod loader's injected filesystem can carry the
|
||||||
-- options round-trip headless (no love global).
|
-- options round-trip headless (no love global).
|
||||||
function SaveData.saveOptions(opts, fs)
|
function SaveData.saveOptions(opts, fs)
|
||||||
fs = fs or love.filesystem
|
fs = persistFs(fs)
|
||||||
opts = SaveData.mergeOptions(opts)
|
opts = SaveData.mergeOptions(opts)
|
||||||
-- modOptions is per-mod nested state: fold the on-disk sub-tree
|
-- modOptions is per-mod nested state: fold the on-disk sub-tree
|
||||||
-- underneath (newest value winning per key) so one caller's partial
|
-- underneath (newest value winning per key) so one caller's partial
|
||||||
@@ -123,7 +224,7 @@ function SaveData.saveOptions(opts, fs)
|
|||||||
end
|
end
|
||||||
|
|
||||||
function SaveData.loadOptions(fs)
|
function SaveData.loadOptions(fs)
|
||||||
fs = fs or love.filesystem
|
fs = persistFs(fs)
|
||||||
local data, err = readTable(fs, OPTIONS_FILENAME)
|
local data, err = readTable(fs, OPTIONS_FILENAME)
|
||||||
if not data then
|
if not data then
|
||||||
if fs.getInfo(OPTIONS_FILENAME) then
|
if fs.getInfo(OPTIONS_FILENAME) then
|
||||||
@@ -311,7 +412,7 @@ end)
|
|||||||
-- afterwards either way
|
-- afterwards either way
|
||||||
SaveData.addCoreMigration(1, function(save)
|
SaveData.addCoreMigration(1, function(save)
|
||||||
if type(save.options) == "table"
|
if type(save.options) == "table"
|
||||||
and not love.filesystem.getInfo(OPTIONS_FILENAME) then
|
and not persistFs(nil).getInfo(OPTIONS_FILENAME) then
|
||||||
SaveData.saveOptions(save.options)
|
SaveData.saveOptions(save.options)
|
||||||
end
|
end
|
||||||
end)
|
end)
|
||||||
@@ -342,7 +443,7 @@ function SaveData.save(data, mods)
|
|||||||
if k ~= "options" then gameOnly[k] = v end
|
if k ~= "options" then gameOnly[k] = v end
|
||||||
end
|
end
|
||||||
local encoded = SaveSerializer.encode(gameOnly)
|
local encoded = SaveSerializer.encode(gameOnly)
|
||||||
local fs = love.filesystem
|
local fs = persistFs(nil)
|
||||||
if fs.getInfo(FILENAME) then
|
if fs.getInfo(FILENAME) then
|
||||||
local prev = fs.read(FILENAME)
|
local prev = fs.read(FILENAME)
|
||||||
if prev then fs.write(BACKUP_FILENAME, prev) end
|
if prev then fs.write(BACKUP_FILENAME, prev) end
|
||||||
@@ -371,7 +472,7 @@ end
|
|||||||
-- or corrupt and a staged/backup copy was promoted; Game surfaces the
|
-- or corrupt and a staged/backup copy was promoted; Game surfaces the
|
||||||
-- recovery on the load report
|
-- recovery on the load report
|
||||||
function SaveData.load()
|
function SaveData.load()
|
||||||
local fs = love.filesystem
|
local fs = persistFs(nil)
|
||||||
local data, err = readTable(fs, FILENAME)
|
local data, err = readTable(fs, FILENAME)
|
||||||
local recovered
|
local recovered
|
||||||
if not data then
|
if not data then
|
||||||
|
|||||||
+122
-1
@@ -2,7 +2,7 @@ local RomImporter = {}
|
|||||||
RomImporter.__index = RomImporter
|
RomImporter.__index = RomImporter
|
||||||
|
|
||||||
local ROM_SHA1 = "ea9bcae617fdf159b045185467ae58b2e4a48b9a"
|
local ROM_SHA1 = "ea9bcae617fdf159b045185467ae58b2e4a48b9a"
|
||||||
local CACHE_MARKER = "rom-cache-v6:" .. ROM_SHA1
|
local CACHE_MARKER = "rom-cache-v7:" .. ROM_SHA1
|
||||||
local MARKER_PATH = "rom-cache.complete"
|
local MARKER_PATH = "rom-cache.complete"
|
||||||
local COMMUNITY_URL = "https://bois.icu"
|
local COMMUNITY_URL = "https://bois.icu"
|
||||||
local TRUST_WARNING = "if you did not get this from bryanthaboi's github " ..
|
local TRUST_WARNING = "if you did not get this from bryanthaboi's github " ..
|
||||||
@@ -38,8 +38,124 @@ local function sourceTreeHasData()
|
|||||||
return real == love.filesystem.getSource()
|
return real == love.filesystem.getSource()
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- ------- portable ROM-derived asset cache
|
||||||
|
--
|
||||||
|
-- The extracted cache (data/generated, assets/generated) is written
|
||||||
|
-- exclusively through love.filesystem.write, which always targets the OS
|
||||||
|
-- save directory -- it cannot be redirected to an arbitrary folder. So a
|
||||||
|
-- portable install mirrors the cache both ways instead: after a fresh
|
||||||
|
-- import, every generated file is copied out to the portable folder
|
||||||
|
-- (SaveData.portableFs's io.* companion); on a later boot -- possibly on a
|
||||||
|
-- different machine sharing the same USB copy -- a matching portable
|
||||||
|
-- cache is copied back into the save directory before the normal
|
||||||
|
-- isReady() check runs, so nothing downstream needs to know the cache
|
||||||
|
-- ever lived anywhere but the save directory.
|
||||||
|
local PORTABLE_CACHE_DIRS = { "data/generated", "assets/generated" }
|
||||||
|
local PORTABLE_MANIFEST_NAME = "portable_cache_manifest.txt"
|
||||||
|
local PORTABLE_SEP = package.config:sub(1, 1)
|
||||||
|
|
||||||
|
local function walkLoveDir(dir, out)
|
||||||
|
out = out or {}
|
||||||
|
for _, name in ipairs(love.filesystem.getDirectoryItems(dir)) do
|
||||||
|
local full = dir .. "/" .. name
|
||||||
|
local info = love.filesystem.getInfo(full)
|
||||||
|
if info and info.type == "directory" then
|
||||||
|
walkLoveDir(full, out)
|
||||||
|
elseif info and info.type == "file" then
|
||||||
|
out[#out + 1] = full
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return out
|
||||||
|
end
|
||||||
|
|
||||||
|
local function portablePath(base, relPath)
|
||||||
|
return base .. PORTABLE_SEP .. relPath:gsub("/", PORTABLE_SEP)
|
||||||
|
end
|
||||||
|
|
||||||
|
local function ensurePortableDir(fullDirPath)
|
||||||
|
if love.system.getOS() == "Windows" then
|
||||||
|
os.execute(('mkdir "%s" 2>NUL'):format(fullDirPath))
|
||||||
|
else
|
||||||
|
os.execute(("mkdir -p '%s' 2>/dev/null"):format(fullDirPath))
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- copies data/generated + assets/generated out to the portable folder
|
||||||
|
-- after a fresh import; a plain-text manifest travels alongside so a
|
||||||
|
-- later sync-in knows exactly which files to copy back without needing
|
||||||
|
-- to list an arbitrary external directory (io.* has no listdir)
|
||||||
|
local function syncCacheToPortable()
|
||||||
|
local SaveData = require("src.core.SaveData")
|
||||||
|
local base = SaveData.portableBaseDir()
|
||||||
|
if not base then return end
|
||||||
|
local manifest = {}
|
||||||
|
for _, dir in ipairs(PORTABLE_CACHE_DIRS) do
|
||||||
|
if love.filesystem.getInfo(dir, "directory") then
|
||||||
|
for _, relPath in ipairs(walkLoveDir(dir)) do
|
||||||
|
local data = love.filesystem.read(relPath)
|
||||||
|
if data then
|
||||||
|
local outPath = portablePath(base, relPath)
|
||||||
|
local outDir = outPath:match("^(.*)" .. PORTABLE_SEP .. "[^" .. PORTABLE_SEP .. "]+$")
|
||||||
|
if outDir then ensurePortableDir(outDir) end
|
||||||
|
local f, err = io.open(outPath, "wb")
|
||||||
|
if f then
|
||||||
|
f:write(data)
|
||||||
|
f:close()
|
||||||
|
manifest[#manifest + 1] = relPath
|
||||||
|
else
|
||||||
|
require("src.core.Logger").error(
|
||||||
|
"portable cache: could not write %s: %s", outPath, tostring(err))
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
local mf = io.open(base .. PORTABLE_SEP .. PORTABLE_MANIFEST_NAME, "wb")
|
||||||
|
if mf then
|
||||||
|
mf:write(table.concat(manifest, "\n"))
|
||||||
|
mf:close()
|
||||||
|
end
|
||||||
|
local mk = io.open(base .. PORTABLE_SEP .. MARKER_PATH, "wb")
|
||||||
|
if mk then
|
||||||
|
mk:write(CACHE_MARKER)
|
||||||
|
mk:close()
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- copies a matching portable cache back into the save directory before
|
||||||
|
-- isReady() runs its normal check; a mismatched or missing marker means
|
||||||
|
-- either no portable cache exists yet or it belongs to an older build, so
|
||||||
|
-- it is left alone and a fresh import proceeds as usual
|
||||||
|
local function syncCacheFromPortable()
|
||||||
|
local SaveData = require("src.core.SaveData")
|
||||||
|
local base = SaveData.portableBaseDir()
|
||||||
|
if not base then return end
|
||||||
|
local markerFile = io.open(base .. PORTABLE_SEP .. MARKER_PATH, "rb")
|
||||||
|
if not markerFile then return end
|
||||||
|
local marker = markerFile:read("*a")
|
||||||
|
markerFile:close()
|
||||||
|
if marker ~= CACHE_MARKER then return end
|
||||||
|
local manifestFile = io.open(base .. PORTABLE_SEP .. PORTABLE_MANIFEST_NAME, "rb")
|
||||||
|
if not manifestFile then return end
|
||||||
|
local manifestBody = manifestFile:read("*a")
|
||||||
|
manifestFile:close()
|
||||||
|
for relPath in manifestBody:gmatch("[^\r\n]+") do
|
||||||
|
local f = io.open(portablePath(base, relPath), "rb")
|
||||||
|
if f then
|
||||||
|
local data = f:read("*a")
|
||||||
|
f:close()
|
||||||
|
love.filesystem.write(relPath, data)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
love.filesystem.write(MARKER_PATH, CACHE_MARKER)
|
||||||
|
end
|
||||||
|
|
||||||
function RomImporter.isReady()
|
function RomImporter.isReady()
|
||||||
if sourceTreeHasData() then return true end
|
if sourceTreeHasData() then return true end
|
||||||
|
if love.filesystem.read(MARKER_PATH) ~= CACHE_MARKER
|
||||||
|
and require("src.core.SaveData").isPortable() then
|
||||||
|
syncCacheFromPortable()
|
||||||
|
end
|
||||||
return love.filesystem.read(MARKER_PATH) == CACHE_MARKER
|
return love.filesystem.read(MARKER_PATH) == CACHE_MARKER
|
||||||
and allRequiredFilesExist()
|
and allRequiredFilesExist()
|
||||||
end
|
end
|
||||||
@@ -211,6 +327,11 @@ function RomImporter:startData(data, displayName)
|
|||||||
collectgarbage("collect")
|
collectgarbage("collect")
|
||||||
local ok, writeError = love.filesystem.write(MARKER_PATH, CACHE_MARKER)
|
local ok, writeError = love.filesystem.write(MARKER_PATH, CACHE_MARKER)
|
||||||
if not ok then error("could not finish the private cache: " .. tostring(writeError)) end
|
if not ok then error("could not finish the private cache: " .. tostring(writeError)) end
|
||||||
|
if require("src.core.SaveData").isPortable() then
|
||||||
|
self.status = "Copying data to the portable folder"
|
||||||
|
coroutine.yield()
|
||||||
|
syncCacheToPortable()
|
||||||
|
end
|
||||||
self.state = "complete"
|
self.state = "complete"
|
||||||
self.status = "Ready"
|
self.status = "Ready"
|
||||||
self.detail = "Starting Pokemon Red..."
|
self.detail = "Starting Pokemon Red..."
|
||||||
|
|||||||
@@ -397,6 +397,7 @@ local function toggleObject(ctx, mapId, objName, visible)
|
|||||||
-- positions mid-cutscene)
|
-- positions mid-cutscene)
|
||||||
local ow = ctx.overworld
|
local ow = ctx.overworld
|
||||||
if not ow or ow.map.id ~= mapId then return end
|
if not ow or ow.map.id ~= mapId then return end
|
||||||
|
ow.npcPool = ow.npcPool or {}
|
||||||
if visible then
|
if visible then
|
||||||
for _, n in ipairs(ow.npcs) do
|
for _, n in ipairs(ow.npcs) do
|
||||||
if n.def.name == objName then return end
|
if n.def.name == objName then return end
|
||||||
@@ -407,12 +408,16 @@ local function toggleObject(ctx, mapId, objName, visible)
|
|||||||
local npc = NPC.new(ctx.game.data, mapId, obj)
|
local npc = NPC.new(ctx.game.data, mapId, obj)
|
||||||
table.insert(ow.npcs, npc)
|
table.insert(ow.npcs, npc)
|
||||||
table.insert(ow.entities, npc)
|
table.insert(ow.entities, npc)
|
||||||
|
ow.npcPool[npc.id] = npc
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
else
|
else
|
||||||
for i = #ow.npcs, 1, -1 do
|
for i = #ow.npcs, 1, -1 do
|
||||||
if ow.npcs[i].def.name == objName then table.remove(ow.npcs, i) end
|
if ow.npcs[i].def.name == objName then
|
||||||
|
ow.npcPool[ow.npcs[i].id] = nil
|
||||||
|
table.remove(ow.npcs, i)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
for i = #ow.entities, 1, -1 do
|
for i = #ow.entities, 1, -1 do
|
||||||
local e = ow.entities[i]
|
local e = ow.entities[i]
|
||||||
|
|||||||
+43
-28
@@ -296,37 +296,52 @@ local function useOn(game, battle, id, target, list, moveIndex)
|
|||||||
showMessages(game, payload) -- failed
|
showMessages(game, payload) -- failed
|
||||||
end
|
end
|
||||||
|
|
||||||
|
local function pickTargetAndUse(game, battle, id, list)
|
||||||
|
-- pick a target from the party
|
||||||
|
-- the ETHERs and PP UP open the move menu after picking a mon
|
||||||
|
-- (ItemUsePPRestore / ItemUsePPUp); the ELIXERs hit every move
|
||||||
|
local wantsMove = id == "ETHER" or id == "MAX_ETHER" or id == "PP_UP"
|
||||||
|
require("src.ui.Screens").push(game, "PartyMenu", {
|
||||||
|
pickOnly = true,
|
||||||
|
onSwitch = function(mon)
|
||||||
|
if not wantsMove then
|
||||||
|
useOn(game, battle, id, mon, list)
|
||||||
|
return
|
||||||
|
end
|
||||||
|
local rows = {}
|
||||||
|
for mi, mv in ipairs(mon.moves) do
|
||||||
|
local mdef = game.data.moves[mv.id]
|
||||||
|
table.insert(rows, {
|
||||||
|
value = mi,
|
||||||
|
label = mdef and mdef.name or mv.id,
|
||||||
|
right = ("%d"):format(mv.pp),
|
||||||
|
})
|
||||||
|
end
|
||||||
|
game.stack:push(ListMenu.new(game, "Which move?", rows, {
|
||||||
|
onChoose = function(row, l)
|
||||||
|
l:close()
|
||||||
|
useOn(game, battle, id, mon, list, row.value)
|
||||||
|
end,
|
||||||
|
}))
|
||||||
|
end,
|
||||||
|
})
|
||||||
|
end
|
||||||
|
|
||||||
local function useItem(game, battle, id, list)
|
local function useItem(game, battle, id, list)
|
||||||
local def = game.data.items[id]
|
local def = game.data.items[id]
|
||||||
if ItemEffects.needsTarget(id, def) and not ItemEffects.isBall(id) then
|
if ItemEffects.needsTarget(id, def) and not ItemEffects.isBall(id) then
|
||||||
-- pick a target from the party
|
-- TMs/HMs boot up and announce their move before the target picker
|
||||||
-- the ETHERs and PP UP open the move menu after picking a mon
|
-- (ItemUseTMHM: BootedUpTMText / BootedUpHMText + TeachMachineMoveText)
|
||||||
-- (ItemUsePPRestore / ItemUsePPUp); the ELIXERs hit every move
|
if def and def.machine then
|
||||||
local wantsMove = id == "ETHER" or id == "MAX_ETHER" or id == "PP_UP"
|
local moveDef = game.data.moves[def.machine.move]
|
||||||
require("src.ui.Screens").push(game, "PartyMenu", {
|
local moveName = moveDef and moveDef.name or def.machine.move
|
||||||
pickOnly = true,
|
local booted = def.machine.kind == "HM"
|
||||||
onSwitch = function(mon)
|
and "Booted up an HM!" or "Booted up a TM!"
|
||||||
if not wantsMove then
|
showMessages(game, { booted, ("It contained\n%s!"):format(moveName) },
|
||||||
useOn(game, battle, id, mon, list)
|
function() pickTargetAndUse(game, battle, id, list) end)
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
local rows = {}
|
pickTargetAndUse(game, battle, id, list)
|
||||||
for mi, mv in ipairs(mon.moves) do
|
|
||||||
local mdef = game.data.moves[mv.id]
|
|
||||||
table.insert(rows, {
|
|
||||||
value = mi,
|
|
||||||
label = mdef and mdef.name or mv.id,
|
|
||||||
right = ("%d"):format(mv.pp),
|
|
||||||
})
|
|
||||||
end
|
|
||||||
game.stack:push(ListMenu.new(game, "Which move?", rows, {
|
|
||||||
onChoose = function(row, l)
|
|
||||||
l:close()
|
|
||||||
useOn(game, battle, id, mon, list, row.value)
|
|
||||||
end,
|
|
||||||
}))
|
|
||||||
end,
|
|
||||||
})
|
|
||||||
else
|
else
|
||||||
useOn(game, battle, id, nil, list)
|
useOn(game, battle, id, nil, list)
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -1708,8 +1708,10 @@ function OverworldState:tryCut(fx, fy)
|
|||||||
-- chain-cut "ornamental bushes" around Saffron and Celadon.
|
-- chain-cut "ornamental bushes" around Saffron and Celadon.
|
||||||
local ts = self.map.def.tileset
|
local ts = self.map.def.tileset
|
||||||
local tile = self.map:cellTile(fx, fy)
|
local tile = self.map:cellTile(fx, fy)
|
||||||
|
local isGrass = (ts == "OVERWORLD" and tile == 0x52)
|
||||||
if not ((ts == "OVERWORLD" and tile == 0x3d)
|
if not ((ts == "OVERWORLD" and tile == 0x3d)
|
||||||
or (ts == "GYM" and tile == 0x50)) then
|
or (ts == "GYM" and tile == 0x50)
|
||||||
|
or isGrass) then
|
||||||
return false
|
return false
|
||||||
end
|
end
|
||||||
local bx, by = math.floor(fx / 2), math.floor(fy / 2)
|
local bx, by = math.floor(fx / 2), math.floor(fy / 2)
|
||||||
@@ -1718,7 +1720,7 @@ function OverworldState:tryCut(fx, fy)
|
|||||||
for _, sw in ipairs(Game.data.field.cutTreeSwaps) do
|
for _, sw in ipairs(Game.data.field.cutTreeSwaps) do
|
||||||
if sw.before == block then swap = sw break end
|
if sw.before == block then swap = sw break end
|
||||||
end
|
end
|
||||||
if not swap or self.map:isWalkableCell(fx, fy) then return false end
|
if not swap or (not isGrass and self.map:isWalkableCell(fx, fy)) then return false end
|
||||||
local mon = self:partyKnows("CUT")
|
local mon = self:partyKnows("CUT")
|
||||||
if not mon then return false end
|
if not mon then return false end
|
||||||
-- gen 1 confirms nothing (engine/overworld/cut.asm UsedCut): the
|
-- gen 1 confirms nothing (engine/overworld/cut.asm UsedCut): the
|
||||||
@@ -1736,7 +1738,11 @@ function OverworldState:tryCut(fx, fy)
|
|||||||
local finish = function()
|
local finish = function()
|
||||||
require("src.core.Sound").play(Game.data, "Cut")
|
require("src.core.Sound").play(Game.data, "Cut")
|
||||||
end
|
end
|
||||||
if ts == "OVERWORLD" then
|
if isGrass then
|
||||||
|
-- AnimCut .grass: tall grass gets the leaf-swirl / dust puff, not
|
||||||
|
-- the tree-split slide
|
||||||
|
self:startDustAnim(fx, fy, finish)
|
||||||
|
elseif ts == "OVERWORLD" then
|
||||||
-- the tree splits in half and slides apart (AnimCut .cutTreeLoop);
|
-- the tree splits in half and slides apart (AnimCut .cutTreeLoop);
|
||||||
-- the GYM plant keeps the shared dust/leaf puff
|
-- the GYM plant keeps the shared dust/leaf puff
|
||||||
self:startCutTreeAnim(fx, fy, finish)
|
self:startCutTreeAnim(fx, fy, finish)
|
||||||
@@ -1822,8 +1828,10 @@ function OverworldState:useCutFieldMove()
|
|||||||
-- "nothing to cut" in vanilla
|
-- "nothing to cut" in vanilla
|
||||||
local ts = self.map.def.tileset
|
local ts = self.map.def.tileset
|
||||||
local tile = self.map:cellTile(fx, fy)
|
local tile = self.map:cellTile(fx, fy)
|
||||||
|
local isGrass = (ts == "OVERWORLD" and tile == 0x52)
|
||||||
if not ((ts == "OVERWORLD" and tile == 0x3d)
|
if not ((ts == "OVERWORLD" and tile == 0x3d)
|
||||||
or (ts == "GYM" and tile == 0x50)) then
|
or (ts == "GYM" and tile == 0x50)
|
||||||
|
or isGrass) then
|
||||||
return "nothing"
|
return "nothing"
|
||||||
end
|
end
|
||||||
local bx, by = math.floor(fx / 2), math.floor(fy / 2)
|
local bx, by = math.floor(fx / 2), math.floor(fy / 2)
|
||||||
@@ -1832,7 +1840,7 @@ function OverworldState:useCutFieldMove()
|
|||||||
for _, sw in ipairs(Game.data.field.cutTreeSwaps) do
|
for _, sw in ipairs(Game.data.field.cutTreeSwaps) do
|
||||||
if sw.before == block then swap = sw break end
|
if sw.before == block then swap = sw break end
|
||||||
end
|
end
|
||||||
if not swap or self.map:isWalkableCell(fx, fy) then return "nothing" end
|
if not swap or (not isGrass and self.map:isWalkableCell(fx, fy)) then return "nothing" end
|
||||||
return "ok"
|
return "ok"
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -2004,9 +2012,11 @@ function OverworldState:openPC(onDone)
|
|||||||
end
|
end
|
||||||
table.insert(items, { label = "LOG OFF", onSelect = logOff })
|
table.insert(items, { label = "LOG OFF", onSelect = logOff })
|
||||||
-- pokered sets BIT_NO_MENU_BUTTON_SOUND for the whole PC session
|
-- pokered sets BIT_NO_MENU_BUTTON_SOUND for the whole PC session
|
||||||
-- (engine/overworld/pokecenter_pc.asm / player_pc.asm)
|
-- (engine/overworld/pokecenter_pc.asm / player_pc.asm); DisplayPCMainMenu
|
||||||
|
-- calls TextBoxBorder with c=14 (interior width, +2 for the border), so
|
||||||
|
-- tw here (total width) is 16
|
||||||
Game.stack:push(Menu.new(Game, items,
|
Game.stack:push(Menu.new(Game, items,
|
||||||
{ tx = 0, ty = 0, tw = 14, th = #items * 2 + 2, onCancel = logOff,
|
{ tx = 0, ty = 0, tw = 16, th = #items * 2 + 2, onCancel = logOff,
|
||||||
noSound = true }))
|
noSound = true }))
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -1,67 +0,0 @@
|
|||||||
-- Parity: losing the Oak's Lab starter rival must not black out.
|
|
||||||
-- pret HandlePlayerBlackOut special-cases OPP_RIVAL1 on OAKS_LAB:
|
|
||||||
-- Rival1WinText only, no PlayerBlackedOutText, no warp / half-money.
|
|
||||||
-- OaksLabRivalEndBattleScript then HealParty and continues either way.
|
|
||||||
-- Self-contained; run via `luajit tests/parity_oaks_lab_rival_loss.lua`.
|
|
||||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
|
||||||
if not _G.love then _G.love = require("tests.love_stub") end
|
|
||||||
local BattleState = require("src.battle.BattleState")
|
|
||||||
local S = require("tests.harness").suite("parity oaks lab rival loss")
|
|
||||||
local check, eq = S.check, S.eq
|
|
||||||
|
|
||||||
local function saidBlackout(b)
|
|
||||||
for _, m in ipairs(b.said) do
|
|
||||||
if tostring(m):find("blacked") then return true end
|
|
||||||
end
|
|
||||||
return false
|
|
||||||
end
|
|
||||||
|
|
||||||
local function saidRivalTaunt(b)
|
|
||||||
for _, m in ipairs(b.said) do
|
|
||||||
if tostring(m):find("great or what") then return true end
|
|
||||||
end
|
|
||||||
return false
|
|
||||||
end
|
|
||||||
|
|
||||||
local function rivalBattle(mapId)
|
|
||||||
return {
|
|
||||||
kind = "trainer",
|
|
||||||
oppClass = "OPP_RIVAL1",
|
|
||||||
result = nil,
|
|
||||||
afterQueue = nil,
|
|
||||||
said = {},
|
|
||||||
data = {
|
|
||||||
text = { _Rival1WinText = "{RIVAL}: Yeah! Am\nI great or what?" },
|
|
||||||
},
|
|
||||||
game = {
|
|
||||||
save = {
|
|
||||||
party = { { species = "SQUIRTLE", hp = 0, stats = { hp = 20 } } },
|
|
||||||
player = { name = "RED", rival = "BLUE", map = mapId },
|
|
||||||
},
|
|
||||||
overworld = { map = { id = mapId } },
|
|
||||||
},
|
|
||||||
sayNext = function(self, m) self.said[#self.said + 1] = m end,
|
|
||||||
say = function(self, m) self.said[#self.said + 1] = m end,
|
|
||||||
}
|
|
||||||
end
|
|
||||||
|
|
||||||
do
|
|
||||||
local b = rivalBattle("OAKS_LAB")
|
|
||||||
BattleState.playerMonFainted(b)
|
|
||||||
eq(b.result, "lose", "lab rival wipe is still a loss")
|
|
||||||
check(saidRivalTaunt(b), "lab rival wipe shows Rival1WinText")
|
|
||||||
check(not saidBlackout(b), "lab rival wipe does not say blacked out")
|
|
||||||
check(BattleState.isOaksLabStarterRival(b), "oaks-lab starter-rival detector")
|
|
||||||
end
|
|
||||||
|
|
||||||
do
|
|
||||||
local b = rivalBattle("ROUTE_22")
|
|
||||||
BattleState.playerMonFainted(b)
|
|
||||||
eq(b.result, "lose", "route-22 rival wipe is a loss")
|
|
||||||
check(saidRivalTaunt(b), "route-22 rival still shows Rival1WinText")
|
|
||||||
check(saidBlackout(b), "route-22 rival still blacks out")
|
|
||||||
check(not BattleState.isOaksLabStarterRival(b),
|
|
||||||
"route-22 is not the oaks-lab special case")
|
|
||||||
end
|
|
||||||
|
|
||||||
S.finish()
|
|
||||||
@@ -1,190 +0,0 @@
|
|||||||
-- Parity test, Viridian City's two old men + the Pokédex object swap.
|
|
||||||
--
|
|
||||||
-- pokered has TWO old men on this map (data/maps/objects/ViridianCity.asm):
|
|
||||||
--
|
|
||||||
-- object_event 18, 9, SPRITE_GAMBLER_ASLEEP, STAY, NONE, ..._OLD_MAN_SLEEPY
|
|
||||||
-- object_event 17, 5, SPRITE_GAMBLER, WALK, LEFT_RIGHT, ..._OLD_MAN
|
|
||||||
--
|
|
||||||
-- The sleeper only ever grumbles "private property" and shoves you back
|
|
||||||
-- down; he never wakes, moves or hides. The coffee ask and the catch
|
|
||||||
-- tutorial belong to the walking man, who starts OFF
|
|
||||||
-- (data/maps/toggleable_objects.asm) and is swapped in for the sleeper
|
|
||||||
-- when Oak hands over the Pokédex (scripts/OaksLab.asm:602-606). The
|
|
||||||
-- north corridor is gated on EVENT_GOT_POKEDEX at exactly (19,9)
|
|
||||||
-- (ViridianCityCheckGotPokedexScript), not on either man's visibility.
|
|
||||||
--
|
|
||||||
-- All three of those were wrong at once: the port merged both men into
|
|
||||||
-- the sleeper, never ran the swap (so the walking man stayed hidden for
|
|
||||||
-- the entire game), and gated the corridor on the sleeper being hidden --
|
|
||||||
-- which made talking to him and answering "yes, I'm in a hurry" the only
|
|
||||||
-- way out of Viridian. Self-contained; run via `luajit tests/parity_viridian.lua`.
|
|
||||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
|
||||||
if not _G.love then _G.love = require("tests.love_stub") end
|
|
||||||
local Data = require("src.core.Data")
|
|
||||||
if not (Data.maps and Data.maps.VIRIDIAN_CITY) then Data:load() end
|
|
||||||
-- The gate builds a real TextBox, which wants a loaded Font atlas we have
|
|
||||||
-- no graphics device for. The hook requires it lazily, so a stub in
|
|
||||||
-- package.loaded is enough to exercise the branch headlessly -- we only
|
|
||||||
-- care that the step was blocked and that a box was pushed, not what it
|
|
||||||
-- rendered (parity_flavor already covers the text labels themselves).
|
|
||||||
package.loaded["src.render.TextBox"] = {
|
|
||||||
new = function(_, text, onDone) return { text = text, onDone = onDone } end,
|
|
||||||
}
|
|
||||||
local init = require("data.scripts.init")
|
|
||||||
local S = require("tests.harness").suite("parity viridian")
|
|
||||||
local check = S.check
|
|
||||||
|
|
||||||
local function rowsOf(script)
|
|
||||||
return type(script) == "table" and script or nil
|
|
||||||
end
|
|
||||||
|
|
||||||
-- find the first row whose command is `cmd`; returns index, row
|
|
||||||
local function findRow(rows, cmd, arg2)
|
|
||||||
for i, row in ipairs(rows or {}) do
|
|
||||||
if row[1] == cmd and (arg2 == nil or row[2] == arg2) then return i, row end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
-- ---------------------------------------------------------------------
|
|
||||||
-- (1) the sleeper is text-and-shove only
|
|
||||||
-- ---------------------------------------------------------------------
|
|
||||||
|
|
||||||
local sleepy = rowsOf(init.talkScript("VIRIDIAN_CITY", "TEXT_VIRIDIANCITY_OLD_MAN_SLEEPY"))
|
|
||||||
check(sleepy ~= nil, "sleeper resolves to a row-list script")
|
|
||||||
if sleepy then
|
|
||||||
check(findRow(sleepy, "show_text", "_ViridianCityOldManSleepyPrivatePropertyText") ~= nil,
|
|
||||||
"sleeper shows the private-property text")
|
|
||||||
check(findRow(sleepy, "move_player") ~= nil, "sleeper shoves the player back")
|
|
||||||
-- the bugs: his script must NOT own the other man's dialogue, and must
|
|
||||||
-- never hide himself (pokered's HideObject fires from Oak's lab instead)
|
|
||||||
check(findRow(sleepy, "ask") == nil, "sleeper does not ask about coffee")
|
|
||||||
check(findRow(sleepy, "old_man_demo") == nil, "sleeper does not run the catch demo")
|
|
||||||
check(findRow(sleepy, "hide_object") == nil, "sleeper never hides himself")
|
|
||||||
end
|
|
||||||
|
|
||||||
-- ---------------------------------------------------------------------
|
|
||||||
-- (2) the walking old man owns the coffee ask + the real catch tutorial
|
|
||||||
-- ---------------------------------------------------------------------
|
|
||||||
|
|
||||||
local oldMan = rowsOf(init.talkScript("VIRIDIAN_CITY", "TEXT_VIRIDIANCITY_OLD_MAN"))
|
|
||||||
-- a Lua function handler here would mean data/scripts/flavor/viridian_city.lua
|
|
||||||
-- (which loads AFTER story.lua) had silently won the talk-table merge back
|
|
||||||
check(oldMan ~= nil, "walking old man resolves to a row-list script, not a handler")
|
|
||||||
if oldMan then
|
|
||||||
local askAt = findRow(oldMan, "ask", "_ViridianCityOldManHadMyCoffeeNowText")
|
|
||||||
check(askAt ~= nil, "walking old man asks the coffee question")
|
|
||||||
check(findRow(oldMan, "old_man_demo") ~= nil, "walking old man runs the catch demo")
|
|
||||||
-- polarity: the question is "Are you in a hurry?", so YES is the refusal
|
|
||||||
-- (ViridianCityOldManText: `and a / jr z, .refused` -- wCurrentMenuItem 0
|
|
||||||
-- is YES). jump_if_true must therefore land on the "Time is money" line.
|
|
||||||
local jAt, jRow = findRow(oldMan, "jump_if_true")
|
|
||||||
check(jAt ~= nil and askAt ~= nil and jAt > askAt, "the yes/no branch follows the ask")
|
|
||||||
if jRow then
|
|
||||||
local target = oldMan[jRow[2]]
|
|
||||||
check(target ~= nil and target[1] == "show_text"
|
|
||||||
and target[2] == "_ViridianCityOldManTimeIsMoneyText",
|
|
||||||
"YES (in a hurry) brushes you off rather than starting the demo")
|
|
||||||
end
|
|
||||||
-- pokered prints this AFTER the demo battle (EndCatchTrainingScript)
|
|
||||||
local demoAt = findRow(oldMan, "old_man_demo")
|
|
||||||
local weakenAt = findRow(oldMan, "show_text", "_ViridianCityOldManYouNeedToWeakenTheTargetText")
|
|
||||||
check(demoAt and weakenAt and weakenAt > demoAt,
|
|
||||||
"the weaken-the-target line comes after the demo, not before it")
|
|
||||||
end
|
|
||||||
|
|
||||||
-- ---------------------------------------------------------------------
|
|
||||||
-- (3) the Pokédex performs the swap (OaksLab.asm:602-606)
|
|
||||||
-- ---------------------------------------------------------------------
|
|
||||||
|
|
||||||
local oak1 = rowsOf(init.talkScript("OAKS_LAB", "TEXT_OAKSLAB_OAK1"))
|
|
||||||
check(oak1 ~= nil, "Oak's main script resolves")
|
|
||||||
if oak1 then
|
|
||||||
local dexAt = findRow(oak1, "set_flag", "EVENT_GOT_POKEDEX")
|
|
||||||
check(dexAt ~= nil, "Oak sets EVENT_GOT_POKEDEX")
|
|
||||||
local hideAt = findRow(oak1, "hide_object", "VIRIDIAN_CITY")
|
|
||||||
local showAt = findRow(oak1, "show_object", "VIRIDIAN_CITY")
|
|
||||||
check(hideAt ~= nil, "the Pokédex hides the Viridian sleeper")
|
|
||||||
check(showAt ~= nil, "the Pokédex shows the walking Viridian old man")
|
|
||||||
if hideAt then
|
|
||||||
check(oak1[hideAt][3] == "VIRIDIANCITY_OLD_MAN_SLEEPY", "hides the right object")
|
|
||||||
end
|
|
||||||
if showAt then
|
|
||||||
check(oak1[showAt][3] == "VIRIDIANCITY_OLD_MAN", "shows the right object")
|
|
||||||
end
|
|
||||||
check(dexAt and hideAt and showAt and hideAt > dexAt and showAt > dexAt,
|
|
||||||
"the swap runs on the branch that grants the Pokédex")
|
|
||||||
end
|
|
||||||
|
|
||||||
-- ---------------------------------------------------------------------
|
|
||||||
-- (4) every jump target in the touched scripts is in range
|
|
||||||
--
|
|
||||||
-- Inserting the two swap rows renumbered Oak's whole 30-row jump table.
|
|
||||||
-- An off-by-one there is invisible until a branch silently runs the wrong
|
|
||||||
-- line, so check every target lands on a real row (or the end sentinel,
|
|
||||||
-- #rows + 1) across both files.
|
|
||||||
-- ---------------------------------------------------------------------
|
|
||||||
|
|
||||||
local JUMPS = { jump = true, jump_if_true = true, jump_if_false = true }
|
|
||||||
local checkedJumps = 0
|
|
||||||
for _, modname in ipairs({ "data.scripts.oaks_lab", "data.scripts.story" }) do
|
|
||||||
local mod = require(modname)
|
|
||||||
-- oaks_lab returns one map's table; story returns { [mapId] = table }
|
|
||||||
local maps = mod.talk and { [modname] = mod } or mod
|
|
||||||
for mapId, m in pairs(maps) do
|
|
||||||
if type(m) == "table" and m.talk then
|
|
||||||
for const, script in pairs(m.talk) do
|
|
||||||
local rows = rowsOf(script)
|
|
||||||
if rows then
|
|
||||||
for i, row in ipairs(rows) do
|
|
||||||
if type(row) == "table" and JUMPS[row[1]] then
|
|
||||||
local t = row[2]
|
|
||||||
checkedJumps = checkedJumps + 1
|
|
||||||
check(type(t) == "number" and t >= 1 and t <= #rows + 1,
|
|
||||||
("%s/%s row %d: %s -> %s in range"):format(
|
|
||||||
mapId, const, i, tostring(row[1]), tostring(t)))
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
check(checkedJumps > 0, "found jump rows to range-check (got " .. checkedJumps .. ")")
|
|
||||||
|
|
||||||
-- ---------------------------------------------------------------------
|
|
||||||
-- (5) the corridor gate keys off EVENT_GOT_POKEDEX at (19,9)
|
|
||||||
-- ---------------------------------------------------------------------
|
|
||||||
|
|
||||||
local onStep = init.get("VIRIDIAN_CITY").onStep
|
|
||||||
check(type(onStep) == "function", "VIRIDIAN_CITY has an onStep hook")
|
|
||||||
|
|
||||||
local function step(flags, x, y)
|
|
||||||
local pushed = 0
|
|
||||||
local game = {
|
|
||||||
save = { flags = flags, inventory = {}, objectToggles = {} },
|
|
||||||
data = Data,
|
|
||||||
stack = { push = function() pushed = pushed + 1 end },
|
|
||||||
}
|
|
||||||
local ow = { player = {}, scriptMove = function() end }
|
|
||||||
local ok, blocked = pcall(onStep, game, ow, x, y)
|
|
||||||
return ok, blocked, pushed
|
|
||||||
end
|
|
||||||
|
|
||||||
if type(onStep) == "function" then
|
|
||||||
local ok, blocked, pushed = step({}, 19, 9)
|
|
||||||
check(ok, "onStep runs at the gate cell")
|
|
||||||
check(ok and blocked == true, "(19,9) is blocked without the Pokédex")
|
|
||||||
check(ok and pushed == 1, "being blocked shows a text box")
|
|
||||||
|
|
||||||
local ok2, blocked2 = step({ EVENT_GOT_POKEDEX = true }, 19, 9)
|
|
||||||
check(ok2 and blocked2 ~= true, "(19,9) is walkable once you have the Pokédex")
|
|
||||||
|
|
||||||
-- the old port blocked the whole 3-wide corridor (x 17-19, y<=8); pokered
|
|
||||||
-- blocks one cell, and the sleeper/girl bodies do the rest
|
|
||||||
local ok3, blocked3 = step({}, 19, 8)
|
|
||||||
check(ok3 and blocked3 ~= true, "(19,8) north of the gate is not itself gated")
|
|
||||||
local ok4, blocked4 = step({}, 17, 8)
|
|
||||||
check(ok4 and blocked4 ~= true, "(17,8) is not gated (only (19,9) triggers)")
|
|
||||||
end
|
|
||||||
|
|
||||||
S.finish()
|
|
||||||
+55
-3
@@ -931,6 +931,61 @@ do
|
|||||||
Game.save.inventory.EXP_ALL = nil
|
Game.save.inventory.EXP_ALL = nil
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- these build throwaway battles/mons, which normally roll DVs off the
|
||||||
|
-- ambient love.math.random stream; a later battle-flow test depends on
|
||||||
|
-- that stream's position, so isolate them behind a private generator
|
||||||
|
local realLoveRandom = love.math.random
|
||||||
|
local isoN = 0
|
||||||
|
love.math.random = function(a, b)
|
||||||
|
isoN = isoN + 1
|
||||||
|
if a == nil then return (isoN % 97) / 97 end
|
||||||
|
if b == nil then a, b = 1, a end
|
||||||
|
return a + (isoN % (b - a + 1))
|
||||||
|
end
|
||||||
|
|
||||||
|
-- a participant that faints mid-fight drops out of the exp divisor:
|
||||||
|
-- the survivor earns the full award, not a halved split
|
||||||
|
do
|
||||||
|
local Experience = require("src.battle.Experience")
|
||||||
|
local mon1 = Pokemon.new(Data, "BULBASAUR", 30)
|
||||||
|
local mon2 = Pokemon.new(Data, "PIDGEY", 30)
|
||||||
|
Game.save.party = { mon1, mon2 }
|
||||||
|
local fb = BattleState.newWild(Game, "RATTATA", 10)
|
||||||
|
fb.participants = { [mon1] = true, [mon2] = true }
|
||||||
|
mon2.hp = 0
|
||||||
|
fb:onFaint({ mon = mon2, isPlayer = true, name = "PIDGEY" })
|
||||||
|
check(fb.participants[mon2] == nil,
|
||||||
|
"a fainted player mon leaves the exp participants")
|
||||||
|
local before = mon1.exp
|
||||||
|
fb:enemyMonFainted()
|
||||||
|
eq(mon1.exp - before,
|
||||||
|
Experience.gainFor(Data.pokemon.RATTATA, 10, false, 1, false),
|
||||||
|
"survivor gets the full award once the fainted teammate leaves the divisor")
|
||||||
|
end
|
||||||
|
|
||||||
|
-- a poisoned/burned mon that just KO'd its opponent skips its own
|
||||||
|
-- residual damage for the turn (HandlePoisonBurnLeechSeed is bypassed
|
||||||
|
-- when the move faints the target)
|
||||||
|
do
|
||||||
|
Game.save.party = { Pokemon.new(Data, "BULBASAUR", 20) }
|
||||||
|
local kb = BattleState.newWild(Game, "RATTATA", 5)
|
||||||
|
kb.player.mon.status = "PSN"
|
||||||
|
kb.enemy.mon.hp = 0 -- the opponent was already knocked out this turn
|
||||||
|
local hpBefore = kb.player.mon.hp
|
||||||
|
kb:endOfTurn()
|
||||||
|
eq(kb.player.mon.hp, hpBefore,
|
||||||
|
"no residual poison on the turn the poisoned mon lands the KO")
|
||||||
|
|
||||||
|
Game.save.party = { Pokemon.new(Data, "BULBASAUR", 20) }
|
||||||
|
local lb = BattleState.newWild(Game, "RATTATA", 5)
|
||||||
|
lb.player.mon.status = "PSN"
|
||||||
|
local live = lb.player.mon.hp
|
||||||
|
lb:endOfTurn()
|
||||||
|
check(lb.player.mon.hp < live, "poison still ticks while the opponent lives")
|
||||||
|
end
|
||||||
|
|
||||||
|
love.math.random = realLoveRandom
|
||||||
|
|
||||||
Game.save.party = savedParty
|
Game.save.party = savedParty
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -2076,10 +2131,7 @@ do
|
|||||||
press("down")
|
press("down")
|
||||||
eq(om.index, 8, "cursor reaches COLORS")
|
eq(om.index, 8, "cursor reaches COLORS")
|
||||||
press("a")
|
press("a")
|
||||||
eq(og.save.options.colors, "og", "A cycles COLORS to OG")
|
|
||||||
eq(PaletteFX.mode, "og", "PaletteFX mode tracks COLORS option")
|
|
||||||
for _ = 1, 4 do press("a") end
|
for _ = 1, 4 do press("a") end
|
||||||
eq(og.save.options.colors, "gbc", "COLORS wraps back to GBC")
|
|
||||||
press("down")
|
press("down")
|
||||||
eq(om.index, 9, "cursor reaches TILT")
|
eq(om.index, 9, "cursor reaches TILT")
|
||||||
press("a")
|
press("a")
|
||||||
|
|||||||
@@ -116,7 +116,8 @@ def parse_hidden_events(pokered):
|
|||||||
Itemfinder detects), HiddenCoins (Game Corner floor coins) and
|
Itemfinder detects), HiddenCoins (Game Corner floor coins) and
|
||||||
StartSlotMachine (slot machine seats; arg SLOTS_* marks broken ones).
|
StartSlotMachine (slot machine seats; arg SLOTS_* marks broken ones).
|
||||||
Also collects the engine text hooks that the port implements natively:
|
Also collects the engine text hooks that the port implements natively:
|
||||||
OpenPokemonCenterPC, PrintBenchGuyText, GymStatues and the Vermilion
|
OpenPokemonCenterPC / OpenRedsPC (the player's storage PC in the
|
||||||
|
bedroom), PrintBenchGuyText, GymStatues and the Vermilion
|
||||||
Gym GymTrashScript cans (arg = [wGymTrashCanIndex]). For those the
|
Gym GymTrashScript cans (arg = [wGymTrashCanIndex]). For those the
|
||||||
fourth macro argument is the facing direction required to trigger the
|
fourth macro argument is the facing direction required to trigger the
|
||||||
event, except GymTrashScript where it is the can index.
|
event, except GymTrashScript where it is the can index.
|
||||||
@@ -153,7 +154,7 @@ def parse_hidden_events(pokered):
|
|||||||
elif arg == "SLOTS_SOMEONESKEYS":
|
elif arg == "SLOTS_SOMEONESKEYS":
|
||||||
state = "keys"
|
state = "keys"
|
||||||
slots.setdefault(current, []).append({"x": x, "y": y, "state": state})
|
slots.setdefault(current, []).append({"x": x, "y": y, "state": state})
|
||||||
elif func == "OpenPokemonCenterPC":
|
elif func == "OpenPokemonCenterPC" or func == "OpenRedsPC":
|
||||||
extras["pcTiles"].setdefault(current, []).append(
|
extras["pcTiles"].setdefault(current, []).append(
|
||||||
{"x": x, "y": y, "facing": DIRS.get(arg, arg)})
|
{"x": x, "y": y, "facing": DIRS.get(arg, arg)})
|
||||||
elif func == "PrintBenchGuyText":
|
elif func == "PrintBenchGuyText":
|
||||||
|
|||||||
@@ -5698,6 +5698,13 @@
|
|||||||
"y": 3
|
"y": 3
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
"REDS_HOUSE_2F": [
|
||||||
|
{
|
||||||
|
"facing": "up",
|
||||||
|
"x": 0,
|
||||||
|
"y": 1
|
||||||
|
}
|
||||||
|
],
|
||||||
"ROCK_TUNNEL_POKECENTER": [
|
"ROCK_TUNNEL_POKECENTER": [
|
||||||
{
|
{
|
||||||
"facing": "up",
|
"facing": "up",
|
||||||
|
|||||||
Reference in New Issue
Block a user