mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-25 23:11:15 +02:00
1675 lines
67 KiB
Lua
1675 lines
67 KiB
Lua
-- 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
|