From 6ff7dd0c386441510e250c2a8f904462fe8f5465 Mon Sep 17 00:00:00 2001 From: 1jamie Date: Wed, 12 Aug 2026 17:45:28 -0500 Subject: [PATCH] refactor(battle): enhance RNG handling and fixed damage mechanics - Always supply Gen 2 battle random (BattleRandom) and Gen 1 / love-style rng. - Add EFFECT_STATIC_DAMAGE for Sonic Boom and Dragon Rage (move power; Ghost immunity via resettypematchup for static damage only). - Keep Magnitude rolling via BattleRandom instead of collapsing to Magnitude 4. - Cover RNG, static damage, and Magnitude in gen2 battle tests. --- src/battle/gen2/Battle.lua | 53 +++++++++++++---- src/battle/gen2/Effects.lua | 22 +++++-- tests/gen2_battle_test.lua | 113 ++++++++++++++++++++++++++++++++++++ 3 files changed, 173 insertions(+), 15 deletions(-) diff --git a/src/battle/gen2/Battle.lua b/src/battle/gen2/Battle.lua index 691ada53..83bab854 100644 --- a/src/battle/gen2/Battle.lua +++ b/src/battle/gen2/Battle.lua @@ -37,8 +37,10 @@ local Prize = require("src.battle.gen2.Prize") -- * Gen 1's `user` / `target` / `battler` are battler wrappers around a mon -- ({ mon = , name = , isPlayer = }); Gen 2's engine works on the party mon -- table directly, so that is what these payloads carry. --- * Gen 1's `rng` is love.math.random (1..n); Gen 2's injected `random` is --- the cart's BattleRandom convention (0..n-1). Both keys are present. +-- * Gen 1's `rng` is love.math.random (rng(n) → 1..n, rng(lo,hi) → lo..hi). +-- Gen 2's cart BattleRandom is random(n) → 0..n-1. Both live on the +-- battle: `random` / `roller()` are BattleRandom for damage, accuracy, +-- Magnitude, etc.; `rng` is the Gen 1 / love-style view of the same stream. local Runtime = require("src.mods.Runtime") -- The two battle lines that carry the cart's own `line` break: a marker-bearing -- literal has to stay reachable from a translation mod (#186, #245), which is @@ -88,6 +90,19 @@ local function rand(random, n) return math.random(n) - 1 end +-- Gen 1 / love.math view of BattleRandom: rng(n) → 1..n, rng(lo,hi) → lo..hi. +local function loveStyleRng(random) + return function(lo, hi) + if hi == nil then + local n = lo or 1 + if n < 1 then n = 1 end + return (rand(random, n) or 0) + 1 + end + if hi < lo then lo, hi = hi, lo end + return lo + (rand(random, hi - lo + 1) or 0) + end +end + -- data/trainers/leaders.asm. The two lists are ONE array in the ROM: only -- KantoGymLeaders carries the -1 terminator, and GymLeaders falls through into -- it, so IsGymLeader matches all twenty-two classes while IsKantoGymLeader @@ -212,12 +227,15 @@ Battle.KANTO_BADGE_ORDER = { -- this wild battle is BATTLETYPE_ROAMING; the caller built `wild` -- through Roamers.beginBattle and reads Battle.roaming back to -- bank the beast's HP afterwards --- random(n) 0..n-1, injected so a test is deterministic +-- random(n) 0..n-1, injected so a test is deterministic (BattleRandom) +-- rng(lo,hi) / rng(n) Gen 1 / love.math contract; defaults over `random` function Battle.new(opts) opts = opts or {} local self = setmetatable({}, Battle) self.data = opts.data or {} self.random = opts.random or function(n) return rand(nil, n) end + -- Same stream as `random`, Gen 1 / love.math calling convention. + self.rng = opts.rng or loveStyleRng(self.random) self.party = opts.party or {} self.trainer = opts.trainer self.save = opts.save @@ -403,11 +421,8 @@ function Battle:endBattle(outcome) Runtime.emit("battle.ended", { battle = self, result = outcome }) end --- A never-nil 0..n-1 roller for the hook contexts. `random` is optional on the --- constructor (a headless test injects one, the game leaves it to love.math), --- and a mod reaching for ctx.rng must not have to know that. Note the --- convention: this is the cart's BattleRandom byte (0..n-1), NOT Gen 1's --- love.math.random (1..n). +-- Never-nil BattleRandom (0..n-1) for call sites that want the cart byte. +-- `battle.rng` is the Gen 1 / love.math view of the same stream. function Battle:roller() if not self.rollerFn then self.rollerFn = function(n) return rand(self.random, n) end @@ -1604,10 +1619,26 @@ function Battle:useMove(attacker, defender, moveId) return end - -- Damage that skips the formula entirely. - local fixed = Effects.fixedDamage(def.effect, attacker, defender, self.random) + -- Damage that skips the formula entirely (constantdamage). + local fixed = Effects.fixedDamage(def.effect, attacker, defender, self.random, def) if fixed then - self:dealDamage(attacker, defender, fixed, { move = def, moveId = moveId }) + -- StaticDamage's effect script runs resettypematchup after constantdamage + -- (data/moves/effects.asm): immunities still miss (Sonic Boom vs Ghost). + -- Flat damage is otherwise unscaled — no STAB / effectiveness multiply. + if def.effect == "EFFECT_STATIC_DAMAGE" then + local defTypes = (self:speciesDef(defender) or {}).types or defender.types + local matchups = self.data.type_chart and self.data.type_chart.matchups + local mult = Damage.typeMultiplier(def.type, defTypes, matchups) + if mult == 0 then + self:markMissed() + self:emit({ kind = "message", + text = "It doesn't affect " .. self:monName(defender) .. "..." }) + return + end + end + self:dealDamage(attacker, defender, fixed, { + move = def, moveId = moveId, effectiveness = 10, + }) return end diff --git a/src/battle/gen2/Effects.lua b/src/battle/gen2/Effects.lua index 19f0b302..f9c1134e 100644 --- a/src/battle/gen2/Effects.lua +++ b/src/battle/gen2/Effects.lua @@ -179,9 +179,10 @@ end -- --------------------------------------------------------------- fixed damage --- BattleCommand_LevelDamage / SuperFang / Psywave, all of which skip the --- damage formula entirely. -function Effects.fixedDamage(effect, attacker, defender, random) +-- BattleCommand_ConstantDamage / LevelDamage / SuperFang / Psywave / +-- StaticDamage (Sonic Boom, Dragon Rage): skip the damage formula entirely. +-- `move` is optional; EFFECT_STATIC_DAMAGE reads move.power (20 / 40). +function Effects.fixedDamage(effect, attacker, defender, random, move) if effect == "EFFECT_LEVEL_DAMAGE" then return math.max(1, attacker.level or 1) end @@ -193,6 +194,9 @@ function Effects.fixedDamage(effect, attacker, defender, random) local ceiling = math.max(1, math.floor((attacker.level or 1) * 3 / 2)) return math.max(1, (random and random(ceiling) or 0) + 1) end + if effect == "EFFECT_STATIC_DAMAGE" then + return math.max(1, (move and move.power) or 1) + end return nil end @@ -257,8 +261,18 @@ Effects.MAGNITUDE_POWER = { -- is what damagecalc reads as the move's power -- data/moves/moves.asm stores -- MAGNITUDE at power 1 precisely because this overwrites it. Returns the -- power and the magnitude number the text prints. +-- +-- `random` is BattleRandom (0..n-1). If none is supplied, roll via love.math +-- / math.random — never hard-code 0 (that always yields Magnitude 4). function Effects.magnitudePower(random) - local roll = random and random(256) or 0 + local roll + if type(random) == "function" then + roll = random(256) or 0 + elseif love and love.math and love.math.random then + roll = love.math.random(256) - 1 + else + roll = math.random(256) - 1 + end for _, row in ipairs(Effects.MAGNITUDE_POWER) do if row[1] >= roll then return row[2], row[3] end end diff --git a/tests/gen2_battle_test.lua b/tests/gen2_battle_test.lua index 719a9acd..fb90ba56 100644 --- a/tests/gen2_battle_test.lua +++ b/tests/gen2_battle_test.lua @@ -58,6 +58,8 @@ local MATCHUPS = { { attacker = "NORMAL", defender = "ROCK", multiplier = 5 }, { attacker = "NORMAL", defender = "STEEL", multiplier = 5 }, { attacker = "ELECTRIC", defender = "GROUND", multiplier = 0 }, + -- Sonic Boom is Normal; Gen 2 StaticDamage still respects Ghost immunity. + { attacker = "NORMAL", defender = "GHOST", multiplier = 0 }, } local MOVES = { @@ -144,6 +146,15 @@ local POKEMON = { levelMoves = { { level = 1, move = "TACKLE" } }, evolutions = {}, }, + GASTLY = { + id = "GASTLY", index = 92, name = "GASTLY", + baseStats = { hp = 30, attack = 35, defense = 30, speed = 80, + specialAttack = 100, specialDefense = 35 }, + types = { "GHOST", "POISON" }, catchRate = 190, baseExp = 62, + growthRate = "GROWTH_MEDIUM_SLOW", genderRatio = 127, + levelMoves = { { level = 1, move = "LICK" } }, + evolutions = {}, + }, } local DATA = { @@ -613,6 +624,21 @@ local battle, player, wild = newBattle() check("battle picks the first healthy mon", battle.player, player) check("wild battle flag", battle.wild, true) +-- Gen 2 battles expose BattleRandom (`random` / :roller()) and the Gen 1 / +-- love.math `rng` over the same stream. +check("battle.rng is present", type(battle.rng), "function") +check("battle.rng(lo,hi) respects bounds", battle.rng(10, 10), 10) +check("battle.rng(lo,hi) another fixed point", battle.rng(50, 50), 50) +do + local roll = battle.rng(0, 255) + check("battle.rng(0,255) is an integer", roll == math.floor(roll), true) + check("battle.rng(0,255) in range", roll >= 0 and roll <= 255, true) +end +-- zeroRandom always returns 0, so the love adapter maps: +-- rng(n) → 0+1 = 1; rng(lo,hi) → lo + 0 = lo +check("battle.rng(n) is 1..n over BattleRandom", battle.rng(7), 1) +check("battle.rng(0,99) uses both args", battle.rng(0, 99), 0) + -- A move spends PP and deals damage. local before = wild.hp battle:takeTurn({ kind = "move", move = "TACKLE" }) @@ -910,6 +936,16 @@ local EFFECT_MOVES = { SEISMIC_TOSS = { id = "SEISMIC_TOSS", name = "SEISMICTOSS", power = 1, type = "FIGHTING", accuracy = 100, pp = 20, effect = "EFFECT_LEVEL_DAMAGE" }, + -- EFFECT_STATIC_DAMAGE: Sonic Boom (20) and Dragon Rage (40). + SONICBOOM = { id = "SONICBOOM", name = "SONICBOOM", power = 20, + type = "NORMAL", accuracy = 90, pp = 20, + effect = "EFFECT_STATIC_DAMAGE" }, + DRAGON_RAGE = { id = "DRAGON_RAGE", name = "DRAGON RAGE", power = 40, + type = "DRAGON", accuracy = 100, pp = 10, + effect = "EFFECT_STATIC_DAMAGE" }, + MAGNITUDE = { id = "MAGNITUDE", name = "MAGNITUDE", power = 1, + type = "GROUND", accuracy = 100, pp = 30, + effect = "EFFECT_MAGNITUDE" }, LOCK_ON = { id = "LOCK_ON", name = "LOCK-ON", power = 0, type = "NORMAL", accuracy = 100, accuracyRaw = 0xff, pp = 5, effect = "EFFECT_LOCK_ON" }, -- data/moves/moves.asm:169, :172, :151. @@ -1078,6 +1114,83 @@ tossBattle:takeTurn({ kind = "move", move = "SEISMIC_TOSS" }) check("seismic toss deals the level", tossBefore - tossWild.hp, tossPlayer.level) +-- EFFECT_STATIC_DAMAGE: Sonic Boom (20) and Dragon Rage (40). +-- Cart: constantdamage + resettypematchup (effects.asm StaticDamage). +do + check("static damage uses move power", + Effects.fixedDamage("EFFECT_STATIC_DAMAGE", { level = 10 }, { hp = 50 }, nil, + { power = 40 }), 40) + check("sonic boom fixed damage is 20", + Effects.fixedDamage("EFFECT_STATIC_DAMAGE", { level = 10 }, { hp = 50 }, nil, + { power = 20 }), 20) + + local boomBattle, _, boomWild = effectBattle({ "SONICBOOM" }) + boomWild.hp = 100 + boomWild.maxHp = 100 + local boomBefore = boomWild.hp + boomBattle:takeTurn({ kind = "move", move = "SONICBOOM" }) + check("sonic boom deals flat 20", boomBefore - boomWild.hp, 20) + + -- Normal vs Ghost is 0x; StaticDamage's resettypematchup misses. + local ghostBattle, _, ghostWild = effectBattle({ "SONICBOOM" }) + ghostWild.species = "GASTLY" + local ghostBefore = ghostWild.hp + ghostBattle:takeTurn({ kind = "move", move = "SONICBOOM" }) + check("sonic boom misses Ghost in Gen 2", ghostWild.hp, ghostBefore) + + local rageBattle, _, rageWild = effectBattle({ "DRAGON_RAGE" }) + rageWild.hp = 100 + rageWild.maxHp = 100 + local rageBefore = rageWild.hp + rageBattle:takeTurn({ kind = "move", move = "DRAGON_RAGE" }) + check("dragon rage deals flat 40", rageBefore - rageWild.hp, 40) +end + +-- Magnitude: BattleRandom walks magnitude_power.asm. Nil random must not +-- collapse to roll 0 (always Magnitude 4); Battle.new always supplies a roller. +do + local p4, n4 = Effects.magnitudePower(function() return 0 end) + check("magnitude roll 0 is power 10", p4, 10) + check("magnitude roll 0 is number 4", n4, 4) + + local p8, n8 = Effects.magnitudePower(function() return 200 end) + check("magnitude roll 200 is power 90", p8, 90) + check("magnitude roll 200 is number 8", n8, 8) + + local seen = {} + for roll = 0, 255 do + local _, number = Effects.magnitudePower(function() return roll end) + seen[number] = true + end + for want = 4, 10 do + check(("magnitude table reaches %d"):format(want), seen[want] == true, true) + end + + -- Engine path: inject a mid-table roll and confirm the announce + damage. + local magBattle, magPlayer, magWild = newBattle({ + random = function(n) + -- Pin the getmagnitude byte: return 200 whenever n == 256. + if n == 256 then return 200 end + return 0 + end, + }) + magPlayer.moves = { { id = "MAGNITUDE", pp = 30, maxPp = 30 } } + magWild.hp = 200 + magWild.maxHp = 200 + local magEvents = magBattle:takeTurn({ kind = "move", move = "MAGNITUDE" }) + local announced + for _, ev in ipairs(magEvents) do + if ev.kind == "message" and type(ev.text) == "string" + and ev.text:match("^Magnitude %d+") then + announced = ev.text + break + end + end + check("magnitude announces rolled number", announced, "Magnitude 8!") + check("magnitude deals more than power-1 would", + magWild.hp < 200, true) +end + -- --------------------------------------------------------------- held items local Ai = require("src.battle.gen2.Ai")