-- 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()
