-- Gen 2 trainer AI (engine/battle/ai/scoring.asm + engine/battle/ai/move.asm).
--
-- The cart scores every move the enemy knows, then picks the *lowest* score:
-- each scoring layer walks the move list and either `dec [hl]` to encourage a
-- move or `inc [hl]` to discourage it.  Which layers run is a per-class bit
-- field, TRNATTR_AI_MOVE_WEIGHTS, which the extractor already carries on each
-- trainer class record as `attributes` -- bytes 4 and 5, little-endian.
--
-- A class with no flags (and every wild mon) simply picks at random, which is
-- what AIChooseMove does when wEnemyTrainerAIFlags is zero.  That is also the
-- honest fallback for a layer this file does not model: it never scores, so it
-- never scores wrongly.

local Damage = require("src.battle.gen2.Damage")

local Ai = {}

-- constants/trainer_data_constants.asm, shift_const order.
Ai.FLAGS = {
  BASIC = 0x0001,
  SETUP = 0x0002,
  TYPES = 0x0004,
  OFFENSIVE = 0x0008,
  SMART = 0x0010,
  OPPORTUNIST = 0x0020,
  AGGRESSIVE = 0x0040,
  CAUTIOUS = 0x0080,
  STATUS = 0x0100,
  RISKY = 0x0200,
}

-- AIChooseMove seeds every slot with this before the layers run.
Ai.BASE_SCORE = 20

-- BASE_AI_SWITCH_SCORE: CheckPlayerMoveTypeMatchups starts here and walks the
-- score down for every super-effective move the player has shown.
Ai.BASE_SWITCH_SCORE = 10

-- TrainerClassAttributes is {item1, item2, baseMoney, aiLo, aiHi, switchLo,
-- switchHi, pad}; the AI word is bytes 4 and 5, little-endian.
function Ai.flagsOf(attributes)
  if type(attributes) ~= "table" then return 0 end
  return (attributes[4] or 0) + (attributes[5] or 0) * 256
end

function Ai.has(flags, name)
  local bit = Ai.FLAGS[name]
  if not bit then return false end
  return math.floor((flags or 0) / bit) % 2 == 1
end

-- data/battle/ai/stall_moves.asm and residual_moves.asm, keyed by effect: the
-- moves AI_Opportunist stops using when it is nearly dead and the ones
-- AI_Cautious stops using after its first turn.
Ai.STALL_EFFECTS = {
  EFFECT_HEAL = true, EFFECT_TOXIC = true, EFFECT_LEECH_SEED = true,
  EFFECT_LIGHT_SCREEN = true, EFFECT_REFLECT = true, EFFECT_SAFEGUARD = true,
  EFFECT_MIST = true, EFFECT_SUBSTITUTE = true, EFFECT_PERISH_SONG = true,
  EFFECT_MEAN_LOOK = true, EFFECT_SPIKES = true, EFFECT_ATTRACT = true,
  EFFECT_CONFUSE = true, EFFECT_DISABLE = true, EFFECT_ENCORE = true,
  EFFECT_RAIN_DANCE = true, EFFECT_SUNNY_DAY = true, EFFECT_SANDSTORM = true,
  EFFECT_MORNING_SUN = true, EFFECT_SYNTHESIS = true, EFFECT_MOONLIGHT = true,
}

Ai.RESIDUAL_EFFECTS = {
  EFFECT_TOXIC = true, EFFECT_LEECH_SEED = true, EFFECT_NIGHTMARE = true,
  EFFECT_CURSE = true, EFFECT_SPIKES = true, EFFECT_PERISH_SONG = true,
  EFFECT_MEAN_LOOK = true, EFFECT_ATTRACT = true, EFFECT_ENCORE = true,
  EFFECT_DISABLE = true, EFFECT_LIGHT_SCREEN = true, EFFECT_REFLECT = true,
  EFFECT_SAFEGUARD = true, EFFECT_MIST = true,
}

-- The two blocks AI_Setup keys off: EFFECT_ATTACK_UP..EFFECT_EVASION_UP and
-- their _2 forms raise the user, the _DOWN forms lower the target.
Ai.STAT_UP_EFFECTS = {}
Ai.STAT_DOWN_EFFECTS = {}
for _, stat in ipairs({ "ATTACK", "DEFENSE", "SPEED", "SP_ATK", "SP_DEF",
    "ACCURACY", "EVASION" }) do
  Ai.STAT_UP_EFFECTS["EFFECT_" .. stat .. "_UP"] = true
  Ai.STAT_UP_EFFECTS["EFFECT_" .. stat .. "_UP_2"] = true
  Ai.STAT_DOWN_EFFECTS["EFFECT_" .. stat .. "_DOWN"] = true
  Ai.STAT_DOWN_EFFECTS["EFFECT_" .. stat .. "_DOWN_2"] = true
end

-- Effects that do nothing but inflict a major status, which is what the
-- BASIC and STATUS layers care about.
Ai.STATUS_EFFECTS = {
  EFFECT_SLEEP = "sleep", EFFECT_POISON = "poison", EFFECT_TOXIC = "toxic",
  EFFECT_PARALYZE = "paralyze", EFFECT_BURN = "burn",
  EFFECT_FREEZE = "freeze", EFFECT_CONFUSE = "confuse",
}

-- The type matchup a move would get, x10 (10 = neutral, 0 = immune).
local function matchupOf(context, def, defender)
  local chart = context.typeChart or {}
  return Damage.typeMultiplier(def.type, defender.types or {}, chart.matchups)
end

-- A rough expected damage, used only to rank moves against each other: the
-- real roll's randomness would make the AI's own choice non-deterministic,
-- which is not what the cart does (AI_Aggressive compares wCurDamage from a
-- no-random pass).
local function expectedDamage(context, attacker, defender, def)
  if (def.power or 0) <= 0 then return 0 end
  local damage = Damage.calc({
    level = attacker.level or 1,
    power = def.power,
    moveType = def.type,
    attacker = {
      attack = (attacker.stats or {}).attack,
      specialAttack = (attacker.stats or {}).specialAttack,
      types = attacker.types,
      stages = context.attackerStages,
    },
    defender = {
      defense = (defender.stats or {}).defense,
      specialDefense = (defender.stats or {}).specialDefense,
      types = defender.types,
      stages = context.defenderStages,
    },
    types = (context.typeChart or {}).types,
    matchups = (context.typeChart or {}).matchups,
    -- AIDamageCalc runs BattleCommand_DamageCalc itself (scoring.asm:3002-3016),
    -- so the enemy's estimate takes the `srl c` too (effect_commands.asm:2904-2909).
    defenseHalved = def.effect == "EFFECT_SELFDESTRUCT",
    critical = false,
    random = function() return 0 end,
  })
  return damage
end

--------------------------------------------------------------------------
-- AI_Smart (engine/battle/ai/scoring.asm)
--------------------------------------------------------------------------
--
-- The heaviest layer: a 70-entry table of per-EFFECT handlers, each one a few
-- lines of "look at the HP, the speed and the statuses, then encourage or
-- discourage".  `dec [hl]` encourages (a LOWER score wins) and `inc [hl]`
-- discourages; AIDiscourageMove adds ten, which is what "dismiss" means.
--
-- The handlers below are transcribed one for one.  An effect with no handler
-- is simply not scored by this layer, which is exactly what the cart does with
-- an effect that is not in its table.

-- AIDiscourageMove.
local DISMISS = 10

-- The two coin flips the scoring layers use.  `cp 20 percent - 1` succeeds
-- (carry set, meaning "return without scoring") on the LOW roll, so the
-- helpers below read as "does the encouragement happen".
local function chance(random, percent)
  return (random(100) + 1) <= percent
end

-- state (all optional; a missing field simply never fires its branch):
--   enemyHp / enemyMaxHp / playerHp / playerMaxHp
--   enemyFaster            AICompareSpeed
--   enemyTurns / playerTurns  how many turns each mon has been out
--   playerStatus / enemyStatus
--   playerToxic, playerLeechSeed, playerCharged, playerFlying
--   enemyRage, enemyProtectCount, enemyFuryCutter
--   stages (the enemy's) / playerStages
--   knownEffects           the effects the enemy's own move list carries
--   enemyMoveIds           the move IDS the enemy knows (AIHasMoveInArray)
--   enemyTypes / playerTypes   both slots, IN ORDER: the weather and Curse
--                          handlers read slot 1 before slot 2 and a swapped
--                          pair scores differently
--   playerSpecialType      either player type is on the special side of
--                          constants/type_constants.asm (`cp SPECIAL`)
--   playerMatchupScore     CheckPlayerMoveTypeMatchups' wEnemyAISwitchScore
--   playerLastMovePp / playerLastMoveMatchup / playerLastMoveSpecial
--   playerSpecialMoves     the special twin of playerPhysicalMoves
--   playerUsedEffects      the EFFECTS behind wPlayerUsedMoves
--   playerFuryCutter / playerRollout   the player's own ramp
--   playerFlyingUp / playerUnderground SUBSTATUS_FLYING and _UNDERGROUND
--                          split apart, where playerFlying is the mask
--   playerLastMon          AICheckLastPlayerMon
--   enemyToxic, enemyLeechSeed, enemySpikes, enemyPartyStatus
--   enemyPerishCount, enemySleepTurns, enemyHasBench
--   enemyInaccurateEffectiveMove   AI_Smart_LockOn's `.checkmove` verdict,
--                          explicitly false when the loop found nothing
--   playerLockOn           SUBSTATUS_LOCK_ON on the player, which is the
--                          enemy's own Lock-On having landed (the cart sets
--                          the bit on the TARGET); it also drives
--                          Ai.lockOnPostPass
--   hiddenPowerPower / hiddenPowerMatchup   HiddenPowerDamage's d and matchup
--   weather                wBattleWeather, "sun" / "rain" / "sandstorm"
--
-- These are read but never produced, because the port models no such
-- volatile yet; their branches are dead until it does:
--   playerTrapped, playerInLove, playerIdentified, playerNightmare,
--   playerCursed, playerMinimized, enemyWrapped, conversion2Matchup
local function fraction(value, max, part)
  if not (value and max and max > 0) then return nil end
  return value >= max * part
