Merge pull request #1494 from AverageConsumer/codex/gen2-catch-previews

feat(gen2): expose stock ball catch previews
This commit is contained in:
bryanthaboi
2026-08-17 19:57:37 -04:00
committed by GitHub
7 changed files with 95 additions and 38 deletions
+4 -4
View File
@@ -268,10 +268,10 @@ an optional stock `catchChance` percentage.
`prompt` describes the currently visible choice (`menu`, `moves`, `party`, `prompt` describes the currently visible choice (`menu`, `moves`, `party`,
`advance`, `safari`, or `mimic`) and is `locked` when another screen or battle `advance`, `safari`, or `mimic`) and is `locked` when another screen or battle
phase owns input. Generation-specific features remain optional: Gen 1 includes phase owns input. Generation-specific features remain optional: Gen 1 includes
battle medicine, balls, catch previews, Safari balls, and Mimic choices; battle medicine, balls, catch previews, Safari balls, and Mimic choices. Gold
Gold currently returns an empty `items` list rather than guessing at its exposes balls and their exact stock catch previews; targeted medicine remains
pocketed PACK flow. Callers should ignore unknown fields and tolerate absent screen-owned and is omitted rather than guessing at its pocketed PACK flow.
optional ones. Callers should ignore unknown fields and tolerate absent optional ones.
## Battle menu intents ## Battle menu intents
+21 -3
View File
@@ -39,6 +39,21 @@ local function messageCopy(screen)
return #lines > 0 and lines or nil return #lines > 0 and lines or nil
end 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) local function signature(game, screen, top)
if not screen then return "none" end if not screen then return "none" end
local battle = screen.battle or {} 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.hp)
parts[#parts + 1] = tostring(mon.status) parts[#parts + 1] = tostring(mon.status)
end end
for _, item in ipairs(itemCopies(game, screen, false)) do
parts[#parts + 1] = item.id .. "=" .. tostring(item.count)
end
return table.concat(parts, "|") return table.concat(parts, "|")
end end
@@ -111,9 +129,9 @@ function BattleAPI:snapshot()
player = monCopy(game.data, battle.player, true), player = monCopy(game.data, battle.player, true),
enemy = monCopy(game.data, battle.enemy, true), enemy = monCopy(game.data, battle.enemy, true),
party = party, moves = moveCopies(game, battle), party = party, moves = moveCopies(game, battle),
-- Gold's PACK is pocketed and target selection is screen-owned. Omit it -- Targeted medicine remains screen-owned, but balls are complete semantic
-- until the engine can expose the same semantic item records as Gen 1. -- records and can safely expose the same read-only preview as Gen 1.
items = {} } items = itemCopies(game, screen, battle.wild and not screen.tutorial) }
end end
local MENU_CHOICES = { fight = true, party = true, item = true, run = true } 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 return math.min(255, rate), false
end 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 -- 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 -- 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: -- live on src/battle/gen2/Battle.lua (Battle.STATUSES) and carry BOTH numbers:
+33 -30
View File
@@ -2945,6 +2945,37 @@ function BattleState:openDexEntry(species)
}) })
end 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 -- 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 -- 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 -- the field pack runs. Anything else reports that it cannot be used, which is
@@ -2974,7 +3005,6 @@ function BattleState:useItem(itemId)
return return
end end
local enemy = self.battle.enemy local enemy = self.battle.enemy
local enemyDef = data.pokemon and data.pokemon[enemy.species]
local caught, rate local caught, rate
if self.tutorial then if self.tutorial then
-- `ld a, [wBattleType] / cp BATTLETYPE_TUTORIAL / -- `ld a, [wBattleType] / cp BATTLETYPE_TUTORIAL /
@@ -2988,35 +3018,8 @@ function BattleState:useItem(itemId)
caught, rate = true, 255 caught, rate = true, 255
else else
-- The specialty-ball conditions (BallMultiplierFunctionTable): each one -- The specialty-ball conditions (BallMultiplierFunctionTable): each one
-- is something this screen already knows. Heavy Ball reads the dex -- is also used by the read-only preview, so both paths stay exact.
-- weight, Moon Ball the species' stone row, Love Ball both genders, caught, rate = Catching.attempt(self:catchOptions(itemId))
-- 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,
})
end end
-- wWildMon carries the answer through the animation, and -- wWildMon carries the answer through the animation, and
-- wThrownBallWobbleCount is the counter GetPokeBallWobble bumps once per -- wThrownBallWobbleCount is the counter GetPokeBallWobble bumps once per
+5
View File
@@ -490,6 +490,11 @@ check("hurt is easier to catch", hurtRate > fullRate, true)
local asleepRate = Catching.rate({ local asleepRate = Catching.rate({
maxHp = 60, hp = 1, catchRate = 45, ball = "POKE_BALL", status = "sleep" }) maxHp = 60, hp = 1, catchRate = 45, ball = "POKE_BALL", status = "sleep" })
check("sleep adds 10", asleepRate, math.min(255, hurtRate + 10)) 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. -- The cart's bug: burn/poison/paralysis add nothing.
check("poison adds nothing (cart bug)", Catching.rate({ check("poison adds nothing (cart bug)", Catching.rate({
maxHp = 60, hp = 1, catchRate = 45, ball = "POKE_BALL", maxHp = 60, hp = 1, catchRate = 45, ball = "POKE_BALL",
+10
View File
@@ -282,6 +282,16 @@ local function runToMenu(screen, cap)
return false return false
end 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 --------------------------------------- -- ---- BattleMenu empties the textbox ---------------------------------------
do do
local screen = newScreen() local screen = newScreen()
+13 -1
View File
@@ -163,14 +163,23 @@ function screen2:chooseMove(slot)
return true return true
end end
function screen2:cancelMove() self.phase = "menu" 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 = { local game2 = {
data = { data = {
pokemon = { CHIKORITA = { name = "CHIKORITA" }, pokemon = { CHIKORITA = { name = "CHIKORITA" },
RATTATA = { name = "RATTATA" } }, RATTATA = { name = "RATTATA" } },
moves = { TACKLE = { name = "TACKLE", type = "NORMAL", moves = { TACKLE = { name = "TACKLE", type = "NORMAL",
power = 35, accuracy = 95, pp = 35 } }, 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) 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") "Gold battle is discovered through its screen id")
eq(snapshot2.player.maxHp, 21, "Gold max HP uses the mon field") eq(snapshot2.player.maxHp, 21, "Gold max HP uses the mon field")
eq(snapshot2.moves[1].name, "TACKLE", "Gold moves are copied") 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.player.hp = 0
snapshot2.moves[1].pp = 0 snapshot2.moves[1].pp = 0
eq(player2.hp, 20, "changing a snapshot cannot change a Gold Pokemon") eq(player2.hp, 20, "changing a snapshot cannot change a Gold Pokemon")