big ass modding update

This commit is contained in:
bryanthaboi
2026-07-19 16:18:18 -04:00
parent b5a673b252
commit 47923d95b3
258 changed files with 31048 additions and 2310 deletions
+10 -2
View File
@@ -422,8 +422,16 @@ function AnimPlayer:start(moveId, attackerIsPlayer, opts)
-- effect runs AFTER each block displays, so block 1 shows normal.
-- PlayAnimation pushes rOBP0 around every subanimation row (:246-251
-- / :259-262), so the ambient palette returns when the toss ends.
local ballFlicker = opts
and (opts.ball == "MASTER_BALL" or opts.ball == "ULTRA_BALL")
-- opts.ballFlicker carries the ball record's flicker flag; the id
-- check covers callers that only pass the ball item.
local wantsFlicker
if opts and opts.ballFlicker ~= nil then
wantsFlicker = opts.ballFlicker
else
wantsFlicker = opts
and (opts.ball == "MASTER_BALL" or opts.ball == "ULTRA_BALL")
end
local ballFlicker = wantsFlicker
and (moveId == "TOSS_ANIM" or moveId == "GREATTOSS_ANIM"
or moveId == "ULTRATOSS_ANIM")
local obp0Flip = false
+400 -422
View File
File diff suppressed because it is too large Load Diff
+71 -29
View File
@@ -1,45 +1,64 @@
-- Gen 1 catch algorithm (engine/items/item_effects.asm, ItemUseBall).
local Status = require("src.battle.Status")
local Catching = {}
local BALL_RAND_MAX = { MASTER_BALL = 0, POKE_BALL = 255, GREAT_BALL = 200,
ULTRA_BALL = 150, SAFARI_BALL = 150 }
local BALL_HP_FACTOR = { POKE_BALL = 12, GREAT_BALL = 8, ULTRA_BALL = 12,
SAFARI_BALL = 12 }
-- randMax is the ceiling of the catch roll, hpFactor the X of the HP term,
-- wobbleFactor the ballFactor2 divisor of the wobble math. MASTER_BALL
-- never rolls (autoCatch), so its factors are unused. tossAnim picks the
-- TossBallAnimation arc and flicker the Master/Ultra OBJ-palette strobe
-- (DoBallTossSpecialEffects).
local BALLS = {
MASTER_BALL = { randMax = 0, autoCatch = true,
tossAnim = "ULTRATOSS_ANIM", flicker = true },
POKE_BALL = { randMax = 255, hpFactor = 12, wobbleFactor = 255,
tossAnim = "TOSS_ANIM" },
GREAT_BALL = { randMax = 200, hpFactor = 8, wobbleFactor = 200,
tossAnim = "GREATTOSS_ANIM" },
ULTRA_BALL = { randMax = 150, hpFactor = 12, wobbleFactor = 150,
tossAnim = "ULTRATOSS_ANIM", flicker = true },
SAFARI_BALL = { randMax = 150, hpFactor = 12, wobbleFactor = 150,
tossAnim = "ULTRATOSS_ANIM" },
}
Catching.BALLS = BALLS
-- Returns caught, shakes (0-3). rateOverride replaces the species catch
-- rate (the Safari game's BAIT/ROCK-modified wEnemyMonActualCatchRate).
--
-- On failure the ball wobbles per the original's shake calculation:
-- Y = rate*100/ballFactor2 (255/200/150), Z = X*Y/255 + status2 (5/10)
-- where X is the HP factor; Z<10: 0 shakes, <30: 1, <70: 2, else 3.
-- (We use the HP factor for X on both failure paths; the original reads
-- a stale quotient when the first roll fails.)
function Catching.attempt(ball, targetMon, targetDef, rng, rateOverride)
rng = rng or love.math.random
if ball == "MASTER_BALL" then return true, 3 end
local randMax = BALL_RAND_MAX[ball] or 255
-- an unknown ball falls back to POKE_BALL's roll and the 150 wobble
-- divisor, which is what the old per-field `or` defaults resolved to
local DEFAULT_BALL = { randMax = 255, hpFactor = 12, wobbleFactor = 150 }
function Catching.registerInto(registry, _, owner)
for id, record in pairs(BALLS) do
registry:register(id, record, owner)
end
end
-- The stock ItemUseBall math. On failure the ball wobbles per the
-- original's shake calculation: Y = rate*100/ballFactor2 (255/200/150),
-- Z = X*Y/255 + status2 (5/10) where X is the HP factor; Z<10: 0 shakes,
-- <30: 1, <70: 2, else 3. (We use the HP factor for X on both failure
-- paths; the original reads a stale quotient when the first roll fails.)
local function stockAttempt(def, targetMon, targetDef, rng, rateOverride, statuses)
if def.autoCatch then return true, 3 end
local randMax = def.randMax
local rate = rateOverride or targetDef.catchRate
local statusBonus = 0
-- the status subtraction and the wobble bonus come off the merged
-- status record (SLP/FRZ 25 and +10, the rest 12 and +5)
local s = targetMon.status
if s == "SLP" or s == "FRZ" then
statusBonus = 25
elseif s == "PSN" or s == "BRN" or s == "PAR" then
statusBonus = 12
end
local record = Status.recordFor(statuses, s)
local statusBonus = record and record.catchBonus or 0
-- HP factor (X)
local maxhp = targetMon.stats.hp
local hpQuarter = math.max(1, math.floor(targetMon.hp / 4))
local factor = BALL_HP_FACTOR[ball] or 12
local factor = def.hpFactor or DEFAULT_BALL.hpFactor
-- the 255 cap applies only after BOTH divisions (ItemUseBall keeps
-- the intermediate in 16 bits); capping early collapses the value
local f = math.min(255, math.floor(math.floor(maxhp * 255 / factor) / hpQuarter))
local function shakes()
local ballFactor2 = ball == "POKE_BALL" and 255
or ball == "GREAT_BALL" and 200 or 150
local ballFactor2 = def.wobbleFactor or DEFAULT_BALL.wobbleFactor
local y = math.floor(rate * 100 / ballFactor2)
local z
if y > 255 then
@@ -47,10 +66,8 @@ function Catching.attempt(ball, targetMon, targetDef, rng, rateOverride)
else
z = math.floor(f * y / 255)
end
if s == "SLP" or s == "FRZ" then
z = z + 10
elseif s then
z = z + 5
if s then
z = z + ((record and record.shakeBonus) or 5)
end
if z < 10 then return 0 elseif z < 30 then return 1
elseif z < 70 then return 2 else return 3 end
@@ -63,4 +80,29 @@ function Catching.attempt(ball, targetMon, targetDef, rng, rateOverride)
return false, shakes()
end
-- Returns caught, shakes (0-3). rateOverride replaces the species catch
-- rate (the Safari game's BAIT/ROCK-modified wEnemyMonActualCatchRate).
-- opts (all optional): ballDef = the merged ball record, statuses = the
-- merged statuses table, battle = the running battle. A ball record's
-- attempt fn supersedes the whole formula; its ctx.vanillaAttempt() runs
-- the stock math with the ctx's (possibly rewritten) rateOverride.
function Catching.attempt(ball, targetMon, targetDef, rng, rateOverride, opts)
rng = rng or love.math.random
opts = opts or {}
local def = opts.ballDef or BALLS[ball] or DEFAULT_BALL
local statuses = opts.statuses
if def.attempt then
local ctx = {
ballDef = def, targetMon = targetMon, targetDef = targetDef,
rng = rng, rateOverride = rateOverride, battle = opts.battle,
}
ctx.vanillaAttempt = function()
return stockAttempt(def, targetMon, targetDef, rng, ctx.rateOverride,
statuses)
end
return def.attempt(ctx)
end
return stockAttempt(def, targetMon, targetDef, rng, rateOverride, statuses)
end
return Catching
+89 -31
View File
@@ -3,27 +3,68 @@
--
-- Battlers carry curStats/curTypes (Transform/Conversion can override the
-- species values) plus reflect/lightScreen/focusEnergy volatile flags.
-- Battlers built by makeBattler also carry the merged badgeBoosts rows and
-- statuses records; hand-built battlers fall back to the vanilla tables.
local Logger = require("src.core.Logger")
local Runtime = require("src.mods.Runtime")
local Stats = require("src.pokemon.Stats")
local Status = require("src.battle.Status")
local TypeChart = require("src.battle.TypeChart")
local Damage = {}
-- Moves with a boosted critical-hit rate (engine/battle/core.asm
-- CriticalHitTest checks these move ids explicitly).
-- CriticalHitTest checks these move ids explicitly). The move-record
-- highCrit field wins; this list covers pre-existing imported caches.
local HIGH_CRIT = {
KARATE_CHOP = true, RAZOR_LEAF = true, CRABHAMMER = true, SLASH = true,
}
-- ApplyBadgeStatBoosts (engine/battle/core.asm): x9/8 per badge on the
-- named battle stat. Data.constants.badgeBoosts replaces this via the
-- battler's badgeBoosts field; these rows are the vanilla values.
Damage.BADGE_BOOSTS = {
{ badge = "BOULDERBADGE", stat = "attack", num = 9, den = 8 },
{ badge = "THUNDERBADGE", stat = "defense", num = 9, den = 8 },
{ badge = "SOULBADGE", stat = "speed", num = 9, den = 8 },
{ badge = "VOLCANOBADGE", stat = "special", num = 9, den = 8 },
}
-- the boost a battler's badge set applies to one battle stat, or nil
local function badgeBoost(battler, stat)
local badges = battler.badges
if not badges then return nil end
for _, row in ipairs(battler.badgeBoosts or Damage.BADGE_BOOSTS) do
if row.stat == stat and badges[row.badge] then return row end
end
return nil
end
-- the merged status record for a battler's persistent condition, or nil
local function statusRecord(battler)
return Status.recordFor(battler.statuses, battler.mon.status)
end
-- Critical chance test, following CriticalHitTest's shift chain exactly
-- (each left shift caps at 255): b = baseSpeed/2, then x2 (or /2 with
-- (each left shift caps at 255): b = speed/2, then x2 (or /2 with
-- Focus Energy's famous right-shift bug), then x4 for high-crit moves
-- or /2 for normal ones. Net rates: normal = speed/512, high-crit =
-- speed*4/256 (capped), Focus Energy bug = 1/4 the usual.
function Damage.critRoll(ruleset, attacker, moveId, rng)
-- critUsesBaseSpeed (default true, the Gen 1 rule) reads the species
-- base speed; a ruleset that sets it false uses the current in-battle
-- speed with stages applied.
function Damage.critRoll(ruleset, attacker, moveId, rng, highCrit)
rng = rng or love.math.random
local function shl(x) return math.min(255, x * 2) end
local b = math.floor(attacker.def.baseStats.speed / 2)
local speed
if ruleset.critUsesBaseSpeed == false then
speed = Stats.applyStage(attacker.curStats.speed,
attacker.stages and attacker.stages.speed or 0)
else
speed = attacker.def.baseStats.speed
end
local b = math.floor(speed / 2)
if attacker.focusEnergy then
if ruleset.focusEnergyBug then
b = math.floor(b / 2) -- srl instead of sla
@@ -33,7 +74,8 @@ function Damage.critRoll(ruleset, attacker, moveId, rng)
else
b = shl(b)
end
if HIGH_CRIT[moveId] then
if highCrit == nil then highCrit = HIGH_CRIT[moveId] end
if highCrit then
b = shl(shl(b))
else
b = math.floor(b / 2)
@@ -63,13 +105,27 @@ function Damage.accuracyRoll(ruleset, move, attacker, defender, rng)
return rng(0, 255) < acc
end
local function isSpecial(moveType)
-- Gen 1: WATER/GRASS/FIRE/ICE/ELECTRIC/PSYCHIC/DRAGON are special
return moveType == "WATER" or moveType == "GRASS" or moveType == "FIRE"
or moveType == "ICE" or moveType == "ELECTRIC" or moveType == "PSYCHIC_TYPE"
or moveType == "DRAGON"
local warnedTypes = {}
-- Gen 1 splits physical from special by TYPE: the move's own category
-- field wins, then the merged type record's, then physical (with one
-- warning per unknown type).
local function categoryOf(move)
local category = move.category or TypeChart.category(move.type)
if category == nil then
if move.type ~= nil and not warnedTypes[move.type] then
warnedTypes[move.type] = true
Logger.warn("move type %s has no category; treated as physical",
tostring(move.type))
end
category = "physical"
end
return category
end
function Damage.isSpecial(moveType)
return TypeChart.category(moveType) == "special"
end
Damage.isSpecial = isSpecial
-- Compute damage. attacker/defender are battler tables.
-- opts: rng, forceCrit, explode (halves defense), typeless (confusion
@@ -80,16 +136,23 @@ Damage.isSpecial = isSpecial
function Damage.compute(ruleset, attacker, defender, move, opts)
opts = opts or {}
local rng = opts.rng or love.math.random
if move.power == 0 then
if move.power == 0 or move.category == "status" then
return 0, { crit = false, typeMult = 10 }
end
local crit = opts.forceCrit
if crit == nil then
crit = Damage.critRoll(ruleset, attacker, move.id, rng)
if Runtime.wantsHook("battle.crit") then
crit = Runtime.call("battle.crit", function(c)
return Damage.critRoll(c.ruleset, c.attacker, c.moveId, c.rng, c.highCrit)
end, { ruleset = ruleset, attacker = attacker, moveId = move.id,
rng = rng, highCrit = move.highCrit })
else
crit = Damage.critRoll(ruleset, attacker, move.id, rng, move.highCrit)
end
end
local special = isSpecial(move.type)
local special = categoryOf(move) == "special"
local atkStat = special and "special" or "attack"
local defStat = special and "special" or "defense"
@@ -105,28 +168,23 @@ function Damage.compute(ruleset, attacker, defender, move, opts)
-- badge boosts (x9/8), engine/battle/core.asm ApplyBadgeStatBoosts:
-- Boulder -> attack, Thunder -> defense, Soul -> speed (TurnOrder),
-- Volcano -> special
local badges = attacker.badges
if badges then
if not special and badges.BOULDERBADGE then
atk = math.floor(atk * 9 / 8)
elseif special and badges.VOLCANOBADGE then
atk = math.floor(atk * 9 / 8)
end
local atkBoost = badgeBoost(attacker, atkStat)
if atkBoost then
atk = math.floor(atk * (atkBoost.num or 9) / (atkBoost.den or 8))
end
local dbadges = defender.badges
if dbadges then
if not special and dbadges.THUNDERBADGE then
dfn = math.floor(dfn * 9 / 8)
elseif special and dbadges.VOLCANOBADGE then
dfn = math.floor(dfn * 9 / 8)
end
local defBoost = badgeBoost(defender, defStat)
if defBoost then
dfn = math.floor(dfn * (defBoost.num or 9) / (defBoost.den or 8))
end
-- burn halves physical attack (applied as part of the stat in Gen 1).
-- burn halves physical attack (applied as part of the stat in Gen 1;
-- the status record's statPenalty names the stat it cuts).
-- hazeStatReset suppresses it: Haze (haze.asm ResetStats) copied the
-- unmodified attack over the burn-halved battle stat, lifting the
-- penalty until the next stat recompute.
if not special and attacker.mon.status == "BRN" and not attacker.hazeStatReset then
atk = math.max(1, math.floor(atk / 2))
local record = statusRecord(attacker)
local penalty = record and record.statPenalty
if penalty and penalty.stat == atkStat and not attacker.hazeStatReset then
atk = math.max(1, math.floor(atk / penalty.div))
end
-- screens double the effective defense (crits bypass them). The
-- confusion self-hit is the quirk case: HandleSelfConfusionDamage
+258
View File
@@ -0,0 +1,258 @@
-- The move-effect execution surface: the ctx facade handed to every
-- move_effects record callback, and the staged damaging pipeline that
-- performMove drives through the record's stage fields
-- (gate/neverMiss/hitCount/beforeAccuracy/chooseDamage/onMiss/afterDamage
-- plus the post-damage secondary run). The ctx is the only supported
-- surface handlers receive; everything else is engine-internal.
local MoveEffects = require("src.battle.MoveEffects")
local Runtime = require("src.mods.Runtime")
local StatusRegistry = require("src.battle.StatusRegistry")
local EffectRegistry = {}
-- pokered's <USER>/<TARGET> text macros print "Enemy " before the enemy
-- mon's nickname (home/text.asm PlaceMoveUsersName)
local function displayName(b)
return b.isPlayer and b.name or ("Enemy " .. b.name)
end
EffectRegistry.displayName = displayName
-- built once per performMove call; closes over the battle
function EffectRegistry.makeCtx(battle, user, target, move, moveInst, isCalled)
local ctx
ctx = {
battle = battle, data = battle.data, rng = battle.rng,
ruleset = battle.ruleset,
user = user, target = target, move = move, moveInst = moveInst,
isCalled = isCalled or false,
field = battle.field,
displayName = displayName,
say = function(text) battle:sayNext(text) end,
sayNext = function(text) battle:sayNext(text) end,
anim = function(animName, isPlayer)
battle:animNext(animName, isPlayer == nil and user.isPlayer or isPlayer)
end,
drain = function() battle:drainNext() end,
-- applyDamage plus the faint queue, like the crash/self-hit paths
damage = function(who, amount)
local dealt = battle:applyDamage(who, amount)
if who.mon.hp <= 0 then battle:onFaint(who) end
return dealt
end,
inflict = function(who, statusId, opts)
return StatusRegistry.inflict(battle, who, statusId, opts)
end,
cure = function(who)
who.mon.status = nil
who.toxicCounter = nil
end,
changeStage = function(who, stat, delta, fromEnemy)
return MoveEffects.changeStage(battle, who, stat, delta, fromEnemy)
end,
computeDamage = function(opts)
return battle:computeDamage(user, target, move, opts)
end,
accuracyRoll = function()
return battle:accuracyRoll(move, user, target)
end,
callMove = function(moveId)
return battle:performMove(user, target, { id = moveId, pp = 1 }, true)
end,
side = function(who) return battle:sideOf(who) end,
}
return ctx
end
-- multi-hit count: the record's hitCount wins, then the move's multiHit
-- field, then a single hit
local function hitCount(ctx, record)
if record and record.hitCount then
return record.hitCount(ctx) or 1
end
local dist = ctx.move.multiHit
if dist == nil then return 1 end
if type(dist) == "number" then return dist end
local r = ctx.rng(0, #dist - 1)
return dist[r + 1]
end
-- The damaging pipeline, extracted from the performMove monolith: every
-- stage keeps the original's exact check order and rng consumption
-- (invulnerability -> gate -> hit count -> pre-accuracy -> accuracy ->
-- damage choice -> hits -> messages -> after-damage -> secondary run).
function EffectRegistry.runDamaging(battle, ctx, record)
local user, target = ctx.user, ctx.target
local move, moveInst = ctx.move, ctx.moveInst
local neverMiss = record and record.neverMiss
-- Swift ignores semi-invulnerability (MoveHitTest returns hit for
-- SWIFT_EFFECT before the INVULNERABLE check)
if target.invulnerable and not neverMiss then
battle:sayNext(("%s's\nattack missed!"):format(displayName(user)))
return
end
-- OHKO speed gate, Dream Eater sleep gate
if record and record.gate then
local ok, failMsg = record.gate(ctx)
if not ok then
if failMsg then battle:sayNext(failMsg) end
return
end
end
local hits = hitCount(ctx, record)
if record and record.beforeAccuracy then record.beforeAccuracy(ctx) end
if not neverMiss then
if not battle:accuracyRoll(move, user, target) then
battle:sayNext(("%s's\nattack missed!"):format(displayName(user)))
-- Jump Kick crash, Explode self-destruct
if record and record.onMiss then record.onMiss(ctx, "accuracy") end
user.trappingTurns = nil
return
end
end
-- damage per hit
local dmg, info
if move.id == "COUNTER" then
-- HandleCounterMove: 2x the last damage dealt in battle, only if
-- the opponent's last move was counterable with >0 power (and not
-- Counter itself); wDamage is shared, so any last damage counts.
-- counterable defaults to the Normal/Fighting whitelist.
local lastId = target.lastMove
local lm = lastId and lastId ~= "COUNTER" and battle.data.moves[lastId]
local counterable = false
if lm and (lm.power or 0) > 0 then
if lm.counterable ~= nil then
counterable = lm.counterable
else
counterable = lm.type == "NORMAL" or lm.type == "FIGHTING"
end
end
if not counterable or (battle.lastDamage or 0) == 0 then
battle:sayNext(("%s's\nattack missed!"):format(displayName(user)))
return
end
dmg = math.min(65535, battle.lastDamage * 2)
info = { crit = false, typeMult = 10 }
elseif record and record.chooseDamage then
-- Counter/Super Fang/OHKO/fixed damage; (nil, msg) means the move
-- failed with that text already chosen
local chosen, extra = record.chooseDamage(ctx)
if not chosen then
if extra then battle:sayNext(extra) end
return
end
dmg, info = chosen, extra or { crit = false, typeMult = 10 }
else
dmg, info = battle:computeDamage(user, target, move,
{ rng = battle.rng, explode = (record and record.explode) or nil })
end
if info.typeMult == 0 then
battle:sayNext(("It doesn't affect\n%s!"):format(displayName(target)))
if record and record.onMiss then record.onMiss(ctx, "immune") end
return
end
if info.missed then
-- 0.25x floored the damage to zero: the original registers a miss
battle:sayNext(("%s's\nattack missed!"):format(displayName(user)))
if record and record.onMiss then record.onMiss(ctx, "floored") end
return
end
battle.lastDamage = dmg -- wDamage (shared by both sides, read by Counter)
-- the hit blink + damage sound ride the queue behind the animation:
-- on the move's anim row when one was announced, else on a bare hit
-- row (thrash/rage continuations), placed BEFORE the drain rows the
-- hits loop inserts so the blink precedes the bar drain
local hitRow = battle.moveAnimRow
if not hitRow then
battle.nextInsert = (battle.nextInsert or 0) + 1
hitRow = { hitRow = true }
table.insert(battle.queue, battle.nextInsert, hitRow)
end
local totalDealt = 0
local landed, brokeSub = 0, false
for h = 1, hits do
if target.mon.hp <= 0 then break end
local hadSub = target.substituteHP ~= nil
local dealt = battle:applyDamage(target, dmg)
totalDealt = totalDealt + dealt
landed = h
if Runtime.wants("battle.damage_dealt") then
Runtime.emit("battle.damage_dealt", {
battle = battle, user = user, target = target, move = move,
damage = dealt, crit = info.crit, typeMult = info.typeMult,
})
end
if hadSub and not target.substituteHP then
-- AttackSubstitute: breaking the substitute ends a multi-hit move
brokeSub = true
break
end
end
hits = landed > 0 and landed or hits
if totalDealt > 0 then
-- the original's per-hit sound: normal / super / not-very-effective
local hitSfx = info.typeMult > 10 and "Super_Effective"
or info.typeMult < 10 and "Not_Very_Effective" or "Damage"
hitRow.hit = { sfx = hitSfx,
blink = battle:animationsOn() and target or nil }
end
-- PrintCriticalOHKOText prints "Critical hit!"/"One-hit KO!" right
-- after the damage lands, BEFORE DisplayEffectiveness (core.asm
-- .moveDidNotMiss); the multi-hit count follows the last hit
if info.crit then battle:sayNext("Critical hit!") end
if info.ohko then battle:sayNext("One-hit KO!") end
if info.typeMult > 10 then
battle:sayNext("It's super\neffective!")
elseif info.typeMult < 10 then
battle:sayNext("It's not very\neffective...")
end
if hits > 1 then
-- player: _MultiHitText; enemy: _HitXTimesText (always plural)
if user.isPlayer then
battle:sayNext(("Hit the enemy\n%d times!"):format(hits))
else
battle:sayNext(("Hit %d times!"):format(hits))
end
end
-- post-damage effect bookkeeping (recoil/drain/trap/thrash/...)
ctx.rawDamage, ctx.totalDealt = dmg, totalDealt
ctx.brokeSub, ctx.hits = brokeSub, hits
if record and record.afterDamage then
record.afterDamage(ctx, totalDealt)
elseif moveInst.struggle then
-- struggle recoils even when its effect id resolves to no record
local recoil = math.max(1, math.floor(dmg / 2))
battle:sayNext(("%s's\nhit with recoil!"):format(displayName(user)))
battle:applyDamage(user, recoil)
end
-- secondary side effects (blocked by fainting)
if record and record.run and record.kind ~= "primary"
and target.mon.hp > 0 and totalDealt > 0 then
for _, m in ipairs(record.run(ctx)) do
battle:sayNext(m)
end
end
if record == nil then
MoveEffects.warnUnknown(move.effect)
end
if target.mon.hp <= 0 then
battle:onFaint(target)
end
if user.mon.hp <= 0 then
battle:onFaint(user)
end
end
return EffectRegistry
+39 -8
View File
@@ -5,6 +5,7 @@
-- participant's stat exp.
local Growth = require("src.pokemon.Growth")
local Runtime = require("src.mods.Runtime")
local Stats = require("src.pokemon.Stats")
local Experience = {}
@@ -21,14 +22,26 @@ local Experience = {}
-- Sequential floor divisions equal one floor division by the product,
-- so callers pass numParticipants = 2*participants for the first pass
-- and 2*participants*partyCount for the whole-party pass.
function Experience.gainFor(defeatedDef, level, isTrainer, numParticipants, traded)
--
-- consts is Data.constants; a constants.exp record can retune the
-- divisor and the traded/trainer multipliers, with the values above as
-- the defaults.
function Experience.gainFor(defeatedDef, level, isTrainer, numParticipants,
traded, consts)
local divisor, tradedMult, trainerMult = 7, nil, nil
local tuning = consts and consts.exp
if tuning then
divisor = tuning.divisor or divisor
tradedMult = tuning.tradedMult
trainerMult = tuning.trainerMult
end
local base = math.floor(defeatedDef.baseExp / math.max(1, numParticipants or 1))
local exp = math.floor(base * level / 7)
local exp = math.floor(base * level / divisor)
if traded then
exp = math.floor(exp * 3 / 2)
exp = math.floor(exp * (tradedMult or 1.5))
end
if isTrainer then
exp = math.floor(exp * 3 / 2)
exp = math.floor(exp * (trainerMult or 1.5))
end
return math.max(1, exp)
end
@@ -46,18 +59,36 @@ function Experience.apply(data, mon, defeatedDef, level, isTrainer,
local gain = math.floor(defeatedDef.baseStats[key] / statShare)
mon.statExp[key] = math.min(65535, (mon.statExp[key] or 0) + gain)
end
local gained = Experience.gainFor(defeatedDef, level, isTrainer,
numParticipants, traded)
local consts = data.constants
local gained
if Runtime.wantsHook("exp.gain") then
gained = Runtime.call("exp.gain", function(c)
return Experience.gainFor(c.defeatedDef, c.level, c.isTrainer,
c.participants, c.traded, consts)
end, { defeatedDef = defeatedDef, level = level, isTrainer = isTrainer,
participants = numParticipants, traded = traded, mon = mon })
else
gained = Experience.gainFor(defeatedDef, level, isTrainer,
numParticipants, traded, consts)
end
mon.exp = mon.exp + gained
local cap = consts and consts.levelCap or 100
local levels = {}
local newLevel = Growth.levelForExp(speciesDef.growthRate, mon.exp)
while mon.level < math.min(newLevel, 100) do
local newLevel = Growth.levelForExp(speciesDef.growthRate, mon.exp, cap,
data.growth_rates)
while mon.level < math.min(newLevel, cap) do
mon.level = mon.level + 1
local old = mon.stats
mon.stats = Stats.calc(speciesDef, mon.level, mon.dvs, mon.statExp)
mon.hp = math.min(mon.stats.hp, mon.hp + (mon.stats.hp - old.hp))
table.insert(levels, mon.level)
if Runtime.wants("pokemon.level_up") then
Runtime.emit("pokemon.level_up", {
mon = mon, level = mon.level, prevLevel = mon.level - 1,
learnable = Experience.movesLearnedAt(speciesDef, mon.level),
})
end
end
return levels, gained
end
+365 -49
View File
@@ -5,8 +5,16 @@
--
-- Substitutes block status/stat effects and side effects aimed at their
-- owner, like Gen 1.
--
-- The primary/secondary tables keep their v1 signatures; MoveEffects.full
-- carries the stage callbacks the damaging pipeline consults, and RECORDS
-- is the registry view of all three -- the merged Data.move_effects a
-- battle dispatches on serves these same objects.
local Logger = require("src.core.Logger")
local StatusRegistry = require("src.battle.StatusRegistry")
local TurnOrder = require("src.battle.TurnOrder")
local TypeChart = require("src.battle.TypeChart")
local MoveEffects = {}
@@ -53,6 +61,7 @@ local function changeStage(battle, who, stat, delta, fromEnemy)
end
return { ("%s's\n%s\ngreatly fell!"):format(displayName(who), STAT_LABEL[stat]) }
end
MoveEffects.changeStage = changeStage
local function statUp(stat, delta)
return function(battle, user, target)
@@ -70,54 +79,10 @@ end
-- status
-- ---------------------------------------------------------------------
local STATUS_LABEL = {
SLP = "fell asleep", PSN = "was poisoned", BRN = "was burned",
FRZ = "was frozen solid",
}
-- opts: toxic (start the Toxic counter), moveType (for the type
-- gates), secondary (side-effect of a damaging move).
-- kept as the module's inflict entry: the registry-backed rules live in
-- StatusRegistry (per-status canInflict/onInflict on the merged records)
local function inflictStatus(battle, target, status, opts)
opts = opts or {}
if target.mon.status then return {} end
-- Substitutes block poison (PoisonEffect calls CheckTargetSubstitute)
-- and every secondary status, but NOT primary Sleep or Thunder Wave,
-- their handlers never check the substitute in Gen 1.
if target.substituteHP and (opts.secondary or status == "PSN") then
return {}
end
for _, t in ipairs(target.curTypes) do
-- can't poison Poison-types (primary or secondary)
if status == "PSN" and t == "POISON" then return {} end
-- ParalyzeEffect_: Electric-type moves can't paralyze Ground-types
if status == "PAR" and opts.moveType == "ELECTRIC" and t == "GROUND" then
return {}
end
-- FreezeBurnParalyzeEffect: a secondary status never lands when
-- the move's type matches either of the target's types (Body Slam
-- can't paralyze Normals, Fire can't burn Fire, Ice can't freeze Ice)
if opts.secondary and status ~= "PSN" and opts.moveType == t then
return {}
end
-- keep the canonical immunities for any non-secondary path
if (status == "BRN" and t == "FIRE") or (status == "FRZ" and t == "ICE") then
return {}
end
end
target.mon.status = status
if status == "SLP" then
target.sleepTurns = battle.rng(1, 7)
end
if opts.toxic then
target.toxicCounter = 1
-- _BadlyPoisonedText
return { ("%s's\nbadly poisoned!"):format(displayName(target)) }
end
if status == "PAR" then
-- _ParalyzedMayNotAttackText (primary and secondary paralysis)
return { ("%s's\nparalyzed! It may\nnot attack!"):format(displayName(target)) }
end
return { ("%s\n%s!"):format(displayName(target), STATUS_LABEL[status]) }
return StatusRegistry.inflict(battle, target, status, opts)
end
local function statusMove(status)
@@ -131,6 +96,7 @@ local function statusMove(status)
local msgs = inflictStatus(battle, target, status, {
toxic = move and move.id == "TOXIC",
moveType = move and move.type,
source = move and move.id,
})
if #msgs == 0 then
return { "But, it failed!" }
@@ -151,6 +117,7 @@ local function statusSide(status, chance)
return inflictStatus(battle, target, status, {
moveType = move and move.type,
secondary = true,
source = move and move.id,
})
end
end
@@ -397,11 +364,326 @@ MoveEffects.secondary = {
-- the second hit reroutes to PoisonEffect with POISON_SIDE_EFFECT1:
-- 20 percent + 1 (52/256)
if battle.rng(0, 255) >= 52 then return {} end
return inflictStatus(battle, target, "PSN", { secondary = true })
return inflictStatus(battle, target, "PSN",
{ secondary = true, source = "TWINEEDLE" })
end,
}
-- effects fully handled inside BattleState's damage pipeline
-- ---------------------------------------------------------------------
-- full records: the damaging pipeline's stage callbacks
-- ---------------------------------------------------------------------
-- Status-move effects whose pokered handlers call MoveHitTest (sleep/
-- poison/paralyze/confusion/leech seed/disable and the primary
-- stat-down moves). Everything else in MoveEffects.primary is
-- self-targeting and never rolls accuracy. Mimic also hit-tests but
-- runs its own mid-move flow (resolveMimic).
local ACC_CHECKED = {
SLEEP_EFFECT = true, POISON_EFFECT = true, PARALYZE_EFFECT = true,
CONFUSION_EFFECT = true, LEECH_SEED_EFFECT = true, DISABLE_EFFECT = true,
ATTACK_DOWN1_EFFECT = true, DEFENSE_DOWN1_EFFECT = true,
DEFENSE_DOWN2_EFFECT = true, SPEED_DOWN1_EFFECT = true,
ACCURACY_DOWN1_EFFECT = true,
}
-- fixed-damage moves (engine/battle/core.asm SpecialDamage); the move
-- field wins, previously imported caches fall back to the id table
local FIXED_DAMAGE = {
SONICBOOM = 20, DRAGON_RAGE = 40,
SEISMIC_TOSS = "level", NIGHT_SHADE = "level", PSYWAVE = "half_level_rand",
}
MoveEffects.FIXED_DAMAGE = FIXED_DAMAGE
local function fixedDamageFor(ctx)
local spec = ctx.move.fixedDamage
if spec == nil then spec = FIXED_DAMAGE[ctx.move.id] end
if type(spec) == "function" then return spec(ctx) end
if spec == "level" then return ctx.user.mon.level end
if spec == "half_level_rand" then
-- PSYWAVE: rand(1, floor(level*3/2) - 1)
local max = math.max(1, math.floor(ctx.user.mon.level * 3 / 2) - 1)
return ctx.rng(1, max)
end
return spec
end
local function plainInfo()
return { crit = false, typeMult = 10 }
end
-- multi-hit count: the move's multiHit field (a count or a distribution)
-- with the effect's classic distribution as the fallback
local function hitsFrom(dist, ctx)
if type(dist) == "number" then return dist end
local r = ctx.rng(0, #dist - 1)
return dist[r + 1]
end
-- drain_hp.asm halves the RAW wDamage IN PLACE (minimum 1) and heals
-- that amount, so Counter would see the halved value
local function drainHalf(text)
return function(ctx)
local heal = math.max(1, math.floor(ctx.rawDamage / 2))
ctx.battle.lastDamage = heal
local mon = ctx.user.mon
mon.hp = math.min(mon.stats.hp, mon.hp + heal)
ctx.drain()
ctx.say(text:format(displayName(ctx.target)))
end
end
-- fixed damage still respects type immunity (AdjustDamageForMoveType
-- flags the miss before the special-damage override)
local function immuneMsg(ctx)
if TypeChart.effectiveness(ctx.move.type, ctx.target.curTypes) == 0 then
return ("It doesn't affect\n%s!"):format(displayName(ctx.target))
end
return nil
end
MoveEffects.full = {
NO_ADDITIONAL_EFFECT = {},
TWO_TO_FIVE_ATTACKS_EFFECT = {
hitCount = function(ctx)
return hitsFrom(ctx.move.multiHit or { 2, 2, 2, 3, 3, 3, 4, 5 }, ctx)
end,
},
ATTACK_TWICE_EFFECT = {
hitCount = function(ctx)
return hitsFrom(ctx.move.multiHit or 2, ctx)
end,
},
-- hits twice AND keeps its secondary poison run (registered below)
TWINEEDLE_EFFECT = {
hitCount = function(ctx)
return hitsFrom(ctx.move.multiHit or 2, ctx)
end,
},
SPECIAL_DAMAGE_EFFECT = {
chooseDamage = function(ctx)
local blocked = immuneMsg(ctx)
if blocked then return nil, blocked end
local dmg = fixedDamageFor(ctx)
if not dmg then return nil, "But, it failed!" end
return dmg, plainInfo()
end,
},
SUPER_FANG_EFFECT = {
chooseDamage = function(ctx)
local blocked = immuneMsg(ctx)
if blocked then return nil, blocked end
return math.max(1, math.floor(ctx.target.mon.hp / 2)), plainInfo()
end,
},
OHKO_EFFECT = {
-- fails against faster opponents (Gen 1 rule) and immune types
gate = function(ctx)
local blocked = immuneMsg(ctx)
if blocked then return false, blocked end
if TurnOrder.effectiveSpeed(ctx.user) < TurnOrder.effectiveSpeed(ctx.target) then
return false, "But, it failed!"
end
return true
end,
chooseDamage = function()
return 65535, { crit = false, typeMult = 10, ohko = true }
end,
},
RECOIL_EFFECT = {
afterDamage = function(ctx)
-- recoil.asm reads the RAW computed wDamage (not the HP actually
-- removed): overkill and substitute hits recoil at full strength
local recoil = math.max(1, math.floor(ctx.rawDamage
/ (ctx.moveInst.struggle and 2 or 4)))
ctx.say(("%s's\nhit with recoil!"):format(displayName(ctx.user)))
ctx.battle:applyDamage(ctx.user, recoil)
end,
},
DRAIN_HP_EFFECT = {
afterDamage = drainHalf("Sucked health from\n%s!"),
},
DREAM_EATER_EFFECT = {
-- only works on sleeping targets (checked before damage)
gate = function(ctx)
if ctx.target.mon.status ~= "SLP" then return false, "But, it failed!" end
return true
end,
afterDamage = drainHalf("%s's\ndream was eaten!"),
},
-- charge moves: first turn just charges; Fly AND Dig go
-- semi-invulnerable (ChargeEffect sets INVULNERABLE for both)
CHARGE_EFFECT = { charge = {} },
FLY_EFFECT = { charge = { invulnerable = true } },
TRAPPING_EFFECT = {
-- TrappingEffect runs BEFORE the hit test and clears the target's
-- Hyper Beam recharge, even if the trapping move then misses
-- (effects.asm:1091-1092 ClearHyperBeam)
beforeAccuracy = function(ctx)
if not ctx.user.trappingTurns then
ctx.target.mustRecharge = nil
end
end,
afterDamage = function(ctx)
local user = ctx.user
if not user.trappingTurns then
-- TrappingEffect (effects.asm:1080-1103) rolls wNumAttacksLeft
-- as 1-4 (weights 3/8 3/8 1/8 1/8): that many CONTINUATION
-- attacks follow this first hit, 2-5 attacks total. The victim
-- is held while the counter runs (live mirror in lockedAction).
local r = ctx.rng(0, 7)
user.trappingTurns = ({ 1, 1, 1, 2, 2, 2, 3, 4 })[r + 1]
user.trapDamage = ctx.rawDamage
-- remember the move so its animation can replay on each locked
-- continuation (core.asm:3554-3566 -> GetPlayerAnimationType)
user.trapMove = ctx.move.id
end
end,
},
THRASH_PETAL_DANCE_EFFECT = {
afterDamage = function(ctx)
local user = ctx.user
if not user.thrashTurns then
user.thrashTurns = ctx.rng(2, 3) -- 3-4 attacks total, then confusion
user.thrashMove = ctx.moveInst
user.thrashAnnounced = true
else
user.thrashTurns = user.thrashTurns - 1
if user.thrashTurns <= 0 then
user.thrashTurns, user.thrashMove, user.thrashAnnounced = nil, nil, nil
if not user.confusedTurns then
user.confusedTurns = ctx.rng(2, 5)
ctx.say(("%s\nbecame confused!"):format(displayName(user)))
end
end
end
end,
},
JUMP_KICK_EFFECT = {
onMiss = function(ctx, reason)
if reason ~= "accuracy" then return end
ctx.say(("%s\nkept going and\ncrashed!"):format(displayName(ctx.user)))
ctx.damage(ctx.user, 1)
end,
},
EXPLODE_EFFECT = {
explode = true, -- Damage.compute halves the defense
onMiss = function(ctx)
ctx.battle:selfDestruct(ctx.user)
end,
afterDamage = function(ctx)
ctx.battle:selfDestruct(ctx.user)
end,
},
HYPER_BEAM_EFFECT = {
afterDamage = function(ctx)
-- no recharge when the target faints OR its substitute breaks
if ctx.target.mon.hp > 0 and not ctx.brokeSub then
ctx.user.mustRecharge = true
end
end,
},
PAY_DAY_EFFECT = {
afterDamage = function(ctx)
local battle = ctx.battle
battle.payDay = (battle.payDay or 0) + 2 * ctx.user.mon.level
ctx.say("Coins scattered\neverywhere!")
end,
},
SWIFT_EFFECT = { neverMiss = true },
RAGE_EFFECT = {
afterDamage = function(ctx)
ctx.user.rageMove = ctx.moveInst
end,
},
BIDE_EFFECT = {
perform = function(ctx)
local user = ctx.user
user.bideTurns = ctx.rng(2, 3)
user.bideDamage = 0
ctx.say(("%s\nis storing energy!"):format(displayName(user)))
end,
},
SWITCH_AND_TELEPORT_EFFECT = {
-- SwitchAndTeleportEffect (effects.asm:810-909): in a wild battle
-- it auto-succeeds when the user's level >= the opponent's;
-- otherwise roll rand[0, userLevel+enemyLevel] and FAIL when the
-- roll is below opponentLevel/4. Teleport's failure text is "But
-- it failed!", Roar/Whirlwind's is DidntAffectText; in trainer
-- battles Teleport fails and Roar/Whirlwind are "unaffected".
perform = function(ctx)
local battle, user, target, move = ctx.battle, ctx.user, ctx.target, ctx.move
if battle.kind == "wild" then
local uLvl, tLvl = user.mon.level, target.mon.level
local ok = uLvl >= tLvl
if not ok then
ok = ctx.rng(0, uLvl + tLvl) >= math.floor(tLvl / 4)
end
if ok then
if move.id == "ROAR" then
ctx.say(("%s\nran away scared!"):format(displayName(target)))
elseif move.id == "WHIRLWIND" then
ctx.say(("%s\nwas blown away!"):format(displayName(target)))
else
ctx.say(("%s\nran from battle!"):format(displayName(user)))
end
battle.result = "run"
battle.afterQueue = "finish"
elseif move.id == "TELEPORT" then
ctx.say("But, it failed!")
else
ctx.say(("It didn't affect\n%s!"):format(displayName(target)))
end
elseif move.id == "TELEPORT" then
ctx.say("But, it failed!")
else
ctx.say(("%s\nis unaffected!"):format(displayName(target)))
end
end,
},
METRONOME_EFFECT = {
callsMove = function(ctx)
local order = ctx.data.constants.moveOrder
local pick
repeat
pick = order[ctx.rng(1, #order)]
until pick ~= "METRONOME" and pick ~= "STRUGGLE" and ctx.data.moves[pick]
return pick
end,
},
MIRROR_MOVE_EFFECT = {
callsMove = function(ctx)
local last = ctx.target.lastMove
if not last then
ctx.say("The MIRROR MOVE\nfailed!")
return nil
end
return last
end,
},
-- Mimic runs its own mid-move flow: hit test, then the copy menu
-- (player) or a random roll (enemy / link), all on the queue.
-- PlayCurrentMoveAnimation runs only after a successful copy
-- (effects.asm:1268), never on a miss -- so no announcement anim row.
MIMIC_EFFECT = {
announceAnim = false,
perform = function(ctx)
ctx.battle:resolveMimic(ctx.user, ctx.target, ctx.move, ctx.moveInst)
end,
},
}
-- ---------------------------------------------------------------------
-- the registry view
-- ---------------------------------------------------------------------
-- effects fully handled inside the damaging pipeline; kept as the v1
-- compat set (BattleState dispatched on it before the records existed)
MoveEffects.special = {
NO_ADDITIONAL_EFFECT = true, TWO_TO_FIVE_ATTACKS_EFFECT = true,
ATTACK_TWICE_EFFECT = true, SPECIAL_DAMAGE_EFFECT = true,
@@ -415,6 +697,40 @@ MoveEffects.special = {
TWINEEDLE_EFFECT = true, MIMIC_EFFECT = true,
}
-- the (battle, user, target, move, moveInst) handlers adapted to the ctx
-- facade the registry records expose
local function shim(fn)
return function(ctx)
return fn(ctx.battle, ctx.user, ctx.target, ctx.move, ctx.moveInst)
end
end
local RECORDS = {}
MoveEffects.RECORDS = RECORDS
for id, fn in pairs(MoveEffects.primary) do
RECORDS[id] = { kind = "primary", run = shim(fn),
accuracyChecked = ACC_CHECKED[id] or nil }
end
for id, fn in pairs(MoveEffects.secondary) do
RECORDS[id] = { kind = "secondary", run = shim(fn) }
end
for id, spec in pairs(MoveEffects.full) do
local record = { kind = "full" }
for key, value in pairs(spec) do record[key] = value end
-- TWINEEDLE: full record with its secondary run honored post-damage
local secondary = MoveEffects.secondary[id]
if secondary then record.run = shim(secondary) end
RECORDS[id] = record
end
-- One record per effect, the same objects performMove dispatches on: the
-- merged Data.move_effects and this table agree by construction.
function MoveEffects.registerInto(registry, _, owner)
for id, record in pairs(RECORDS) do
registry:register(id, record, owner)
end
end
local warned = {}
function MoveEffects.warnUnknown(effect)
+171 -32
View File
@@ -1,9 +1,151 @@
-- Per-turn status/volatile condition handling (Gen 1 semantics).
--
-- The persistent conditions live in Status.RECORDS; a battle passes its
-- merged Data.statuses so mod statuses join the same beforeMove gauntlet
-- and residual sweep. Callers without a battle (pure-module tests) fall
-- back to the vanilla records, which is bit-identical behavior.
local Status = {}
-- Returns canMove, messages, selfHit (true -> hurt itself in confusion)
function Status.beforeMove(battler, rng)
-- pokered's <USER>/<TARGET> text macros print "Enemy " before the enemy
-- mon's nickname; these records only know the raw name -- BattleState
-- splices the prefix in (prefixEnemy), same as always
local function name(battler)
return battler.name
end
-- statuses with beforeMovePriority above this run before the engine's
-- held/disable/confusion volatiles; at or below, after (sleep 40 and
-- freeze 30 come first, paralysis 10 comes last, like the original
-- CheckPlayerStatusConditions order)
local VOLATILE_PRIORITY = 20
local function hasType(battler, wanted)
for _, t in ipairs(battler.curTypes or {}) do
if t == wanted then return true end
end
return false
end
-- shared PSN/BRN residual: 1/16 max HP, multiplied (and advanced) by the
-- Toxic counter (HandlePoisonBurnLeechSeed)
local function damageOverTime(what)
return function(battler)
local mon = battler.mon
local base = math.max(1, math.floor(mon.stats.hp / 16))
local dmg = base
if battler.toxicCounter then
dmg = base * battler.toxicCounter
battler.toxicCounter = battler.toxicCounter + 1
end
mon.hp = math.max(0, mon.hp - dmg)
return { ("%s's\nhurt by %s!"):format(name(battler), what) }
end
end
-- The five persistent conditions as records: the beforeMove gauntlet, the
-- residual sweep, the inflict text/immunities (StatusRegistry.inflict),
-- the catch/wobble bonuses (Catching.attempt), the HUD label, and the
-- burn/paralysis stat cut (Damage.compute, TurnOrder.effectiveSpeed) all
-- read these fields, so a mod's sixth status plugs into every consumer.
Status.RECORDS = {
SLP = {
id = "SLP", label = "SLP", hudLabel = "SLP",
catchBonus = 25, shakeBonus = 10,
beforeMovePriority = 40,
beforeMove = function(battler)
battler.sleepTurns = (battler.sleepTurns or 1) - 1
if battler.sleepTurns <= 0 then
battler.mon.status = nil
return false, { name(battler) .. "\nwoke up!" } -- wakes but loses the turn
end
return false, { name(battler) .. "\nis fast asleep!" }
end,
onInflict = function(battle, target, opts, display)
target.sleepTurns = battle.rng(1, 7)
return { ("%s\nfell asleep!"):format(display) }
end,
},
FRZ = {
id = "FRZ", label = "FRZ", hudLabel = "FRZ",
catchBonus = 25, shakeBonus = 10,
beforeMovePriority = 30,
beforeMove = function(battler)
return false, { name(battler) .. "\nis frozen solid!" }
end,
canInflict = function(target) return not hasType(target, "ICE") end,
onInflict = function(_, _, _, display)
return { ("%s\nwas frozen solid!"):format(display) }
end,
},
PSN = {
id = "PSN", label = "PSN", hudLabel = "PSN",
catchBonus = 12, shakeBonus = 5,
residual = damageOverTime("poison"),
canInflict = function(target) return not hasType(target, "POISON") end,
onInflict = function(_, target, opts, display)
if opts.toxic then
target.toxicCounter = 1
-- _BadlyPoisonedText
return { ("%s's\nbadly poisoned!"):format(display) }
end
return { ("%s\nwas poisoned!"):format(display) }
end,
},
BRN = {
id = "BRN", label = "BRN", hudLabel = "BRN",
catchBonus = 12, shakeBonus = 5,
statPenalty = { stat = "attack", div = 2 },
residual = damageOverTime("the burn"),
canInflict = function(target) return not hasType(target, "FIRE") end,
onInflict = function(_, _, _, display)
return { ("%s\nwas burned!"):format(display) }
end,
},
PAR = {
id = "PAR", label = "PAR", hudLabel = "PAR",
catchBonus = 12, shakeBonus = 5,
statPenalty = { stat = "speed", div = 4 },
beforeMovePriority = 10,
beforeMove = function(battler, rng)
-- cp 25 percent / jr nc: fully paralyzed on rand < 63 (63/256)
if rng(0, 255) < 63 then
return false, { name(battler) .. "'s\nfully paralyzed!" }
end
return true, {}
end,
canInflict = function(target, opts)
-- ParalyzeEffect_: Electric-type moves can't paralyze Ground-types
return not (opts.moveType == "ELECTRIC" and hasType(target, "GROUND"))
end,
onInflict = function(_, _, _, display)
-- _ParalyzedMayNotAttackText (primary and secondary paralysis)
return { ("%s's\nparalyzed! It may\nnot attack!"):format(display) }
end,
},
}
function Status.registerInto(registry, _, owner)
for id, record in pairs(Status.RECORDS) do
registry:register(id, record, owner)
end
end
-- the merged view when a battle is on hand, the vanilla records otherwise
function Status.recordFor(statuses, id)
if id == nil then return nil end
return (statuses or Status.RECORDS)[id]
end
local function battleStatuses(battle)
return battle and battle.data and battle.data.statuses
end
-- Returns canMove, messages, selfHit (true -> hurt itself in confusion).
-- The active status record's beforeMove runs at its priority slot: above
-- VOLATILE_PRIORITY before the held/disable/confusion block (sleep,
-- freeze), at or below after it (paralysis) -- the original's order.
function Status.beforeMove(battler, rng, battle)
local mon = battler.mon
-- Haze curing this mon's sleep/freeze forfeits its pending move for
-- the turn, silently (haze.asm writes $ff/CANNOT_MOVE to the selected
@@ -14,71 +156,68 @@ function Status.beforeMove(battler, rng)
end
if battler.flinched then
battler.flinched = false
return false, { battler.name .. "\nflinched!" }
return false, { name(battler) .. "\nflinched!" }
end
if mon.status == "SLP" then
battler.sleepTurns = (battler.sleepTurns or 1) - 1
if battler.sleepTurns <= 0 then
mon.status = nil
return false, { battler.name .. "\nwoke up!" } -- wakes but loses the turn
end
return false, { battler.name .. "\nis fast asleep!" }
local record = Status.recordFor(battleStatuses(battle), mon.status)
local handler = record and record.beforeMove
local priority = handler and (record.beforeMovePriority or 0)
local msgs = {}
local function runStatus()
local canMove, statusMsgs, selfHit = handler(battler, rng, battle)
for _, m in ipairs(statusMsgs or {}) do msgs[#msgs + 1] = m end
return canMove, selfHit
end
if mon.status == "FRZ" then
return false, { battler.name .. "\nis frozen solid!" }
if handler and priority > VOLATILE_PRIORITY then
local canMove, selfHit = runStatus()
if not canMove or selfHit then return canMove, msgs, selfHit end
handler = nil
end
if battler.boundTurns and battler.boundTurns > 0 then
battler.boundTurns = battler.boundTurns - 1
return false, { battler.name .. "\ncan't move!" }
msgs[#msgs + 1] = name(battler) .. "\ncan't move!"
return false, msgs
end
local msgs = {}
if battler.disabledTurns then
battler.disabledTurns = battler.disabledTurns - 1
if battler.disabledTurns <= 0 then
battler.disabledTurns, battler.disabledSlot = nil, nil
table.insert(msgs, battler.name .. "'s\ndisabled no more!")
table.insert(msgs, name(battler) .. "'s\ndisabled no more!")
end
end
if battler.confusedTurns then
battler.confusedTurns = battler.confusedTurns - 1
if battler.confusedTurns <= 0 then
battler.confusedTurns = nil
table.insert(msgs, battler.name .. "\nsnapped out of\nconfusion!")
table.insert(msgs, name(battler) .. "\nsnapped out of\nconfusion!")
else
table.insert(msgs, battler.name .. "\nis confused!")
table.insert(msgs, name(battler) .. "\nis confused!")
-- cp 50 percent + 1 / jr c: hurt itself on rand >= 128 (128/256)
if rng(0, 255) < 128 then
return false, msgs, true -- hurt itself
end
end
end
-- cp 25 percent / jr nc: fully paralyzed on rand < 63 (63/256)
if mon.status == "PAR" and rng(0, 255) < 63 then
table.insert(msgs, battler.name .. "'s\nfully paralyzed!")
return false, msgs
if handler then
local canMove, selfHit = runStatus()
if not canMove or selfHit then return canMove, msgs, selfHit end
end
return true, msgs
end
-- End-of-turn residual damage; opponent is needed for Leech Seed.
-- Returns messages.
function Status.residual(battler, opponent)
function Status.residual(battler, opponent, battle)
local msgs = {}
local mon = battler.mon
-- the Haze move-forfeit only covers the turn Haze was used; if this
-- mon had already moved, drop the flag before it leaks into next turn
battler.skipMove = nil
if mon.hp <= 0 then return msgs end
if mon.status == "PSN" or mon.status == "BRN" then
local base = math.max(1, math.floor(mon.stats.hp / 16))
local dmg = base
if battler.toxicCounter then
dmg = base * battler.toxicCounter
battler.toxicCounter = battler.toxicCounter + 1
local record = Status.recordFor(battleStatuses(battle), mon.status)
if record and record.residual then
for _, m in ipairs(record.residual(battler, opponent, battle) or {}) do
msgs[#msgs + 1] = m
end
mon.hp = math.max(0, mon.hp - dmg)
local what = mon.status == "PSN" and "poison" or "the burn"
table.insert(msgs, ("%s's\nhurt by %s!"):format(battler.name, what))
end
if battler.leechSeeded and mon.hp > 0 and opponent.mon.hp > 0 then
-- the shared Toxic counter multiplies (and advances on) the seed
@@ -92,7 +231,7 @@ function Status.residual(battler, opponent)
dmg = math.min(dmg, mon.hp)
mon.hp = mon.hp - dmg
opponent.mon.hp = math.min(opponent.mon.stats.hp, opponent.mon.hp + dmg)
table.insert(msgs, ("LEECH SEED saps\n%s!"):format(battler.name))
table.insert(msgs, ("LEECH SEED saps\n%s!"):format(name(battler)))
end
return msgs
end
+57
View File
@@ -0,0 +1,57 @@
-- Status infliction against the merged statuses registry: the shared
-- immunity rules stay here, the per-status ones live on the records
-- (canInflict) and so does the landing text (onInflict), so a mod status
-- inflicts through the same path as the vanilla five.
local Runtime = require("src.mods.Runtime")
local Status = require("src.battle.Status")
local StatusRegistry = {}
-- pokered's <USER>/<TARGET> text macros (home/text.asm
-- PlaceMoveUsersName): enemy-mon texts print "Enemy " before the name
local function displayName(b)
return b.isPlayer and b.name or ("Enemy " .. b.name)
end
-- opts: toxic (start the Toxic counter), moveType (for the type gates),
-- secondary (side-effect of a damaging move), source (inflicting move id).
-- Returns messages; empty means the status did not land.
function StatusRegistry.inflict(battle, target, status, opts)
opts = opts or {}
if target.mon.status then return {} end
-- Substitutes block poison (PoisonEffect calls CheckTargetSubstitute)
-- and every secondary status, but NOT primary Sleep or Thunder Wave,
-- their handlers never check the substitute in Gen 1.
if target.substituteHP and (opts.secondary or status == "PSN") then
return {}
end
-- FreezeBurnParalyzeEffect: a secondary status never lands when the
-- move's type matches either of the target's types (Body Slam can't
-- paralyze Normals, Fire can't burn Fire, Ice can't freeze Ice)
if opts.secondary and status ~= "PSN" then
for _, t in ipairs(target.curTypes or {}) do
if opts.moveType == t then return {} end
end
end
local statuses = battle and battle.data and battle.data.statuses
local record = Status.recordFor(statuses, status)
if record and record.canInflict and not record.canInflict(target, opts) then
return {}
end
target.mon.status = status
local msgs
local display = displayName(target)
if record and record.onInflict then
msgs = record.onInflict(battle, target, opts, display)
else
msgs = { ("%s\nwas afflicted\nby %s!"):format(display,
record and record.label or tostring(status)) }
end
Runtime.emit("battle.status_inflicted", {
battle = battle, target = target, status = status, source = opts.source,
})
return msgs
end
return StatusRegistry
+78 -26
View File
@@ -24,13 +24,25 @@ local TrainerAI = {}
local HEAL_AMOUNT = { POTION = 20, SUPER_POTION = 50, HYPER_POTION = 200 }
local X_STAT = { X_ATTACK = "attack", X_DEFEND = "defense", X_SPEED = "speed" }
-- The trainer's ai_classes record from the merged registry; the direct
-- require covers battles built without a loader. A trainer record's
-- aiClass field picks a record other than its own id.
function TrainerAI.classFor(battle)
local trainer = battle and battle.trainer
if not trainer then return nil end
local id = trainer.aiClass or trainer.id
local classes = battle.data and battle.data.ai_classes
if classes then return classes[id] end
return require("data.scripts.ai_classes")[id]
end
-- Item use / switching per trainer class (engine/battle/trainer_ai.asm
-- via data/scripts/ai_classes.lua). Runs before move choice each enemy
-- via the ai_classes registry). Runs before move choice each enemy
-- turn; returns an action { special = "aiItem"/"aiSwitch", ... } or nil.
-- battle.aiUses is initialized per enemy Pokémon (wAICount).
function TrainerAI.classAction(battle)
if battle.kind ~= "trainer" or not battle.trainer then return nil end
local class = require("data.scripts.ai_classes")[battle.trainer.id]
local class = TrainerAI.classFor(battle)
if not class then return nil end
if (battle.aiUses or 0) <= 0 then return nil end
local rng = battle.rng
@@ -154,6 +166,50 @@ local function hasBetterMove(battler, judged, battle)
return false
end
-- The three vanilla passes as ai_classes layer records: vanilla is just the
-- first registrant, so a mod patches one instead of reimplementing trainer
-- AI. src/mods/Builtins.lua registers them; chooseMove dispatches through
-- whatever the registry holds and falls back here when a battle was built
-- without a loader. view.encourageTurn is wAILayer2Encouragement == 1.
TrainerAI.LAYERS = {
LAYER_1 = { kind = "layer", score = function(view, def, score)
-- `add $5`: heavily discourage a zero-power status move that would
-- fail because the player is already statused
if def and view.target.mon.status and def.power == 0
and STATUS_EFFECTS[def.effect] then
return score + 5
end
return score
end },
LAYER_2 = { kind = "layer", score = function(view, def, score)
if def and view.encourageTurn and ENCOURAGE_EFFECTS[def.effect] then
return score - 1 -- `dec [hl]`: slightly encourage
end
return score
end },
-- AIGetTypeEffectiveness only reads the FIRST matching TypeEffects row for
-- (move type vs either defender type) -- no dual-type product -- and runs
-- for non-damaging moves too. The table holds no value-10 rows, so
-- >10 / <10 reproduces the oracle's compare against $10.
LAYER_3 = { kind = "layer", score = function(view, def, score)
if not def then return score end
local row = TypeChart.rows(def.type, view.target.curTypes)[1]
if row and row > 10 then
return score - 1 -- `dec [hl]`: encourage a super-effective move
elseif row and row < 10 and hasBetterMove(view.user, def, view.battle) then
return score + 1 -- `inc [hl]`: discourage when a better move is known
end
return score
end },
}
-- vanilla registrations, kept beside the other Builtins delegations
function TrainerAI.registerInto(registry, _, owner)
for id, record in pairs(TrainerAI.LAYERS) do
registry:register(id, record, owner)
end
end
function TrainerAI.chooseMove(battler, rng, battle)
rng = rng or love.math.random
local usable = {}
@@ -179,40 +235,36 @@ function TrainerAI.chooseMove(battler, rng, battle)
return usable[rng(1, #usable)]
end
-- aiMods entries may name registered ai_classes layer records; a number n
-- resolves through "LAYER_<n>", which is how the vanilla three are keyed.
-- A battle built without a loader has no merged registry, so the built-in
-- records answer directly.
local classes = battle.data and battle.data.ai_classes
local layers, view = {}, nil
for _, mod in ipairs(mods) do
local id = type(mod) == "string" and mod or ("LAYER_" .. tostring(mod))
local record = classes and classes[id]
if not (record and record.score) then record = TrainerAI.LAYERS[id] end
if record and record.score then
layers[#layers + 1] = record.score
view = view or { battle = battle, user = battler, target = battle.player,
data = battle.data, rng = rng,
encourageTurn = encourageTurn }
end
end
-- AIEnemyTrainerChooseMoves (engine/battle/trainer_ai.asm:3-257): every
-- usable move starts at a base score of 10; the class's modification
-- functions adjust it additively, then the MINIMUM-scored move is chosen
-- with ties broken uniformly among the minima (core.asm:2971-3002 rolls a
-- fresh byte among the value-1 slots). A non-minimal move is never
-- selectable.
local target = battle.player
local scores = {}
for i, mv in ipairs(usable) do
local def = battle.data.moves[mv.id]
local s = 10
for _, mod in ipairs(mods) do
if mod == 1 and def and target.mon.status
and def.power == 0 and STATUS_EFFECTS[def.effect] then
-- AIMoveChoiceModification1: `add $5` -- heavily discourage a
-- zero-power status move that would fail (player already statused)
s = s + 5
elseif mod == 2 and def and encourageTurn
and ENCOURAGE_EFFECTS[def.effect] then
-- AIMoveChoiceModification2: `dec [hl]` -- slightly encourage
s = s - 1
elseif mod == 3 and def then
-- AIMoveChoiceModification3 via AIGetTypeEffectiveness only reads
-- the FIRST matching TypeEffects row for (move type vs either
-- defender type) -- no dual-type product -- and runs for
-- non-damaging moves too. The table holds no value-10 rows, so
-- >10 / <10 reproduces the oracle's compare against $10.
local row = TypeChart.rows(def.type, target.curTypes)[1]
if row and row > 10 then
s = s - 1 -- `dec [hl]`: encourage a super-effective move
elseif row and row < 10 and hasBetterMove(battler, def, battle) then
s = s + 1 -- `inc [hl]`: discourage when a better move is known
end
end
for _, score in ipairs(layers) do
s = score(view, def, s) or s
end
scores[i] = s
end
+30 -15
View File
@@ -1,31 +1,46 @@
-- Turn order, from engine/battle/core.asm MainInBattleLoop: compare
-- effective speed; ties are a coin flip. QUICK_ATTACK moves first and
-- COUNTER last (Gen 1 has only these two priority moves, checked by id).
-- effective speed; ties are a coin flip. Move priority reads the move
-- record's priority field; the id table below covers pre-existing
-- imported caches (Gen 1 has only QUICK_ATTACK first and COUNTER last).
local Damage = require("src.battle.Damage")
local Stats = require("src.pokemon.Stats")
local Status = require("src.battle.Status")
local TurnOrder = {}
local function effectiveSpeed(battler)
local spd = Stats.applyStage(battler.curStats.speed,
battler.stages and battler.stages.speed or 0)
-- ApplyBadgeStatBoosts: the SOULBADGE (bit 4) boosts speed
if battler.badges and battler.badges.SOULBADGE then
spd = math.floor(spd * 9 / 8)
-- ApplyBadgeStatBoosts: the SOULBADGE boosts speed; the rows come from
-- the battler's merged badgeBoosts with the vanilla list as fallback
local badges = battler.badges
if badges then
for _, row in ipairs(battler.badgeBoosts or Damage.BADGE_BOOSTS) do
if row.stat == "speed" and badges[row.badge] then
spd = math.floor(spd * (row.num or 9) / (row.den or 8))
break
end
end
end
-- paralysis quarters speed; hazeStatReset suppresses it because Haze
-- (haze.asm ResetStats) copied the unmodified speed over the quartered
-- battle stat, lifting the penalty until the next stat recompute.
if battler.mon.status == "PAR" and not battler.hazeStatReset then
spd = math.max(1, math.floor(spd / 4))
-- paralysis quarters speed (the status record's statPenalty);
-- hazeStatReset suppresses it because Haze (haze.asm ResetStats)
-- copied the unmodified speed over the quartered battle stat, lifting
-- the penalty until the next stat recompute.
local record = Status.recordFor(battler.statuses, battler.mon.status)
local penalty = record and record.statPenalty
if penalty and penalty.stat == "speed" and not battler.hazeStatReset then
spd = math.max(1, math.floor(spd / penalty.div))
end
return spd
end
local function priority(moveId)
if moveId == "QUICK_ATTACK" then return 1 end
if moveId == "COUNTER" then return -1 end
return 0
local PRIORITY = { QUICK_ATTACK = 1, COUNTER = -1 }
local function priority(move)
if not move then return 0 end
if move.priority then return move.priority end
return PRIORITY[move.id] or 0
end
-- Returns true when battler a moves before battler b. invertTie flips
@@ -34,7 +49,7 @@ end
-- who moves first.
function TurnOrder.firstMover(a, aMove, b, bMove, rng, invertTie)
rng = rng or love.math.random
local pa, pb = priority(aMove and aMove.id), priority(bMove and bMove.id)
local pa, pb = priority(aMove), priority(bMove)
if pa ~= pb then return pa > pb end
local sa, sb = effectiveSpeed(a), effectiveSpeed(b)
if sa ~= sb then return sa > sb end
+51
View File
@@ -6,6 +6,7 @@ local TypeChart = {}
local index -- [atk][def] -> x10 multiplier
local matchups -- ROM-ordered TypeEffects rows
local types -- merged type records (physical/special category, display name)
function TypeChart.load(data)
index = {}
@@ -14,6 +15,21 @@ function TypeChart.load(data)
index[m.attacker] = index[m.attacker] or {}
index[m.attacker][m.defender] = m.multiplier
end
types = data.type_chart.types
end
-- the merged type record's category; falls back to the vanilla records
-- so pure-module callers need no load
function TypeChart.category(typeId)
local record = types and types[typeId] or TypeChart.TYPES[typeId]
return record and record.category or nil
end
-- display name for the move-select TYPE/ box (mod types render their
-- name instead of their raw id)
function TypeChart.displayName(typeId)
local record = types and types[typeId] or TypeChart.TYPES[typeId]
return record and record.name or typeId
end
-- The x10 multipliers of every TypeEffects row that applies, in ROM
@@ -52,4 +68,39 @@ function TypeChart.effectiveness(moveType, defenderTypes)
return mult
end
-- Gen 1 splits physical from special by TYPE, not by move: the seven types
-- from FIRE up are special (engine/battle/effect_commands.asm compares the
-- type id against SPECIAL). The list Damage.isSpecial carries is the same
-- one, restated here as the type records the type_chart registry serves.
TypeChart.TYPES = {
NORMAL = { name = "NORMAL", category = "physical" },
FIGHTING = { name = "FIGHTING", category = "physical" },
FLYING = { name = "FLYING", category = "physical" },
POISON = { name = "POISON", category = "physical" },
GROUND = { name = "GROUND", category = "physical" },
ROCK = { name = "ROCK", category = "physical" },
BUG = { name = "BUG", category = "physical" },
GHOST = { name = "GHOST", category = "physical" },
FIRE = { name = "FIRE", category = "special" },
WATER = { name = "WATER", category = "special" },
GRASS = { name = "GRASS", category = "special" },
ELECTRIC = { name = "ELECTRIC", category = "special" },
PSYCHIC_TYPE = { name = "PSYCHIC", category = "special" },
ICE = { name = "ICE", category = "special" },
DRAGON = { name = "DRAGON", category = "special" },
}
-- The matchup rows come from the generated chart, so a dataset with a
-- different table registers a different world without touching this file.
function TypeChart.registerInto(registry, data, owner)
for id, record in pairs(TypeChart.TYPES) do
registry:register(id, record, owner)
end
local chart = data and data.type_chart
for _, row in ipairs(chart and chart.matchups or {}) do
registry:register(row.attacker .. ">" .. row.defender,
{ multiplier = row.multiplier }, owner)
end
end
return TypeChart