Files
gen1recomp/src/battle/gen2/Mon.lua
T
bryanthaboi ae6cac89e1 G2 support
2026-08-11 11:53:30 -04:00

481 lines
20 KiB
Lua

-- A Gen 2 party member: stats, moves, level-up and experience.
--
-- Separate from src/pokemon/Pokemon.lua because the struct itself changed:
-- Gen 2 splits `special` into Special Attack and Special Defense, adds a held
-- item, happiness and pokerus, and its level-up moves come from EvosAttacks
-- rather than a Gen 1 learnset table.
--
-- Stat formula is unchanged from Gen 1 (data/pokemon/base_stats + DVs):
-- stat = floor((base * 2 + DV * 2 + floor(sqrt(statExp) / 4)) * level / 100) + 5
-- HP is the same but + level + 10
-- with the Gen 2 twist that a mon's SpA and SpD share one Special DV, which is
-- why a high-Special DV raises both.
--
-- Experience curves come from data/growth_rates.asm, whose `growth_rate` macro
-- documents its own polynomial:
-- [1]/[2] * n^3 + [3] * n^2 + [4] * n - [5]
-- with a sign bit on the n^2 term. pokemon.lua carries those five numbers per
-- GROWTH_* so this needs no hardcoded table.
local Unown = require("src.core.gen2.Unown")
-- The mod event bus. pokemon.level_up and pokemon.move_learned are the SAME
-- names src/battle/Experience.lua and src/battle/BattleState.lua raise on
-- Gen 1, with the same payload keys: a mod that watches a Red party watches a
-- Gold one unchanged (docs/mod-api-gen2-compat.md).
local Runtime = require("src.mods.Runtime")
local Mon = {}
Mon.MAX_LEVEL = 100
Mon.PARTY_SIZE = 6
-- DVs are 0..15 each; Attack's low bit pair also decides gender and shininess.
Mon.MAX_DV = 15
local function rand(a, b)
if love and love.math and love.math.random then
return love.math.random(a, b)
end
return math.random(a, b)
end
function Mon.randomDVs()
return {
hp = nil, -- derived below
attack = rand(0, Mon.MAX_DV),
defense = rand(0, Mon.MAX_DV),
speed = rand(0, Mon.MAX_DV),
special = rand(0, Mon.MAX_DV),
}
end
-- The HP DV is not stored: it is the low bit of each of the other four
-- (Gen 1 and 2 both build it this way), which is why a perfect-HP mon needs
-- all four others odd.
function Mon.hpDV(dvs)
local function bit(value) return (value or 0) % 2 end
return bit(dvs.attack) * 8 + bit(dvs.defense) * 4
+ bit(dvs.speed) * 2 + bit(dvs.special)
end
local function statValue(base, dv, level, statExp)
local exp = math.floor(math.sqrt(statExp or 0) / 4)
return math.floor((((base or 1) * 2 + (dv or 0) * 2 + exp) * level) / 100) + 5
end
-- All six stats at a level. `statExp` is optional per-stat effort.
function Mon.stats(baseStats, dvs, level, statExp)
baseStats = baseStats or {}
dvs = dvs or {}
statExp = statExp or {}
local hpDv = dvs.hp or Mon.hpDV(dvs)
local hp = math.floor((((baseStats.hp or 1) * 2 + hpDv * 2
+ math.floor(math.sqrt(statExp.hp or 0) / 4)) * level) / 100)
+ level + 10
return {
hp = hp,
attack = statValue(baseStats.attack, dvs.attack, level, statExp.attack),
defense = statValue(baseStats.defense, dvs.defense, level, statExp.defense),
speed = statValue(baseStats.speed, dvs.speed, level, statExp.speed),
-- One Special DV feeds both special stats, and so does one Special stat
-- exp: the Gen 2 party struct kept Gen 1's five exp words (macros/ram.asm
-- box_struct ends them at SpcExp), so SpA and SpD grow together. The
-- per-stat keys are still read as a fallback for a record written before
-- the shared word existed.
specialAttack = statValue(baseStats.specialAttack, dvs.special, level,
statExp.special or statExp.specialAttack),
specialDefense = statValue(baseStats.specialDefense, dvs.special, level,
statExp.special or statExp.specialDefense),
}
end
-- The five stat exp words, in struct order. There is no sixth: see Mon.stats.
Mon.STAT_EXP_ORDER = { "hp", "attack", "defense", "speed", "special" }
-- Each word is 16 bit and GiveExperiencePoints stops it at $ffff rather than
-- letting it wrap (.stat_exp_maxed_out).
Mon.MAX_STAT_EXP = 65535
function Mon.newStatExp()
return { hp = 0, attack = 0, defense = 0, speed = 0, special = 0 }
end
-- GiveExperiencePoints' .stat_exp_loop (engine/battle/core.asm): the defeated
-- mon's base stats are added to every participant's stat exp, and the loop runs
-- NUM_EXP_STATS = 5 times over a six-entry base stat block, so the Special word
-- takes the loser's Special ATTACK and the Special Defense base stat is never
-- read at all.
--
-- `.EvenlyDivideExpAmongParticipants` divides the base stats in place before
-- any of this, and only when two or more mons took part, which is why the
-- divisor is shared with Mon.experienceGain rather than computed here.
--
-- Pokerus adds the same value a SECOND time (`jr z, .stat_exp_awarded` skips
-- the second add when the byte is zero) -- doubled, not multiplied by a rate,
-- so it stacks with nothing.
-- `halved` is the EXP.SHARE tax: with any holder in the party the whole
-- wEnemyMon base stat block is `srl`'d in place before EITHER pass runs
-- (engine/battle/core.asm, the IsAnyMonHoldingExpShare block ahead of the
-- first GiveExperiencePoints call), so participants and holders both draw
-- stat exp from the halved values.
function Mon.gainStatExp(mon, loserDef, participants, doubled, halved)
if type(mon) ~= "table" then return nil end
local base = (loserDef and loserDef.baseStats) or {}
local share = math.max(1, math.floor(participants or 1))
mon.statExp = mon.statExp or Mon.newStatExp()
local gains = {}
for _, key in ipairs(Mon.STAT_EXP_ORDER) do
local from = (key == "special") and base.specialAttack or base[key]
from = from or 0
if halved then from = math.floor(from / 2) end
local gain = math.floor(from / share)
if doubled then gain = gain * 2 end
local value = (mon.statExp[key] or 0) + gain
if value > Mon.MAX_STAT_EXP then value = Mon.MAX_STAT_EXP end
mon.statExp[key] = value
gains[key] = gain
end
return gains
end
-- Total experience needed to *be* `level`, from a GROWTH_* record.
-- The single point every experience calculation resolves its curve through.
-- The merged growth_rates registry wins, then the extractor's own coefficient
-- rows on data.pokemon.growthRates, so a mod-free boot reads exactly the table
-- it always did and a boot with a registered curve reads that instead.
--
-- Both live at the same key space (GROWTH_MEDIUM_FAST and friends), and the
-- registry's Data path is the shared `growth_rates` one Gen 1 uses -- the
-- point of routing it there rather than to a gen2 table is that a mod writes
-- ONE record for both games (src/mods/Builtins.lua's Gen 2 registrant seeds
-- this registry from the coefficient rows, so the vanilla curves are Gold's
-- own).
function Mon.growthFor(data, curve)
if not curve then return nil end
local registered = data and data.growth_rates and data.growth_rates[curve]
if registered then return registered end
return data and data.pokemon and data.pokemon.growthRates
and data.pokemon.growthRates[curve]
end
-- Seeds the growth_rates registry with Gold's own curves, as records carrying
-- expForLevel so the id space and the record shape both match Gen 1's. Called
-- by src/mods/Builtins.lua under Gen 2. A dataset with no coefficient rows
-- (the ROM-free fixtures) seeds nothing rather than registering broken curves.
function Mon.registerInto(registry, data, owner)
local rows = data and data.pokemon and data.pokemon.growthRates
if type(rows) ~= "table" then return end
for curve, row in pairs(rows) do
-- the closure holds the coefficient row, so the registered record computes
-- exactly what the arm below would have
registry:register(curve, {
expForLevel = function(level) return Mon.experienceForLevel(row, level) end,
}, owner)
end
end
-- `growth` is either the extractor's coefficient row (numerator / denominator /
-- squared / linear / constant, straight off GrowthRates in the ROM) or a
-- growth_rates REGISTRY record, which carries expForLevel(level) instead --
-- the same record shape Gen 1's registry uses (src/pokemon/Growth.lua), so a
-- mod that registers a custom curve writes one record and it works in both
-- games. A registered curve wins outright; the coefficient arm is what a
-- mod-free boot and every driver still run.
function Mon.experienceForLevel(growth, level)
if growth and growth.expForLevel then
return math.max(0, math.floor(growth.expForLevel(level) or 0))
end
if not growth then return level * level * level end
local n = level
local numerator = growth.numerator or 1
local denominator = growth.denominator or 1
local value = math.floor(numerator * n * n * n / denominator)
value = value + (growth.squared or 0) * n * n
value = value + (growth.linear or 0) * n
value = value - (growth.constant or 0)
return math.max(0, value)
end
-- The level a total experience buys. Walks up rather than inverting the
-- polynomial, which the cart also does (it only ever compares against the next
-- level's threshold).
function Mon.levelForExperience(growth, experience)
local level = 1
while level < Mon.MAX_LEVEL do
if experience < Mon.experienceForLevel(growth, level + 1) then break end
level = level + 1
end
return level
end
-- The moves a species knows on arrival at `level`: its last four level-up
-- moves at or below it (EvosAttacks order, later moves pushing earlier ones
-- out, which is what makes a caught mon's moveset deterministic).
function Mon.movesAtLevel(def, level, moves)
local known = {}
for _, entry in ipairs((def and def.levelMoves) or {}) do
if entry.level <= level then
-- A move already known is not learned twice.
local duplicate = false
for _, existing in ipairs(known) do
if existing == entry.move then duplicate = true break end
end
if not duplicate then
known[#known + 1] = entry.move
if #known > 4 then table.remove(known, 1) end
end
end
end
local out = {}
for _, id in ipairs(known) do
local moveDef = moves and moves[id]
out[#out + 1] = {
id = id,
pp = moveDef and moveDef.pp or 0,
maxPp = moveDef and moveDef.pp or 0,
}
end
return out
end
-- Build a party member. `data` needs `pokemon` and `moves`; growth records
-- live on data.pokemon.growthRates (written by the extractor).
function Mon.new(data, species, level, opts)
opts = opts or {}
local def = data and data.pokemon and data.pokemon[species]
if not def then return nil end
level = math.max(1, math.min(Mon.MAX_LEVEL, level or 5))
local dvs = opts.dvs or Mon.randomDVs()
dvs.hp = Mon.hpDV(dvs)
local statExp = opts.statExp or Mon.newStatExp()
local stats = Mon.stats(def.baseStats, dvs, level, statExp)
local growth = Mon.growthFor(data, def.growthRate)
return {
species = species,
name = def.name or species,
nickname = opts.nickname,
level = level,
experience = Mon.experienceForLevel(growth, level),
dvs = dvs,
-- The five stat exp words. A wild or gift mon starts at zero: nothing in
-- the cart seeds them, MON_STAT_EXP is zeroed by _MoveMon.
statExp = statExp,
-- MON_PKRS. Zero is "never infected"; src/core/gen2/Pokerus.lua owns every
-- read and write of it after this.
pokerus = opts.pokerus or 0,
stats = stats,
hp = opts.hp or stats.hp,
maxHp = stats.hp,
types = def.types,
moves = opts.moves or Mon.movesAtLevel(def, level, data.moves),
-- Held item; wild mons roll one from BaseData's two item slots on the cart,
-- which is not modeled yet, so only scripted gifts carry one.
item = opts.item,
status = nil,
-- 70 for a caught mon, 120 for a gift/hatched one.
happiness = opts.happiness or 70,
caughtLevel = level,
-- shiny.roll / gender.roll get the species and level as context; opts.shiny
-- still wins, because a FORCED shiny battle (Red Gyarados) is the cart
-- overriding the roll rather than a roll to be hooked.
shiny = opts.shiny or Mon.isShiny(dvs,
{ species = species, def = def, level = level }),
gender = Mon.gender(def, dvs, { species = species, level = level }),
-- Unown has no gender and no shininess worth looking at, but it does have
-- a FORM, and the form is the same DVs read a different way
-- (GetUnownLetter, engine/gfx/load_pics.asm). Stamped at build time so
-- every screen that shows an Unown -- the battle pic, the box, the #DEX --
-- reads one field instead of each redoing the bit shuffle.
unownLetter = (species == Unown.SPECIES)
and Unown.letterFromDVs(dvs) or nil,
}
end
-- AddPartyMon copies wPlayerName into wPartyMonOTs and wPlayerID into MON_ID
-- (move_mon.asm:44-56, :143-149); SendMonIntoBox does the same (:970-994).
function Mon.stampOT(save, mon)
local player = save and save.player
if not (mon and player) then return mon end
if player.id == nil then player.id = rand(0, 65535) end
mon.ot = mon.ot or player.name
-- NpcTrade.lua:150: `ot` is what Breeding reads, `otName` what the summary prints.
mon.otName = mon.otName or mon.ot
mon.otId = mon.otId or player.id
return mon
end
-- shiny.roll and gender.roll, two of the names Gen 2 invents: Gen 1 has
-- neither shininess nor gender in the ROM at all (Red's shiny indicator mods
-- read src/pokemon/Stats.lua's virtual pattern, which is a mod-side
-- convention, not an engine seam), so there is no Gen 1 name to share.
--
-- Both wrap the DV-derived roll rather than the mon that comes out of it,
-- because on the cart these ARE the roll: CheckShininess and GetGender read
-- the same two DV bytes LoadEnemyMon just generated, and nothing later can
-- change the answer without changing the DVs. Wrapping here means a shiny-odds
-- mod and a gender-ratio mod work on every route a mon arrives by -- a wild
-- encounter, a gift, a hatch, a trade -- because Mon.new is the one builder.
--
-- Shared ctx keys:
-- dvs the DV set being read, exactly as stored on the mon
-- species the species id, nil when the caller had none to give
-- def that species' record, nil likewise
-- level the level the mon is being built at, nil for a bare query
--
-- gender.roll's ctx carries `ratio` as well, BaseData's genderRatio byte, so a
-- mod can shift the threshold rather than restate the whole rule. A chain that
-- returns something that is not one of "male" / "female" / "unknown" is
-- ignored, because every screen that prints a gender indexes by those three.
-- Gen 2 shininess: the classic DV pattern (Speed/Defense/Special all 10, and
-- Attack in {2,3,6,7,10,11,14,15}).
function Mon.vanillaShiny(dvs)
if not dvs then return false end
if dvs.speed ~= 10 or dvs.defense ~= 10 or dvs.special ~= 10 then
return false
end
local attack = dvs.attack or 0
return attack % 4 == 2 or attack % 4 == 3
end
function Mon.isShiny(dvs, ctx)
if not Runtime.wantsHook("shiny.roll") then return Mon.vanillaShiny(dvs) end
local shiny = Runtime.call("shiny.roll", function(c)
return Mon.vanillaShiny(c.dvs)
end, { dvs = dvs, species = ctx and ctx.species, def = ctx and ctx.def,
level = ctx and ctx.level })
return shiny and true or false
end
-- Gender comes from the Attack DV against the species' ratio threshold: an
-- Attack DV *below* the threshold is female (BaseData's `db GENDER_F12_5` is
-- already scaled out of 256).
function Mon.vanillaGender(def, dvs)
local ratio = def and def.genderRatio
if not ratio then return "unknown" end
if ratio == 0xff then return "unknown" end
-- The DV is 0..15; the threshold is out of 256 in steps of 16.
local threshold = math.floor(ratio / 16)
return ((dvs and dvs.attack or 0) < threshold) and "female" or "male"
end
local GENDERS = { male = true, female = true, unknown = true }
function Mon.gender(def, dvs, ctx)
if not Runtime.wantsHook("gender.roll") then
return Mon.vanillaGender(def, dvs)
end
local gender = Runtime.call("gender.roll", function(c)
return Mon.vanillaGender(c.def, c.dvs)
end, { def = def, dvs = dvs, ratio = def and def.genderRatio,
species = (ctx and ctx.species) or (def and def.id),
level = ctx and ctx.level })
if not GENDERS[gender] then return Mon.vanillaGender(def, dvs) end
return gender
end
-- Experience for defeating `loser`, per recipient. Gen 2:
-- exp = baseExp * loserLevel / 7, split among the recipients of the pass,
-- then GiveExperiencePoints' three BoostExp arms in the cart's own order,
-- each a floored x1.5 on the running amount:
-- traded the mon's OT id differs from the player's (BoostedExpPointsText)
-- trainer a trainer battle (wBattleMode)
-- luckyEgg the mon HOLDS a LUCKY_EGG -- checked by item id, not held
-- effect, exactly as the cart's `cp LUCKY_EGG` does
-- opts.halved is the EXP.SHARE tax: any holder in the party halves the base
-- exp byte before either pass (the same `srl` block that halves stat exp).
function Mon.experienceGain(loserDef, loserLevel, participants, trainer, opts)
opts = opts or {}
local baseExp = (loserDef and loserDef.baseExp) or 0
if opts.halved then baseExp = math.floor(baseExp / 2) end
local value = math.floor(baseExp * (loserLevel or 1) / 7)
value = math.floor(value / math.max(1, participants or 1))
if opts.traded then value = math.floor(value * 3 / 2) end
if trainer then value = math.floor(value * 3 / 2) end
if opts.luckyEgg then value = math.floor(value * 3 / 2) end
return math.max(1, value)
end
-- Award experience, level up as far as it reaches, and report what happened so
-- the battle can print "grew to level N!" and offer new moves.
function Mon.gainExperience(mon, amount, data)
local def = data and data.pokemon and data.pokemon[mon.species]
local growth = Mon.growthFor(data, def and def.growthRate)
mon.experience = (mon.experience or 0) + math.max(0, amount or 0)
local before = mon.level
local capped = Mon.experienceForLevel(growth, Mon.MAX_LEVEL)
if mon.experience > capped then mon.experience = capped end
local after = Mon.levelForExperience(growth, mon.experience)
if after <= before then
return { levels = 0, learned = {} }
end
mon.level = after
-- Recompute stats and carry the HP gain, the way the cart adds the delta
-- rather than refilling.
local previousMax = mon.maxHp or (mon.stats and mon.stats.hp) or 1
mon.stats = Mon.stats(def and def.baseStats, mon.dvs, after, mon.statExp)
mon.maxHp = mon.stats.hp
mon.hp = math.min(mon.maxHp, (mon.hp or previousMax)
+ (mon.maxHp - previousMax))
-- pokemon.level_up, once per level crossed and after the stats were
-- recalculated, exactly as src/battle/Experience.lua raises it on Gen 1 --
-- a jump of three levels is three events, not one. `learnable` is the moves
-- this species learns at exactly that level, the same list Gen 1 carries.
if Runtime.wants("pokemon.level_up") then
for level = before + 1, after do
local learnable = {}
for _, entry in ipairs((def and def.levelMoves) or {}) do
if entry.level == level then learnable[#learnable + 1] = entry.move end
end
Runtime.emit("pokemon.level_up", {
mon = mon, level = level, prevLevel = level - 1, learnable = learnable,
})
end
end
-- Every level-up move between the old and new level is offered.
local learned = {}
for _, entry in ipairs((def and def.levelMoves) or {}) do
if entry.level > before and entry.level <= after then
learned[#learned + 1] = entry.move
end
end
return { levels = after - before, learned = learned, from = before, to = after }
end
-- Teach a move, or report that all four slots are full so the caller can ask
-- which to forget.
function Mon.learnMove(mon, moveId, data)
mon.moves = mon.moves or {}
for _, move in ipairs(mon.moves) do
if move.id == moveId then return false, "known" end
end
local def = data and data.moves and data.moves[moveId]
local entry = {
id = moveId,
pp = def and def.pp or 0,
maxPp = def and def.pp or 0,
}
if #mon.moves >= 4 then return false, "full", entry end
mon.moves[#mon.moves + 1] = entry
-- pokemon.move_learned, the payload BattleState:learnMove emits on Gen 1.
-- This is Gen 2's single choke point for teaching a move -- the level-up
-- award, an evolution's new move and the TM path all arrive here -- so the
-- event covers all three rather than only the battle's.
Runtime.emit("pokemon.move_learned", { mon = mon, moveId = moveId })
return true
end
-- Which evolution (if any) fires at this level.
function Mon.evolutionAtLevel(def, level)
for _, entry in ipairs((def and def.evolutions) or {}) do
if entry.method == "EVOLVE_LEVEL" and (entry.level or 0) <= level then
return entry
end
end
return nil
end
return Mon