mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-20 20:50:21 +02:00
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.
This commit is contained in:
+42
-11
@@ -37,8 +37,10 @@ local Prize = require("src.battle.gen2.Prize")
|
|||||||
-- * Gen 1's `user` / `target` / `battler` are battler wrappers around a mon
|
-- * Gen 1's `user` / `target` / `battler` are battler wrappers around a mon
|
||||||
-- ({ mon = , name = , isPlayer = }); Gen 2's engine works on the party mon
|
-- ({ mon = , name = , isPlayer = }); Gen 2's engine works on the party mon
|
||||||
-- table directly, so that is what these payloads carry.
|
-- 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
|
-- * Gen 1's `rng` is love.math.random (rng(n) → 1..n, rng(lo,hi) → lo..hi).
|
||||||
-- the cart's BattleRandom convention (0..n-1). Both keys are present.
|
-- 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")
|
local Runtime = require("src.mods.Runtime")
|
||||||
-- The two battle lines that carry the cart's own `line` break: a marker-bearing
|
-- 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
|
-- 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
|
return math.random(n) - 1
|
||||||
end
|
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
|
-- data/trainers/leaders.asm. The two lists are ONE array in the ROM: only
|
||||||
-- KantoGymLeaders carries the -1 terminator, and GymLeaders falls through into
|
-- KantoGymLeaders carries the -1 terminator, and GymLeaders falls through into
|
||||||
-- it, so IsGymLeader matches all twenty-two classes while IsKantoGymLeader
|
-- 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`
|
-- this wild battle is BATTLETYPE_ROAMING; the caller built `wild`
|
||||||
-- through Roamers.beginBattle and reads Battle.roaming back to
|
-- through Roamers.beginBattle and reads Battle.roaming back to
|
||||||
-- bank the beast's HP afterwards
|
-- 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)
|
function Battle.new(opts)
|
||||||
opts = opts or {}
|
opts = opts or {}
|
||||||
local self = setmetatable({}, Battle)
|
local self = setmetatable({}, Battle)
|
||||||
self.data = opts.data or {}
|
self.data = opts.data or {}
|
||||||
self.random = opts.random or function(n) return rand(nil, n) end
|
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.party = opts.party or {}
|
||||||
self.trainer = opts.trainer
|
self.trainer = opts.trainer
|
||||||
self.save = opts.save
|
self.save = opts.save
|
||||||
@@ -403,11 +421,8 @@ function Battle:endBattle(outcome)
|
|||||||
Runtime.emit("battle.ended", { battle = self, result = outcome })
|
Runtime.emit("battle.ended", { battle = self, result = outcome })
|
||||||
end
|
end
|
||||||
|
|
||||||
-- A never-nil 0..n-1 roller for the hook contexts. `random` is optional on the
|
-- Never-nil BattleRandom (0..n-1) for call sites that want the cart byte.
|
||||||
-- constructor (a headless test injects one, the game leaves it to love.math),
|
-- `battle.rng` is the Gen 1 / love.math view of the same stream.
|
||||||
-- 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).
|
|
||||||
function Battle:roller()
|
function Battle:roller()
|
||||||
if not self.rollerFn then
|
if not self.rollerFn then
|
||||||
self.rollerFn = function(n) return rand(self.random, n) end
|
self.rollerFn = function(n) return rand(self.random, n) end
|
||||||
@@ -1604,10 +1619,26 @@ function Battle:useMove(attacker, defender, moveId)
|
|||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
-- Damage that skips the formula entirely.
|
-- Damage that skips the formula entirely (constantdamage).
|
||||||
local fixed = Effects.fixedDamage(def.effect, attacker, defender, self.random)
|
local fixed = Effects.fixedDamage(def.effect, attacker, defender, self.random, def)
|
||||||
if fixed then
|
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
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -179,9 +179,10 @@ end
|
|||||||
|
|
||||||
-- --------------------------------------------------------------- fixed damage
|
-- --------------------------------------------------------------- fixed damage
|
||||||
|
|
||||||
-- BattleCommand_LevelDamage / SuperFang / Psywave, all of which skip the
|
-- BattleCommand_ConstantDamage / LevelDamage / SuperFang / Psywave /
|
||||||
-- damage formula entirely.
|
-- StaticDamage (Sonic Boom, Dragon Rage): skip the damage formula entirely.
|
||||||
function Effects.fixedDamage(effect, attacker, defender, random)
|
-- `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
|
if effect == "EFFECT_LEVEL_DAMAGE" then
|
||||||
return math.max(1, attacker.level or 1)
|
return math.max(1, attacker.level or 1)
|
||||||
end
|
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))
|
local ceiling = math.max(1, math.floor((attacker.level or 1) * 3 / 2))
|
||||||
return math.max(1, (random and random(ceiling) or 0) + 1)
|
return math.max(1, (random and random(ceiling) or 0) + 1)
|
||||||
end
|
end
|
||||||
|
if effect == "EFFECT_STATIC_DAMAGE" then
|
||||||
|
return math.max(1, (move and move.power) or 1)
|
||||||
|
end
|
||||||
return nil
|
return nil
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -257,8 +261,18 @@ Effects.MAGNITUDE_POWER = {
|
|||||||
-- is what damagecalc reads as the move's power -- data/moves/moves.asm stores
|
-- 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
|
-- MAGNITUDE at power 1 precisely because this overwrites it. Returns the
|
||||||
-- power and the magnitude number the text prints.
|
-- 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)
|
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
|
for _, row in ipairs(Effects.MAGNITUDE_POWER) do
|
||||||
if row[1] >= roll then return row[2], row[3] end
|
if row[1] >= roll then return row[2], row[3] end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -58,6 +58,8 @@ local MATCHUPS = {
|
|||||||
{ attacker = "NORMAL", defender = "ROCK", multiplier = 5 },
|
{ attacker = "NORMAL", defender = "ROCK", multiplier = 5 },
|
||||||
{ attacker = "NORMAL", defender = "STEEL", multiplier = 5 },
|
{ attacker = "NORMAL", defender = "STEEL", multiplier = 5 },
|
||||||
{ attacker = "ELECTRIC", defender = "GROUND", multiplier = 0 },
|
{ 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 = {
|
local MOVES = {
|
||||||
@@ -144,6 +146,15 @@ local POKEMON = {
|
|||||||
levelMoves = { { level = 1, move = "TACKLE" } },
|
levelMoves = { { level = 1, move = "TACKLE" } },
|
||||||
evolutions = {},
|
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 = {
|
local DATA = {
|
||||||
@@ -613,6 +624,21 @@ local battle, player, wild = newBattle()
|
|||||||
check("battle picks the first healthy mon", battle.player, player)
|
check("battle picks the first healthy mon", battle.player, player)
|
||||||
check("wild battle flag", battle.wild, true)
|
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.
|
-- A move spends PP and deals damage.
|
||||||
local before = wild.hp
|
local before = wild.hp
|
||||||
battle:takeTurn({ kind = "move", move = "TACKLE" })
|
battle:takeTurn({ kind = "move", move = "TACKLE" })
|
||||||
@@ -910,6 +936,16 @@ local EFFECT_MOVES = {
|
|||||||
SEISMIC_TOSS = { id = "SEISMIC_TOSS", name = "SEISMICTOSS", power = 1,
|
SEISMIC_TOSS = { id = "SEISMIC_TOSS", name = "SEISMICTOSS", power = 1,
|
||||||
type = "FIGHTING", accuracy = 100, pp = 20,
|
type = "FIGHTING", accuracy = 100, pp = 20,
|
||||||
effect = "EFFECT_LEVEL_DAMAGE" },
|
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",
|
LOCK_ON = { id = "LOCK_ON", name = "LOCK-ON", power = 0, type = "NORMAL",
|
||||||
accuracy = 100, accuracyRaw = 0xff, pp = 5, effect = "EFFECT_LOCK_ON" },
|
accuracy = 100, accuracyRaw = 0xff, pp = 5, effect = "EFFECT_LOCK_ON" },
|
||||||
-- data/moves/moves.asm:169, :172, :151.
|
-- data/moves/moves.asm:169, :172, :151.
|
||||||
@@ -1078,6 +1114,83 @@ tossBattle:takeTurn({ kind = "move", move = "SEISMIC_TOSS" })
|
|||||||
check("seismic toss deals the level",
|
check("seismic toss deals the level",
|
||||||
tossBefore - tossWild.hp, tossPlayer.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
|
-- --------------------------------------------------------------- held items
|
||||||
|
|
||||||
local Ai = require("src.battle.gen2.Ai")
|
local Ai = require("src.battle.gen2.Ai")
|
||||||
|
|||||||
Reference in New Issue
Block a user