Files
gen1recomp/src/battle/gen2/Mon.lua
T
bryanthaboi 6731937841 Pulled every open Gold bug (bodies, comments, screenshots/videos), matched them against the port and ../pokegold, then split them so each fixer owns a disjoint file set. Duplicates collapsed: #1164 and #1188 are the same Radio Tower stair softlock. #1150 and #1190 are the same Gold rebind freeze. Enhancements (#1161, #1130, #1112, #1100) were left out. Battle 1 src/battle/gen2/*, src/ui/gen2/BattleState.lua #1168 multi-hit always 2 #1180 DV / stats #1185 learn-move text order after a trainer fight #1152 no "Use next POKéMON?" / run after a wild faint Overworld 2 src/world/gen2/*, trade anim, hidden items #1184 Kurt blocking Slowpoke Well #1164 / #1188 Radio Tower gentleman on the stairs #1173 Rock Smash rocks walk left each visit #1172 last item name used Rock Smash #1167 Ilex Forest hidden items #1165 trade movie + ledge hop still missing (#1121 / #1126) UI 3 naming, party, mart, pokegear #1166 grey nickname screen #1162 TM/HM missing ABLE / NOT ABLE #1169 underground herb lady whites out the map #1151 Pokegear black player icon + extra A/B on the phone card Platform 4 Gold options, save slots, pack, mod tab #1150 / #1190 controls freeze #1178 no vibration row #1177 cannot move Gold touch controls #1107 SAVE with no launcher slot #1145 Gold mod tab still lists Gen 1-only mods #1192 RGXX missing tools/rom_manifest_gold.json Drivers only for the progress / data-loss ones (faint-run, multi-hit, Kurt, Radio Tower, Rock Smash shift, herb shop, save slot). No new unit tests. New comments are pokegold file.asm:line only. A verifier agent will get this same list once the four finish and check that the diffs actually fix the reports. Verifier checked the batch against pokegold. Almost everything landed. I closed the two leftovers that were still real bugs: #1185: the active mon no longer reprints GrewToLevel (engine/battle/core.asm:7044). Bench mons still print it, then the stats box, then the learn-move line. #1180: the party list recalcs stats on open, same as Summary. Gold boot now applies options.touchControls / haptics from the Gold options block, not the shared Red pad. # Verdict 1168 multi-hit FIXED 1180 DVs / stats FIXED 1185 learn-move order FIXED (was partial) 1152 run after faint FIXED 1184 Kurt FIXED 1164 / 1188 Radio Tower FIXED 1173 rocks slide FIXED 1172 Rock Smash name FIXED 1167 Ilex hidden items SKIP-OK (already in the Gold cache) 1165 trade + ledge FIXED 1166 nickname color FIXED 1162 TM ABLE / NOT ABLE FIXED 1169 herb shop white-out FIXED 1151 Pokegear FIXED 1150 / 1190 rebind freeze FIXED 1178 vibration FIXED 1177 touch layout FIXED 1107 save slot FIXED 1145 mod tab filter FIXED 1192 RGXX Gold manifest FIXED Nothing is committed. Drivers, from the repo root: POKEPORT_GAME=gold POKEPORT_TOUCH=0 POKEPORT_DRIVER=tests/drivers/multihit_bug1168_test.lua love . Fury Attack should hit 2-5 times. Always twice is still broken. POKEPORT_GAME=gold POKEPORT_TOUCH=0 POKEPORT_DRIVER=tests/drivers/use_next_mon_bug1152_test.lua love . After the lead faints: "Use next POKéMON?" YES opens the party. NO/B tries to run. POKEPORT_GAME=gold POKEPORT_TOUCH=0 POKEPORT_DRIVER=tests/drivers/kurt_well_bug1184_test.lua love . Kurt at the well entrance (16, 14), not on the inner path. POKEPORT_GAME=gold POKEPORT_TOUCH=0 POKEPORT_DRIVER=tests/drivers/radiotower_softlock_bug1164_test.lua love . Gentleman in the 5F office (3, 6). Stairs at (12, 0) clear. POKEPORT_GAME=gold POKEPORT_TOUCH=0 POKEPORT_DRIVER=tests/drivers/rocksmash_shift_bug1173_test.lua love . Route 40 rocks stay put. A slide one cell left is the old bug. POKEPORT_GAME=gold POKEPORT_TOUCH=0 POKEPORT_DRIVER=tests/drivers/herbshop_bug1169_test.lua love . Herb shop intro over the Underground map, not a white screen. POKEPORT_GAME=gold POKEPORT_IDENTITY=gold-bug1107 POKEPORT_DRIVER=tests/drivers/gold_save_slot_bug1107_test.lua love . Look for PASS and a saves/gold/slot*.lua path. CONTINUE should see it. Worth a hand check with no driver: nickname color, a TM for ABLE/NOT ABLE, Pokegear phone B-to-close, OPTION > CONTROLS, a ledge hop, a trade movie, and on a phone OPTION > TOUCH LAYOUT / VIBRATION. give me CLOSES #93939, in a comma separated list each saying CLOSES before each issue number that is fixed CLOSES #1107, CLOSES #1145, CLOSES #1150, CLOSES #1151, CLOSES #1152, CLOSES #1162, CLOSES #1164, CLOSES #1165, CLOSES #1166, CLOSES #1168, CLOSES #1169, CLOSES #1172, CLOSES #1173, CLOSES #1177, CLOSES #1178, CLOSES #1180, CLOSES #1184, CLOSES #1185, CLOSES #1188, CLOSES #1190, CLOSES #1192
2026-08-12 16:51:27 -04:00

504 lines
21 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 {}
-- engine/pokemon/move_mon.asm:1540
local specialDv = dvs.special
if specialDv == nil then
specialDv = dvs.specialAttack or dvs.specialDefense
end
-- engine/pokemon/move_mon.asm:1496
local hpDv = Mon.hpDV({
attack = dvs.attack, defense = dvs.defense,
speed = dvs.speed, special = specialDv,
})
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, specialDv, level,
statExp.special or statExp.specialAttack),
specialDefense = statValue(baseStats.specialDefense, specialDv, level,
statExp.special or statExp.specialDefense),
}
end
function Mon.refreshStats(mon, data)
if type(mon) ~= "table" then return mon end
local def = data and data.pokemon and data.pokemon[mon.species]
if not (def and def.baseStats) then return mon end
-- engine/pokemon/move_mon.asm:1402
local stats = Mon.stats(def.baseStats, mon.dvs, mon.level or 1, mon.statExp)
mon.stats = stats
mon.maxHp = stats.hp
if mon.hp == nil or mon.hp > stats.hp then
mon.hp = stats.hp
end
return mon
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