mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-21 13:09:54 +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 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
|
||||
|
||||
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
|
||||
-- unobservable (a switch or cure downgrades Toxic to plain poison).
|
||||
self.sideToxic = self.sideToxic or {}
|
||||
for _, pair in ipairs({ { self.player, self.enemy, "player" },
|
||||
{ self.enemy, self.player, "enemy" } }) do
|
||||
local b, opp, side = pair[1], pair[2], pair[3]
|
||||
if b.mon.hp > 0 then
|
||||
-- a battler whose opponent was already knocked out by a move this turn
|
||||
-- skips its own residual (HandlePoisonBurnLeechSeed is bypassed when the
|
||||
-- move faints the target); snapshot before residual so one side's
|
||||
-- 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)
|
||||
for _, m in ipairs(msgs) do self:sayNext(prefixEnemy(m, b)) end
|
||||
if #msgs > 0 then self:drainNext() end -- poison/burn/seed HP moved
|
||||
@@ -2442,6 +2448,9 @@ end
|
||||
function BattleState:onFaint(battler)
|
||||
if battler.faintQueued then return end
|
||||
battler.faintQueued = true
|
||||
if battler.isPlayer and self.participants then
|
||||
self.participants[battler.mon] = nil
|
||||
end
|
||||
Runtime.emit("battle.fainted", { battle = self, battler = battler })
|
||||
-- the faint slide + cry ride the queue (after the move animation and
|
||||
-- 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
|
||||
-- (engine/battle/experience.asm); traded mons earn x1.5; each
|
||||
-- participant gets the full stat exp
|
||||
-- the divisor counts EVERY participant, fainted ones included
|
||||
-- (DivideExpDataByNumMonsGainingExp keeps their flag bits); only the
|
||||
-- living ones are actually paid
|
||||
-- a mon that fainted mid-fight has had its gain-exp flag cleared
|
||||
-- (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
|
||||
if self.participants and self.participants[mon] then
|
||||
|
||||
+13
-1
@@ -283,10 +283,22 @@ function Game:keypressed(key)
|
||||
self:zoomStep(1)
|
||||
return
|
||||
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
|
||||
-- forces Game.overworld:reloadMap, which rebuilds the live NPC array, so
|
||||
-- hold it while a warp/transition or an on-screen scripted cutscene is
|
||||
-- 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
|
||||
elseif key == "3" then
|
||||
-- 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 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
|
||||
-- from this table so old options.lua files stay compatible.
|
||||
function SaveData.defaultOptions()
|
||||
@@ -94,7 +195,7 @@ end
|
||||
-- love.filesystem, so the mod loader's injected filesystem can carry the
|
||||
-- options round-trip headless (no love global).
|
||||
function SaveData.saveOptions(opts, fs)
|
||||
fs = fs or love.filesystem
|
||||
fs = persistFs(fs)
|
||||
opts = SaveData.mergeOptions(opts)
|
||||
-- modOptions is per-mod nested state: fold the on-disk sub-tree
|
||||
-- underneath (newest value winning per key) so one caller's partial
|
||||
@@ -123,7 +224,7 @@ function SaveData.saveOptions(opts, fs)
|
||||
end
|
||||
|
||||
function SaveData.loadOptions(fs)
|
||||
fs = fs or love.filesystem
|
||||
fs = persistFs(fs)
|
||||
local data, err = readTable(fs, OPTIONS_FILENAME)
|
||||
if not data then
|
||||
if fs.getInfo(OPTIONS_FILENAME) then
|
||||
@@ -311,7 +412,7 @@ end)
|
||||
-- afterwards either way
|
||||
SaveData.addCoreMigration(1, function(save)
|
||||
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)
|
||||
end
|
||||
end)
|
||||
@@ -342,7 +443,7 @@ function SaveData.save(data, mods)
|
||||
if k ~= "options" then gameOnly[k] = v end
|
||||
end
|
||||
local encoded = SaveSerializer.encode(gameOnly)
|
||||
local fs = love.filesystem
|
||||
local fs = persistFs(nil)
|
||||
if fs.getInfo(FILENAME) then
|
||||
local prev = fs.read(FILENAME)
|
||||
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
|
||||
-- recovery on the load report
|
||||
function SaveData.load()
|
||||
local fs = love.filesystem
|
||||
local fs = persistFs(nil)
|
||||
local data, err = readTable(fs, FILENAME)
|
||||
local recovered
|
||||
if not data then
|
||||
|
||||
+122
-1
@@ -2,7 +2,7 @@ local RomImporter = {}
|
||||
RomImporter.__index = RomImporter
|
||||
|
||||
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 COMMUNITY_URL = "https://bois.icu"
|
||||
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()
|
||||
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()
|
||||
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
|
||||
and allRequiredFilesExist()
|
||||
end
|
||||
@@ -211,6 +327,11 @@ function RomImporter:startData(data, displayName)
|
||||
collectgarbage("collect")
|
||||
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 require("src.core.SaveData").isPortable() then
|
||||
self.status = "Copying data to the portable folder"
|
||||
coroutine.yield()
|
||||
syncCacheToPortable()
|
||||
end
|
||||
self.state = "complete"
|
||||
self.status = "Ready"
|
||||
self.detail = "Starting Pokemon Red..."
|
||||
|
||||
@@ -397,6 +397,7 @@ local function toggleObject(ctx, mapId, objName, visible)
|
||||
-- positions mid-cutscene)
|
||||
local ow = ctx.overworld
|
||||
if not ow or ow.map.id ~= mapId then return end
|
||||
ow.npcPool = ow.npcPool or {}
|
||||
if visible then
|
||||
for _, n in ipairs(ow.npcs) do
|
||||
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)
|
||||
table.insert(ow.npcs, npc)
|
||||
table.insert(ow.entities, npc)
|
||||
ow.npcPool[npc.id] = npc
|
||||
return
|
||||
end
|
||||
end
|
||||
else
|
||||
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
|
||||
for i = #ow.entities, 1, -1 do
|
||||
local e = ow.entities[i]
|
||||
|
||||
+18
-3
@@ -296,9 +296,7 @@ local function useOn(game, battle, id, target, list, moveIndex)
|
||||
showMessages(game, payload) -- failed
|
||||
end
|
||||
|
||||
local function useItem(game, battle, id, list)
|
||||
local def = game.data.items[id]
|
||||
if ItemEffects.needsTarget(id, def) and not ItemEffects.isBall(id) then
|
||||
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
|
||||
@@ -327,6 +325,23 @@ local function useItem(game, battle, id, list)
|
||||
}))
|
||||
end,
|
||||
})
|
||||
end
|
||||
|
||||
local function useItem(game, battle, id, list)
|
||||
local def = game.data.items[id]
|
||||
if ItemEffects.needsTarget(id, def) and not ItemEffects.isBall(id) then
|
||||
-- TMs/HMs boot up and announce their move before the target picker
|
||||
-- (ItemUseTMHM: BootedUpTMText / BootedUpHMText + TeachMachineMoveText)
|
||||
if def and def.machine then
|
||||
local moveDef = game.data.moves[def.machine.move]
|
||||
local moveName = moveDef and moveDef.name or def.machine.move
|
||||
local booted = def.machine.kind == "HM"
|
||||
and "Booted up an HM!" or "Booted up a TM!"
|
||||
showMessages(game, { booted, ("It contained\n%s!"):format(moveName) },
|
||||
function() pickTargetAndUse(game, battle, id, list) end)
|
||||
return
|
||||
end
|
||||
pickTargetAndUse(game, battle, id, list)
|
||||
else
|
||||
useOn(game, battle, id, nil, list)
|
||||
end
|
||||
|
||||
@@ -1708,8 +1708,10 @@ function OverworldState:tryCut(fx, fy)
|
||||
-- chain-cut "ornamental bushes" around Saffron and Celadon.
|
||||
local ts = self.map.def.tileset
|
||||
local tile = self.map:cellTile(fx, fy)
|
||||
local isGrass = (ts == "OVERWORLD" and tile == 0x52)
|
||||
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
|
||||
end
|
||||
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
|
||||
if sw.before == block then swap = sw break 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")
|
||||
if not mon then return false end
|
||||
-- gen 1 confirms nothing (engine/overworld/cut.asm UsedCut): the
|
||||
@@ -1736,7 +1738,11 @@ function OverworldState:tryCut(fx, fy)
|
||||
local finish = function()
|
||||
require("src.core.Sound").play(Game.data, "Cut")
|
||||
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 GYM plant keeps the shared dust/leaf puff
|
||||
self:startCutTreeAnim(fx, fy, finish)
|
||||
@@ -1822,8 +1828,10 @@ function OverworldState:useCutFieldMove()
|
||||
-- "nothing to cut" in vanilla
|
||||
local ts = self.map.def.tileset
|
||||
local tile = self.map:cellTile(fx, fy)
|
||||
local isGrass = (ts == "OVERWORLD" and tile == 0x52)
|
||||
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"
|
||||
end
|
||||
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
|
||||
if sw.before == block then swap = sw break 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"
|
||||
end
|
||||
|
||||
@@ -2004,9 +2012,11 @@ function OverworldState:openPC(onDone)
|
||||
end
|
||||
table.insert(items, { label = "LOG OFF", onSelect = logOff })
|
||||
-- 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,
|
||||
{ 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 }))
|
||||
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
|
||||
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
|
||||
end
|
||||
|
||||
@@ -2076,10 +2131,7 @@ do
|
||||
press("down")
|
||||
eq(om.index, 8, "cursor reaches COLORS")
|
||||
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
|
||||
eq(og.save.options.colors, "gbc", "COLORS wraps back to GBC")
|
||||
press("down")
|
||||
eq(om.index, 9, "cursor reaches TILT")
|
||||
press("a")
|
||||
|
||||
@@ -116,7 +116,8 @@ def parse_hidden_events(pokered):
|
||||
Itemfinder detects), HiddenCoins (Game Corner floor coins) and
|
||||
StartSlotMachine (slot machine seats; arg SLOTS_* marks broken ones).
|
||||
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
|
||||
fourth macro argument is the facing direction required to trigger the
|
||||
event, except GymTrashScript where it is the can index.
|
||||
@@ -153,7 +154,7 @@ def parse_hidden_events(pokered):
|
||||
elif arg == "SLOTS_SOMEONESKEYS":
|
||||
state = "keys"
|
||||
slots.setdefault(current, []).append({"x": x, "y": y, "state": state})
|
||||
elif func == "OpenPokemonCenterPC":
|
||||
elif func == "OpenPokemonCenterPC" or func == "OpenRedsPC":
|
||||
extras["pcTiles"].setdefault(current, []).append(
|
||||
{"x": x, "y": y, "facing": DIRS.get(arg, arg)})
|
||||
elif func == "PrintBenchGuyText":
|
||||
|
||||
@@ -5698,6 +5698,13 @@
|
||||
"y": 3
|
||||
}
|
||||
],
|
||||
"REDS_HOUSE_2F": [
|
||||
{
|
||||
"facing": "up",
|
||||
"x": 0,
|
||||
"y": 1
|
||||
}
|
||||
],
|
||||
"ROCK_TUNNEL_POKECENTER": [
|
||||
{
|
||||
"facing": "up",
|
||||
|
||||
Reference in New Issue
Block a user