-- Gen 2 move effects with real handlers: confusion, Curse (both arms), -- Reflect / Light Screen, Leech Seed, the *_HIT flinch, the Bind class -- partial trap, Solarbeam's sun charge-skip and False Swipe's 1-HP floor. -- -- luajit tests/gen2_move_effects_test.lua -- -- ROM-free. Each block names the pokegold routine it asserts: -- effect_commands.asm BattleCommand_FinishConfusingTarget / _Screen / -- _FlinchTarget / _TrapTarget / _SkipSunCharge, move_effects/curse.asm, -- move_effects/leech_seed.asm, move_effects/false_swipe.asm, and core.asm -- HandleWrap / HandleScreens / ResidualDamage. package.path = "./?.lua;./?/init.lua;" .. package.path local S = require("tests.harness").suite("gen2 move effects") local check, eq = S.check, S.eq local Battle = require("src.battle.gen2.Battle") local Mon = require("src.battle.gen2.Mon") -- ---------------------------------------------------------------- fixtures local TYPES = { NORMAL = { id = "NORMAL", index = 0, category = "physical" }, GHOST = { id = "GHOST", index = 8, category = "physical" }, ROCK = { id = "ROCK", index = 5, category = "physical" }, GRASS = { id = "GRASS", index = 22, category = "special" }, FIRE = { id = "FIRE", index = 20, category = "special" }, GROUND = { id = "GROUND", index = 4, category = "physical" }, PSYCHIC = { id = "PSYCHIC", index = 24, category = "special" }, WATER = { id = "WATER", index = 21, category = "special" }, } local MOVES = { TACKLE = { id = "TACKLE", name = "TACKLE", power = 35, type = "NORMAL", accuracy = 95, pp = 35, effect = "EFFECT_NORMAL_HIT" }, CONFUSE_RAY = { id = "CONFUSE_RAY", name = "CONFUSE RAY", power = 0, type = "GHOST", accuracy = 100, pp = 10, effect = "EFFECT_CONFUSE" }, CURSE = { id = "CURSE", name = "CURSE", power = 0, type = "NORMAL", accuracy = 0, pp = 10, effect = "EFFECT_CURSE" }, REFLECT = { id = "REFLECT", name = "REFLECT", power = 0, type = "NORMAL", accuracy = 0, pp = 20, effect = "EFFECT_REFLECT" }, LIGHT_SCREEN = { id = "LIGHT_SCREEN", name = "LIGHT SCREEN", power = 0, type = "NORMAL", accuracy = 0, pp = 30, effect = "EFFECT_LIGHT_SCREEN" }, LEECH_SEED = { id = "LEECH_SEED", name = "LEECH SEED", power = 0, type = "GRASS", accuracy = 90, pp = 10, effect = "EFFECT_LEECH_SEED" }, ROCK_SLIDE = { id = "ROCK_SLIDE", name = "ROCK SLIDE", power = 75, type = "ROCK", accuracy = 90, pp = 10, effect = "EFFECT_FLINCH_HIT", effectChance = 30 }, WRAP = { id = "WRAP", name = "WRAP", power = 15, type = "NORMAL", accuracy = 85, pp = 20, effect = "EFFECT_TRAP_TARGET" }, FIRE_SPIN = { id = "FIRE_SPIN", name = "FIRE SPIN", power = 15, type = "FIRE", accuracy = 70, pp = 15, effect = "EFFECT_TRAP_TARGET" }, SOLARBEAM = { id = "SOLARBEAM", name = "SOLARBEAM", power = 120, type = "GRASS", accuracy = 100, pp = 10, effect = "EFFECT_SOLARBEAM" }, SUNNY_DAY = { id = "SUNNY_DAY", name = "SUNNY DAY", power = 0, type = "FIRE", accuracy = 0, pp = 5, effect = "EFFECT_SUNNY_DAY" }, FALSE_SWIPE = { id = "FALSE_SWIPE", name = "FALSE SWIPE", power = 40, type = "NORMAL", accuracy = 100, pp = 40, effect = "EFFECT_FALSE_SWIPE" }, EMBER = { id = "EMBER", name = "EMBER", power = 40, type = "FIRE", accuracy = 100, pp = 25, effect = "EFFECT_BURN_HIT", effectChance = 100 }, -- data/moves/moves.asm: SPLASH is power 0, MAGNITUDE is stored at power 1 -- because getmagnitude overwrites it, SPITE is power 0. SPLASH = { id = "SPLASH", name = "SPLASH", power = 0, type = "NORMAL", accuracy = 100, pp = 40, effect = "EFFECT_SPLASH" }, MAGNITUDE = { id = "MAGNITUDE", name = "MAGNITUDE", power = 1, type = "GROUND", accuracy = 100, pp = 30, effect = "EFFECT_MAGNITUDE" }, SPITE = { id = "SPITE", name = "SPITE", power = 0, type = "GHOST", accuracy = 100, pp = 10, effect = "EFFECT_SPITE" }, DREAM_EATER = { id = "DREAM_EATER", name = "DREAM EATER", power = 100, type = "PSYCHIC", accuracy = 100, pp = 15, effect = "EFFECT_DREAM_EATER" }, TRANSFORM = { id = "TRANSFORM", name = "TRANSFORM", power = 0, type = "NORMAL", accuracy = 0, pp = 10, effect = "EFFECT_TRANSFORM" }, -- STRUGGLE is in the move table like any other move and in nobody's move -- list, which is the whole of what makes Battle.STRUGGLE work. STRUGGLE = { id = "STRUGGLE", name = "STRUGGLE", power = 50, type = "NORMAL", accuracy = 100, pp = 1, effect = "EFFECT_RECOIL_HIT" }, } local GROWTH = { GROWTH_MEDIUM_FAST = { numerator = 1, denominator = 1, squared = 0, linear = 0, constant = 0 }, } local POKEMON = { growthRates = GROWTH, MACHOP = { id = "MACHOP", index = 66, name = "MACHOP", baseStats = { hp = 70, attack = 80, defense = 50, speed = 35, specialAttack = 35, specialDefense = 35 }, types = { "NORMAL", "NORMAL" }, catchRate = 180, baseExp = 75, growthRate = "GROWTH_MEDIUM_FAST", genderRatio = 63, levelMoves = { { level = 1, move = "TACKLE" } }, evolutions = {}, }, GASTLY = { id = "GASTLY", index = 92, name = "GASTLY", baseStats = { hp = 30, attack = 35, defense = 30, speed = 80, specialAttack = 100, specialDefense = 35 }, types = { "GHOST", "GHOST" }, catchRate = 190, baseExp = 95, growthRate = "GROWTH_MEDIUM_FAST", genderRatio = 127, levelMoves = { { level = 1, move = "TACKLE" } }, evolutions = {}, }, TANGELA = { id = "TANGELA", index = 114, name = "TANGELA", baseStats = { hp = 65, attack = 55, defense = 115, speed = 60, specialAttack = 100, specialDefense = 40 }, types = { "GRASS", "GRASS" }, catchRate = 45, baseExp = 166, growthRate = "GROWTH_MEDIUM_FAST", genderRatio = 127, levelMoves = { { level = 1, move = "TACKLE" } }, evolutions = {}, }, } local DATA = { pokemon = POKEMON, moves = MOVES, type_chart = { types = TYPES, matchups = {} }, items = {}, } local perfect = { attack = 15, defense = 15, speed = 15, special = 15 } perfect.hp = Mon.hpDV(perfect) local function rolls(queue, fill) local at = 0 return function(n) at = at + 1 local value = queue[at] if value == nil then value = fill or 0 end return value % math.max(1, n or 1) end end local function newBattle(opts) opts = opts or {} local player = Mon.new(DATA, opts.playerSpecies or "MACHOP", opts.playerLevel or 15, { dvs = perfect }) player.moves = opts.playerMoves or { { id = "TACKLE", pp = 35, maxPp = 35 } } local wild = Mon.new(DATA, opts.wildSpecies or "MACHOP", opts.wildLevel or 15, { dvs = perfect }) wild.moves = opts.wildMoves or { { id = "TACKLE", pp = 35, maxPp = 35 } } local battle = Battle.new({ data = DATA, party = { player }, wild = wild, random = opts.random }) return battle, player, wild end local function findText(events, text) for _, event in ipairs(events or {}) do if event.kind == "message" and event.text == text then return true end end return false end -- ---- confusion: a volatile with a 2-5 turn count -------------------------- do -- accuracy roll 0 hits; the count roll 1 -> and %11 = 1, plus 2 = 3 turns. local battle, player, wild = newBattle({ random = rolls({ 0, 1 }, 0) }) battle:useMove(player, wild, "CONFUSE_RAY") eq(wild.volatile.confuseCount, 3, "FinishConfusingTarget: `and %11` plus two turns") eq(wild.status, nil, "SUBSTATUS_CONFUSED never touches the status byte") check(findText(battle:takeEvents(), "MACHOP became confused!"), "BecameConfusedText") -- A second ray answers AlreadyConfusedText and changes nothing. battle:useMove(player, wild, "CONFUSE_RAY") eq(wild.volatile.confuseCount, 3, "no re-roll on a repeat") check(findText(battle:takeEvents(), "MACHOP's already confused!"), "AlreadyConfusedText") -- The 50 percent self-hit: canAct decrements, then a byte under 128 hurts. local before = wild.hp battle.random = rolls({ 0 }, 0) -- roll 0 < 128: self-hit eq(battle:canAct(wild), false, "the confused turn is spent on the self-hit") eq(wild.volatile.confuseCount, 2, "the count decrements first") check(wild.hp < before, "HitConfusion's typeless 40-power hit landed") check(findText(battle:takeEvents(), "It hurt itself in its confusion!"), "with the cart's line") -- A byte at or above 128 lets the mon act. battle.random = rolls({ 200 }, 0) eq(battle:canAct(wild), true, "at or over 50 percent + 1 it acts") eq(wild.volatile.confuseCount, 1, "count now on its last turn") -- The next decrement snaps out, and the mon still acts THAT turn. battle.random = rolls({}, 0) eq(battle:canAct(wild), true, "the snap-out turn is not lost") eq(wild.volatile.confuseCount, nil, "ConfusedNoMoreText clears the count") -- The status byte stayed free the whole time: a burn lands on a mon that -- is confused (the old status-slot storage shielded it). battle.random = rolls({ 0, 1, 0, 0 }, 0) battle:useMove(player, wild, "CONFUSE_RAY") battle:useMove(player, wild, "EMBER") eq(wild.status, "burn", "a confused mon can still be burned") eq(wild.volatile.confuseCount, 3, "and stays confused") end -- ---- Curse, non-Ghost arm ------------------------------------------------- do local battle, player, wild = newBattle({ playerMoves = { { id = "CURSE", pp = 10, maxPp = 10 } }, random = rolls({}, 0) }) battle:useMove(player, wild, "CURSE") eq(battle.stages.player.speed, -1, "Speed falls first") eq(battle.stages.player.attack, 1, "then Attack rises") eq(battle.stages.player.defense, 1, "then Defense rises") check(wild.volatile == nil or wild.volatile.cursed == nil, "no curse lands on the target from the stat arm") -- Refused only when BOTH raises are capped. battle.stages.player.attack = 6 battle.stages.player.defense = 6 local speedBefore = battle.stages.player.speed battle:useMove(player, wild, "CURSE") eq(battle.stages.player.speed, speedBefore, "with Attack and Defense capped, nothing moves at all") check(findText(battle:takeEvents(), "MACHOP's ATTACK won't rise anymore!"), "WontRiseAnymoreText") end -- ---- Curse, Ghost arm ----------------------------------------------------- do local battle, player, wild = newBattle({ playerSpecies = "GASTLY", playerMoves = { { id = "CURSE", pp = 10, maxPp = 10 } }, random = rolls({}, 0) }) local maxHp = player.maxHp battle:useMove(player, wild, "CURSE") eq(wild.volatile.cursed, true, "SUBSTATUS_CURSE set on the target") eq(player.hp, maxHp - math.floor(maxHp / 2), "the user pays half its max HP") eq(battle.stages.player.attack, 0, "no stat change from the Ghost arm") -- ResidualDamage's curse arm: a quarter of max HP at the end of the turn. local before = wild.hp battle:tickSeedAndCurse(wild) eq(wild.hp, before - math.floor((wild.maxHp) / 4), "the cursed mon loses a quarter of max HP a turn") -- A second Ghost curse on the same target fails. battle:takeEvents() battle:useMove(player, wild, "CURSE") check(findText(battle:takeEvents(), "But it failed!"), "an already-cursed target refuses") end -- ---- Reflect and Light Screen --------------------------------------------- do -- Reflect doubles Defense against a physical hit: with the seeded -- variation the halving is exact enough to compare two identical hits. local battle, player, wild = newBattle({ random = rolls({}, 1) }) local bare = wild.hp battle:useMove(player, wild, "TACKLE") local plainDamage = bare - wild.hp check(plainDamage > 2, "the unscreened tackle deals real damage") battle, player, wild = newBattle({ random = rolls({}, 1) }) battle:useMove(wild, player, "REFLECT") eq(battle.screens.enemy.reflect, 5, "BattleCommand_Screen: five turns") check(findText(battle:takeEvents(), "MACHOP's DEFENSE rose!"), "ReflectEffectText") local screened = wild.hp battle:useMove(player, wild, "TACKLE") local screenedDamage = screened - wild.hp check(screenedDamage < plainDamage, "Reflect halves the physical hit (got " .. screenedDamage .. " vs bare " .. plainDamage .. ")") -- A second cast while the first is up fails. battle:takeEvents() battle:useMove(wild, player, "REFLECT") check(findText(battle:takeEvents(), "But it failed!"), "one Reflect at a time per side") -- A special hit sails through Reflect (Light Screen's business). battle, player, wild = newBattle({ random = rolls({}, 1) }) bare = wild.hp battle:useMove(player, wild, "EMBER") local emberPlain = bare - wild.hp wild.status = nil battle, player, wild = newBattle({ random = rolls({}, 1) }) battle:useMove(wild, player, "REFLECT") screened = wild.hp battle:useMove(player, wild, "EMBER") eq(screened - wild.hp, emberPlain, "Reflect ignores special moves") -- HandleScreens: the count ticks each turn and the screen falls at zero. battle, player, wild = newBattle({ random = rolls({}, 1) }) battle:useMove(wild, player, "LIGHT_SCREEN") eq(battle.screens.enemy.lightScreen, 5, "Light Screen is five turns too") for _ = 1, 4 do battle:tickScreens() end eq(battle.screens.enemy.lightScreen, 1, "four ticks down") battle:takeEvents() battle:tickScreens() eq(battle.screens.enemy.lightScreen, nil, "the fifth drops it") check(findText(battle:takeEvents(), "Enemy POKéMON's LIGHT SCREEN fell!"), "BattleText_MonsLightScreenFell") end -- ---- Leech Seed ----------------------------------------------------------- do local battle, player, wild = newBattle({ playerMoves = { { id = "LEECH_SEED", pp = 10, maxPp = 10 } }, random = rolls({ 0 }, 0) }) battle:useMove(player, wild, "LEECH_SEED") eq(wild.volatile.leechSeed, true, "SUBSTATUS_LEECH_SEED on the target") check(findText(battle:takeEvents(), "MACHOP was seeded!"), "WasSeededText") -- ResidualDamage: an eighth crosses to the other active mon. player.hp = 10 local seededBefore = wild.hp battle:tickSeedAndCurse(wild) local drained = seededBefore - wild.hp eq(drained, math.floor(wild.maxHp / 8), "an eighth of max HP drains") eq(player.hp, 10 + drained, "and lands on the seeder's side") check(findText(battle:takeEvents(), "LEECH SEED saps MACHOP!"), "LeechSeedSapsText") -- A second seed "evaded"; a Grass target is immune outright. battle:useMove(player, wild, "LEECH_SEED") check(findText(battle:takeEvents(), "MACHOP evaded the attack!"), "a seeded target evades the repeat") local battle2, player2, tangela = newBattle({ wildSpecies = "TANGELA", playerMoves = { { id = "LEECH_SEED", pp = 10, maxPp = 10 } }, random = rolls({ 0 }, 0) }) battle2:useMove(player2, tangela, "LEECH_SEED") check(tangela.volatile == nil or tangela.volatile.leechSeed == nil, "a Grass target cannot be seeded") check(findText(battle2:takeEvents(), "It doesn't affect TANGELA..."), "PrintDoesntAffect for Grass") end -- ---- the *_HIT flinch ----------------------------------------------------- do -- Rolls: crit roll, accuracy, damage variation, then the 30% flinch: 10 -- lands under 30. local battle, player, wild = newBattle({ playerMoves = { { id = "ROCK_SLIDE", pp = 10, maxPp = 10 } }, random = rolls({ 1, 0, 0, 10 }, 1) }) battle:useMove(player, wild, "ROCK_SLIDE") eq(wild.volatile.flinched, true, "BattleCommand_FlinchTarget: the effect chance landed") eq(battle:canAct(wild), false, "the flinched mon loses its turn") check(findText(battle:takeEvents(), "MACHOP flinched!"), "FlinchedText") -- A roll past the chance leaves no flinch. battle, player, wild = newBattle({ playerMoves = { { id = "ROCK_SLIDE", pp = 10, maxPp = 10 } }, random = rolls({ 1, 0, 0, 90 }, 1) }) battle:useMove(player, wild, "ROCK_SLIDE") check(wild.volatile == nil or wild.volatile.flinched == nil, "a 90 roll misses the 30 percent chance") end -- ---- the Bind class partial trap ------------------------------------------ do -- Rolls: crit, accuracy, variation, then the wrap count roll 1 -> 1+3 = 4. local battle, player, wild = newBattle({ playerMoves = { { id = "WRAP", pp = 20, maxPp = 20 } }, random = rolls({ 1, 0, 0, 1 }, 1) }) battle:useMove(player, wild, "WRAP") eq(wild.volatile.wrapCount, 4, "BattleCommand_TrapTarget: `and %11` plus three") eq(wild.volatile.wrapMove, "WRAP", "the trapping move is remembered") check(findText(battle:takeEvents(), "MACHOP was WRAPPED by MACHOP!"), "WrappedByText") -- HandleWrap: decrement first, then a sixteenth -- and release at zero. local before = wild.hp battle:tickWrap(wild) eq(wild.volatile.wrapCount, 3, "the count decrements first") eq(before - wild.hp, math.max(1, math.floor(wild.maxHp / 16)), "a sixteenth of max HP a turn") battle:tickWrap(wild) battle:tickWrap(wild) battle:takeEvents() battle:tickWrap(wild) eq(wild.volatile.wrapCount, nil, "the last tick releases") check(findText(battle:takeEvents(), "MACHOP was released from WRAP!"), "UserWasReleasedFromStringBuffer1") -- No re-trap while one is running. battle.random = rolls({ 1, 0, 0, 1 }, 1) battle:useMove(player, wild, "WRAP") battle.random = rolls({ 1, 0, 0, 1 }, 1) local count = wild.volatile.wrapCount battle:useMove(player, wild, "WRAP") eq(wild.volatile.wrapCount, count, "an existing trap is not re-rolled") end -- ---- Solarbeam under the sun ---------------------------------------------- do local battle, player, wild = newBattle({ playerMoves = { { id = "SOLARBEAM", pp = 10, maxPp = 10 } }, random = rolls({}, 1) }) local before = wild.hp battle:useMove(player, wild, "SOLARBEAM") eq(wild.hp, before, "without sun, turn one only charges") eq(player.volatile.chargeMove, "SOLARBEAM", "the charge is stored") battle, player, wild = newBattle({ playerMoves = { { id = "SOLARBEAM", pp = 10, maxPp = 10 } }, random = rolls({}, 1) }) battle.weather = "sun" before = wild.hp battle:useMove(player, wild, "SOLARBEAM") check(wild.hp < before, "BattleCommand_SkipSunCharge: in sun the beam fires in one turn") eq(player.volatile.chargeMove, nil, "no charge stored") end -- ---- False Swipe's 1-HP floor --------------------------------------------- do -- A hit that would KO leaves exactly 1 HP. local battle, player, wild = newBattle({ playerMoves = { { id = "FALSE_SWIPE", pp = 40, maxPp = 40 } }, random = rolls({ 1 }, 1) }) wild.hp = 3 battle:useMove(player, wild, "FALSE_SWIPE") eq(wild.hp, 1, "BattleCommand_FalseSwipe: damage is capped at the target's HP minus 1") -- At 1 HP already, the swipe deals nothing at all. battle.random = rolls({ 1 }, 1) battle:useMove(player, wild, "FALSE_SWIPE") eq(wild.hp, 1, "a 1-HP target cannot be KOed by it either") end -- ---- Splash prints the line and does nothing else ------------------------- do -- BattleCommand_Splash (move_effects/splash.asm) is the animation and then -- `jp PrintNothingHappened`; the effect list has no checkhit at all. local battle, player, wild = newBattle({ playerMoves = { { id = "SPLASH", pp = 40, maxPp = 40 } }, random = rolls({}, 1) }) local before = wild.hp local playerBefore = player.hp battle:useMove(player, wild, "SPLASH") check(findText(battle:takeEvents(), "But nothing\nhappened."), "NothingHappenedText, with the cart's own line break") eq(wild.hp, before, "and the target is never touched") eq(player.hp, playerBefore, "nor the user") end -- ---- Magnitude rolls its power off MagnitudePower ------------------------- do -- One BattleRandom byte walks data/moves/magnitude_power.asm and the first -- row whose threshold is not below it wins: 200 falls in the `85 percent + 1` -- row (217), which is Magnitude 8 at 90 power. local battle, player, wild = newBattle({ playerMoves = { { id = "MAGNITUDE", pp = 30, maxPp = 30 } }, random = rolls({ 200 }, 1) }) local before = wild.hp battle:useMove(player, wild, "MAGNITUDE") check(findText(battle:takeEvents(), "Magnitude 8!"), "MagnitudeText names the magnitude before checkhit") check(before - wild.hp > 5, "and the rolled power replaces the ROM's stored 1") -- The bottom row: a roll of 0 is under every threshold, so Magnitude 4. battle, player, wild = newBattle({ playerMoves = { { id = "MAGNITUDE", pp = 30, maxPp = 30 } }, random = rolls({ 0 }, 1) }) battle:useMove(player, wild, "MAGNITUDE") check(findText(battle:takeEvents(), "Magnitude 4!"), "`cp b / jr nc` takes the first row whose threshold covers the roll") end -- ---- Spite drains 2-5 PP from the target's last move ---------------------- do -- BattleCommand_Spite reads BATTLE_VARS_LAST_COUNTER_MOVE_OPP: the move the -- TARGET used last. `and %11` plus two is 2-5, clamped to what is left. local battle, player, wild = newBattle({ playerMoves = { { id = "SPITE", pp = 10, maxPp = 10 } }, random = rolls({ 0, 3 }, 0) }) battle:volatile(wild).lastMove = "TACKLE" battle:useMove(player, wild, "SPITE") eq(wild.moves[1].pp, 30, "a roll of 3 takes five PP") check(findText(battle:takeEvents(), "MACHOP's TACKLE was reduced by 5!"), "SpiteEffectText names the move and the figure") -- The clamp: never more than the slot still holds. battle, player, wild = newBattle({ playerMoves = { { id = "SPITE", pp = 10, maxPp = 10 } }, random = rolls({ 0, 3 }, 0) }) battle:volatile(wild).lastMove = "TACKLE" wild.moves[1].pp = 3 battle:useMove(player, wild, "SPITE") eq(wild.moves[1].pp, 0, "`cp b / jr nc` keeps the loss inside the slot") -- Nothing to spite: `.failed` is `jp PrintDidntAffect2`. battle, player, wild = newBattle({ playerMoves = { { id = "SPITE", pp = 10, maxPp = 10 } }, random = rolls({ 0 }, 0) }) battle:useMove(player, wild, "SPITE") check(findText(battle:takeEvents(), "It didn't affect MACHOP!"), "a target that has not moved yet cannot be spited") eq(wild.moves[1].pp, 35, "and nothing was taken") end -- ---- Dream Eater is gated by checkhit, not by the damage block ------------ do -- CheckHit opens on `call .DreamEater / jp z, .Miss` -- (effect_commands.asm:1554), so an awake target takes nothing at all. local battle, player, wild = newBattle({ playerMoves = { { id = "DREAM_EATER", pp = 15, maxPp = 15 } }, random = rolls({}, 1) }) player.hp = math.max(1, player.hp - 20) local before, healthBefore = wild.hp, player.hp battle:useMove(player, wild, "DREAM_EATER") check(findText(battle:takeEvents(), "MACHOP's attack missed!"), "AttackMissedText: .Miss, not ButItFailedText") eq(wild.hp, before, "no damage against a target that is awake") eq(player.hp, healthBefore, "and nothing was sapped") -- Asleep, it lands and drains half of what it dealt. battle, player, wild = newBattle({ playerMoves = { { id = "DREAM_EATER", pp = 15, maxPp = 15 } }, random = rolls({}, 1) }) player.hp = math.max(1, player.hp - 20) wild.status = "sleep" before, healthBefore = wild.hp, player.hp battle:useMove(player, wild, "DREAM_EATER") check(wild.hp < before, "a sleeping target takes the hit") check(player.hp > healthBefore, "and the user drains half of it") end -- ---- TRANSFORM is battle ram: it never leaves the battle on the mon -------- do -- BattleCommand_Transform copies the target into wBattleMon / wEnemyMon and -- leaves the struct the mon was loaded from alone, so SwitchOutMon's reload -- and PokeBallEffect's `.catch_without_fail` (which reads -- wTempEnemyMonSpecies, a byte no move rewrites) both hand back a DITTO. -- This port has one table per mon, so what the copy overwrites is kept on -- the volatile and Battle:untransform puts it back -- once at every route -- out of the battle. Before this a DITTO that transformed was a permanent -- copy: the caught record and the player's own party slot went into the save -- as the wrong species, with the wrong moves and the wrong stats. local battle, player, wild = newBattle({ playerMoves = { { id = "TRANSFORM", pp = 10, maxPp = 10 } }, wildSpecies = "TANGELA", wildMoves = { { id = "TACKLE", pp = 35, maxPp = 35 } }, random = rolls({}, 0) }) local ownAttack = player.stats.attack battle:useMove(player, wild, "TRANSFORM") eq(player.species, "TANGELA", "the copy lands at once: the battler IS it") eq(player.moves[1].id, "TACKLE", "with the target's moves") eq(player.stats.attack, wild.stats.attack, "and the target's stats") local state = battle:volatile(player) eq(state.preTransform.species, "MACHOP", "and the record it was is kept") -- The event the screen swaps its pic on: src/ui/gen2/BattleState.lua draws -- from `shownMon`, which follows the event queue, and a whole round is -- resolved before its first message is read. local sawEvent = false for _, event in ipairs(battle:takeEvents()) do if event.kind == "transform" then sawEvent = true eq(event.species, "TANGELA", "the transform event names the copy") eq(event.from, "MACHOP", "and what it was") end end check(sawEvent, "TRANSFORM emits its own moment for the screen") -- CleanUpBattleRAM, which is where the screen's finishBattle sends it. battle:clearAllVolatiles() eq(player.species, "MACHOP", "the party slot leaves the battle as itself") eq(player.moves[1].id, "TRANSFORM", "with its own moves") eq(player.stats.attack, ownAttack, "and its own stats") eq(player.volatile, nil, "and no volatile left on a save table") end -- ---- a DITTO caught while transformed is caught as a DITTO ----------------- do local Catching = require("src.battle.gen2.Catching") local battle, player, wild = newBattle({ wildMoves = { { id = "TRANSFORM", pp = 10, maxPp = 10 } }, playerSpecies = "TANGELA", random = rolls({}, 0) }) battle:useMove(wild, player, "TRANSFORM") eq(wild.species, "TANGELA", "the wild mon is the copy while the battle runs") -- PokeBallEffect's captured tail, reached through the one call the catch -- site makes into the battle rules. A MASTER BALL so the roll is not what -- is being tested. local caught = Catching.attempt({ ball = "MASTER_BALL", battle = battle, mon = wild, maxHp = wild.maxHp, hp = wild.hp, catchRate = 45 }) eq(caught, true, "the MASTER BALL never fails") eq(wild.species, "MACHOP", "and the record the catch keeps is the real mon") eq(wild.moves[1].id, "TRANSFORM", "with its own move list") end -- ---- STRUGGLE: full damage, then a quarter of it back ---------------------- do -- data/moves/moves.asm gives STRUGGLE 50 power and EFFECT_RECOIL_HIT, and -- BattleCommand_Recoil takes a QUARTER of the damage dealt (Gen 1's half is -- not Gen 2's formula), minimum 1. local battle, player, wild = newBattle({ random = rolls({}, 1) }) local before, mine = wild.hp, player.hp battle:useMove(player, wild, Battle.STRUGGLE) local dealt = before - wild.hp check(dealt > 5, "STRUGGLE swings its 50 power, not chip damage") eq(mine - player.hp, math.max(1, math.floor(dealt / 4)), "and the user takes a quarter of what it dealt") check(findText(battle:takeEvents(), "MACHOP is hit with recoil!"), "RecoilText") end -- ---- RUN is refused by a trainer battle without spending the turn ---------- do -- `.cant_run_from_trainer` leaves wBattlePlayerAction alone and falls into -- `jp BattleMenu` (engine/battle/core.asm:5035-5038), so the 2x2 menu comes -- straight back and the trainer never gets a free swing. Only -- `.cant_escape_2`, the failed ROLL, writes BATTLEPLAYERACTION_USEITEM and -- costs the round. local foe = Mon.new(DATA, "MACHOP", 15, { dvs = perfect }) foe.moves = { { id = "TACKLE", pp = 35, maxPp = 35 } } local player = Mon.new(DATA, "MACHOP", 15, { dvs = perfect }) player.moves = { { id = "TACKLE", pp = 35, maxPp = 35 } } local battle = Battle.new({ data = DATA, party = { player }, trainer = { class = "YOUNGSTER", party = { foe } }, random = rolls({}, 0) }) local before = player.hp local events = battle:takeTurn({ kind = "run" }) eq(player.hp, before, "the trainer never moved") eq(battle.over, false, "and the battle is still running") eq(battle.runRefused, true, "the refusal is what stopped the round") check(findText(events, "No! There's no running from a trainer battle!"), "NoRunningText") end S.finish()