end

Ai.SMART = {}
local S = Ai.SMART

-- Greatly encourage a sleep move when the enemy can follow it with Dream
-- Eater or Nightmare; a coin flip otherwise.
S.EFFECT_SLEEP = function(ctx, st)
  local combo = st.knownEffects
    and (st.knownEffects.EFFECT_DREAM_EATER or st.knownEffects.EFFECT_NIGHTMARE)
  if not combo then return 0 end
  if not chance(ctx.random, 50) then return 0 end
  return -2
end

-- Dream Eater: 90% chance to greatly encourage.  AI_Basic is what keeps it
-- off an awake target.
S.EFFECT_DREAM_EATER = function(ctx)
  if chance(ctx.random, 10) then return 0 end
  return -3
end

-- Absorb and friends: discouraged when resisted, encouraged when the enemy is
-- hurt and the matchup is at least neutral.
S.EFFECT_LEECH_HIT = function(ctx, st, matchup)
  if (matchup or 10) < 10 then
    if chance(ctx.random, 39) then return 0 end
    return 1
  end
  if (matchup or 10) == 10 then return 0 end
  if fraction(st.enemyHp, st.enemyMaxHp, 1) then return 0 end
  if chance(ctx.random, 20) then return 0 end
  return -1
end

-- Toxic and Leech Seed (AI_Smart_Toxic, which AI_Smart_LeechSeed shares):
-- pointless once the target is already low, since the residual will not get
-- the turns to matter.  AICheckPlayerHalfHP sets carry when the player is
-- ABOVE half and the routine is `ret c`, so the discouragement lands BELOW
-- half; S.EFFECT_OHKO reads the same idiom the same way.
local function halfHpDiscourage(_, st)
  if fraction(st.playerHp, st.playerMaxHp, 0.5) then return 0 end
  return 1
end
S.EFFECT_TOXIC = halfHpDiscourage
S.EFFECT_LEECH_SEED = halfHpDiscourage

-- Light Screen / Reflect: only worth it at full HP.
local function fullHpOnly(ctx, st)
  if fraction(st.enemyHp, st.enemyMaxHp, 1) then return 0 end
  if chance(ctx.random, 8) then return 0 end
  return 1
end
S.EFFECT_LIGHT_SCREEN = fullHpOnly
S.EFFECT_REFLECT = fullHpOnly

-- Evasion up: dismissed at the cap, greatly encouraged at full HP (and
-- especially against a badly poisoned target), discouraged when nearly dead.
S.EFFECT_EVASION_UP = function(ctx, st)
  if (st.stages and st.stages.evasion or 0) >= 6 then return DISMISS end
  if fraction(st.enemyHp, st.enemyMaxHp, 1) then
    if st.playerToxic then return -2 end
    if chance(ctx.random, 70) then return -2 end
    return 0
  end
  if not fraction(st.enemyHp, st.enemyMaxHp, 0.25) then return 3 end
  if chance(ctx.random, 4) then return -2 end
  return 0
end

-- Swift and friends: worth it once accuracy or evasion has moved three stages.
S.EFFECT_ALWAYS_HIT = function(ctx, st)
  local accDown = (st.stages and st.stages.accuracy or 0) <= -3
  local evaUp = (st.playerStages and st.playerStages.evasion or 0) >= 3
  if not (accDown or evaUp) then return 0 end
  if chance(ctx.random, 20) then return 0 end
  return -2
end

-- OHKO: dismissed against a higher-level target, discouraged once the target
-- is below half.
S.EFFECT_OHKO = function(_, st)
  if (st.playerLevel or 1) > (st.enemyLevel or 1) then return DISMISS end
  if fraction(st.playerHp, st.playerMaxHp, 0.5) then return 0 end
  return 1
end

-- Confusion: worth less the lower the target already is.
S.EFFECT_CONFUSE = function(ctx, st)
  local score = 0
  if not fraction(st.playerHp, st.playerMaxHp, 0.5) then
    if not chance(ctx.random, 10) then score = score + 1 end
    if not fraction(st.playerHp, st.playerMaxHp, 0.25) then
      score = score + 1
    end
  end
  return score
end

-- Paralysis: greatly encouraged when the enemy is the SLOWER one, discouraged
-- against a nearly-dead target.
S.EFFECT_PARALYZE = function(ctx, st)
  if not fraction(st.playerHp, st.playerMaxHp, 0.25) then
    if chance(ctx.random, 50) then return 0 end
    return 1
  end
  if st.enemyFaster then return 0 end
  if not fraction(st.enemyHp, st.enemyMaxHp, 0.25) then return 0 end
  if chance(ctx.random, 20) then return 0 end
  return -2
end

-- Substitute: dismissed below half HP.
S.EFFECT_SUBSTITUTE = function(_, st)
  if fraction(st.enemyHp, st.enemyMaxHp, 0.5) then return 0 end
  return DISMISS
end

-- Hyper Beam: a finisher, not an opener.
S.EFFECT_HYPER_BEAM = function(ctx, st)
  if fraction(st.enemyHp, st.enemyMaxHp, 0.5) then
    if chance(ctx.random, 35) then return 0 end
    local score = 1
    if not chance(ctx.random, 50) then score = score + 1 end
    return score
  end
  if fraction(st.enemyHp, st.enemyMaxHp, 0.25) then return 0 end
  if chance(ctx.random, 50) then return 0 end
  return -1
end

-- Reversal and Skull Bash both want the enemy nearly dead.
local function needsLowHp(_, st)
  if not fraction(st.enemyHp, st.enemyMaxHp, 0.25) then return 0 end
  return 1
end
S.EFFECT_REVERSAL = needsLowHp
S.EFFECT_SKULL_BASH = needsLowHp

-- Belly Drum: full HP or nothing.
S.EFFECT_BELLY_DRUM = function(_, st)
  if (st.stages and st.stages.attack or 0) >= 3 then return 5 end
  if fraction(st.enemyHp, st.enemyMaxHp, 1) then return 0 end
  if fraction(st.enemyHp, st.enemyMaxHp, 0.5) then return 1 end
  return 5
end

-- Attract: an opener.
S.EFFECT_ATTRACT = function(ctx, st)
  if (st.playerTurns or 0) == 0 then
    if chance(ctx.random, 79) then return -1 end
    return 0
  end
  if chance(ctx.random, 20) then return 0 end
  return 1
end

-- Quick Attack and friends: only when the enemy is already slower, dismissed
-- against something off the field, encouraged when it would finish the job.
S.EFFECT_PRIORITY_HIT = function(_, st, _, damage)
  if st.enemyFaster then return 0 end
  if st.playerFlying then return DISMISS end
  if damage and st.playerHp and damage >= st.playerHp then return -1 end
  return 0
end

-- Protect: never twice running, and worth it against a charging or poisoned
-- target.
S.EFFECT_PROTECT = function(_, st)
  if (st.enemyProtectCount or 0) > 0 then return 2 end
  if st.playerLockOn then return 1 end
  if (st.playerFuryCutter or 0) >= 3 then return -1 end
  if st.playerCharged or st.playerToxic or st.playerLeechSeed then return -1 end
  return 0
end

-- Endure: for a Reversal follow-up, and never at high HP.
S.EFFECT_ENDURE = function(ctx, st)
  if (st.enemyProtectCount or 0) > 0 then return 2 end
  if fraction(st.enemyHp, st.enemyMaxHp, 1) then return 2 end
  if fraction(st.enemyHp, st.enemyMaxHp, 0.25) then return 1 end
  if st.knownEffects and st.knownEffects.EFFECT_REVERSAL then
    if chance(ctx.random, 20) then return 0 end
    return -3
  end
  return 0
end

-- Rollout and Fury Cutter: the ramp is only worth starting when nothing is
-- going to interrupt it.
local function rollout(ctx, st)
  local risky = st.enemyStatus == "paralyze" or st.enemyConfused
    or st.enemyInLove
    or not fraction(st.enemyHp, st.enemyMaxHp, 0.25)
    or (st.stages and st.stages.accuracy or 0) < 0
    or (st.playerStages and st.playerStages.evasion or 0) >= 1
  if not risky then return 0 end
  if chance(ctx.random, 20) then return 0 end
  return 1
end
S.EFFECT_ROLLOUT = rollout

-- Fury Cutter adds its own ramp bonus and then falls through to Rollout's
-- check, which is literally what the ASM does.
S.EFFECT_FURY_CUTTER = function(ctx, st)
  local count = st.enemyFuryCutterCount or 0
  local score = 0
  if count >= 1 then score = score - 1 end
  if count >= 2 then score = score - 2 end
  if count >= 3 then score = score - 3 end
  return score + rollout(ctx, st)
end

