From c655217120f861ca7b73069d50d044d3e6b97412 Mon Sep 17 00:00:00 2001 From: AverageConsumer <35539970+AverageConsumer@users.noreply.github.com> Date: Mon, 17 Aug 2026 23:00:38 +0200 Subject: [PATCH] feat(gen2): expose stock ball catch previews --- docs/modding.md | 8 ++-- src/battle/gen2/BattleAPI.lua | 24 ++++++++++-- src/battle/gen2/Catching.lua | 9 +++++ src/ui/gen2/BattleState.lua | 63 ++++++++++++++++-------------- tests/gen2_battle_test.lua | 5 +++ tests/gen2_battle_ui_test.lua | 10 +++++ tests/mod_battle_snapshot_test.lua | 14 ++++++- 7 files changed, 95 insertions(+), 38 deletions(-) diff --git a/docs/modding.md b/docs/modding.md index e7c32c39..c487e5c2 100644 --- a/docs/modding.md +++ b/docs/modding.md @@ -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 diff --git a/src/battle/gen2/BattleAPI.lua b/src/battle/gen2/BattleAPI.lua index e8381964..d5ef773f 100644 --- a/src/battle/gen2/BattleAPI.lua +++ b/src/battle/gen2/BattleAPI.lua @@ -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 } diff --git a/src/battle/gen2/Catching.lua b/src/battle/gen2/Catching.lua index bfd54b30..8284add5 100644 --- a/src/battle/gen2/Catching.lua +++ b/src/battle/gen2/Catching.lua @@ -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: diff --git a/src/ui/gen2/BattleState.lua b/src/ui/gen2/BattleState.lua index cd1b7d15..c2d33a5d 100644 --- a/src/ui/gen2/BattleState.lua +++ b/src/ui/gen2/BattleState.lua @@ -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 diff --git a/tests/gen2_battle_test.lua b/tests/gen2_battle_test.lua index 790de5a6..8f8cfb84 100644 --- a/tests/gen2_battle_test.lua +++ b/tests/gen2_battle_test.lua @@ -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", diff --git a/tests/gen2_battle_ui_test.lua b/tests/gen2_battle_ui_test.lua index e6090a1c..11486dc0 100644 --- a/tests/gen2_battle_ui_test.lua +++ b/tests/gen2_battle_ui_test.lua @@ -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() diff --git a/tests/mod_battle_snapshot_test.lua b/tests/mod_battle_snapshot_test.lua index 851d3646..f2d058d1 100644 --- a/tests/mod_battle_snapshot_test.lua +++ b/tests/mod_battle_snapshot_test.lua @@ -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")