This commit is contained in:
bryanthaboi
2026-08-17 20:15:40 -04:00
11 changed files with 243 additions and 46 deletions
+6 -3
View File
@@ -733,7 +733,8 @@ local function museumClerk(game, ow, done, onDecline)
local t = game.data.text or {}
if game.save.flags.EVENT_BOUGHT_MUSEUM_TICKET then
game.stack:push(TextBox.new(game,
"Take your time,\nand enjoy it all!", done))
t._Museum1FScientist1TakePlentyOfTimeText
or "Take your time,\nand enjoy it all!", done))
return
end
-- scripts/Museum1F.asm:72
@@ -751,10 +752,12 @@ local function museumClerk(game, ow, done, onDecline)
{ money = money }))
elseif yes then
game.stack:push(TextBox.new(game,
"You don't have\nenough money.", onDecline or done, { money = money }))
t._Museum1FScientist1DontHaveEnoughMoneyText
or "You don't have\nenough money.", onDecline or done, { money = money }))
else
game.stack:push(TextBox.new(game,
"Come again!", onDecline or done, { money = money }))
t._Museum1FScientist1ComeAgainText
or "Come again!", onDecline or done, { money = money }))
end
end }))
end
+4 -4
View File
@@ -268,10 +268,10 @@ an optional stock `catchChance` percentage.
`prompt` describes the currently visible choice (`menu`, `moves`, `party`,
`advance`, `safari`, or `mimic`) and is `locked` when another screen or battle
phase owns input. Generation-specific features remain optional: Gen 1 includes
battle medicine, balls, catch previews, Safari balls, and Mimic choices;
Gold currently returns an empty `items` list rather than guessing at its
pocketed PACK flow. Callers should ignore unknown fields and tolerate absent
optional ones.
battle medicine, balls, catch previews, Safari balls, and Mimic choices. Gold
exposes balls and their exact stock catch previews; targeted medicine remains
screen-owned and is omitted rather than guessing at its pocketed PACK flow.
Callers should ignore unknown fields and tolerate absent optional ones.
## Battle menu intents
+21 -3
View File
@@ -39,6 +39,21 @@ local function messageCopy(screen)
return #lines > 0 and lines or nil
end
local function itemCopies(game, screen, catchable)
local out = {}
for id, count in pairs((game.save and game.save.inventory) or {}) do
local def = game.data.items and game.data.items[id]
if count > 0 and def and def.pocket == "BALL" then
out[#out + 1] = { id = id, name = def.name or id, count = count,
ball = true, needsTarget = false,
catchChance = catchable and screen.catchChance
and screen:catchChance(id) or nil }
end
end
table.sort(out, function(a, b) return a.name < b.name end)
return out
end
local function signature(game, screen, top)
if not screen then return "none" end
local battle = screen.battle or {}
@@ -56,6 +71,9 @@ local function signature(game, screen, top)
parts[#parts + 1] = tostring(mon.hp)
parts[#parts + 1] = tostring(mon.status)
end
for _, item in ipairs(itemCopies(game, screen, false)) do
parts[#parts + 1] = item.id .. "=" .. tostring(item.count)
end
return table.concat(parts, "|")
end
@@ -111,9 +129,9 @@ function BattleAPI:snapshot()
player = monCopy(game.data, battle.player, true),
enemy = monCopy(game.data, battle.enemy, true),
party = party, moves = moveCopies(game, battle),
-- Gold's PACK is pocketed and target selection is screen-owned. Omit it
-- until the engine can expose the same semantic item records as Gen 1.
items = {} }
-- Targeted medicine remains screen-owned, but balls are complete semantic
-- records and can safely expose the same read-only preview as Gen 1.
items = itemCopies(game, screen, battle.wild and not screen.tutorial) }
end
local MENU_CHOICES = { fight = true, party = true, item = true, run = true }
+9
View File
@@ -305,6 +305,15 @@ function Catching.rate(opts)
return math.min(255, rate), false
end
-- Exact stock catch probability for read-only previews. A catch.rate hook
-- may replace the roll entirely, so nil is safer than presenting a guess.
function Catching.chance(opts)
if Runtime.wantsHook("catch.rate") then return nil end
local rate, guaranteed = Catching.rate(opts)
if guaranteed or rate >= 255 then return 100 end
return rate * 100 / 256
end
-- The status half of the rate, off the merged `statuses` record the same way
-- src/battle/Catching.lua reads record.catchBonus on Gen 1. Gold's records
-- live on src/battle/gen2/Battle.lua (Battle.STATUSES) and carry BOTH numbers:
+32 -5
View File
@@ -988,8 +988,18 @@ local function pickerHasKind(kind)
return false
end
local function findPendingRequiredImport()
local function findPendingRequiredImport(self)
local names = { "picked_required_import.bin", "picked_stadium.z64" }
-- Builds released before required_import was added to the Android JNI bridge
-- only understand the long-standing "rom" picker kind. While a required
-- import request is in flight, it is safe to treat its staging name as a
-- dependency file: the pending IDs below select the same validation/copy
-- path as a current bridge. Never scan picked_rom.gb otherwise, since that
-- remains reserved for an ordinary game-ROM import.
if self and self.requiredImportLegacyRomPick
and self.pickerPendingKind == "required_import" then
names[#names + 1] = "picked_rom.gb"
end
for _, name in ipairs(names) do
if love.filesystem.getInfo(name, "file") then return name end
end
@@ -1504,12 +1514,16 @@ function RomImporter:focus(f)
local text = "Could not read the picked file. Reopen the picker and choose "
.. "it with the Files (Documents) app, or copy it into: "
.. love.filesystem.getSaveDirectory()
local legacyRequiredPick = self.requiredImportLegacyRomPick
and self.pickerPendingKind == "required_import"
if pickError:find("picked_required_import", 1, true)
or pickError:find("picked_stadium", 1, true) then
or pickError:find("picked_stadium", 1, true)
or (legacyRequiredPick and pickError:find("picked_rom", 1, true)) then
self.modNotice = { ok = false, text = text }
self.pickerPendingKind = nil
self.pickerPendingModId = nil
self.pickerPendingImportId = nil
self.requiredImportLegacyRomPick = nil
elseif pickError:find("picked_mod", 1, true) then
if self.pickerPendingKind == "skin" then
self.pickerPendingKind = nil
@@ -1526,11 +1540,12 @@ function RomImporter:focus(f)
end
return
end
local requiredName = findPendingRequiredImport()
local requiredName = findPendingRequiredImport(self)
if requiredName then
local modId, importId = self.pickerPendingModId, self.pickerPendingImportId
self.pickerPendingKind = nil
self.pickerPendingModId, self.pickerPendingImportId = nil, nil
self.requiredImportLegacyRomPick = nil
local imported = modId and importId
and self:_importRequiredSource(modId, importId, requiredName)
consumePick(self, requiredName, requiredName, imported)
@@ -2069,7 +2084,17 @@ function RomImporter:chooseRequiredImport(modId, importId)
return
end
if self.nativePicker then
if self.mobileFileBridge and not pickerHasKind("required_import") then
-- Android 13+ uses the Storage Access Framework for both paths. Some
-- Android 15 installs carry the newer Lua launcher with an older native
-- bridge, however, so they do not advertise required_import yet. Fall
-- back to that bridge's known "rom" picker and quarantine its result by
-- the pending required-import IDs. iOS has a different asynchronous
-- bridge and deliberately keeps the explicit capability requirement.
local legacyAndroidPicker = self.mobileFileBridge
and love.system.getOS() == "Android"
and not pickerHasKind("required_import")
if self.mobileFileBridge and not pickerHasKind("required_import")
and not legacyAndroidPicker then
requiredImportNotice(self, modId, importId,
"This app build cannot pick required mod files yet. Update the app and try again.")
self.modNotice = nil
@@ -2078,10 +2103,12 @@ function RomImporter:chooseRequiredImport(modId, importId)
self.pickerPendingKind = "required_import"
self.pickerPendingModId = modId
self.pickerPendingImportId = importId
if not pickFile("required_import") then
self.requiredImportLegacyRomPick = legacyAndroidPicker or nil
if not pickFile(legacyAndroidPicker and "rom" or "required_import") then
self.pickerPendingKind = nil
self.pickerPendingModId = nil
self.pickerPendingImportId = nil
self.requiredImportLegacyRomPick = nil
requiredImportNotice(self, modId, importId, "Could not open the file picker.")
self.modNotice = nil
elseif self.android then
+33 -30
View File
@@ -2945,6 +2945,37 @@ function BattleState:openDexEntry(species)
})
end
function BattleState:catchOptions(itemId)
local data = self.game and self.game.data or {}
local battle = self.battle
local enemy = battle and battle.enemy
if not enemy then return nil end
local enemyDef = data.pokemon and data.pokemon[enemy.species]
local dexEntry = data.gen2Pokedex and data.gen2Pokedex[enemy.species]
local evolveItem
for _, entry in ipairs((enemyDef and enemyDef.evolutions) or {}) do
if entry.method == "EVOLVE_ITEM" then evolveItem = entry.item end
end
local player = battle.player
return {
battle = battle, mon = enemy, def = enemyDef,
maxHp = enemy.maxHp or (enemy.stats and enemy.stats.hp), hp = enemy.hp,
catchRate = enemyDef and enemyDef.catchRate or 45, ball = itemId,
status = enemy.status, random = battle.random,
weight = dexEntry and dexEntry.weight, level = enemy.level,
playerLevel = player and player.level,
fishing = battle.battleType == "fish", species = enemy.species,
gender = enemy.gender, playerSpecies = player and player.species,
playerGender = player and player.gender, evolveItem = evolveItem,
}
end
function BattleState:catchChance(itemId)
if self.tutorial then return 100 end
local opts = self:catchOptions(itemId)
return opts and Catching.chance(opts) or nil
end
-- Items in battle: balls try a catch, the stat items apply their stage, and
-- everything with a ported party effect runs the same item_effects.asm routine
-- the field pack runs. Anything else reports that it cannot be used, which is
@@ -2974,7 +3005,6 @@ function BattleState:useItem(itemId)
return
end
local enemy = self.battle.enemy
local enemyDef = data.pokemon and data.pokemon[enemy.species]
local caught, rate
if self.tutorial then
-- `ld a, [wBattleType] / cp BATTLETYPE_TUTORIAL /
@@ -2988,35 +3018,8 @@ function BattleState:useItem(itemId)
caught, rate = true, 255
else
-- The specialty-ball conditions (BallMultiplierFunctionTable): each one
-- is something this screen already knows. Heavy Ball reads the dex
-- weight, Moon Ball the species' stone row, Love Ball both genders,
-- Level Ball the two levels, Lure Ball wBattleType.
local dexEntry = data.gen2Pokedex and data.gen2Pokedex[enemy.species]
local evolveItem
for _, entry in ipairs((enemyDef and enemyDef.evolutions) or {}) do
if entry.method == "EVOLVE_ITEM" then evolveItem = entry.item end
end
local player = self.battle.player
caught, rate = Catching.attempt({
battle = self.battle,
mon = enemy,
def = enemyDef,
maxHp = enemy.maxHp or (enemy.stats and enemy.stats.hp),
hp = enemy.hp,
catchRate = enemyDef and enemyDef.catchRate or 45,
ball = itemId,
status = enemy.status,
random = self.battle.random,
weight = dexEntry and dexEntry.weight,
level = enemy.level,
playerLevel = player and player.level,
fishing = self.battle.battleType == "fish",
species = enemy.species,
gender = enemy.gender,
playerSpecies = player and player.species,
playerGender = player and player.gender,
evolveItem = evolveItem,
})
-- is also used by the read-only preview, so both paths stay exact.
caught, rate = Catching.attempt(self:catchOptions(itemId))
end
-- wWildMon carries the answer through the animation, and
-- wThrownBallWobbleCount is the counter GetPokeBallWobble bumps once per
@@ -0,0 +1,68 @@
-- The museum 1F ticket clerk's take-your-time/not-enough-money/come-again
-- lines used to be bare English literals, invisible to game.data.text no
-- matter what a translation mod put there. tests/engine/museum_money_box_
-- bug1335.lua only ever runs with an empty game.data.text, so it can't
-- tell a properly-wired t._Key or "..." fallback apart from a literal that
-- never looked at t at all -- every assertion there passes either way.
-- This test populates game.data.text with translated values and checks
-- they actually reach the pushed TextBox, for all three lines.
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.harness")
package.loaded["src.render.TextBox"] = {
new = function(_, text, onDone, opts)
return { text = text, onDone = onDone, opts = opts }
end,
}
local M = assert(loadfile("data/scripts/story2.lua"))()
local clerk = M.MUSEUM_1F.talk.TEXT_MUSEUM1F_SCIENTIST1
local TRANSLATED = {
_Museum1FScientist1TakePlentyOfTimeText = "Prends ton temps\net profite bien !",
_Museum1FScientist1DontHaveEnoughMoneyText = "Tu n'as pas assez\nd'argent.",
_Museum1FScientist1ComeAgainText = "Reviens vite !",
}
local pushed
local function mkGame(cash)
pushed = {}
return {
data = { text = TRANSLATED },
save = { money = cash, flags = {} },
stack = { push = function(_, box) pushed[#pushed + 1] = box end },
}
end
-- already ticketed: take-your-time line
local g = mkGame(3000)
g.save.flags.EVENT_BOUGHT_MUSEUM_TICKET = true
clerk(g, nil, nil, function() end)
T.eq(pushed[1].text, TRANSLATED._Museum1FScientist1TakePlentyOfTimeText,
"an existing ticket holder gets the translated take-your-time line")
-- YES but short on cash: not-enough-money line
g = mkGame(20)
clerk(g, nil, nil, function() end)
pushed[1].opts.choice(true)
T.eq(pushed[2].text, TRANSLATED._Museum1FScientist1DontHaveEnoughMoneyText,
"short on cash opens the translated not-enough-money box")
-- NO: come-again line
g = mkGame(3000)
clerk(g, nil, nil, function() end)
pushed[1].opts.choice(false)
T.eq(pushed[2].text, TRANSLATED._Museum1FScientist1ComeAgainText,
"declining opens the translated come-again box")
-- unpopulated game.data.text still falls back to the English literal
g = { data = { text = {} }, save = { money = 3000, flags = {} },
stack = { push = function(_, box) pushed[#pushed + 1] = box end } }
pushed = {}
g.save.flags.EVENT_BOUGHT_MUSEUM_TICKET = true
clerk(g, nil, nil, function() end)
T.check(tostring(pushed[1].text):find("Take your time", 1, true) ~= nil,
"an empty catalog still falls back to the English take-your-time line")
T.finish("museum_1f_clerk_translation_test")
+5
View File
@@ -490,6 +490,11 @@ check("hurt is easier to catch", hurtRate > fullRate, true)
local asleepRate = Catching.rate({
maxHp = 60, hp = 1, catchRate = 45, ball = "POKE_BALL", status = "sleep" })
check("sleep adds 10", asleepRate, math.min(255, hurtRate + 10))
checkNear("preview converts the exact byte roll", Catching.chance({
maxHp = 60, hp = 60, catchRate = 45, ball = "POKE_BALL" }),
fullRate * 100 / 256, 0.000001)
check("master ball preview is certain", Catching.chance({
maxHp = 60, hp = 60, catchRate = 1, ball = "MASTER_BALL" }), 100)
-- The cart's bug: burn/poison/paralysis add nothing.
check("poison adds nothing (cart bug)", Catching.rate({
maxHp = 60, hp = 1, catchRate = 45, ball = "POKE_BALL",
+10
View File
@@ -282,6 +282,16 @@ local function runToMenu(screen, cap)
return false
end
do
local screen = newScreen({ inventory = {
MASTER_BALL = 1, POKE_BALL = 1,
} })
eq(screen:catchChance("MASTER_BALL"), 100,
"the battle screen exposes a certain Master Ball preview")
check(type(screen:catchChance("POKE_BALL")) == "number",
"the battle screen exposes the live wild catch preview")
end
-- ---- BattleMenu empties the textbox ---------------------------------------
do
local screen = newScreen()
+13 -1
View File
@@ -163,14 +163,23 @@ function screen2:chooseMove(slot)
return true
end
function screen2:cancelMove() self.phase = "menu" return true end
function screen2:catchChance(ball)
return ball == "MASTER_BALL" and 100 or 37.5
end
local game2 = {
data = {
pokemon = { CHIKORITA = { name = "CHIKORITA" },
RATTATA = { name = "RATTATA" } },
moves = { TACKLE = { name = "TACKLE", type = "NORMAL",
power = 35, accuracy = 95, pp = 35 } },
items = {
MASTER_BALL = { name = "MASTER BALL", pocket = "BALL" },
POTION = { name = "POTION", pocket = "ITEM" },
},
},
save = { party = { player2 } }, stack = { states = { screen2 } },
save = { party = { player2 }, inventory = {
MASTER_BALL = 1, POTION = 2,
} }, stack = { states = { screen2 } },
}
local api2 = require("src.battle.gen2.BattleAPI").new(game2)
@@ -179,6 +188,9 @@ check(snapshot2 and snapshot2.kind == "wild" and snapshot2.prompt == "menu",
"Gold battle is discovered through its screen id")
eq(snapshot2.player.maxHp, 21, "Gold max HP uses the mon field")
eq(snapshot2.moves[1].name, "TACKLE", "Gold moves are copied")
eq(#snapshot2.items, 1, "Gold exposes balls without guessing targeted items")
eq(snapshot2.items[1].catchChance, 100,
"Gold ball records expose the exact catch preview")
snapshot2.player.hp = 0
snapshot2.moves[1].pp = 0
eq(player2.hp, 20, "changing a snapshot cannot change a Gold Pokemon")
@@ -166,6 +166,47 @@ eq(ri._requiredImported.importId, "source", "focus routes to the pending declara
check(love.filesystem.getInfo("picked_required_import.bin") == nil,
"focus removes the staged required-file pick")
-- Android releases with the updated launcher but the older native bridge do
-- not advertise required_import. They still support the established ROM SAF
-- picker, whose result must be quarantined to the pending dependency request.
love.system.pickFileKinds = function() return "rom,mod,sav" end
pickCalls = {}
ri = freshImporter({ red = true, blue = true })
ri.nativePicker = true
ri.mobileFileBridge = true
ri.mods = { {
id = "needs_source",
manifest = { id = "needs_source", name = "Needs Source",
required_imports = { { id = "source", name = "Source", file = "source.bin",
format = "raw", md5 = { "00000000000000000000000000000000" } } } },
} }
ri:chooseRequiredImport("needs_source", "source")
eq(pickCalls[1], "rom", "legacy Android bridge falls back to its ROM SAF picker")
check(ri.requiredImportLegacyRomPick,
"legacy Android ROM picker result is marked as a required import")
ri._importRequiredSource = function(self, modId, importId, source)
self._requiredImported = { modId = modId, importId = importId, source = source }
return true
end
love.filesystem.write("picked_rom.gb", "source bytes")
ri:focus(true)
eq(ri._requiredImported.source, "picked_rom.gb",
"legacy Android ROM staging name is routed to the required import")
eq(ri._requiredImported.modId, "needs_source",
"legacy Android picker preserves the requested mod")
check(love.filesystem.getInfo("picked_rom.gb") == nil,
"legacy Android dependency pick is removed after import")
-- A legacy bridge reports a failed copy using that same staging basename; it
-- must stay on the dependency page rather than becoming a game-ROM error.
ri:chooseRequiredImport("needs_source", "source")
love.filesystem.write("pick_error.flag", "picked_rom.gb")
ri:focus(true)
check(ri.modNotice ~= nil and ri.modNotice.ok == false,
"legacy Android picker errors are shown as dependency import errors")
check(ri.pickerPendingKind == nil,
"legacy Android picker error clears the pending dependency request")
love.system.getOS = saved.getOS
love.system.pickFile = saved.pickFile
love.system.pickFileKinds = saved.pickFileKinds
@@ -174,5 +215,6 @@ love.filesystem.remove("usb_mod.zip")
love.filesystem.remove("picked_mod.zip")
love.filesystem.remove("picked_save.sav")
love.filesystem.remove("picked_required_import.bin")
love.filesystem.remove("picked_rom.gb")
S.finish()