-- Rage: worth continuing once it is building.
S.EFFECT_RAGE = function(ctx, st)
  if not st.enemyRage then return 0 end
  if chance(ctx.random, 50) then return -1 end
  return -1 - math.min(3, st.enemyRageCount or 0)
end

-- Encore: only from ahead, and only against a weak or resisted move.
S.EFFECT_ENCORE = function(_, st, _, _, playerLastPower)
  if not st.enemyFaster then return 1 end
  if not st.playerLastMove then return DISMISS end
  if (playerLastPower or 0) == 0 then return -1 end
  return 0
end

-- Counter: worth it when the player's known moves are mostly physical.
S.EFFECT_COUNTER = function(_, st)
  if (st.playerPhysicalMoves or 0) == 0 then return 1 end
  return -1
end

--------------------------------------------------------------------------
-- The rest of AI_Smart_EffectHandlers, in the jumptable's own order.
--------------------------------------------------------------------------
--
-- Several entries in the table point at ONE cart body carrying two or more
-- labels; those share a Lua local here rather than being copied, and the
-- comment names every label that sits on it.  Where a shared body is reached
-- by an ASM fallthrough (`.greatly_discourage` dropping into `.discourage`)
-- the two deltas are added together into a single return, because the cart
-- really does run both.

-- Selfdestruct and Explosion (AI_Smart_Selfdestruct): a last resort.  Greatly
-- discouraged above half HP, left alone at or below a quarter (nothing left to
-- lose), and greatly discouraged 92% of the time in between.  `.discourage` is
-- reached from both ends, which is why the same +3 appears twice.
S.EFFECT_SELFDESTRUCT = function(ctx, st)
  if fraction(st.enemyHp, st.enemyMaxHp, 0.5) then return 3 end
  if not fraction(st.enemyHp, st.enemyMaxHp, 0.25) then return 0 end
  if chance(ctx.random, 8) then return 0 end
  return 3
end

-- data/battle/ai/useful_moves.asm: the nineteen moves AI_Smart_MirrorMove,
-- AI_Smart_Mimic and AI_Smart_Disable test the player's last move against.
-- The keys are the cache's own move ids, so PSYCHIC is PSYCHIC_M.
local USEFUL_MOVES = {
  DOUBLE_EDGE = true, SING = true, FLAMETHROWER = true, HYDRO_PUMP = true,
  SURF = true, ICE_BEAM = true, BLIZZARD = true, HYPER_BEAM = true,
  SLEEP_POWDER = true, THUNDERBOLT = true, THUNDER = true, EARTHQUAKE = true,
  TOXIC = true, PSYCHIC_M = true, HYPNOSIS = true, RECOVER = true,
  FIRE_BLAST = true, SOFTBOILED = true, SUPER_FANG = true,
}

-- Mirror Move (AI_Smart_MirrorMove).  With nothing to copy it is dismissed
-- only when the enemy is FASTER, because a faster enemy moves before the
-- player and would copy nothing; from behind the player will have moved by
-- then, so the cart says nothing.  With a useful move on the table it is a
-- coin flip to encourage, and a faster enemy encourages again.
S.EFFECT_MIRROR_MOVE = function(ctx, st)
  if not st.playerLastMove then
    if not st.enemyFaster then return 0 end
    return DISMISS
  end
  if not USEFUL_MOVES[st.playerLastMove] then return 0 end
  if chance(ctx.random, 50) then return 0 end
  if not st.enemyFaster then return -1 end
  if chance(ctx.random, 10) then return -1 end
  return -2
end

-- Sand-Attack and friends (AI_Smart_AccuracyDown).  The HP block picks one
-- bonus: a full-health player facing a healthy enemy is a big encouragement, a
-- nearly dead player a big discouragement.  The tail then re-reads the board
-- (badly poisoned, seeded, accuracy already under the enemy's evasion, mid
-- ramp) and can cancel it, which the cart's own comments admit to.
S.EFFECT_ACCURACY_DOWN = function(ctx, st)
  local score = 0
  if fraction(st.playerHp, st.playerMaxHp, 1)
      and fraction(st.enemyHp, st.enemyMaxHp, 0.5) then
    if st.playerToxic then return -2 end
    if chance(ctx.random, 70) then return -2 end
  elseif not fraction(st.playerHp, st.playerMaxHp, 0.25) then
    score = 2
  elseif chance(ctx.random, 4) then
    return -2
  elseif fraction(st.playerHp, st.playerMaxHp, 0.5) then
    if chance(ctx.random, 20) then return -2 end
  elseif not chance(ctx.random, 50) then
    -- `.hp_mismatch_3`'s 50% miss falls THROUGH into `.hp_mismatch_2`, so the
    -- move is at +2 before the tail below ever runs.
    score = 2
  end
  -- .not_encouraged, which `.hp_mismatch_2` also falls into: a move already at
  -- +2 can still be pulled back down here.
  if st.playerToxic then
    if chance(ctx.random, 31) then return score end
    return score - 2
  end
  if st.playerLeechSeed then
    if chance(ctx.random, 50) then return score end
    return score - 1
  end
  local enemyEva = st.stages and st.stages.evasion or 0
  local playerAcc = st.playerStages and st.playerStages.accuracy or 0
  if playerAcc < enemyEva then return score + 1 end
  if (st.playerFuryCutter or 0) > 0 then return score - 2 end
  if st.playerRollout then return score - 2 end
  return score + 1
end

-- wPlayerStatLevels / wEnemyStatLevels order, in Battle.newStages' names.
-- AI_Smart_ResetStats' loop counter is NUM_LEVEL_STATS (8) but it decrements
-- BEFORE every read, so it walks these seven and stops short of the ABILITY
-- pseudo-stat BattleCommand_Curse uses.
local STAGE_KEYS = { "attack", "defense", "speed", "specialAttack",
  "specialDefense", "accuracy", "evasion" }

-- Haze (AI_Smart_ResetStats): worth it once the board has turned, meaning any
-- of the enemy's own stages sits at -3 or worse or any of the player's at +3 or
-- better.  84% to encourage then, a flat discouragement when neither is true.
-- The cart bails out of the enemy loop the moment it finds a low stage and only
-- then walks the player's, which is the same answer as one combined pass.
S.EFFECT_RESET_STATS = function(ctx, st)
  local worth = false
  for _, key in ipairs(STAGE_KEYS) do
    if (st.stages and st.stages[key] or 0) <= -3 then worth = true end
    if (st.playerStages and st.playerStages[key] or 0) >= 3 then worth = true end
  end
  if not worth then return 1 end
  if chance(ctx.random, 16) then return 0 end
  return -1
end

-- Bide (AI_Smart_Bide): full HP or nothing.  The same shape as Light Screen's
-- check, but the cart rolls 10% here where fullHpOnly rolls 8%, so the two
-- cannot share a body.
S.EFFECT_BIDE = function(ctx, st)
  if fraction(st.enemyHp, st.enemyMaxHp, 1) then return 0 end
  if chance(ctx.random, 10) then return 0 end
  return 1
end

-- Whirlwind and Roar (AI_Smart_ForceSwitch), which AI_Smart_BatonPass repeats
-- instruction for instruction: only worth blowing the player away once it HAS
-- shown a super-effective move, which CheckPlayerMoveTypeMatchups reports as a
-- switch score below BASE_AI_SWITCH_SCORE.
local function switchMatchup(_, st)
  local score = st.playerMatchupScore
  if score == nil then return 0 end
  if score < Ai.BASE_SWITCH_SCORE then return 0 end
  return 1
end
S.EFFECT_FORCE_SWITCH = switchMatchup

-- Recover, Rest, Softboiled (AI_Smart_Heal): 90% to greatly encourage below a
-- quarter HP, discouraged above half, nothing in between.  AI_Smart_MorningSun,
-- AI_Smart_Synthesis and AI_Smart_Moonlight are three more labels on this one
-- body, so all four effects share it.
local function healSelf(ctx, st)
  if not fraction(st.enemyHp, st.enemyMaxHp, 0.25) then
    if chance(ctx.random, 10) then return 0 end
    return -2
  end
  if not fraction(st.enemyHp, st.enemyMaxHp, 0.5) then return 0 end
  return 1
end
S.EFFECT_HEAL = healSelf

