mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-19 04:06:10 +02:00
big ass modding update
This commit is contained in:
@@ -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
File diff suppressed because it is too large
Load Diff
+71
-29
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user