mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-17 03:02:39 +02:00
Merge branch 'dev' of https://github.com/bryanthaboi/gen1recomp into dev
This commit is contained in:
+55
-11
@@ -638,6 +638,44 @@ local function newBattle(game)
|
||||
return self
|
||||
end
|
||||
|
||||
local function scopedPlayerParty(game, indices)
|
||||
if indices == nil then return nil, nil end
|
||||
if type(indices) ~= "table" then
|
||||
Logger.warn("trainer battle party scope is not a table; using full party")
|
||||
return nil, nil
|
||||
end
|
||||
local count = #indices
|
||||
local keyCount = 0
|
||||
for key in pairs(indices) do
|
||||
keyCount = keyCount + 1
|
||||
if type(key) ~= "number" or key % 1 ~= 0 or key < 1 or key > count then
|
||||
Logger.warn("trainer battle party scope is malformed; using full party")
|
||||
return nil, nil
|
||||
end
|
||||
end
|
||||
if count == 0 or keyCount ~= count then
|
||||
Logger.warn("trainer battle party scope is empty or sparse; using full party")
|
||||
return nil, nil
|
||||
end
|
||||
local party, normalized, seen = {}, {}, {}
|
||||
for i = 1, count do
|
||||
local index = indices[i]
|
||||
if type(index) ~= "number" or index % 1 ~= 0
|
||||
or not game.save.party[index] or seen[index] then
|
||||
Logger.warn("trainer battle party scope contains an invalid index; using full party")
|
||||
return nil, nil
|
||||
end
|
||||
seen[index] = true
|
||||
normalized[i] = index
|
||||
party[i] = game.save.party[index]
|
||||
end
|
||||
return party, normalized
|
||||
end
|
||||
|
||||
function BattleState:playerPartyView()
|
||||
return self.playerParty or self.game.save.party
|
||||
end
|
||||
|
||||
-- opts.hooked: rod encounter, announced with _HookedMonAttackedText
|
||||
function BattleState.newWild(game, species, level, opts)
|
||||
local self = newBattle(game)
|
||||
@@ -713,13 +751,15 @@ local function applySpecialMoves(data, oppClass, partyIndex, party)
|
||||
end
|
||||
end
|
||||
|
||||
function BattleState.newTrainer(game, oppClass, partyIndex)
|
||||
function BattleState.newTrainer(game, oppClass, partyIndex, opts)
|
||||
local self = newBattle(game)
|
||||
self.kind = "trainer"
|
||||
self.oppClass = oppClass
|
||||
-- the object_event trainer arg (roster index). computeMusicKind keys
|
||||
-- data/scripts/victories.lua on class#party, so keep it on the battle (#782).
|
||||
self.partyIndex = partyIndex or 1
|
||||
self.playerParty, self.playerPartyIndices = scopedPlayerParty(game,
|
||||
type(opts) == "table" and opts.playerPartyIndices or nil)
|
||||
self.trainer = game.data.trainers[oppClass]
|
||||
assert(self.trainer, "unknown trainer class " .. tostring(oppClass))
|
||||
-- pret GetTrainerName_: RIVAL1/2/3 copy wRivalName into wTrainerName
|
||||
@@ -763,7 +803,7 @@ function BattleState.newTrainer(game, oppClass, partyIndex)
|
||||
end
|
||||
end
|
||||
self.enemyIndex = 1
|
||||
local playerMon = Party.firstHealthy(game.save.party)
|
||||
local playerMon = Party.firstHealthy(self:playerPartyView())
|
||||
if not playerMon then
|
||||
Logger.warn("trainer battle with no healthy party; skipping")
|
||||
self.dead = true
|
||||
@@ -2022,7 +2062,7 @@ function BattleState:update(dt)
|
||||
-- loops the party menu until a healthy mon is picked, so B and
|
||||
-- fainted picks land back here and reopen it
|
||||
if self.player.mon.hp <= 0 then
|
||||
if Party.firstHealthy(self.game.save.party) then
|
||||
if Party.firstHealthy(self:playerPartyView()) then
|
||||
self:openReplacementMenu()
|
||||
end
|
||||
return
|
||||
@@ -3910,7 +3950,8 @@ function BattleState:awardExp()
|
||||
-- (RemoveFaintedPlayerMon), so it drops out of the divisor and only
|
||||
-- the surviving participants are counted and paid
|
||||
local participants, alive = 0, {}
|
||||
for _, mon in ipairs(self.game.save.party) do
|
||||
local playerParty = self:playerPartyView()
|
||||
for _, mon in ipairs(playerParty) do
|
||||
if self.participants and self.participants[mon] then
|
||||
participants = participants + 1
|
||||
if mon.hp > 0 then table.insert(alive, mon) end
|
||||
@@ -4004,9 +4045,9 @@ function BattleState:awardExp()
|
||||
-- experience.asm:9-13); each mon gets its own GainedText with the
|
||||
-- "with EXP.ALL," tail (wBoostExpByExpAll) -- pokered prints no
|
||||
-- summary line
|
||||
for _, mon in ipairs(self.game.save.party) do
|
||||
for _, mon in ipairs(playerParty) do
|
||||
if mon.hp > 0 then
|
||||
ctx.applyShare(mon, math.max(1, ctx.participants) * #self.game.save.party * 2, "expAll")
|
||||
ctx.applyShare(mon, math.max(1, ctx.participants) * #playerParty * 2, "expAll")
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -4047,7 +4088,7 @@ function BattleState:enemyMonFainted()
|
||||
local nextName = nextMon.nickname or self.data.pokemon[nextMon.species].name
|
||||
local style = tostring((self.game.save.options or {}).battleStyle or "shift")
|
||||
:lower()
|
||||
local partyCount = #self.game.save.party
|
||||
local partyCount = #self:playerPartyView()
|
||||
-- ReplaceFaintedEnemyMon (core.asm:892-896): DrawEnemyPokeballs puts the
|
||||
-- foe's party ball row -- and the HUD chrome PlaceEnemyHUDTiles lays
|
||||
-- down under it (draw_hud_pokeball_gfx.asm:9-11, 33-45, 134-141) -- into
|
||||
@@ -4074,6 +4115,7 @@ function BattleState:enemyMonFainted()
|
||||
local game = self.game
|
||||
Screens.push(game, "PartyMenu", {
|
||||
battle = self,
|
||||
party = self:playerPartyView(),
|
||||
forceSwitch = true,
|
||||
onSwitch = function(mon)
|
||||
if mon ~= self.player.mon and mon.hp > 0 then
|
||||
@@ -4246,7 +4288,7 @@ function BattleState.isOaksLabStarterRival(self)
|
||||
end
|
||||
|
||||
function BattleState:playerMonFainted()
|
||||
local nextMon = Party.firstHealthy(self.game.save.party)
|
||||
local nextMon = Party.firstHealthy(self:playerPartyView())
|
||||
-- Being out of useable POKéMON blacks you out even when the battle was
|
||||
-- already decided in our favour. A double faint -- our last mon dying
|
||||
-- to residual damage on the turn it lands the KO -- used to hit the
|
||||
@@ -4319,6 +4361,7 @@ function BattleState:openReplacementMenu()
|
||||
self:ui(function()
|
||||
return self:buildScreen("PartyMenu", {
|
||||
battle = self,
|
||||
party = self:playerPartyView(),
|
||||
-- ChooseNextMon: pick immediately (no SWITCH/STATS/CANCEL)
|
||||
forceSwitch = true,
|
||||
onSwitch = function(mon)
|
||||
@@ -4799,6 +4842,7 @@ function BattleState:openParty()
|
||||
self:ui(function()
|
||||
return self:buildScreen("PartyMenu", {
|
||||
battle = self,
|
||||
party = self:playerPartyView(),
|
||||
onSwitch = function(mon)
|
||||
if mon == self.player.mon then
|
||||
self:say(Strings("%s is\nalready out!", self.player.name))
|
||||
@@ -4845,8 +4889,8 @@ function BattleState:finish()
|
||||
-- here it did not, so say so rather than silently papering over it.
|
||||
-- The old-man / PROF.OAK demo also skips it: the party never fought
|
||||
-- (Yellow's Pallet intro runs before the player owns a mon at all).
|
||||
if self.result ~= "lose" and not self.demo
|
||||
and not Party.firstHealthy(self.game.save.party) then
|
||||
if self.kind ~= "link" and self.result ~= "lose" and not self.demo
|
||||
and not Party.firstHealthy(self:playerPartyView()) then
|
||||
Logger.warn("battle finished %s with no healthy party; forcing blackout",
|
||||
tostring(self.result))
|
||||
self.result = "lose"
|
||||
@@ -5727,7 +5771,7 @@ function BattleState:drawHUDs(slide)
|
||||
for i = 10, 17 do hudTile(0x76, i * 8, 88) end
|
||||
hudTile(0x6F, 72, 88)
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
self:drawBallRow(self.playerParty or self.game.save.party, 88, 80, 8)
|
||||
self:drawBallRow(self:playerPartyView(), 88, 80, 8)
|
||||
end
|
||||
local hidePlayer = self.safari or self.demo
|
||||
if showStatus and self.player and not hidePlayer and not self.showPlayerBack
|
||||
|
||||
@@ -44,6 +44,7 @@ local BATTLE_FIELDS = {
|
||||
"sideToxic", "isGymLeader", "musicKind", "lastBall", "lockedBall",
|
||||
"lowHealthAlarmDisabled", "lowHealthAlarmOn", "victoryMusicPlayed",
|
||||
"endBattleText",
|
||||
"playerPartyIndices",
|
||||
}
|
||||
|
||||
local function partyIndex(party, mon)
|
||||
@@ -91,6 +92,23 @@ local function integer(value, min, max)
|
||||
and value >= (min or -math.huge) and value <= (max or math.huge)
|
||||
end
|
||||
|
||||
local function exactIndexSet(indices, maxIndex, requireMember)
|
||||
if type(indices) ~= "table" then return nil end
|
||||
local count, keys = #indices, 0
|
||||
for key in pairs(indices) do
|
||||
keys = keys + 1
|
||||
if not integer(key, 1, count) then return nil end
|
||||
end
|
||||
if keys ~= count or (requireMember and count == 0) then return nil end
|
||||
local seen = {}
|
||||
for i = 1, count do
|
||||
local index = indices[i]
|
||||
if not integer(index, 1, maxIndex) or seen[index] then return nil end
|
||||
seen[index] = true
|
||||
end
|
||||
return seen
|
||||
end
|
||||
|
||||
local function validateMoveList(data, moves)
|
||||
if type(moves) ~= "table" then return false end
|
||||
for _, move in ipairs(moves) do
|
||||
@@ -200,6 +218,16 @@ function BattleCheckpoint.validate(game, checkpoint)
|
||||
if type(party) ~= "table" or not validateBattler(game.data, model.player, #party) then
|
||||
return nil, "invalid_content", "Player battle state is invalid."
|
||||
end
|
||||
local scopedIndices
|
||||
if model.playerPartyIndices ~= nil then
|
||||
if model.kind ~= "trainer" then
|
||||
return nil, "invalid_checkpoint", "Battle party scope is invalid."
|
||||
end
|
||||
scopedIndices = exactIndexSet(model.playerPartyIndices, #party, true)
|
||||
if not scopedIndices or not scopedIndices[model.player.index] then
|
||||
return nil, "invalid_checkpoint", "Battle party scope is invalid."
|
||||
end
|
||||
end
|
||||
if model.kind == "wild" then
|
||||
if not validateMon(game.data, model.enemyMon)
|
||||
or not validateBattler(game.data, model.enemy, 1) then
|
||||
@@ -220,12 +248,15 @@ function BattleCheckpoint.validate(game, checkpoint)
|
||||
end
|
||||
end
|
||||
for _, indices in ipairs({ model.participants, model.leveledUp }) do
|
||||
if type(indices) ~= "table" then
|
||||
local referenced = exactIndexSet(indices, #party, false)
|
||||
if not referenced then
|
||||
return nil, "invalid_checkpoint", "Battle party reference set is missing."
|
||||
end
|
||||
for _, index in ipairs(indices) do
|
||||
if not integer(index, 1, #party) then
|
||||
return nil, "invalid_checkpoint", "Battle party reference is invalid."
|
||||
if scopedIndices then
|
||||
for index in pairs(referenced) do
|
||||
if not scopedIndices[index] then
|
||||
return nil, "invalid_checkpoint", "Battle party reference is invalid."
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -276,7 +307,9 @@ function BattleCheckpoint.restore(game, checkpoint, copy)
|
||||
local model = checkpoint.runtime.battle
|
||||
local battle
|
||||
if model.kind == "trainer" then
|
||||
battle = BattleState.newTrainer(game, model.oppClass, model.partyIndex)
|
||||
battle = BattleState.newTrainer(game, model.oppClass, model.partyIndex, {
|
||||
playerPartyIndices = model.playerPartyIndices,
|
||||
})
|
||||
battle.enemyParty = assert(copy(model.enemyParty))
|
||||
battle.enemyIndex = model.enemyIndex
|
||||
else
|
||||
|
||||
@@ -2356,6 +2356,20 @@ function RomImporter:_updatePadCursor(dt)
|
||||
local ny = self._padCursor.y + dy * speed * dt
|
||||
self._padCursor.x = math.max(ox, math.min(ox + w, nx))
|
||||
self._padCursor.y = math.max(oy, math.min(oy + h, ny))
|
||||
-- Pushing INTO the top/bottom edge scrolls the page instead of stalling.
|
||||
-- The cursor is clamped to the safe area above, so on a short window the
|
||||
-- rows below the fold are unreachable on a stickless handheld: no mouse
|
||||
-- wheel, no touchscreen, and no right stick to feed the existing wheel
|
||||
-- path. Only the OVERSHOOT scrolls -- parking the cursor at the edge does
|
||||
-- nothing, it has to be actively pushed -- and this block only runs on pad
|
||||
-- input, so a real mouse is unaffected. /48 matches the pixels-per-notch
|
||||
-- LauncherView.draw multiplies back out.
|
||||
local overY = 0
|
||||
if ny > oy + h then overY = ny - (oy + h)
|
||||
elseif ny < oy then overY = ny - oy end
|
||||
if overY ~= 0 and self._flex then
|
||||
require("src.import.LauncherView").wheelmoved(self, 0, -overY / 48)
|
||||
end
|
||||
-- Desktop: FlexLove polls the real mouse, so warp it with the pad pointer.
|
||||
-- NX: the getPosition bridge already returns pad coords — skip setPosition.
|
||||
if not self.isNX and love.mouse.setPosition then
|
||||
|
||||
@@ -737,7 +737,8 @@ COVERAGE["src.pokemon.Boxes"] = {
|
||||
COVERAGE["src.world.WorldAPI"] = {
|
||||
kind = "alias", target = "src.world.gen2.WorldAPI",
|
||||
backed = "new __index overworld current mapOverview warpTo toggleObject replaceBlock "
|
||||
.. "spawnNpc removeNpc npc queueScript invalidateMap",
|
||||
.. "spawnNpc removeNpc npc queueScript invalidateMap "
|
||||
.. "availableFieldActions useFieldAction",
|
||||
warned = "setFlag getFlag",
|
||||
absent = "",
|
||||
notes = {
|
||||
|
||||
+6
-1
@@ -1069,7 +1069,8 @@ function Loader:_api(mod)
|
||||
bucket[key] = value
|
||||
end,
|
||||
},
|
||||
-- Data-only state independent of the vanilla progress checkpoint. The
|
||||
-- Data-only and opaque-byte state independent of the vanilla progress
|
||||
-- checkpoint. The
|
||||
-- engine binds version/playthrough/mod scope and portable persistence;
|
||||
-- callers never receive paths or a raw filesystem handle.
|
||||
storage = {
|
||||
@@ -1077,6 +1078,10 @@ function Loader:_api(mod)
|
||||
selected = function(_, game) return storage:selected(game) end,
|
||||
write = function(_, game, key, value) return storage:write(game, key, value) end,
|
||||
read = function(_, game, key) return storage:read(game, key) end,
|
||||
writeBytes = function(_, game, key, bytes)
|
||||
return storage:writeBytes(game, key, bytes)
|
||||
end,
|
||||
readBytes = function(_, game, key) return storage:readBytes(game, key) end,
|
||||
list = function(_, game, prefix) return storage:list(game, prefix) end,
|
||||
delete = function(_, game, key) return storage:delete(game, key) end,
|
||||
},
|
||||
|
||||
+132
-11
@@ -1,4 +1,5 @@
|
||||
-- Data-only per-mod persistence, scoped by game version and opaque playthrough.
|
||||
-- Data-only and opaque-byte per-mod persistence, scoped by game version and
|
||||
-- opaque playthrough.
|
||||
-- This module is engine-private; Loader exposes only the bound facade methods.
|
||||
|
||||
local SaveData = require("src.core.SaveData")
|
||||
@@ -7,6 +8,7 @@ local Version = require("src.core.Version")
|
||||
|
||||
local Storage = {}
|
||||
Storage.__index = Storage
|
||||
Storage.MAX_BYTES = 512 * 1024 * 1024
|
||||
|
||||
local ROOT = "mod_storage"
|
||||
|
||||
@@ -49,6 +51,20 @@ local function decodeAt(fs, path)
|
||||
return data, body
|
||||
end
|
||||
|
||||
local function readOpaqueAt(fs, path)
|
||||
if not (fs.getInfo and fs.getInfo(path)) then return nil end
|
||||
local body = fs.read and fs.read(path)
|
||||
if type(body) ~= "string" then return nil end
|
||||
return body
|
||||
end
|
||||
|
||||
local function hasAny(fs, paths)
|
||||
for _, path in ipairs(paths) do
|
||||
if fs.getInfo(path) then return true end
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
function Storage.new(modId, fs)
|
||||
assert(validSegment(modId), "Storage.new needs a safe mod id")
|
||||
return setmetatable({ modId = modId, injectedFs = fs }, Storage)
|
||||
@@ -132,6 +148,10 @@ function Storage:selected(game)
|
||||
end,
|
||||
read = function(_, key) return self:read(selectedGame, key) end,
|
||||
write = function(_, key, value) return self:write(selectedGame, key, value) end,
|
||||
readBytes = function(_, key) return self:readBytes(selectedGame, key) end,
|
||||
writeBytes = function(_, key, bytes)
|
||||
return self:writeBytes(selectedGame, key, bytes)
|
||||
end,
|
||||
list = function(_, prefix) return self:list(selectedGame, prefix) end,
|
||||
delete = function(_, key) return self:delete(selectedGame, key) end,
|
||||
}
|
||||
@@ -147,7 +167,7 @@ function Storage:context(game)
|
||||
}
|
||||
end
|
||||
|
||||
function Storage:_names(game, key, allowEmpty)
|
||||
function Storage:_names(game, key, allowEmpty, extension)
|
||||
if not validKey(key, allowEmpty) then
|
||||
return failure("invalid_key",
|
||||
"Storage keys use nonempty letters, numbers, underscore, dash and slash segments.")
|
||||
@@ -155,12 +175,19 @@ function Storage:_names(game, key, allowEmpty)
|
||||
local scope, code, message = self:_scope(game)
|
||||
if not scope then return nil, code, message end
|
||||
local path = scope.base .. (key ~= "" and ("/" .. key) or "")
|
||||
return scope, path .. ".lua", path .. ".lua.bak", path .. ".lua.tmp"
|
||||
extension = extension or ".lua"
|
||||
return scope, path .. extension, path .. extension .. ".bak",
|
||||
path .. extension .. ".tmp", path
|
||||
end
|
||||
|
||||
function Storage:write(game, key, value)
|
||||
local scope, main, bak, tmp = self:_names(game, key, false)
|
||||
local scope, main, bak, tmp, path = self:_names(game, key, false)
|
||||
if not scope then return false, main, bak end
|
||||
local fs = scope.fs
|
||||
if hasAny(fs, { path .. ".bin", path .. ".bin.bak", path .. ".bin.tmp" }) then
|
||||
return false, "type_conflict",
|
||||
"A byte value already exists for this storage key; delete it first."
|
||||
end
|
||||
if type(value) ~= "table" then
|
||||
return false, "encode_failed", "Storage values must be data-only tables."
|
||||
end
|
||||
@@ -170,7 +197,6 @@ function Storage:write(game, key, value)
|
||||
.. tostring(encoded)
|
||||
end
|
||||
|
||||
local fs = scope.fs
|
||||
ensureParent(fs, main)
|
||||
local _, previous = decodeAt(fs, main)
|
||||
if not previous then _, previous = decodeAt(fs, bak) end
|
||||
@@ -206,9 +232,13 @@ function Storage:write(game, key, value)
|
||||
end
|
||||
|
||||
function Storage:read(game, key)
|
||||
local scope, main, bak, tmp = self:_names(game, key, false)
|
||||
local scope, main, bak, tmp, path = self:_names(game, key, false)
|
||||
if not scope then return nil, main, bak end
|
||||
local fs = scope.fs
|
||||
if hasAny(fs, { path .. ".bin", path .. ".bin.bak", path .. ".bin.tmp" }) then
|
||||
return failure("type_mismatch",
|
||||
"This storage key contains opaque bytes; use readBytes instead.")
|
||||
end
|
||||
local data, body = decodeAt(fs, main)
|
||||
if data then return data end
|
||||
|
||||
@@ -226,6 +256,80 @@ function Storage:read(game, key)
|
||||
return data
|
||||
end
|
||||
|
||||
function Storage:writeBytes(game, key, bytes)
|
||||
local scope, main, bak, tmp, path = self:_names(game, key, false, ".bin")
|
||||
if not scope then return false, main, bak end
|
||||
if type(bytes) ~= "string" then
|
||||
return false, "invalid_bytes", "Opaque storage values must be strings."
|
||||
end
|
||||
if #bytes > Storage.MAX_BYTES then
|
||||
return false, "size_limit",
|
||||
("Opaque storage values cannot exceed %d bytes."):format(Storage.MAX_BYTES)
|
||||
end
|
||||
|
||||
local fs = scope.fs
|
||||
if hasAny(fs, { path .. ".lua", path .. ".lua.bak", path .. ".lua.tmp" }) then
|
||||
return false, "type_conflict",
|
||||
"A table value already exists for this storage key; delete it first."
|
||||
end
|
||||
|
||||
ensureParent(fs, main)
|
||||
local previous = readOpaqueAt(fs, main)
|
||||
if previous == nil then previous = readOpaqueAt(fs, bak) end
|
||||
|
||||
local ok, err = fs.write(tmp, bytes)
|
||||
if not ok then
|
||||
return false, "write_failed", "Could not stage opaque storage data: " .. tostring(err)
|
||||
end
|
||||
local staged = readOpaqueAt(fs, tmp)
|
||||
if staged == nil or staged ~= bytes then
|
||||
remove(fs, tmp)
|
||||
return false, "verify_failed", "Staged opaque storage data could not be verified."
|
||||
end
|
||||
|
||||
if previous ~= nil then fs.write(bak, previous) end
|
||||
ok, err = fs.write(main, bytes)
|
||||
if not ok then
|
||||
remove(fs, tmp)
|
||||
return false, "write_failed",
|
||||
"Could not replace opaque storage data: " .. tostring(err)
|
||||
end
|
||||
local verified = readOpaqueAt(fs, main)
|
||||
if verified == nil or verified ~= bytes then
|
||||
remove(fs, main)
|
||||
remove(fs, tmp)
|
||||
return false, "verify_failed",
|
||||
"Replacement opaque storage data could not be verified."
|
||||
end
|
||||
|
||||
fs.write(bak, bytes)
|
||||
remove(fs, tmp)
|
||||
return true
|
||||
end
|
||||
|
||||
function Storage:readBytes(game, key)
|
||||
local scope, main, bak, tmp, path = self:_names(game, key, false, ".bin")
|
||||
if not scope then return nil, main, bak end
|
||||
local fs = scope.fs
|
||||
if hasAny(fs, { path .. ".lua", path .. ".lua.bak", path .. ".lua.tmp" }) then
|
||||
return failure("type_mismatch",
|
||||
"This storage key contains table data; use read instead.")
|
||||
end
|
||||
|
||||
local bytes = readOpaqueAt(fs, main)
|
||||
if bytes ~= nil then return bytes end
|
||||
bytes = readOpaqueAt(fs, tmp)
|
||||
if bytes == nil then bytes = readOpaqueAt(fs, bak) end
|
||||
if bytes == nil then
|
||||
return nil, "not_found", "No valid opaque value exists for this key."
|
||||
end
|
||||
|
||||
ensureParent(fs, main)
|
||||
if fs.write(main, bytes) then fs.write(bak, bytes) end
|
||||
remove(fs, tmp)
|
||||
return bytes
|
||||
end
|
||||
|
||||
function Storage:list(game, prefix)
|
||||
prefix = prefix or ""
|
||||
local scope, main, codeOrBak = self:_names(game, prefix, true)
|
||||
@@ -237,13 +341,23 @@ function Storage:list(game, prefix)
|
||||
|
||||
local base = scope.base
|
||||
local start = prefix == "" and base or (base .. "/" .. prefix)
|
||||
local out = {}
|
||||
local out, seen = {}, {}
|
||||
|
||||
local function add(logical)
|
||||
if not seen[logical] then
|
||||
seen[logical] = true
|
||||
out[#out + 1] = logical
|
||||
end
|
||||
end
|
||||
|
||||
local function walk(path, logical)
|
||||
local info = fs.getInfo(path)
|
||||
if not info then return end
|
||||
if info.type == "file" then
|
||||
if path:sub(-4) == ".lua" then out[#out + 1] = logical:sub(1, -5) end
|
||||
local suffix = path:sub(-4)
|
||||
if suffix == ".lua" or suffix == ".bin" then
|
||||
add(logical:sub(1, -5))
|
||||
end
|
||||
return
|
||||
end
|
||||
for _, child in ipairs(fs.getDirectoryItems(path) or {}) do
|
||||
@@ -254,7 +368,9 @@ function Storage:list(game, prefix)
|
||||
|
||||
-- A prefix may identify one exact key or a directory of keys.
|
||||
if fs.getInfo(start .. ".lua") then
|
||||
out[#out + 1] = prefix
|
||||
add(prefix)
|
||||
elseif fs.getInfo(start .. ".bin") then
|
||||
add(prefix)
|
||||
else
|
||||
walk(start, prefix)
|
||||
end
|
||||
@@ -263,15 +379,20 @@ function Storage:list(game, prefix)
|
||||
end
|
||||
|
||||
function Storage:delete(game, key)
|
||||
local scope, main, bak, tmp = self:_names(game, key, false)
|
||||
local scope, main, bak, tmp, path = self:_names(game, key, false)
|
||||
if not scope then return false, main, bak end
|
||||
local fs = scope.fs
|
||||
if not (fs.getInfo(main) or fs.getInfo(bak) or fs.getInfo(tmp)) then
|
||||
local byteMain, byteBak, byteTmp = path .. ".bin", path .. ".bin.bak", path .. ".bin.tmp"
|
||||
if not (fs.getInfo(main) or fs.getInfo(bak) or fs.getInfo(tmp)
|
||||
or fs.getInfo(byteMain) or fs.getInfo(byteBak) or fs.getInfo(byteTmp)) then
|
||||
return false, "not_found", "No stored value exists for this key."
|
||||
end
|
||||
remove(fs, main)
|
||||
remove(fs, bak)
|
||||
remove(fs, tmp)
|
||||
remove(fs, byteMain)
|
||||
remove(fs, byteBak)
|
||||
remove(fs, byteTmp)
|
||||
return true
|
||||
end
|
||||
|
||||
|
||||
@@ -383,6 +383,7 @@ local function pickTargetAndUse(game, battle, id, list)
|
||||
local def = game.data.items[id]
|
||||
local opts = {
|
||||
pickOnly = true,
|
||||
battle = battle,
|
||||
-- HP medicine animates its bar with the picker still up (#252). Only
|
||||
-- out of battle: the in-battle tail closes the bag list underneath
|
||||
-- first, which needs the picker already gone.
|
||||
|
||||
@@ -289,6 +289,7 @@ function PartyMenu.new(game, opts)
|
||||
opts = opts or {}
|
||||
local self = setmetatable({}, PartyMenu)
|
||||
self.game = game
|
||||
local party = opts.party or (opts.battle and opts.battle.playerParty)
|
||||
-- PartyMenuInit (home/pokemon.asm) seeds the cursor from
|
||||
-- wPartyAndBillsPCSavedMenuItem rather than from zero, and
|
||||
-- HandlePartyMenuInput writes wCurrentMenuItem back into it on every
|
||||
@@ -297,7 +298,7 @@ function PartyMenu.new(game, opts)
|
||||
-- both zero the byte, which BattleState mirrors. The clamp covers a
|
||||
-- party that shrank (deposit / release) while the saved index was
|
||||
-- pointing past the end. #768
|
||||
local count = #(opts.party or (game.save and game.save.party) or {})
|
||||
local count = #(party or (game.save and game.save.party) or {})
|
||||
self.index = math.min(math.max(1, game.partyMenuSavedIndex or 1),
|
||||
math.max(1, count))
|
||||
self.onSwitch = opts.onSwitch
|
||||
@@ -314,7 +315,7 @@ function PartyMenu.new(game, opts)
|
||||
self.tmhm = opts.tmhm
|
||||
self.forceSwitch = opts.forceSwitch
|
||||
self.battle = opts.battle
|
||||
self.party = opts.party -- link battles pass their clamped copies
|
||||
self.party = party -- link/scoped battles pass their local party view
|
||||
self.swapFrom = nil
|
||||
self.submenu = nil
|
||||
self.subIndex = 1
|
||||
|
||||
@@ -230,6 +230,7 @@ function OverworldState:enter(mapId, x, y, facing, opts)
|
||||
-- a fresh entry, or a stale flag can freeze player input forever
|
||||
self.engaging = false
|
||||
self.emote = nil
|
||||
self.cancelledTrainerSight = nil
|
||||
-- volatile WRAM state in pokered; never serialize across save/load
|
||||
self.wildEncounterGraceSteps = 0
|
||||
-- survives save/load: a loaded game may start inside a building whose
|
||||
@@ -762,6 +763,27 @@ function OverworldState:bikeAllowed(mapId)
|
||||
return false
|
||||
end
|
||||
|
||||
-- Field-item entry points keep presentation and state transitions in the
|
||||
-- owning world instead of asking a supported facade to reproduce either one.
|
||||
function OverworldState:useBicycle()
|
||||
local name = Game.save.player.name
|
||||
if Game.save.onBike then
|
||||
if Game.save.forcedBike then return false end
|
||||
Game.save.onBike = false
|
||||
require("src.core.Music").playMap(Game.data, self.map.id, false)
|
||||
Game.stack:push(TextBox.new(Game,
|
||||
Strings("%s got off\nthe BICYCLE.", name)))
|
||||
elseif self:bikeAllowed(self.map.id) and not self.player.surfing then
|
||||
Game.save.onBike = true
|
||||
require("src.core.Music").playMap(Game.data, self.map.id, true)
|
||||
Game.stack:push(TextBox.new(Game,
|
||||
Strings("%s got on\nthe BICYCLE!", name)))
|
||||
else
|
||||
return false
|
||||
end
|
||||
return true
|
||||
end
|
||||
|
||||
-- The battle transition's dungeon wipe uses the explicit map lists in
|
||||
-- data/maps/dungeon_maps.asm (field.dungeonTransitionMaps): singles plus
|
||||
-- inclusive map-id ranges -- faithful to the original's omissions
|
||||
@@ -1725,6 +1747,12 @@ function OverworldState:goFishing(rod)
|
||||
end))
|
||||
end
|
||||
|
||||
function OverworldState:useFishingRod(rod)
|
||||
if self.player.surfing or not self:facingIsShoreOrWater() then return false end
|
||||
self:goFishing(rod)
|
||||
return true
|
||||
end
|
||||
|
||||
-- Fly to a visited town (called from the party menu).
|
||||
function OverworldState:flyTo(mapId)
|
||||
local spot = Game.data.field.flyWarps[mapId]
|
||||
@@ -3092,6 +3120,32 @@ local function meetTrainerTheme(cls)
|
||||
or "Music_MeetMaleTrainer"
|
||||
end
|
||||
|
||||
-- Public pre-trainer gate. A mod may retain continueBattle while a registered
|
||||
-- preparation screen is on top, then resume once with an optional ordered
|
||||
-- save-party index scope. The hook is cold on a no-mod boot.
|
||||
function OverworldState.prepareTrainerBattle(game, context, startBattle,
|
||||
cancelBattle)
|
||||
if not Runtime.wantsHook("trainer.before_battle") then
|
||||
startBattle()
|
||||
return false
|
||||
end
|
||||
local started = false
|
||||
local function continueBattle(options)
|
||||
if started then return false end
|
||||
started = true
|
||||
if type(options) == "table" and options.cancel == true then
|
||||
if cancelBattle then cancelBattle() end
|
||||
else
|
||||
startBattle(options)
|
||||
end
|
||||
return true
|
||||
end
|
||||
local deferred = Runtime.call("trainer.before_battle",
|
||||
function() return false end, game, context, continueBattle)
|
||||
if deferred ~= true and not started then continueBattle() end
|
||||
return deferred == true
|
||||
end
|
||||
|
||||
-- Run the pre-battle text -> battle -> won text -> flags sequence.
|
||||
-- skipBattleText is for map scripts shaped like SilphCo11FDefaultScript
|
||||
-- (scripts/SilphCo11F.asm), which DisplayTextID the challenge line BEFORE
|
||||
@@ -3119,7 +3173,8 @@ function OverworldState:engageTrainer(npc, onDone, endBattleText, skipBattleText
|
||||
or (header and header.won and Game.data.text[header.won])
|
||||
|
||||
local BattleState = require("src.battle.BattleState")
|
||||
local function startBattle()
|
||||
local function startBattle(options)
|
||||
self.cancelledTrainerSight = nil
|
||||
-- TalkToTrainer (home/trainers.asm:88) prints the before-battle text
|
||||
-- FIRST and only then runs `call EngageMapTrainer` / `jp
|
||||
-- StartTrainerBattle`, so a trainer challenged on foot gets the sting
|
||||
@@ -3134,7 +3189,8 @@ function OverworldState:engageTrainer(npc, onDone, endBattleText, skipBattleText
|
||||
local theme = meetTrainerTheme(d.trainerClass)
|
||||
if theme then require("src.core.Music").play(Game.data, theme) end
|
||||
end
|
||||
local battle = BattleState.newTrainer(Game, d.trainerClass, d.trainerParty)
|
||||
local battle = BattleState.newTrainer(Game, d.trainerClass, d.trainerParty,
|
||||
options)
|
||||
battle.checkpointOrigin = {
|
||||
kind = "trainer_encounter",
|
||||
map = self.map.id,
|
||||
@@ -3171,10 +3227,31 @@ function OverworldState:engageTrainer(npc, onDone, endBattleText, skipBattleText
|
||||
end
|
||||
self:pushBattle(battle)
|
||||
end
|
||||
local function prepareBattle()
|
||||
if not Runtime.wantsHook("trainer.before_battle") then
|
||||
startBattle()
|
||||
return
|
||||
end
|
||||
OverworldState.prepareTrainerBattle(Game, {
|
||||
trainerClass = d.trainerClass,
|
||||
partyIndex = d.trainerParty or 1,
|
||||
mapId = self.map.id,
|
||||
npcId = npc.id,
|
||||
}, startBattle, function()
|
||||
if self.player then
|
||||
self.cancelledTrainerSight = {
|
||||
npcId = npc.id,
|
||||
playerX = self.player.cellX,
|
||||
playerY = self.player.cellY,
|
||||
}
|
||||
end
|
||||
if onDone then onDone() end
|
||||
end)
|
||||
end
|
||||
if skipBattleText then
|
||||
startBattle()
|
||||
prepareBattle()
|
||||
else
|
||||
Game.stack:push(TextBox.new(Game, battleText, startBattle))
|
||||
Game.stack:push(TextBox.new(Game, battleText, prepareBattle))
|
||||
end
|
||||
end
|
||||
|
||||
@@ -3372,11 +3449,18 @@ function OverworldState:checkTrainerSight()
|
||||
if self.player.moving or self.engaging then return end
|
||||
if Game.stack:top() ~= self then return end
|
||||
local p = self.player
|
||||
local cancelled = self.cancelledTrainerSight
|
||||
if cancelled and (cancelled.playerX ~= p.cellX
|
||||
or cancelled.playerY ~= p.cellY) then
|
||||
self.cancelledTrainerSight = nil
|
||||
cancelled = nil
|
||||
end
|
||||
for _, npc in ipairs(self.npcs) do
|
||||
local d = npc.def
|
||||
-- CheckFightingMapTrainers engages ANY aligned trainer sprite,
|
||||
-- walkers included (they sight between steps)
|
||||
if d.trainerClass and not npc.moving
|
||||
and not (cancelled and cancelled.npcId == npc.id)
|
||||
and not self:trainerDefeated(npc)
|
||||
and not mapScripts.talkScript(self.map.id, d.text)
|
||||
and trainerSpriteOnScreen(npc, p) then
|
||||
|
||||
@@ -15,6 +15,7 @@ local WorldAPI = {}
|
||||
WorldAPI.__index = WorldAPI
|
||||
|
||||
local NO_OVERWORLD = "no overworld"
|
||||
local RODS = { "OLD_ROD", "GOOD_ROD", "SUPER_ROD" }
|
||||
|
||||
local function acceptsMenuInput(game, ow)
|
||||
local stack = game and game.stack
|
||||
@@ -86,6 +87,60 @@ function WorldAPI:reorderParty(fromSlot, toSlot)
|
||||
return true
|
||||
end
|
||||
|
||||
-- Contextual field-item shortcuts. Only actions that can start immediately
|
||||
-- are listed; callers receive copied labels and never inspect world internals.
|
||||
function WorldAPI:availableFieldActions()
|
||||
local game, ow, out = self.game, self:overworld(), {}
|
||||
if not (game and game.save and ow and ow.map and ow.player)
|
||||
or not acceptsMenuInput(game, ow) then return out end
|
||||
local save, inventory = game.save, game.save.inventory or {}
|
||||
local items = game.data and game.data.items or {}
|
||||
|
||||
if (inventory.BICYCLE or 0) > 0 and not ow.player.surfing
|
||||
and not (save.onBike and save.forcedBike)
|
||||
and (save.onBike or ow:bikeAllowed(ow.map.id)) then
|
||||
out[#out + 1] = { id = "bicycle",
|
||||
label = save.onBike and "BIKE OFF" or "BICYCLE" }
|
||||
end
|
||||
|
||||
if not ow.player.surfing and ow:facingIsShoreOrWater() then
|
||||
local rods = {}
|
||||
for _, id in ipairs(RODS) do
|
||||
if (inventory[id] or 0) > 0 then
|
||||
local def = items[id]
|
||||
rods[#rods + 1] = { id = id, label = def and def.name or id }
|
||||
end
|
||||
end
|
||||
if #rods > 0 then
|
||||
out[#out + 1] = { id = "fish", label = "FISH", rods = rods }
|
||||
end
|
||||
end
|
||||
return out
|
||||
end
|
||||
|
||||
function WorldAPI:useFieldAction(id, opts)
|
||||
local game, ow = self.game, self:overworld()
|
||||
if not ow then return nil, NO_OVERWORLD end
|
||||
if not acceptsMenuInput(game, ow) then return nil, "world is busy" end
|
||||
local found
|
||||
for _, action in ipairs(self:availableFieldActions()) do
|
||||
if action.id == id then found = action break end
|
||||
end
|
||||
if not found then return nil, "field action unavailable" end
|
||||
|
||||
if id == "bicycle" then
|
||||
if ow:useBicycle() then return true end
|
||||
elseif id == "fish" then
|
||||
local rod = opts and opts.rod
|
||||
if not rod and #found.rods == 1 then rod = found.rods[1].id end
|
||||
for _, choice in ipairs(found.rods) do
|
||||
if choice.id == rod and ow:useFishingRod(rod) then return true end
|
||||
end
|
||||
return nil, "fishing rod unavailable"
|
||||
end
|
||||
return nil, "field action unavailable"
|
||||
end
|
||||
|
||||
-- A compact, read-only view of the active map for minimaps and companion UIs.
|
||||
-- `rows` describes collision terrain; optional `tileRows` reduces each real
|
||||
-- 8x8 map tile to its average Game Boy shade ("0" lightest, "3" darkest).
|
||||
|
||||
@@ -27,11 +27,15 @@ local Movement = require("src.script.gen2.Movement")
|
||||
local Runtime = require("src.mods.Runtime")
|
||||
local HiddenItems = require("src.world.gen2.HiddenItems")
|
||||
local MapOverview = require("src.world.MapOverview")
|
||||
local Bike = require("src.world.gen2.Bike")
|
||||
local FieldMoves = require("src.world.gen2.FieldMoves")
|
||||
local Permissions = require("src.world.gen2.Permissions")
|
||||
|
||||
local WorldAPI = {}
|
||||
WorldAPI.__index = WorldAPI
|
||||
|
||||
local NO_OVERWORLD = "no overworld"
|
||||
local RODS = { "OLD_ROD", "GOOD_ROD", "SUPER_ROD" }
|
||||
|
||||
function WorldAPI.new(game, modId)
|
||||
return setmetatable({ game = game, modId = modId }, WorldAPI)
|
||||
@@ -52,6 +56,77 @@ function WorldAPI:current()
|
||||
facing = p and p.facing }
|
||||
end
|
||||
|
||||
local function itemLabel(game, id)
|
||||
local def = game and game.data and game.data.items
|
||||
and game.data.items[id]
|
||||
return (def and def.name) or id
|
||||
end
|
||||
|
||||
-- The same field-item contract as Gen 1, resolved through Gold's own bike,
|
||||
-- collision and fishing rules.
|
||||
function WorldAPI:availableFieldActions()
|
||||
local world, game, out = self:overworld(), self.game, {}
|
||||
if not (world and game and game.save and world.map and world.player)
|
||||
or not world:acceptsMenuInput() then return out end
|
||||
local inventory = game.save.inventory or {}
|
||||
|
||||
if (inventory.BICYCLE or 0) > 0 then
|
||||
local bike = Bike.tryBike({
|
||||
state = world.playerState,
|
||||
environment = world.map.def and world.map.def.environment,
|
||||
collision = world:playerCollision(),
|
||||
alwaysOnBike = world:alwaysOnBike(),
|
||||
})
|
||||
if bike == "mount" or bike == "dismount" then
|
||||
out[#out + 1] = { id = "bicycle",
|
||||
label = bike == "dismount" and "BIKE OFF" or "BICYCLE" }
|
||||
end
|
||||
end
|
||||
|
||||
local context = world:fieldContext()
|
||||
if not FieldMoves.isSurfing(world.playerState)
|
||||
and Permissions.isWater(context.facingColl) then
|
||||
local rods = {}
|
||||
for _, id in ipairs(RODS) do
|
||||
if (inventory[id] or 0) > 0 then
|
||||
rods[#rods + 1] = { id = id, label = itemLabel(game, id) }
|
||||
end
|
||||
end
|
||||
if #rods > 0 then
|
||||
out[#out + 1] = { id = "fish", label = "FISH", rods = rods }
|
||||
end
|
||||
end
|
||||
return out
|
||||
end
|
||||
|
||||
function WorldAPI:useFieldAction(id, opts)
|
||||
local world = self:overworld()
|
||||
if not world then return nil, NO_OVERWORLD end
|
||||
if not world:acceptsMenuInput() then return nil, "world is busy" end
|
||||
local found
|
||||
for _, action in ipairs(self:availableFieldActions()) do
|
||||
if action.id == id then found = action break end
|
||||
end
|
||||
if not found then return nil, "field action unavailable" end
|
||||
|
||||
if id == "bicycle" then
|
||||
local outcome = world:useFieldItem("BICYCLE")
|
||||
if outcome and outcome ~= "nowhere" then return true end
|
||||
elseif id == "fish" then
|
||||
local rod = opts and opts.rod
|
||||
if not rod and #found.rods == 1 then rod = found.rods[1].id end
|
||||
for _, choice in ipairs(found.rods) do
|
||||
if choice.id == rod then
|
||||
local outcome = world:useFieldItem(rod)
|
||||
if outcome and outcome ~= "nowhere" then return true end
|
||||
break
|
||||
end
|
||||
end
|
||||
return nil, "fishing rod unavailable"
|
||||
end
|
||||
return nil, "field action unavailable"
|
||||
end
|
||||
|
||||
-- The same read-only minimap contract as Gen 1, with Gold's object/event
|
||||
-- visibility rules supplying the semantic markers.
|
||||
function WorldAPI:mapOverview()
|
||||
|
||||
Reference in New Issue
Block a user