-- Razor Wind (AI_Smart_RazorWind, shared with AI_Smart_Unused2B).  A two turn
-- move, so the cart drops it while a Perish count is running out, hits it with
-- a flat +6 (deliberately NOT AIDiscourageMove's ten) if the player has ever
-- shown Protect, and discourages it four times in five while the enemy is
-- confused or at or below half HP.  The confused case falls straight into the
-- 79% roll without ever testing HP.
local function razorWind(ctx, st)
  if st.enemyPerishCount and st.enemyPerishCount < 3 then return 1 end
  if st.playerUsedEffects and st.playerUsedEffects.EFFECT_PROTECT then
    return 6
  end
  if not st.enemyConfused then
    if fraction(st.enemyHp, st.enemyMaxHp, 0.5) then return 0 end
  end
  if chance(ctx.random, 79) then return 0 end
  return 1
end
S.EFFECT_RAZOR_WIND = razorWind

-- Super Fang (AI_Smart_SuperFang) halves what is left, so the only thing the
-- cart checks is whether there is enough left to halve.
S.EFFECT_SUPER_FANG = function(_, st)
  if fraction(st.playerHp, st.playerMaxHp, 0.25) then return 0 end
  return 1
end

-- Bind, Wrap, Fire Spin, Clamp (AI_Smart_TrapTarget): half the time greatly
-- encouraged against a target that is already suffering (badly poisoned, in
-- love, identified, mid Rollout, having a Nightmare) or still on its first
-- turn, and half the time discouraged otherwise or while the trap is already
-- running.  The encourage side also wants the enemy above a quarter HP to
-- survive the lock.
S.EFFECT_TRAP_TARGET = function(ctx, st)
  local encourage = false
  if not st.playerTrapped then
    encourage = st.playerToxic or st.playerInLove or st.playerRollout
      or st.playerIdentified or st.playerNightmare
      or (st.playerTurns or 0) == 0
  end
  if not encourage then
    if chance(ctx.random, 50) then return 0 end
    return 1
  end
  if not fraction(st.enemyHp, st.enemyMaxHp, 0.25) then return 0 end
  if chance(ctx.random, 50) then return 0 end
  return -2
end

-- AI_Smart_Unused2B is the second label on AI_Smart_RazorWind's body.
S.EFFECT_UNUSED_2B = razorWind

-- Amnesia (AI_Smart_SpDefenseUp2): discouraged below half HP or once Sp.Def is
-- already at +4, ignored from +2 up, and 80% to greatly encourage below that
-- when the player carries a special type.  The cart reuses the value already in
-- `a` for the second compare, so the +2 gate reads the same Sp.Def stage it
-- just tested against +4.
S.EFFECT_SP_DEF_UP_2 = function(ctx, st)
  if not fraction(st.enemyHp, st.enemyMaxHp, 0.5) then return 1 end
  local stage = st.stages and st.stages.specialDefense or 0
  if stage >= 4 then return 1 end
  if stage >= 2 then return 0 end
  if not st.playerSpecialType then return 0 end
  if chance(ctx.random, 20) then return 0 end
  return -2
end

-- Icy Wind, and ONLY Icy Wind (AI_Smart_SpeedDownHit): the cart gates on
-- wEnemyMoveStruct + MOVE_ANIM, so Bubble, Bubblebeam and Constrict share the
-- effect but never reach the body.  Almost 90% to greatly encourage on the
-- player's first turn, while the player is the faster one and the enemy is
-- still above a quarter HP.
S.EFFECT_SPEED_DOWN_HIT = function(ctx, st)
  if ctx.moveId ~= "ICY_WIND" then return 0 end
  if not fraction(st.enemyHp, st.enemyMaxHp, 0.25) then return 0 end
  if (st.playerTurns or 0) ~= 0 then return 0 end
  if st.enemyFaster then return 0 end
  if chance(ctx.random, 12) then return 0 end
  return -2
end

-- Mimic (AI_Smart_Mimic): with nothing to copy it is dismissed from ahead and
-- merely discouraged from behind, because `.dismiss` falls through into
-- `.discourage`.  Otherwise it wants the enemy above half HP and a copied move
-- that is at least neutral coming back at its owner: the cart sets hBattleTurn
-- to 1, so BattleCheckTypeMatchup defends with the PLAYER's types.
S.EFFECT_MIMIC = function(ctx, st)
  if not st.playerLastMove then
    if st.enemyFaster then return DISMISS end
    return 1
  end
  if not fraction(st.enemyHp, st.enemyMaxHp, 0.5) then return 1 end
  local copied = st.playerLastMoveMatchup
  if copied == nil then return 0 end
  if copied < 10 then return 1 end
  local score = 0
  if copied > 10 and not chance(ctx.random, 50) then score = -1 end
  if not USEFUL_MOVES[st.playerLastMove] then return score end
  if chance(ctx.random, 50) then return score end
  return score - 1
end

-- AI_Smart_Disable.  Only worth it from ahead: the slower enemy skips straight
-- to the discourage.  From ahead, a 61% encourage when the player's last move
-- is one of UsefulMoves.  The "does my own move have power" test on the way out
-- reads wEnemyMoveStruct + MOVE_POWER, which is 0 for every stock
-- EFFECT_DISABLE move, so on an unmodded cart a boring last move always falls
-- through into `.discourage`; the `damage` argument stands in for it so a
-- modded Disable with real power keeps the branch.
S.EFFECT_DISABLE = function(ctx, st, _, damage)
  if st.enemyFaster then
    if USEFUL_MOVES[st.playerLastMove] then
      if chance(ctx.random, 39) then return 0 end
      return -1
    end
    if (damage or 0) > 0 then return 0 end
  end
  if chance(ctx.random, 8) then return 0 end
  return 1
end

-- AI_Smart_PainSplit: discourage while doubling the enemy's HP would still
-- overshoot the player's, since the split would then hand HP away.  The cart
-- does this as one 16 bit compare of [player HP] against [enemy HP * 2]; its
-- own comment states the test backwards.
S.EFFECT_PAIN_SPLIT = function(_, st)
  if not (st.enemyHp and st.playerHp) then return 0 end
  if st.playerHp >= st.enemyHp * 2 then return 0 end
  return 1
end

-- AI_Smart_Snore, shared verbatim with AI_Smart_SleepTalk (one label falls
-- into the other).  The cart tests the sleep counter against 1, so it
-- discourages only on the last sleeping turn and greatly encourages everything
-- else, an AWAKE enemy included; AI_Redundant is what keeps these off an awake
-- mon.
local function snoreOrSleepTalk(_, st)
  local count = st.enemySleepTurns
  if count == nil then return 0 end
  if count == 1 then return 3 end
  return -3
end
S.EFFECT_SNORE = snoreOrSleepTalk

-- AI_Smart_Conversion2.  CART BUG (docs/bugs_and_glitches.md): the guard reads
-- `ld a, [wLastPlayerMove] / and a / jr nz, .discourage`, so it discourages
-- once the player HAS moved and takes the matchup path only on turn one, where
-- the move index it looks up is 0 - 1 = $ff, past the end of Moves.  The
-- inverted test is kept; the garbage read becomes st.conversion2Matchup, which
-- the port leaves nil so that path scores nothing.
S.EFFECT_CONVERSION2 = function(ctx, st)
  if not st.playerLastMove then
    local matchup = st.conversion2Matchup
    if matchup ~= nil then
      if matchup > 10 then
        if chance(ctx.random, 50) then return 0 end
        return -1
      end
      if matchup == 10 then return 0 end
    else
      return 0
    end
  end
  -- .discourage
  if chance(ctx.random, 10) then return 0 end
  return 1
end

-- AI_Smart_LockOn.  Pointless when the player is already locked on, worthless
-- when nearly dead, and only from ahead once past half HP.  It then wants a
-- reason: the player's evasion up three, the enemy's accuracy down three, or
-- failing both, at least one shaky-but-effective move to aim.
-- The dismissal is only half of `.player_locked_on`: the branch also walks the
-- enemy's OWN move list and encourages every shaky move by two, which is a
-- score edit on OTHER moves and so cannot live in a per-move handler.  That
-- half is Ai.lockOnPostPass, run from Ai.choose once the table is done.
S.EFFECT_LOCK_ON = function(ctx, st)
  if st.playerLockOn then return DISMISS end
  if not fraction(st.enemyHp, st.enemyMaxHp, 0.25) then return 1 end
  if not fraction(st.enemyHp, st.enemyMaxHp, 0.5) and not st.enemyFaster then
    return 1
  end
  local evasion = st.playerStages and st.playerStages.evasion or 0
  if evasion >= 3 then
    if chance(ctx.random, 50) then return 0 end
    return -2
  end
  if evasion >= 1 then return 0 end
  local accuracy = st.stages and st.stages.accuracy or 0
  if accuracy <= -3 then
    if chance(ctx.random, 50) then return 0 end
    return -2
  end
  if accuracy < 0 then return 0 end
  -- .checkmove: the loop reaches .discourage only when no move qualified.
  if st.enemyInaccurateEffectiveMove == false then return 1 end
  return 0
end

-- `71 percent - 1`, the raw ($ff-scaled) accuracy AI_Smart_LockOn calls shaky.
Ai.LOCK_ON_ACCURACY = 0xb4

-- AI_Smart_LockOn's `.player_locked_on` half, as a post-pass over the finished
-- score table.  With the lock-on already up the layer stops caring about its
-- own slot and doubly encourages every move the enemy would otherwise struggle
-- to land ("dec [hl]" twice per move under `71 percent - 1`); the `.dismiss`
-- tail that then buries Lock-On itself is what S.EFFECT_LOCK_ON returns.
--
-- The loop is per Lock-On in the list, not per turn: a mon carrying both
-- Lock-On and Mind Reader runs the scoring layer twice and so lands the
-- encouragement twice, which is exactly what the cart does.
--
-- `defs` is the move definition for each score slot, in the same order.
function Ai.lockOnPostPass(scores, defs)
  local rounds = 0
  for i = 1, #scores do
    local def = defs[i]
    if def and def.effect == "EFFECT_LOCK_ON" then rounds = rounds + 1 end
  end
  if rounds == 0 then return scores end
  for i = 1, #scores do
    local def = defs[i]
    -- accuracyRaw is the cart's own byte; the percentage is the fallback for a
    -- caller that only has the human-readable number.
    local raw = def and (def.accuracyRaw
      or (def.accuracy and math.floor(def.accuracy * 255 / 100)))
    if raw and raw < Ai.LOCK_ON_ACCURACY then
      scores[i] = scores[i] - 2 * rounds
    end
  end
  return scores
end

-- AI_Smart_DefrostOpponent.  Dead twice over, and both are kept: no move
-- carries EFFECT_DEFROST_OPPONENT (the cart says so itself), and the status it
-- reads is wEnemyMonStatus, the AI's OWN freeze, not the opponent the effect
-- names.
S.EFFECT_DEFROST_OPPONENT = function(_, st)
  if st.enemyStatus ~= "freeze" then return 0 end
  return -3
end

-- AI_Smart_SleepTalk is the same label body as AI_Smart_Snore.
S.EFFECT_SLEEP_TALK = snoreOrSleepTalk

-- AI_Smart_DestinyBond is the third label on the body `needsLowHp` already
-- carries for AI_Smart_Reversal and AI_Smart_SkullBash.
S.EFFECT_DESTINY_BOND = needsLowHp

-- AI_Smart_Spite.  With nothing to drain yet it is a stall move: dismissed
-- from ahead, half discouraged from behind.  Once the player has shown a move
-- it goes by that move's remaining PP: under 6 is worth taking, 15 or more is
-- not.  The cart reads the raw PP byte without masking off the PP Up bits, so
-- a move with any PP Ups always lands on `.discourage`; the port stores plain
-- PP and has no such bits to mask, so that quirk cannot be reproduced.
S.EFFECT_SPITE = function(ctx, st)
  if not st.playerLastMove then
    if st.enemyFaster then return DISMISS end
    if chance(ctx.random, 50) then return 0 end
    return 1
  end
  -- `.moveloop` falls out with no score when that move is not in the player's
  -- current move list, which is what a nil PP stands in for here.
  local pp = st.playerLastMovePp
  if pp == nil then return 0 end
  if pp < 6 then
    if chance(ctx.random, 39) then return 0 end
    return -2
  end
  if pp >= 15 then return 1 end
  if not chance(ctx.random, 39) then return 0 end
  return 1
end

-- AI_Smart_HealBell.  The cart ORs the status byte of every unfainted mon in
-- wOTParty: nothing statused and a clean active mon dismisses the move.
-- Otherwise one step of encouragement for the active mon being statused, and a
-- coin flip for two more when that status is sleep or freeze, the two it cannot
-- simply wait out.  `.ok` is reached both by the `jr z` and by falling through
-- the `dec [hl]`, so the bonus stacks on top of the first step.
S.EFFECT_HEAL_BELL = function(ctx, st)
  if st.enemyPartyStatus == nil then return 0 end
  if not st.enemyPartyStatus then
    -- .no_status: the party copy can lag the active mon, which is the only way
    -- this test and the one above disagree.
    if st.enemyStatus then return 0 end
    return DISMISS
  end
  local score = 0
  if st.enemyStatus then score = score - 1 end
  if st.enemyStatus == "sleep" or st.enemyStatus == "freeze" then
    if chance(ctx.random, 50) then return score end
    score = score - 2
  end
  return score
end

-- AI_Smart_Thief: `ld a, [hl] / add $1e`.  Three times a dismissal, so Thief is
-- only ever picked when nothing else is left.
S.EFFECT_THIEF = function()
  return 30
end

-- AI_Smart_MeanLook.  Needs the enemy above half HP and the player holding
-- something in reserve (trapping the player's last mon is dismissed outright).
-- 80% to greatly encourage against a player who is already suffering, else
-- discourage unless CheckPlayerMoveTypeMatchups says the player has nothing
-- effective to answer with.
-- CART BUG (docs/bugs_and_glitches.md): the badly-poisoned test reads
-- wEnemySubStatus5, so the AI encourages Mean Look when IT is the poisoned one.
S.EFFECT_MEAN_LOOK = function(ctx, st)
  local encourage = false
  if fraction(st.enemyHp, st.enemyMaxHp, 0.5) then
    if st.playerLastMon then return DISMISS end
    if st.enemyToxic then
      encourage = true
    elseif st.playerInLove or st.playerRollout or st.playerIdentified
        or st.playerNightmare then
      encourage = true
    elseif (st.playerMatchupScore or Ai.BASE_SWITCH_SCORE)
        >= Ai.BASE_SWITCH_SCORE + 1 then
      return 0
    end
  end
  if not encourage then return 1 end
  if chance(ctx.random, 20) then return 0 end
  return -3
end

-- AI_Smart_Nightmare: a flat coin flip.  AI_Basic is what keeps it off a
-- target that is not asleep.
S.EFFECT_NIGHTMARE = function(ctx)
  if chance(ctx.random, 50) then return 0 end
  return -1
end

-- AI_Smart_FlameWheel: five steps of encouragement when the enemy is frozen,
-- because Flame Wheel and Sacred Fire thaw their own user in Gen 2.  Its status
-- read really is the enemy's own, unlike AI_Smart_DefrostOpponent's.
S.EFFECT_FLAME_WHEEL = function(_, st)
  if st.enemyStatus ~= "freeze" then return 0 end
  return -5
end

-- Curse (AI_Smart_Curse).  A Ghost type enemy pays half its HP for a residual,
-- so that half of the routine wants a healthy enemy, an uncursed target and the
-- target's very first turn; the non-Ghost half is an Attack boost and wants a
-- target it can actually punch, meaning neither of its types is special.
S.EFFECT_CURSE = function(ctx, st)
  local enemyTypes = st.enemyTypes or {}
  local playerTypes = st.playerTypes or {}
  if enemyTypes[1] == "GHOST" or enemyTypes[2] == "GHOST" then
    -- .ghost_curse: dismissed at or below 25% (the cut would be suicide), and
    -- discouraged at or below 50%.
    if not fraction(st.enemyHp, st.enemyMaxHp, 0.25) then return DISMISS end
    if not fraction(st.enemyHp, st.enemyMaxHp, 0.5) then return 1 end
    if st.playerCursed then return DISMISS end
    if (st.playerTurns or 0) > 0 then return 0 end
    if chance(ctx.random, 50) then return 0 end
    return -2
  end
  if not fraction(st.enemyHp, st.enemyMaxHp, 0.5) then return 1 end
  -- wEnemyAtkLevel against BASE_STAT_LEVEL + 4 and + 2: at +4 the boost is
  -- discouraged outright, at +2 the AI simply has no opinion left.
  local attack = st.stages and st.stages.attack or 0
  if attack >= 4 then return 1 end
  if attack >= 2 then return 0 end
  -- `cp GHOST` comes before `cp SPECIAL`, and `.greatly_discourage` falls
  -- THROUGH into `.discourage`, so a Ghost FIRST type is +2, not +1.
  if playerTypes[1] == "GHOST" then return 2 end
  if st.playerSpecialType then return 0 end
  if chance(ctx.random, 20) then return 0 end
  return -2
end

-- Foresight (AI_Smart_Foresight).  Worth 61% encouragement when the accuracy
-- war has already been lost (enemy accuracy at -3, player evasion at +3) or
-- when the target is a Ghost the enemy's Normal and Fighting moves cannot
-- touch; a flat 92% discouragement otherwise.
S.EFFECT_FORESIGHT = function(ctx, st)
  local playerTypes = st.playerTypes or {}
  local encourage = (st.stages and st.stages.accuracy or 0) <= -3
    or (st.playerStages and st.playerStages.evasion or 0) >= 3
    or playerTypes[1] == "GHOST" or playerTypes[2] == "GHOST"
  if not encourage then
    if chance(ctx.random, 8) then return 0 end
    return 1
  end
  if chance(ctx.random, 39) then return 0 end
  return -2
end

-- Perish Song (AI_Smart_PerishSong).  FindAliveEnemyMons first: with nothing on
-- the bench the countdown kills the enemy too, which is worth five points of
-- discouragement.  A trapped player cannot run from it, so that is the one case
-- the cart encourages; otherwise it is only left alone while the AI is losing
-- the matchup and would rather rotate out anyway.
S.EFFECT_PERISH_SONG = function(ctx, st)
  if st.enemyHasBench == false then return 5 end
  if st.playerTrapped then
    if chance(ctx.random, 50) then return 0 end
    return -1
  end
  local score = st.playerMatchupScore or Ai.BASE_SWITCH_SCORE
  if score < Ai.BASE_SWITCH_SCORE then return 0 end
  if chance(ctx.random, 50) then return 0 end
  return 1
end

-- AI_Smart_Sandstorm's own .SandstormImmuneTypes, walked with IsInArray once
-- per type slot.
local SANDSTORM_IMMUNE = { ROCK = true, GROUND = true, STEEL = true }

-- Sandstorm (AI_Smart_Sandstorm).  Greatly discouraged against anything that
-- shrugs the chip off (`.greatly_discourage` falls through into `.discourage`,
-- hence +2), discouraged once the target is at or below half (the chip will not
-- decide the fight any more), a coin flip otherwise.
S.EFFECT_SANDSTORM = function(ctx, st)
  local playerTypes = st.playerTypes or {}
  if SANDSTORM_IMMUNE[playerTypes[1] or ""]
      or SANDSTORM_IMMUNE[playerTypes[2] or ""] then
    return 2
  end
  if not fraction(st.playerHp, st.playerMaxHp, 0.5) then return 1 end
  if chance(ctx.random, 50) then return 0 end
  return -1
end

-- Swagger (AI_Smart_Swagger) jumps straight into AI_Smart_Attract: both are
-- openers, 80% encouraged on the target's first turn and 80% discouraged after.
S.EFFECT_SWAGGER = S.EFFECT_ATTRACT

-- Safeguard (AI_Smart_Safeguard).  80% discouraged once the PLAYER is at or
-- below half HP: the cart reads the player's bar, not the enemy's, on the
-- theory that a nearly dead target is not going to status anything.
-- AICheckPlayerHalfHP sets carry when the player is ABOVE half and the routine
-- is `ret c`, so this layer only ever discourages.
S.EFFECT_SAFEGUARD = function(ctx, st)
  if fraction(st.playerHp, st.playerMaxHp, 0.5) then return 0 end
  if chance(ctx.random, 20) then return 0 end
  return 1
end

-- Magnitude (AI_Smart_Magnitude), which AI_Smart_Earthquake shares outright.
-- It only ever fires when the player's last move was Dig: greatly encouraged if
-- the player is underground right now and the enemy moves first, and a coin
-- flip when the player has surfaced but the enemy is SLOWER, which is the
-- cart's guess that the player is about to dig again.  The two speed tests are
-- opposite senses of the same AICompareSpeed carry.
local function smartEarthquake(ctx, st)
  if st.playerLastMove ~= "DIG" then return 0 end
  if st.playerUnderground then
    if not st.enemyFaster then return 0 end
    return -2
  end
  -- .could_dig
  if st.enemyFaster then return 0 end
  if chance(ctx.random, 50) then return 0 end
  return -1
end
S.EFFECT_MAGNITUDE = smartEarthquake

-- Baton Pass (AI_Smart_BatonPass) is AI_Smart_ForceSwitch's body again: the
-- cart never looks at what the enemy would actually be passing.
S.EFFECT_BATON_PASS = switchMatchup

-- Pursuit (AI_Smart_Pursuit).  50% chance to greatly encourage it once the
-- target is at or below 25% and likely to run or rotate; 80% discouraged
-- otherwise, since at full HP it is just a 40 power Dark move.
S.EFFECT_PURSUIT = function(ctx, st)
  if not fraction(st.playerHp, st.playerMaxHp, 0.25) then
    if chance(ctx.random, 50) then return 0 end
    return -2
  end
  if chance(ctx.random, 20) then return 0 end
  return 1
end

-- Rapid Spin (AI_Smart_RapidSpin).  80% chance to greatly encourage it when it
-- would actually clear something off the ENEMY's own side: a Bind style trap,
-- Leech Seed, or Spikes.  No opinion at all otherwise.
S.EFFECT_RAPID_SPIN = function(ctx, st)
  if not (st.enemyWrapped or st.enemyLeechSeed or st.enemySpikes) then
    return 0
  end
  if chance(ctx.random, 20) then return 0 end
  return -2
end

-- AI_Smart_MorningSun, AI_Smart_Synthesis and AI_Smart_Moonlight are three more
-- labels on AI_Smart_Heal's body.  The cart makes no weather check at all here,
-- even though the three moves heal different fractions by weather.
S.EFFECT_MORNING_SUN = healSelf
S.EFFECT_SYNTHESIS = healSelf
S.EFFECT_MOONLIGHT = healSelf

-- Hidden Power (AI_Smart_HiddenPower): the cart throws away the Normal-type
-- stub in the move table and recomputes the move's real type and base power
-- from the enemy's DVs (HiddenPowerDamage), then scores THAT.  Resisted, or
-- under 50 power, is discouraged; super effective, or a full 70 power at
-- neutral, is encouraged.  The `matchup` argument the layer passes in is
-- deliberately unused: it is the declared type's, which is what the cart
-- discards.
S.EFFECT_HIDDEN_POWER = function(_, st)
  local power, matchup = st.hiddenPowerPower, st.hiddenPowerMatchup
  if not (power and matchup) then return 0 end
  -- cp EFFECTIVE: not very effective, or immune, is `.bad`.
  if matchup < 10 then return 1 end
  if power < 50 then return 1 end
  -- cp EFFECTIVE + 1: super effective is `.good` whatever the power is.
  if matchup > 10 then return -1 end
  if power < 70 then return 0 end
  return -1
end

-- Rain Dance and Sunny Day share AI_Smart_WeatherMove and its two tails,
-- AIBadWeatherType (three inc [hl]) and AIGoodWeatherType (two dec [hl]).
-- The player's type slots are read IN ORDER, bad then good per slot, so a
-- Fire/Water target reads as "good" for Rain Dance where a Water/Fire one reads
-- as "bad": that ordering is load bearing, do not fold it into a set.
local function weatherTypeVerdict(types, badType, goodType)
  for i = 1, 2 do
    local slot = (types or {})[i]
    if slot == badType then return "bad" end
    if slot == goodType then return "good" end
  end
  return nil
end

-- AIBadWeatherType.
local BAD_WEATHER = 3

-- AIGoodWeatherType: the weather would disfavour the player type-wise, so set
-- it up while the player is still above half, and only while one of the two
-- mons is still on its first turn.
local function goodWeatherType(_, st)
  if not fraction(st.playerHp, st.playerMaxHp, 0.5) then return 0 end
  if (st.playerTurns or 0) == 0 then return -2 end
  if (st.enemyTurns or 0) ~= 0 then return 0 end
  return -2
end

-- AI_Smart_WeatherMove: greatly discouraged unless the enemy actually knows a
-- move off the matching list, and again once the player is at or below half; a
-- coin flip encourages it otherwise.
local function weatherMove(ctx, st, moves)
  if not st.enemyMoveIds then return 0 end
  local hasOne = false
  for _, id in ipairs(moves) do
    if st.enemyMoveIds[id] then hasOne = true break end
  end
  if not hasOne then return BAD_WEATHER end
  if not fraction(st.playerHp, st.playerMaxHp, 0.5) then return BAD_WEATHER end
  if chance(ctx.random, 50) then return 0 end
  return -1
end

-- data/battle/ai/rain_dance_moves.asm, in list order.
local RAIN_DANCE_MOVES = {
  "WATER_GUN", "HYDRO_PUMP", "SURF", "BUBBLEBEAM", "THUNDER", "WATERFALL",
  "CLAMP", "BUBBLE", "CRABHAMMER", "OCTAZOOKA", "WHIRLPOOL",
}

-- Rain Dance (AI_Smart_RainDance): greatly discouraged against a Water-type (it
-- would hand the player the boost), taken eagerly against a Fire-type, and
-- otherwise only worth it when the enemy has something on RainDanceMoves to
-- spend the weather on.
S.EFFECT_RAIN_DANCE = function(ctx, st)
  local verdict = weatherTypeVerdict(st.playerTypes, "WATER", "FIRE")
  if verdict == "bad" then return BAD_WEATHER end
  if verdict == "good" then return goodWeatherType(ctx, st) end
  return weatherMove(ctx, st, RAIN_DANCE_MOVES)
end

-- data/battle/ai/sunny_day_moves.asm, in list order.  CART BUG, kept: the list
-- leaves out SOLARBEAM, FLAME_WHEEL and MOONLIGHT, so the AI never encourages
-- Sunny Day for the three moves that want it most
-- (docs/bugs_and_glitches.md).
local SUNNY_DAY_MOVES = {
  "FIRE_PUNCH", "EMBER", "FLAMETHROWER", "FIRE_SPIN", "FIRE_BLAST",
  "SACRED_FIRE", "MORNING_SUN", "SYNTHESIS",
}

-- Sunny Day (AI_Smart_SunnyDay): the mirror of Rain Dance, Fire-type bad and
-- Water-type good, sharing AI_Smart_WeatherMove by fallthrough in the cart.
S.EFFECT_SUNNY_DAY = function(ctx, st)
  local verdict = weatherTypeVerdict(st.playerTypes, "FIRE", "WATER")
  if verdict == "bad" then return BAD_WEATHER end
  if verdict == "good" then return goodWeatherType(ctx, st) end
  return weatherMove(ctx, st, SUNNY_DAY_MOVES)
end

-- Psych Up copies the player's stat levels, so it is only worth it when the
-- player is the one who has been setting up: AI_Smart_PsychUp sums both sides
-- and discourages when the enemy is already ahead.
-- Two cart quirks, both kept.  The sums walk NUM_LEVEL_STATS = 8 entries, one
-- past EVASION into the ABILITY slot BattleCommand_Curse uses; both sides read
-- the same slot, so the comparison is unchanged and the Lua sums the seven real
-- stages.  And the encouraging tail asks for a player evasion that is both at
-- least +2 and below +1, so its 80% dec [hl] is dead code the cart itself
-- flags: this handler can only ever return +1 or 0.
S.EFFECT_PSYCH_UP = function(ctx, st)
  local mine, theirs = st.stages, st.playerStages
  if not (mine and theirs) then return 0 end
  local enemySum, playerSum = 0, 0
  for _, key in ipairs(STAGE_KEYS) do
    enemySum = enemySum + (mine[key] or 0)
    playerSum = playerSum + (theirs[key] or 0)
  end
  if enemySum >= playerSum then return 1 end
  if (theirs.accuracy or 0) < -1 then return 0 end
  if (theirs.evasion or 0) < 2 then return 0 end
  -- Never reached: evasion cannot be at least +2 and below +1 at once.
  if (theirs.evasion or 0) >= 1 then return 0 end
  if chance(ctx.random, 20) then return 0 end
  return -1
end

-- Mirror Coat answers special damage, so AI_Smart_MirrorCoat counts how many of
-- the moves the player has ACTUALLY used are special AND do damage.  Three or
-- more is enough on its own; one or two only counts when the player's last move
-- was special damage too; none at all is discouraged outright.  This is
-- AI_Smart_Counter's routine with `jr c` and `jr nc` swapped on the type test.
S.EFFECT_MIRROR_COAT = function(ctx, st, _, _, playerLastPower)
  local special = st.playerSpecialMoves
  if special == nil then return 0 end
  if special == 0 then return 1 end
  local encourage = special >= 3
  if not encourage then
    encourage = st.playerLastMove ~= nil and (playerLastPower or 0) > 0
      and st.playerLastMoveSpecial == true
  end
  if not encourage then return 0 end
  if chance(ctx.random, 39) then return 0 end
  return -1
end

-- Twister and Gust share one body (AI_Smart_Twister falls straight into
-- AI_Smart_Gust): both hit a target that is up in the air, so the cart only
-- looks at them when the player's last move was Fly.  Already flying and slower
-- than the enemy is a free double hit; still on the ground is a coin flip on
-- predicting the Fly, and only from behind, since going second is what lands
-- the hit.  This reads SUBSTATUS_FLYING specifically, not the FLYING|UNDERGROUND
-- mask st.playerFlying carries.
local function smartGust(ctx, st)
  if st.playerLastMove ~= "FLY" then return 0 end
  if st.playerFlyingUp then
    if not st.enemyFaster then return 0 end
    return -2
  end
  -- .couldFly: try to predict the Fly this turn.
  if st.enemyFaster then return 0 end
  if chance(ctx.random, 50) then return 0 end
  return -1
end
S.EFFECT_TWISTER = smartGust

-- AI_Smart_Earthquake is the label AI_Smart_Magnitude sits on.
S.EFFECT_EARTHQUAKE = smartEarthquake

-- Future Sight (AI_Smart_FutureSight) lands a turn late, which is exactly when
-- a player who is flying or underground comes back down.  The cart checks the
-- speed first here and the substatus first in AI_Smart_Fly; same answer either
-- way, and both read the combined FLYING|UNDERGROUND mask.
S.EFFECT_FUTURE_SIGHT = function(_, st)
  if not st.enemyFaster then return 0 end
  if not st.playerFlying then return 0 end
  return -2
end

-- AI_Smart_Gust shares AI_Smart_Twister's body; see smartGust above.
S.EFFECT_GUST = smartGust

-- Stomp (AI_Smart_Stomp) doubles against a minimized target, so an 80%
-- encourage once the player has used Minimize at all.
S.EFFECT_STOMP = function(ctx, st)
  if not st.playerMinimized then return 0 end
  if chance(ctx.random, 20) then return 0 end
  return -1
end

-- SolarBeam (AI_Smart_Solarbeam) skips its charge turn in sun and is halved in
-- rain, and the cart scores exactly that: 80% to greatly encourage while the
-- sun is out, 90% to greatly discourage while it is raining, no opinion in
-- anything else.
S.EFFECT_SOLARBEAM = function(ctx, st)
  if st.weather == "sun" then
    if chance(ctx.random, 20) then return 0 end
    return -2
  end
  if st.weather ~= "rain" then return 0 end
  if chance(ctx.random, 10) then return 0 end
  return 2
end

-- Thunder (AI_Smart_Thunder) drops to 50% accuracy in sun, so a 90% chance to
-- discourage it while the sun is out.  The cart scores nothing at all for rain,
-- even though Thunder never misses then: that asymmetry is the routine as
-- written.
S.EFFECT_THUNDER = function(ctx, st)
  if st.weather ~= "sun" then return 0 end
  if chance(ctx.random, 10) then return 0 end
  return 1
end

-- Fly and Dig, both EFFECT_FLY (AI_Smart_Fly): a semi-invulnerable player is
-- about to come back down, so a FASTER enemy can start its own two-turn move
-- now and land it as the player reappears.  Three dec [hl], the layer's
-- strongest push.  AICompareSpeed returns carry when the ENEMY is faster and
-- the routine is `ret nc`, which reads backwards against the cart's own comment.
S.EFFECT_FLY = function(_, st)
  if not st.playerFlying then return 0 end
  if not st.enemyFaster then return 0 end
  return -3
end

--------------------------------------------------------------------------
-- The switch / item layer (engine/battle/ai/items.asm, switch.asm)
--------------------------------------------------------------------------
--
-- TRNATTR_AI_ITEM_SWITCH is bytes 6-7 of the class attributes.  Three flags
-- decide how eager the class is to rotate; CheckAbleToSwitch scores the bench
-- and the flag turns that score into a probability.

Ai.SWITCH_FLAGS = {
  OFTEN = 0x0001,
  RARELY = 0x0002,
  SOMETIMES = 0x0004,
}

function Ai.switchFlagsOf(attributes)
  if type(attributes) ~= "table" then return 0 end
  return (attributes[6] or 0) + (attributes[7] or 0) * 256
end

-- CheckAbleToSwitch's answer, as its two nybbles: the high one is how strongly
-- the AI wants to rotate ($10 / $20 / $30) and the low one is the party slot.
-- Perish Song at one turn left is the maximum; otherwise it is whether the
-- player's moves beat what is out and something on the bench does better.
--
-- state:
--   bench        array of { index, mon, resists, superEffective, healthy }
--   perishCount  the active mon's perish counter, or nil
--   matchupScore CheckPlayerMoveTypeMatchups' score (10 is neutral)
function Ai.switchScore(state)
  state = state or {}
  local candidates = {}
  for _, entry in ipairs(state.bench or {}) do
    if entry.healthy then candidates[#candidates + 1] = entry end
  end
  if #candidates == 0 then return 0, nil end
  if state.perishCount == 1 then
    return 0x30, candidates[1].index
  end
  -- Below BASE_AI_SWITCH_SCORE means the player's moves are winning.
  if (state.matchupScore or 10) >= 10 then return 0, nil end
  local best, weight
  for _, entry in ipairs(candidates) do
    if entry.resists and entry.superEffective then
      best, weight = entry.index, 0x20
      break
    elseif entry.resists and not best then
      best, weight = entry.index, 0x10
    end
  end
  if not best then return 0, nil end
  return weight, best
end

-- The flag turns the score into a roll.  These are the cart's own percentages
-- (SwitchOften / SwitchRarely / SwitchSometimes), and a score of $30 inverts
-- the test: the AI switches UNLESS the roll comes up.
Ai.SWITCH_CHANCES = {
  OFTEN = { [0x10] = 50, [0x20] = 79, [0x30] = 96 },
  SOMETIMES = { [0x10] = 20, [0x20] = 50, [0x30] = 80 },
  RARELY = { [0x10] = 8, [0x20] = 12, [0x30] = 21 },
}

function Ai.shouldSwitch(attributes, score, random)
  if score == 0 then return false end
  local flags = Ai.switchFlagsOf(attributes)
  local name
  for key, mask in pairs(Ai.SWITCH_FLAGS) do
    if math.floor(flags / mask) % 2 == 1 then name = key break end
  end
  if not name then return false end
  local percent = (Ai.SWITCH_CHANCES[name] or {})[score]
  if not percent then return false end
  return ((random and random(100) or 0) + 1) <= percent
end

-- AI_TryItem's table, in the order the cart walks it: the first item the
-- trainer holds whose condition is met is the one used.  A trainer only uses
-- an item at all when its active mon is its highest-level one (.IsHighestLevel).
Ai.ITEM_ORDER = {
  "FULL_RESTORE", "MAX_POTION", "HYPER_POTION", "SUPER_POTION", "POTION",
  "X_ACCURACY", "FULL_HEAL", "GUARD_SPEC", "DIRE_HIT", "X_ATTACK",
  "X_DEFEND", "X_SPEED", "X_SPECIAL",
}

Ai.HEAL_ITEMS = {
  FULL_RESTORE = math.huge, MAX_POTION = math.huge,
  HYPER_POTION = 200, SUPER_POTION = 50, POTION = 20,
}

-- The healing items want the mon below half and missing at least what they
-- would restore; FULL_HEAL wants a status.  Everything else is a stat booster
-- and is used on the first turn.
function Ai.chooseItem(state)
  state = state or {}
  local held = {}
  for _, id in ipairs(state.items or {}) do held[id] = true end
  if not state.isHighestLevel then return nil end
  local hp, maxHp = state.hp or 0, state.maxHp or 1
  for _, id in ipairs(Ai.ITEM_ORDER) do
    if held[id] then
      local heal = Ai.HEAL_ITEMS[id]
      if heal then
        if hp * 2 <= maxHp and (maxHp - hp) >= math.min(heal, maxHp) / 2 then
          return id
        end
      elseif id == "FULL_HEAL" then
        if state.status then return id end
      elseif (state.enemyTurns or 0) == 0 then
        return id
      end
    end
  end
  return nil
end

--------------------------------------------------------------------------
-- The scoring layers, as ai_classes records
--------------------------------------------------------------------------
--
-- One record per AI_* pass of engine/battle/ai/scoring.asm, in the shape
-- src/mods/Schemas.lua's `ai_classes` registry validates.  Same registry NAME
-- Gen 1 fills from src/battle/TrainerAI.lua, the same `kind = "layer"`, and
-- the same score signature fn(view, def, score) -> score -- Gen 1's LAYER_1..
-- LAYER_3 and these ten are the same kind of thing, so they share the noun.
--
-- The ids are the TRNATTR_AI_MOVE_WEIGHTS flag names, which is what makes them
-- addressable: `flag` names the bit in Ai.FLAGS that turns the layer on, so a
-- class with no bits set runs no layers at all -- AIChooseMove's own answer
-- when wEnemyTrainerAIFlags is zero.  `flag` is the one field Gen 2 adds; a
-- mod's own layer may leave it out, and then it runs for every class that runs
-- any AI at all.
--
-- `view` is the state a layer scores against, rebuilt per move by Ai.choose:
--   context / random / flags     the caller's own
--   attacker / defender          the two mons, types resolved
--   move / damage / damaging / status   the move being scored
--   best                         the highest expected damage of the set
Ai.LAYERS = {
  -- AI_Basic: never throw a status move at a target that already has one, and
  -- never use a move whose only effect has already landed.  The confusion
  -- moves read SUBSTATUS_CONFUSED (defender.confused), not the status byte,
  -- since confusion is a volatile on the cart.
  BASIC = { kind = "layer", flag = "BASIC",
    score = function(view, _, score)
      local status, defender = view.status, view.defender
      if status and (defender.status
          or (status == "confuse" and defender.confused)) then
        return score + 5
      end
      return score
    end },
  -- AI_Types: dismiss what the target is immune to, encourage super-effective,
  -- discourage not very effective.
  TYPES = { kind = "layer", flag = "TYPES",
    score = function(view, def, score)
      if not view.damaging then return score end
      local matchup = matchupOf(view.context, def, view.defender)
      if matchup == 0 then return score + 10 end
      if matchup > 10 then return score - 1 end
      if matchup < 10 then return score + 1 end
      return score
    end },
  -- AI_Offensive: discourage anything that does not do damage.
  OFFENSIVE = { kind = "layer", flag = "OFFENSIVE",
    score = function(view, _, score)
      if view.damaging then return score end
      return score + 1
    end },
  -- AI_Aggressive: encourage whichever move hits hardest and discourage every
  -- other damaging one.
  AGGRESSIVE = { kind = "layer", flag = "AGGRESSIVE",
    score = function(view, _, score)
      if not view.damaging then return score end
      if view.damage >= view.best and view.best > 0 then return score - 1 end
      return score + 1
    end },
  -- AI_Status: refuse a status move outright against a target that already has
  -- that status.
  STATUS = { kind = "layer", flag = "STATUS",
    score = function(view, _, score)
      if view.status and view.defender.status == view.status then
        return score + 10
      end
      return score
    end },
  -- AI_Risky: take a kill when one is on the table, whatever else says.
  RISKY = { kind = "layer", flag = "RISKY",
    score = function(view, _, score)
      if view.damage >= (view.defender.hp or 0) and view.damage > 0 then
        return score - 5
      end
      return score
    end },
  -- AI_Setup: stat moves on turn one, and almost never after.
  SETUP = { kind = "layer", flag = "SETUP",
    score = function(view, def, score)
      local context, random = view.context, view.random
      local up = Ai.STAT_UP_EFFECTS[def.effect]
      local down = Ai.STAT_DOWN_EFFECTS[def.effect]
      if up then
        if (context.enemyTurns or 0) == 0 and random(2) == 0 then
          return score - 2
        end
        return score + 2
      elseif down then
        if (context.playerTurns or 0) == 0 and random(2) == 0 then
          return score - 2
        end
        return score + 2
      end
      return score
    end },
  -- AI_Opportunist: no stalling when the enemy is nearly dead.
  OPPORTUNIST = { kind = "layer", flag = "OPPORTUNIST",
    score = function(view, def, score)
      if not Ai.STALL_EFFECTS[def.effect] then return score end
      local hp, maxHp = view.context.enemyHp, view.context.enemyMaxHp
      if not (hp and maxHp and hp * 2 <= maxHp) then return score end
      local low = hp * 4 <= maxHp
      if low or view.random(2) == 0 then return score + 1 end
      return score
    end },
  -- AI_Cautious: 90% chance to drop a residual move after turn one.
  CAUTIOUS = { kind = "layer", flag = "CAUTIOUS",
    score = function(view, def, score)
      if (view.context.enemyTurns or 0) <= 0 then return score end
      if not Ai.RESIDUAL_EFFECTS[def.effect] then return score end
      if view.random(100) < 90 then return score + 1 end
      return score
    end },
  -- AI_Smart: the per-effect layer.  Ai.lockOnPostPass is its one pass that
  -- edits somebody else's slot, so Ai.choose runs that after the move loop.
  SMART = { kind = "layer", flag = "SMART",
    score = function(view, def, score)
      local handler = Ai.SMART[def.effect]
      if not handler then return score end
      local context, random = view.context, view.random
      local state = context.smart or {}
      state.random = state.random or random
      -- AI_Smart_SpeedDownHit is the one handler that reads the move it is
      -- scoring (wEnemyMoveStruct + MOVE_ANIM), so the id rides along.
      local delta = handler({ random = random, moveId = view.move.id }, state,
        matchupOf(context, def, view.defender), view.damage,
        context.playerLastPower)
      return score + (delta or 0)
    end },
}

-- scoring.asm's own order.  It is load bearing: SETUP, OPPORTUNIST and
-- CAUTIOUS each roll, so a reordering changes which move gets which byte.
Ai.LAYER_ORDER = {
  "BASIC", "TYPES", "OFFENSIVE", "AGGRESSIVE", "STATUS", "RISKY",
  "SETUP", "OPPORTUNIST", "CAUTIOUS", "SMART",
}

-- vanilla registrations, engine-owned (Schemas.ENGINE), so a mod's register of
-- one of these ids collides the way it does on Red and has to say override
function Ai.registerInto(registry, _, owner)
  for id, record in pairs(Ai.LAYERS) do
    registry:register(id, record, owner)
  end
end

-- the merged `ai_classes` record for an id, the module's own when no loader ran
function Ai.classFor(data, id)
  if id == nil then return nil end
  local merged = data and data.gen2AiClasses
  return (merged and merged[id]) or Ai.LAYERS[id]
end

-- The ordered layer list for one AI word: the vanilla ten in scoring.asm order
-- first, each resolved through the registry so a mod's patch of AI_Smart is the
-- one that runs, then any layer a mod registered under a new id, in sorted id
-- order so the roll sequence is the same on every boot.  A layer's `flag` gates
-- it on the class's bits; a mod layer without one runs for every class that
-- runs any AI at all, which is the only honest default when the ten flag bits
-- are all spoken for.
function Ai.layersFor(data, flags)
  local out, seen = {}, {}
  for _, id in ipairs(Ai.LAYER_ORDER) do
    seen[id] = true
    if Ai.has(flags, id) then
      local record = Ai.classFor(data, id)
      if record and record.score then out[#out + 1] = record end
    end
  end
  local merged = data and data.gen2AiClasses
  if merged then
    local extra = {}
    for id, record in pairs(merged) do
      if not seen[id] and record and record.score
          and (record.kind == nil or record.kind == "layer")
          and (record.flag == nil or Ai.has(flags, record.flag)) then
        extra[#extra + 1] = id
      end
    end
    table.sort(extra)
    for _, id in ipairs(extra) do out[#out + 1] = merged[id] end
  end
  return out
end

-- context:
--   moves        array of { id, pp } the enemy may use
--   moveDef(id)  the move record
--   attacker     the enemy mon, with .types resolved
--   defender     the player's mon, with .types resolved
--   typeChart    { types, matchups }
--   attackerStages / defenderStages
--   flags        the class's AI word
--   random(n)    0..n-1
--
-- Returns the chosen move id, plus the score table for the tests to read.
function Ai.choose(context)
  local moves = {}
  for _, move in ipairs(context.moves or {}) do
    if (move.pp or 0) > 0 then moves[#moves + 1] = move end
  end
  if #moves == 0 then return nil, {} end

  local random = context.random or function(n) return 0 end
  local flags = context.flags or 0
  if flags == 0 then
    -- No AI: pick at random, the way a wild mon does.
    return moves[random(#moves) + 1].id, {}
  end

  local attacker, defender = context.attacker or {}, context.defender or {}
  local scores, damages, defs = {}, {}, {}
  local best = -1
  for i, move in ipairs(moves) do
    scores[i] = Ai.BASE_SCORE
    local def = context.moveDef and context.moveDef(move.id) or nil
    defs[i] = def
    damages[i] = def and expectedDamage(context, attacker, defender, def) or 0
    if damages[i] > best then best = damages[i] end
  end

  -- The scoring layers this class runs, resolved once: which records exist is
  -- a property of the boot, not of the move being scored, and the per-move
  -- loop below is hot.
  local layers = Ai.layersFor(context.data, flags)

  local view = {
    context = context, random = random, flags = flags,
    attacker = attacker, defender = defender, best = best,
  }
  for i, move in ipairs(moves) do
    local def = defs[i]
    if def then
      -- the per-move half of the view, rebuilt in place so ten layers share
      -- one table rather than allocating ten
      view.move = move
      view.damage = damages[i]
      view.damaging = (def.power or 0) > 0
      view.status = Ai.STATUS_EFFECTS[def.effect]
      for _, record in ipairs(layers) do
        scores[i] = record.score(view, def, scores[i]) or scores[i]
      end
    end
  end

  -- The one scoring layer that edits somebody else's slot, so it cannot run
  -- inside the per-move pass above.
  if Ai.has(flags, "SMART") and (context.smart or {}).playerLockOn then
    Ai.lockOnPostPass(scores, defs)
  end

  -- Lowest score wins; ties are broken by a roll so a trainer is not perfectly
  -- predictable turn to turn.
  local lowest = math.huge
  for _, score in ipairs(scores) do
    if score < lowest then lowest = score end
  end
  local tied = {}
  for i, score in ipairs(scores) do
    if score == lowest then tied[#tied + 1] = moves[i].id end
  end
  return tied[random(#tied) + 1], scores
end

return Ai
