-- Gen 2 move effects, as data plus the small amount of arithmetic each one -- needs. Ported from engine/battle/effect_commands.asm; the battle engine -- (src/battle/gen2/Battle.lua) owns the turn loop and calls in here for what a -- move does beyond "roll damage, maybe inflict a status". -- -- Everything is keyed by the move's *effect*, the same byte data/moves/moves.asm -- stores, so a modded move that copies an effect inherits its behaviour -- and -- an effect this table does not name still lands as an ordinary hit rather than -- silently doing the wrong thing. -- -- No love calls and no engine state: every function takes what it needs and -- returns a value or a small table, which is what lets the tests drive them -- directly. local Effects = {} -- --------------------------------------------------------------- stat stages -- -- Stat changes come in four shapes and the effect name says which: -- *_UP / *_UP_2 raise the user, by one stage or two -- *_DOWN / *_DOWN_2 lower the target -- *_UP_HIT raise the user after a damaging hit -- *_DOWN_HIT lower the target after a damaging hit -- StatUpMessage / StatDownMessage are the same either way, so the direction -- and the target are the only things worth tabulating. -- { stat, stages, target } where target is "self" or "foe". Effects.STAT_CHANGES = { EFFECT_ATTACK_UP = { "attack", 1, "self" }, EFFECT_DEFENSE_UP = { "defense", 1, "self" }, EFFECT_SP_ATK_UP = { "specialAttack", 1, "self" }, EFFECT_EVASION_UP = { "evasion", 1, "self" }, EFFECT_ATTACK_UP_2 = { "attack", 2, "self" }, EFFECT_DEFENSE_UP_2 = { "defense", 2, "self" }, EFFECT_SPEED_UP_2 = { "speed", 2, "self" }, EFFECT_SP_DEF_UP_2 = { "specialDefense", 2, "self" }, -- Defense Curl also arms Rollout, which Battle tracks separately. EFFECT_DEFENSE_CURL = { "defense", 1, "self" }, EFFECT_ATTACK_DOWN = { "attack", -1, "foe" }, EFFECT_DEFENSE_DOWN = { "defense", -1, "foe" }, EFFECT_SPEED_DOWN = { "speed", -1, "foe" }, EFFECT_ACCURACY_DOWN = { "accuracy", -1, "foe" }, EFFECT_EVASION_DOWN = { "evasion", -1, "foe" }, EFFECT_ATTACK_DOWN_2 = { "attack", -2, "foe" }, EFFECT_DEFENSE_DOWN_2 = { "defense", -2, "foe" }, EFFECT_SPEED_DOWN_2 = { "speed", -2, "foe" }, } -- The secondary versions, rolled against the move's effect chance after a hit. Effects.STAT_CHANGES_ON_HIT = { EFFECT_ATTACK_UP_HIT = { "attack", 1, "self" }, EFFECT_DEFENSE_UP_HIT = { "defense", 1, "self" }, EFFECT_ATTACK_DOWN_HIT = { "attack", -1, "foe" }, EFFECT_DEFENSE_DOWN_HIT = { "defense", -1, "foe" }, EFFECT_SPEED_DOWN_HIT = { "speed", -1, "foe" }, EFFECT_ACCURACY_DOWN_HIT = { "accuracy", -1, "foe" }, EFFECT_SP_DEF_DOWN_HIT = { "specialDefense", -1, "foe" }, } -- Ancient Power raises every one of the user's stats at once. Effects.ALL_UP_STATS = { "attack", "defense", "speed", "specialAttack", "specialDefense", } Effects.STAT_NAMES = { attack = "ATTACK", defense = "DEFENSE", speed = "SPEED", specialAttack = "SPCL.ATK", specialDefense = "SPCL.DEF", accuracy = "ACCURACY", evasion = "EVASION", } -- Stages clamp at ±6 (BattleCommand_StatUp's .CantRaise / .CantLower). Effects.MAX_STAGE = 6 -- Applies a change and says what happened, so the caller can emit the cart's -- own message: nil when the stage was already at the cap. function Effects.applyStage(stages, stat, delta) if not (stages and stat) then return nil end local current = stages[stat] or 0 local wanted = current + delta if wanted > Effects.MAX_STAGE then wanted = Effects.MAX_STAGE end if wanted < -Effects.MAX_STAGE then wanted = -Effects.MAX_STAGE end if wanted == current then return nil end stages[stat] = wanted return wanted - current end -- BattleCommand_StatUpMessage / StatDownMessage: one stage is "rose"/"fell", -- two are "sharply rose" / "sharply fell". function Effects.stageMessage(name, stat, applied) local label = Effects.STAT_NAMES[stat] or stat local sharply = math.abs(applied) >= 2 and "sharply " or "" local verb = applied > 0 and "rose" or "fell" return ("%s's %s %s%s!"):format(name, label, sharply, verb) end -- ------------------------------------------------------------------ hit count -- -- BattleCommand_CheckHit's multi-hit roll: 2 and 3 hits are 3/8 each, 4 and 5 -- are 1/8 each, which is what the `and 3` on a 0-3 roll plus the two-step -- fallthrough in .DetermineNumberOfHits produces. function Effects.multiHitCount(random) local function roll(n) if random then return random(n) end if love and love.math and love.math.random then return love.math.random(n) - 1 end return math.random(n) - 1 end -- engine/battle/effect_commands.asm:5228 local first = roll(4) if first < 2 then return first + 2 end return roll(4) + 2 end Effects.HIT_COUNTS = { EFFECT_DOUBLE_HIT = 2, EFFECT_POISON_MULTI_HIT = 2, -- Triple Kick stops early if a hit misses; Battle rolls that per hit. EFFECT_TRIPLE_KICK = 3, } function Effects.hitCount(effect, random) if effect == "EFFECT_MULTI_HIT" then return Effects.multiHitCount(random) end return Effects.HIT_COUNTS[effect] or 1 end -- Triple Kick's power climbs 10/20/30 across its three kicks -- (BattleCommand_TripleKick). function Effects.tripleKickPower(base, hit) return (base or 10) * hit end -- ----------------------------------------------------------- recoil and drain -- BattleCommand_Recoil: a quarter of the damage dealt, minimum 1. function Effects.recoilDamage(damageDealt) return math.max(1, math.floor((damageDealt or 0) / 4)) end -- BattleCommand_DrainTarget: half the damage dealt, minimum 1. function Effects.drainAmount(damageDealt) return math.max(1, math.floor((damageDealt or 0) / 2)) end Effects.DRAIN = { EFFECT_LEECH_HIT = true, EFFECT_DREAM_EATER = true, } -- --------------------------------------------------------------- two-turn -- -- The charge moves all share BattleCommand_Charge: turn one prints a line and -- stores the move, turn two attacks. Fly and Dig also make the user -- untargetable in between, which is the `semi-invulnerable` flag here. Effects.CHARGE = { EFFECT_RAZOR_WIND = { text = "%s made a whirlwind!" }, EFFECT_SOLARBEAM = { text = "%s took in sunlight!" }, EFFECT_SKULL_BASH = { text = "%s lowered its head!" }, EFFECT_SKY_ATTACK = { text = "%s is glowing!" }, EFFECT_FLY = { text = "%s flew up high!", vanish = true }, } -- CheckHit's .FlyDigMoves (effect_commands.asm:1713-1746): a vanished target -- is not a flat miss, four moves reach it in the air and three underground. Effects.FLY_DIG_EXCEPTIONS = { FLY = { GUST = true, WHIRLWIND = true, THUNDER = true, TWISTER = true }, DIG = { EARTHQUAKE = true, FISSURE = true, MAGNITUDE = true }, } -- Keyed by the charge move the target is partway through, which is what the -- port carries in place of SUBSTATUS_FLYING / SUBSTATUS_UNDERGROUND. function Effects.hitsVanished(chargeMove, moveId) local reaches = Effects.FLY_DIG_EXCEPTIONS[chargeMove] return (reaches and reaches[moveId]) and true or false end -- --------------------------------------------------------------- fixed damage -- BattleCommand_ConstantDamage (engine/battle/effect_commands.asm:3131-3205), -- one command for the whole SuperFang / Psywave / StaticDamage list. function Effects.fixedDamage(effect, attacker, defender, random, power) if effect == "EFFECT_LEVEL_DAMAGE" then return math.max(1, attacker.level or 1) end if effect == "EFFECT_SUPER_FANG" then return math.max(1, math.floor((defender.hp or 1) / 2)) end if effect == "EFFECT_PSYWAVE" then -- .psywave rerolls until the byte is nonzero AND below level * 1.5, so the -- top of the range is that ceiling minus one (effect_commands.asm:3163). local ceiling = math.max(2, math.floor((attacker.level or 1) * 3 / 2)) return math.max(1, (random and random(ceiling - 1) or 0) + 1) end -- SONIC BOOM and DRAGON RAGE share EFFECT_STATIC_DAMAGE, whose arm reads -- BATTLE_VARS_MOVE_POWER straight into the damage word: their stored power -- (20 and 40) IS the damage, never a formula input -- (effect_commands.asm:3157-3161). if effect == "EFFECT_STATIC_DAMAGE" then return math.max(1, math.floor(power or 0)) end return nil end -- --------------------------------------------------------------- Substitute -- BattleCommand_Substitute: a quarter of max HP, which is also what the user -- pays. Refuses when the user has that much HP or less. function Effects.substituteCost(maxHp) return math.max(1, math.floor((maxHp or 1) / 4)) end -- ------------------------------------------------------------ counter moves -- Counter answers physical damage, Mirror Coat special, both at double and -- both only when the foe hit the user this turn (BattleCommand_Counter). Effects.COUNTER = { EFFECT_COUNTER = "physical", EFFECT_MIRROR_COAT = "special", } function Effects.counterDamage(taken) return math.max(1, (taken or 0) * 2) end -- ------------------------------------------------------ rollout / fury cutter -- Both double their power per consecutive use, Rollout for five turns and Fury -- Cutter until it misses; the cart caps the doubling at 5 steps either way. Effects.RAMPING = { EFFECT_ROLLOUT = 5, EFFECT_FURY_CUTTER = 5, } function Effects.rampedPower(base, count, curled) local steps = math.min(math.max(count or 0, 0), 4) local power = (base or 1) * 2 ^ steps -- Defense Curl doubles Rollout again (BattleCommand_RolloutPower). if curled then power = power * 2 end return math.floor(power) end -- ----------------------------------------------------------------- magnitude -- -- data/moves/magnitude_power.asm, one row per magnitude: { chance, power, -- magnitude number }. The chance column is assembled through `percent` -- (`* $ff / 100`, macros/data.asm:23), so `5 percent + 1` is 13 and -- `100 percent` is 255 -- the thresholds below are those bytes, not the -- percentages they were written as. Effects.MAGNITUDE_POWER = { { 13, 10, 4 }, { 38, 30, 5 }, { 89, 50, 6 }, { 166, 70, 7 }, { 217, 90, 8 }, { 242, 110, 9 }, { 255, 150, 10 }, } -- BattleCommand_GetMagnitude (engine/battle/move_effects/magnitude.asm): ONE -- random byte walks the table and the first row whose threshold is not below -- it wins (`ld a, [hli] / cp b / jr nc`). The row's power goes into d, which -- is what damagecalc reads as the move's power -- data/moves/moves.asm stores -- MAGNITUDE at power 1 precisely because this overwrites it. Returns the -- power and the magnitude number the text prints. -- -- `random` is BattleRandom (0..n-1). If none is supplied, roll via love.math -- / math.random — never hard-code 0 (that always yields Magnitude 4). function Effects.magnitudePower(random) local roll if type(random) == "function" then roll = random(256) or 0 elseif love and love.math and love.math.random then roll = love.math.random(256) - 1 else roll = math.random(256) - 1 end for _, row in ipairs(Effects.MAGNITUDE_POWER) do if row[1] >= roll then return row[2], row[3] end end local last = Effects.MAGNITUDE_POWER[#Effects.MAGNITUDE_POWER] return last[2], last[3] end -- ------------------------------------------------------------------- weather -- -- BattleCommand_StartRain / StartSun / StartSandstorm all set wWeatherCount to -- 5, which HandleWeather decrements at the end of every turn; the turn it -- reaches zero the weather ends. data/battle/weather_modifiers.asm is the -- whole of what weather does to damage. Effects.WEATHER = { EFFECT_RAIN_DANCE = "rain", EFFECT_SUNNY_DAY = "sun", EFFECT_SANDSTORM = "sandstorm", } Effects.WEATHER_TURNS = 5 Effects.WEATHER_START_TEXT = { rain = "It started to rain!", sun = "The sunlight got bright!", sandstorm = "A sandstorm brewed!", } Effects.WEATHER_TURN_TEXT = { rain = "Rain continues to fall.", sun = "The sunlight is strong.", sandstorm = "The sandstorm rages.", } Effects.WEATHER_END_TEXT = { rain = "The rain stopped.", sun = "The sunlight faded.", sandstorm = "The sandstorm subsided.", } -- data/battle/weather_modifiers.asm pairs each weather with MORE_EFFECTIVE or -- NOT_VERY_EFFECTIVE, and those are 15 and 05 in tenths -- (constants/battle_constants.asm:22, :24) -- MORE_EFFECTIVE is x1.5, NOT the -- type chart's x2, which is SUPER_EFFECTIVE (20). Gen 2's weather boost is a -- half again, and only the type chart doubles. Effects.WEATHER_TYPE_MODIFIERS = { rain = { WATER = 1.5, FIRE = 0.5 }, sun = { FIRE = 1.5, WATER = 0.5 }, } -- The one move whose EFFECT rather than type is modified: Solarbeam in rain. Effects.WEATHER_MOVE_MODIFIERS = { rain = { EFFECT_SOLARBEAM = 0.5 }, } function Effects.weatherModifier(weather, moveType, effect) if not weather then return 1 end local byType = Effects.WEATHER_TYPE_MODIFIERS[weather] if byType and byType[moveType] then return byType[moveType] end local byMove = Effects.WEATHER_MOVE_MODIFIERS[weather] if byMove and byMove[effect] then return byMove[effect] end return 1 end -- HandleWeather's .SandstormDamage: an eighth of max HP, and Rock, Ground and -- Steel are immune. A mon underground (Dig) is skipped too. Effects.SANDSTORM_IMMUNE = { ROCK = true, GROUND = true, STEEL = true } function Effects.sandstormDamage(maxHp) return math.max(1, math.floor((maxHp or 8) / 8)) end function Effects.sandstormHits(types) for _, type_ in ipairs(types or {}) do if Effects.SANDSTORM_IMMUNE[type_] then return false end end return true end -- Morning Sun / Synthesis / Moonlight heal a HALF normally, and the weather -- shifts that one step either way: x2 in sun, /2 in rain or sandstorm -- (BattleCommand_Heal's .Weather block walks a multiplier index). Effects.SUN_HEAL = { EFFECT_MORNING_SUN = true, EFFECT_SYNTHESIS = true, EFFECT_MOONLIGHT = true, } function Effects.weatherHealFraction(weather) if weather == "sun" then return 2 / 3 end if weather == "rain" or weather == "sandstorm" then return 1 / 4 end return 1 / 2 end -- ---------------------------------------------------------------- Perish Song -- -- BattleCommand_PerishSong sets the counter to 4 on BOTH sides; it ticks down -- at the end of every turn and the mon faints when it reaches 0. Effects.PERISH_TURNS = 4 -- -------------------------------------------------------------------- Encore -- -- 3-6 turns (`and $3` plus three increments), and the target is locked into -- the move it last used. Encore, Mirror Move and Struggle cannot be encored, -- and neither can a move with no PP left. Effects.ENCORE_BLOCKED = { ENCORE = true, MIRROR_MOVE = true, STRUGGLE = true, } function Effects.encoreTurns(random) return (random and random(4) or 0) + 3 end -- ------------------------------------------------------------------- Disable -- -- The count is a packed byte: the low nybble is the number of turns (1-8, the -- `and 7` retried until nonzero, then incremented) and the high nybble is the -- move slot plus one. Only the turn count matters to the port, but the shape -- is what says a slot of 0 means "nothing disabled". function Effects.disableTurns(random) local roll = 0 for _ = 1, 8 do roll = (random and random(8) or 1) % 8 if roll ~= 0 then break end end if roll == 0 then roll = 1 end return roll + 1 end -- --------------------------------------------------------- Protect and Endure -- -- ProtectChance halves the success chance for every CONSECUTIVE use: the -- threshold starts at $ff and is shifted right once per use, so use n -- succeeds with probability (256 >> n) / 256. Once the shift reaches zero the -- move always fails, which is five uses. function Effects.protectChance(consecutive) local threshold = 0xff for _ = 1, (consecutive or 0) do threshold = math.floor(threshold / 2) if threshold == 0 then return 0 end end return threshold end -- The roll is a non-zero byte, decremented, and the move succeeds when it is -- BELOW the threshold. function Effects.protectSucceeds(consecutive, random) local threshold = Effects.protectChance(consecutive) if threshold == 0 then return false end local roll = (random and random(255) or 0) + 1 return (roll - 1) < threshold end -- ---------------------------------------------------------------------- Bide -- -- BattleCommand_UnleashEnergy stores for 2 or 3 turns (`and 1` plus two -- increments) and BattleCommand_StoreEnergy pays back DOUBLE everything the -- user took while storing, capped at the 16-bit maximum. function Effects.bideTurns(random) return (random and random(2) or 0) + 2 end function Effects.bideDamage(stored) return math.min(0xffff, (stored or 0) * 2) end -- ---------------------------------------------------------------------- Rage -- -- SUBSTATUS_RAGE: while it is set, every hit the user takes raises its Attack -- one stage. It is cleared by using any other move. -- ---------------------------------------------------------------- Future Sight -- -- Four turns: the damage is rolled NOW, stored, and lands when the counter -- reaches one. The move itself does nothing on the turn it is used. Effects.FUTURE_SIGHT_TURNS = 4 -- ---------------------------------------------------------------------- OHKO -- -- BattleCommand_OHKO fails outright when the target is the higher level; when -- it is not, the move's accuracy becomes acc + 2 * (level difference), capped -- at 255, and a hit sets damage to $ffff. function Effects.ohkoAccuracy(baseAccuracy, userLevel, targetLevel) if (targetLevel or 1) > (userLevel or 1) then return nil end local bonus = ((userLevel or 1) - (targetLevel or 1)) * 2 return math.min(255, (baseAccuracy or 0) + bonus) end -- ------------------------------------------------------------------- Beat Up -- -- One hit per party member that is alive and free of any major status, each -- swinging with that member's own base Attack and level against the target's -- base Defense. The move fails outright when nobody qualifies. function Effects.beatUpParty(party, activeIndex) local hits = {} for index, mon in ipairs(party or {}) do local healthy = (mon.hp or 0) > 0 -- The ACTIVE mon is checked against its battle status rather than its -- party record, which is the same thing here. local clean = not mon.status or (index == activeIndex and not mon.status) if healthy and clean then hits[#hits + 1] = { index = index, mon = mon } end end return hits end -- --------------------------------------------------------------- Baton Pass -- -- ResetBatonPassStatus: what does NOT survive the switch. Everything else -- -- the stat stages, Substitute, Leech Seed, Perish Song, the confusion counter -- -- goes with the incoming mon, which is the whole point of the move. -- -- `preTransform` is deliberately NOT on this list even though `transformed` is: -- it is the passer's own identity waiting to be put back, and the drops run -- BEFORE Battle:clearVolatile, which is what puts it back and clears both keys -- (Battle:untransform). Dropping it here would strand a passing DITTO as a -- permanent copy instead. Effects.BATON_PASS_DROPS = { "nightmare", "disable", "disableTurns", "attract", "transformed", "encore", "encoreTurns", "lastMove", } -- ------------------------------------------------------------------ Metronome -- -- data/moves/metronome_exception_moves.asm: Metronome cannot pick these, and -- it also never picks a move the user already knows. Effects.METRONOME_EXCEPTIONS = { METRONOME = true, STRUGGLE = true, SKETCH = true, MIMIC = true, COUNTER = true, MIRROR_COAT = true, PROTECT = true, DETECT = true, ENDURE = true, DESTINY_BOND = true, SLEEP_TALK = true, THIEF = true, } -- Picks a move id uniformly out of `moveOrder`, rerolling on an excepted move -- or one the user already has -- the same reject loop .GetMove runs. function Effects.metronomePick(moveOrder, known, random) local count = #(moveOrder or {}) if count == 0 then return nil end local owned = {} for _, move in ipairs(known or {}) do owned[move.id or move] = true end for _ = 1, 64 do local pick = moveOrder[(random and random(count) or 0) + 1] if pick and not Effects.METRONOME_EXCEPTIONS[pick] and not owned[pick] then return pick end end return nil end -- ---------------------------------------------------------------- Mirror Move -- -- Copies the OPPONENT's last move, and fails when there is none or when the -- user already knows it -- CheckUserMove returns "found", and Mirror Move -- takes the .failed branch on a hit. return Effects