Files
gen1recomp/tests/game3_battle_move_effects_test.lua
bryanthaboi dee935a4ea CLOSES #2430
2026-09-23 11:58:32 -04:00

657 lines
30 KiB
Lua

#!/usr/bin/env luajit
package.path = "./?.lua;./?/init.lua;" .. package.path
require("tests.game3_cache").mountOrSkip("game3_battle_move_effects_test: move effect messages are ROM battle text")
require("tests.fixture_data.game3_items").install()
local Moves = require("src.core.game3.battle.moves")
Moves.loadRomPack()
local State = require("src.core.game3.battle.state")
local Engine = require("src.core.game3.battle.engine")
local Adapter = require("src.core.game3.battle.adapter")
local Damage = require("src.core.game3.battle.damage")
local Types = require("src.core.game3.battle.types")
local Rules = require("src.core.game3.battle.rules")
local Hazards = require("src.core.game3.battle.effects.hazards")
local T = Types.ID
local failed, passed = 0, 0
local function check(cond, msg)
if cond then
passed = passed + 1
print("[ok] " .. msg)
else
failed = failed + 1
print("[FAIL] " .. msg)
end
end
local function mon(o)
local m = {
species = o.species or 1, level = o.level or 50, hp = o.hp or 100, maxHp = o.maxHp or 100,
attack = o.attack or 50, defense = o.defense or 50, spAtk = o.spAtk or 50, spDef = o.spDef or 50,
speed = o.speed or 50, ability = o.ability or 0, nickname = o.nickname,
moves = o.moves or { 33 }, pp = o.pp or { 20, 20, 20, 20 }, status = o.status,
item = o.item, heldItem = o.item, ivs = o.ivs, gender = o.gender, friendship = o.friendship,
}
return m
end
local function mkRng(map)
map = map or {}
local cursor = {}
return function(lo, hi)
local key = tostring(lo) .. "," .. tostring(hi)
local v = map[key]
if type(v) == "table" then
local i = (cursor[key] or 0) + 1
cursor[key] = i
v = v[math.min(i, #v)]
end
if v ~= nil then return v end
if lo == 1 and hi == 100 then return 1 end
return hi
end
end
local function battle(p, e, extra)
extra = extra or {}
p.nickname = p.nickname or "ALPHA"
e.nickname = e.nickname or "BRAVO"
local party = { mon(p) }
for _, x in ipairs(extra.bench or {}) do party[#party + 1] = mon(x) end
local foeParty = { mon(e) }
for _, x in ipairs(extra.foeBench or {}) do foeParty[#foeParty + 1] = mon(x) end
local st = State.new({ wild = extra.wild ~= false, playerParty = party, foeParty = foeParty })
st.player.type1, st.player.type2 = p.type1 or T.NORMAL, p.type2
st.enemy.type1, st.enemy.type2 = e.type1 or T.NORMAL, e.type2
st.rng = mkRng(extra.rng)
st.terrain = extra.terrain
local ad = Adapter.new(st)
return st, ad
end
local function use(st, ad, user, move, slot, opts)
local target = (user == st.player) and st.enemy or st.player
local out = {}
Engine.resolveMove(user, target, move, slot or 1, ad, st, out, opts)
return table.concat(out, " || "), out
end
local function has(s, needle) return s:find(needle, 1, true) ~= nil end
print("=== multi-hit 2-5 distribution (3/8,3/8,1/8,1/8) ===")
do
local counts = { 0, 0, 0, 0, 0 }
for a = 0, 3 do
for b = 0, 3 do
local st, ad = battle({ moves = { 31 } }, { hp = 999, maxHp = 999 }, { rng = { ["0,3"] = { a, b } } })
local _, out = use(st, ad, st.player, 31)
local n = #out._anim.hits
counts[n] = counts[n] + 1
end
end
check(counts[2] == 6 and counts[3] == 6 and counts[4] == 2 and counts[5] == 2,
"setmultihitcounter 0 gives 2:6/16 3:6/16 4:2/16 5:2/16")
end
print("=== BIDE ===")
do
local st, ad = battle({ moves = { 117 }, speed = 10 }, { moves = { 33 } })
local txt = use(st, ad, st.player, 117)
check(st.player.bideTurns == 2, "Bide stores for 2 turns")
check(has(txt, "used\n"), "Bide prints attack string")
use(st, ad, st.enemy, 33)
local taken = 100 - ad:hp(st.player)
check(st.player.expBideDamage == taken and taken > 0, "damage taken while biding is stored")
txt = use(st, ad, st.player, 33)
check(has(txt, "storing\nenergy!"), "turn 2 stores energy (locked move)")
local foeBefore = ad:hp(st.enemy)
txt = use(st, ad, st.player, 33)
check(has(txt, "unleashed\nenergy!"), "turn 3 unleashes")
check(foeBefore - ad:hp(st.enemy) == taken * 2, "Bide deals double the stored damage")
end
print("=== RAMPAGE (Thrash) ===")
do
local st, ad = battle({ moves = { 37 } }, { hp = 999, maxHp = 999 }, { rng = { ["0,1"] = 0 } })
use(st, ad, st.player, 37)
check(st.player.expRampageTurns == 2 and st.player.expLockedMove == 37, "Thrash locks for 2-3 turns")
local pp = st.player.mon.pp[1]
use(st, ad, st.player, 33)
check(st.player.mon.pp[1] == pp, "continuing Thrash does not use PP and ignores chosen move")
Engine.collectResidualEvents(st, ad)
local events = Engine.collectResidualEvents(st, ad)
local found = false
for _, ev in ipairs(events) do
for _, m in ipairs(ev.msgs) do if has(m, "confused due to fatigue") then found = true end end
end
check(found and (st.player.confusionTurns or 0) > 0, "Thrash ends with fatigue confusion")
end
print("=== ROAR / WHIRLWIND ===")
do
local st, ad = battle({ moves = { 46 }, level = 50 }, { level = 40 })
local _, out = use(st, ad, st.player, 46)
check(st.over == true and st.result == "run", "Roar ends a wild battle")
local ended = false
for _, ev in ipairs(out._anim.events) do if ev.kind == "end" then ended = true end end
check(ended, "Roar emits an end event")
check(st.endReason == "roar", "Roar records the end reason for the battle flow")
local st2, ad2 = battle({ moves = { 18 } }, { species = 1 }, { wild = false, foeBench = { { species = 4, nickname = "CHARLIE" } }, rng = { ["1,1"] = 1 } })
local txt2 = use(st2, ad2, st2.player, 18)
check(st2.enemy.partyIndex == 2, "Whirlwind drags out the other trainer mon")
check(has(txt2, "dragged out!"), "dragged out message")
local st3, ad3 = battle({ moves = { 18 } }, {}, { wild = false })
local txt3 = use(st3, ad3, st3.player, 18)
check(has(txt3, "But it failed!") and not st3.over, "Whirlwind fails with no bench")
end
print("=== CONVERSION / CONVERSION 2 ===")
do
local st, ad = battle({ moves = { 160, 52, 57 } }, {}, { rng = { ["1,2"] = 1 } })
local txt = use(st, ad, st.player, 160)
check(st.player.type1 == T.FIRE and st.player.type2 == T.FIRE, "Conversion picks a move type the user lacks")
check(has(txt, "into the FIRE type!"), "Conversion message")
local st2, ad2 = battle({ moves = { 176 } }, { moves = { 52 } })
use(st2, ad2, st2.enemy, 52)
use(st2, ad2, st2.player, 176, 1)
local _, flags = Types.typeCalc(T.FIRE, st2.player.type1, st2.player.type2)
check(flags.notVery or flags.immune, "Conversion 2 resists the last move that hit")
end
print("=== TRI ATTACK ===")
do
local st, ad = battle({ moves = { 161 } }, { hp = 999, maxHp = 999 }, { rng = { ["0,99"] = 0, ["0,2"] = 2 } })
use(st, ad, st.player, 161)
check(ad:status(st.enemy) == "PAR", "Tri Attack rolled paralysis")
local st2, ad2 = battle({ moves = { 161 } }, { hp = 999, maxHp = 999, type1 = T.FIRE }, { rng = { ["0,99"] = 0, ["0,2"] = 0 } })
use(st2, ad2, st2.player, 161)
check(ad2:status(st2.enemy) == nil, "Tri Attack burn roll on a Fire type does nothing")
end
print("=== TRAP (Wrap / Bind / Fire Spin / Whirlpool) ===")
do
local st, ad = battle({ moves = { 35 } }, { hp = 160, maxHp = 160 }, { rng = { ["0,3"] = 0 } })
local txt = use(st, ad, st.player, 35)
check(st.enemy.expTrapTurns == 3, "Wrap sets (Random & 3) + 3 turns")
check(has(txt, "was WRAPPED by"), "Wrap message")
local events = Engine.collectResidualEvents(st, ad)
local chip, anim = false, false
for _, ev in ipairs(events) do
if ev.phase == "partial_trap_chip" then
chip = ev.hpChanges[1] and (ev.hpChanges[1].from - ev.hpChanges[1].to) == 10
for _, e in ipairs(ev.events) do if e.kind == "anim" and e.name == "TURN_TRAP" then anim = true end end
end
end
check(chip, "trap chip is maxHP/16")
check(anim, "TURN_TRAP general anim recorded")
Engine.collectResidualEvents(st, ad)
local ev3 = Engine.collectResidualEvents(st, ad)
local freed = false
for _, ev in ipairs(ev3) do for _, m in ipairs(ev.msgs) do if has(m, "was freed") then freed = true end end end
check(freed and st.enemy.expTrapTurns == nil, "trap ends with freed message")
local st2, ad2 = battle({ moves = { 250 } }, { hp = 300, maxHp = 300 })
st2.enemy.semiInvulnerable = "UNDERWATER"
local _, out = use(st2, ad2, st2.player, 250)
check(#out._anim.hits == 1, "Whirlpool hits an underwater Dive user")
end
print("=== TRANSFORM ===")
do
local st, ad = battle({ moves = { 144 } }, { attack = 99, defense = 88, speed = 77, moves = { 52, 57 }, type1 = T.WATER })
st.enemy.stages.attack = 2
local party = st.playerParty[1]
use(st, ad, st.player, 144)
check(st.player.transformed and st.player.mon.attack == 99 and st.player.mon.defense == 88, "Transform copies stats")
check(st.player.type1 == T.WATER and st.player.stages.attack == 2, "Transform copies types and stages")
check(st.player.mon.moves[1] == 52 and st.player.mon.pp[1] == 5, "Transform copies moves with 5 PP")
State.syncBattlerToParty(st.player, st.playerParty)
check(party.moves[1] == 144 and party.attack == 50, "party mon is untouched by Transform")
end
print("=== TWINEEDLE ===")
do
local st, ad = battle({ moves = { 41 } }, { hp = 999, maxHp = 999 }, { rng = { ["0,99"] = 0 } })
local txt, out = use(st, ad, st.player, 41)
check(#out._anim.hits == 2 and has(txt, "Hit 2 time(s)!"), "Twineedle hits twice")
check(ad:status(st.enemy) == "PSN", "Twineedle poison roll")
end
print("=== RAGE ===")
do
local st, ad = battle({ moves = { 99 } }, { moves = { 33 } })
use(st, ad, st.player, 99)
check(st.player.rage == true, "Rage sets the rage flag")
local txt = use(st, ad, st.enemy, 33)
check(st.player.stages.attack == 1 and has(txt, "RAGE\nis building!"), "getting hit raises Attack")
end
print("=== MIMIC / DISABLE / SKETCH ===")
do
local st, ad = battle({ moves = { 102, 33 } }, { moves = { 52 } })
use(st, ad, st.enemy, 52)
use(st, ad, st.player, 102, 1)
check(st.player.mon.moves[1] == 52 and st.player.mon.pp[1] == 5, "Mimic copies the last move into its slot")
State.syncBattlerToParty(st.player, st.playerParty)
check(st.playerParty[1].moves[1] == 102, "Mimic does not persist to the party")
local st2, ad2 = battle({ moves = { 50 } }, { moves = { 52, 33 } }, { rng = { ["0,3"] = 0 } })
use(st2, ad2, st2.enemy, 52)
local txt = use(st2, ad2, st2.player, 50)
check(st2.enemy.expDisabledMove == 52 and st2.enemy.expDisableTurns == 2, "Disable disables for 2-5 turns")
check(has(txt, "was disabled!"), "Disable message")
local txt2 = use(st2, ad2, st2.enemy, 52)
check(has(txt2, "is disabled!"), "disabled move is cancelled")
local st3, ad3 = battle({ moves = { 166 } }, { moves = { 52 } })
use(st3, ad3, st3.enemy, 52)
use(st3, ad3, st3.player, 166, 1)
check(st3.playerParty[1].moves[1] == 52 and st3.playerParty[1].pp[1] == 25, "Sketch permanently learns with full PP")
end
print("=== SNORE / SLEEP ===")
do
local st, ad = battle({ moves = { 173 } }, { hp = 999, maxHp = 999 })
local txt = use(st, ad, st.player, 173)
check(has(txt, "But it failed!"), "Snore fails while awake")
local st2, ad2 = battle({ moves = { 173 }, status = "SLP" }, { hp = 999, maxHp = 999 })
st2.player.sleepTurns = 3
local txt2, out2 = use(st2, ad2, st2.player, 173)
check(#out2._anim.hits == 1 and has(txt2, "fast\nasleep."), "Snore hits while asleep")
local st3, ad3 = battle({ moves = { 47 } }, {}, { rng = { ["0,3"] = 3 } })
use(st3, ad3, st3.player, 47)
check(ad3:status(st3.enemy) == "SLP" and st3.enemy.sleepTurns == 5, "sleep counter is (Random & 3) + 2")
local st4, ad4 = battle({ moves = { 156 }, hp = 10 }, {})
use(st4, ad4, st4.player, 156)
check(st4.player.sleepTurns == 3 and ad4:hp(st4.player) == 100, "Rest sleeps for STATUS1_SLEEP_TURN(3)")
end
print("=== TRIPLE KICK ===")
do
local st, ad = battle({ moves = { 167 }, type1 = T.FIGHTING }, { hp = 999, maxHp = 999, defense = 50 })
local txt, out = use(st, ad, st.player, 167)
local d1 = out._anim.hits[1].from - out._anim.hits[1].to
local d3 = out._anim.hits[3].from - out._anim.hits[3].to
check(#out._anim.hits == 3 and has(txt, "Hit 3 time(s)!"), "Triple Kick hits 3 times")
check(d3 > d1 * 2, "Triple Kick power climbs 10/20/30")
local st2, ad2 = battle({ moves = { 167 } }, { hp = 999, maxHp = 999 }, { rng = { ["1,100"] = { 1, 100 } } })
local _, out2 = use(st2, ad2, st2.player, 167)
check(#out2._anim.hits == 1, "Triple Kick stops at the first miss")
end
print("=== THIEF / TRICK / KNOCK OFF / RECYCLE ===")
do
local st, ad = battle({ moves = { 168 } }, { hp = 999, maxHp = 999, item = 200 })
local txt, out = use(st, ad, st.player, 168)
check(st.player.item == 200 and st.playerParty[1].item == 200 and st.enemy.item == 0, "Thief steals and keeps the item")
local anim = false
for _, ev in ipairs(out._anim.events) do if ev.kind == "anim" and ev.name == "ITEM_STEAL" then anim = true end end
check(anim and has(txt, "stole\n"), "ITEM_STEAL anim + message")
local st2, ad2 = battle({ item = 200 }, { moves = { 168 } })
use(st2, ad2, st2.enemy, 168)
check(st2.player.item == 200, "opponents cannot steal in regular battles")
local st3, ad3 = battle({ moves = { 271 }, item = 198 }, { item = 200 })
local txt3 = use(st3, ad3, st3.player, 271)
check(st3.player.item == 200 and st3.enemy.item == 198 and st3.playerParty[1].item == 200, "Trick swaps items")
check(has(txt3, "switched\nitems"), "Trick message")
local st4, ad4 = battle({ moves = { 282 } }, { hp = 999, maxHp = 999, item = 200 })
local txt4, out4 = use(st4, ad4, st4.player, 282)
check(st4.enemy.item == 0 and st4.foeParty[1].item == 200, "Knock Off removes the item for the battle only")
local ko = false
for _, ev in ipairs(out4._anim.events) do if ev.kind == "anim" and ev.name == "ITEM_KNOCKOFF" then ko = true end end
check(ko and has(txt4, "knocked off"), "ITEM_KNOCKOFF anim + message")
local st5, ad5 = battle({ moves = { 278 } }, {})
st5.player.expUsedHeldItem = 139
local txt5 = use(st5, ad5, st5.player, 278)
check(st5.player.item == 139 and has(txt5, "found\none"), "Recycle restores the used item")
end
print("=== ROLLOUT / FURY CUTTER ===")
do
local st, ad = battle({ moves = { 205 } }, { hp = 9999, maxHp = 9999 })
local dmg = {}
for i = 1, 5 do
local _, out = use(st, ad, st.player, 205)
dmg[i] = out._anim.hits[1].from - out._anim.hits[1].to
end
check(dmg[2] >= dmg[1] * 2 - 2 and dmg[5] >= dmg[4] * 2 - 2, "Rollout doubles each turn")
check(st.player.expLockedMove == nil, "Rollout unlocks after 5 turns")
local st2, ad2 = battle({ moves = { 210 } }, { hp = 9999, maxHp = 9999 })
use(st2, ad2, st2.player, 210)
use(st2, ad2, st2.player, 210)
check(st2.player.expFuryCutter == 2, "Fury Cutter counter climbs")
st2.rng = mkRng({ ["1,100"] = 100 })
use(st2, ad2, st2.player, 210)
check(st2.player.expFuryCutter == 0, "Fury Cutter resets on a miss")
end
print("=== PRESENT ===")
do
local st, ad = battle({ moves = { 217 } }, { hp = 50, maxHp = 100 }, { rng = { ["0,255"] = 250 } })
local txt = use(st, ad, st.player, 217)
check(ad:hp(st.enemy) == 75 and has(txt, "regained\nhealth!"), "Present heal branch restores 1/4")
local st2, ad2 = battle({ moves = { 217 } }, { hp = 999, maxHp = 999 }, { rng = { ["0,255"] = 0 } })
local _, out = use(st2, ad2, st2.player, 217)
check(#out._anim.hits == 1, "Present damage branch hits")
end
print("=== BATON PASS ===")
do
local st, ad = battle({ moves = { 226 } }, {}, { bench = { { species = 4, nickname = "DELTA" } } })
st.player.stages.attack = 2
st.player.substituteHP = 20
local txt, out = use(st, ad, st.player, 226)
check(st.player.partyIndex == 2 and st.player.stages.attack == 2 and st.player.substituteHP == 20,
"Baton Pass switches and keeps stages/substitute")
local sw = false
for _, ev in ipairs(out._anim.events) do if ev.kind == "switch" and ev.reason == "baton_pass" then sw = true end end
check(sw and has(txt, "Go! DELTA!"), "switch event + send-out text")
local st2, ad2 = battle({ moves = { 226 } }, {})
check(has((use(st2, ad2, st2.player, 226)), "But it failed!"), "Baton Pass fails with no bench")
end
print("=== HIDDEN POWER ===")
do
local ivs = { hp = 31, atk = 31, def = 31, spe = 31, spa = 31, spd = 31 }
local p, t = Damage.hiddenPower({ ivs = ivs })
check(p == 70 and t == T.DARK, "all-31 IVs give 70 power Dark")
local p0, t0 = Damage.hiddenPower({ ivs = { hp = 0, atk = 0, def = 0, spe = 0, spa = 0, spd = 0 } })
check(p0 == 30 and t0 == T.FIGHTING, "all-0 IVs give 30 power Fighting")
end
print("=== semi-invulnerable interplay ===")
do
local st, ad = battle({ moves = { 16 } }, { hp = 999, maxHp = 999 })
local _, base = use(st, ad, st.player, 16)
local d0 = base._anim.hits[1].from - base._anim.hits[1].to
st.enemy.semiInvulnerable = "ON_AIR"
local _, out = use(st, ad, st.player, 16)
local d1 = out._anim.hits[1] and (out._anim.hits[1].from - out._anim.hits[1].to) or 0
check(d1 >= d0 * 2 - 1, "Gust hits Fly for double damage")
local st2, ad2 = battle({ moves = { 239 } }, { hp = 999, maxHp = 999 })
st2.enemy.semiInvulnerable = "ON_AIR"
local _, o2 = use(st2, ad2, st2.player, 239)
check(#o2._anim.hits == 1, "Twister hits a Fly user")
local st3, ad3 = battle({ moves = { 89 } }, { hp = 999, maxHp = 999 })
st3.enemy.semiInvulnerable = "UNDERGROUND"
local _, o3 = use(st3, ad3, st3.player, 89)
check(#o3._anim.hits == 1, "Earthquake hits a Dig user")
local st4, ad4 = battle({ moves = { 57 } }, { hp = 999, maxHp = 999 })
st4.enemy.semiInvulnerable = "UNDERWATER"
local _, o4 = use(st4, ad4, st4.player, 57)
check(#o4._anim.hits == 1, "Surf hits a Dive user")
local st5, ad5 = battle({ moves = { 33 } }, { hp = 999, maxHp = 999 })
st5.enemy.semiInvulnerable = "ON_AIR"
local txt5 = use(st5, ad5, st5.player, 33)
check(has(txt5, "attack missed!"), "normal moves miss a Fly user")
local st6, ad6 = battle({ moves = { 327 } }, { hp = 999, maxHp = 999 })
st6.enemy.semiInvulnerable = "ON_AIR"
local _, o6 = use(st6, ad6, st6.player, 327)
check(#o6._anim.hits == 1, "Sky Uppercut hits a Fly user")
local st7, ad7 = battle({ moves = { 87 } }, { hp = 999, maxHp = 999 })
st7.enemy.semiInvulnerable = "ON_AIR"
local _, o7 = use(st7, ad7, st7.player, 87)
check(#o7._anim.hits == 1, "Thunder hits a Fly user")
local st8, ad8 = battle({ moves = { 87 } }, { hp = 999, maxHp = 999 }, { rng = { ["1,100"] = 100 } })
st8.weather = "RAINY"
local _, o8 = use(st8, ad8, st8.player, 87)
check(#o8._anim.hits == 1, "Thunder never misses in rain")
local st9, ad9 = battle({ moves = { 87 } }, { hp = 999, maxHp = 999 }, { rng = { ["1,100"] = 51 } })
st9.weather = "SUNNY"
local txt9 = use(st9, ad9, st9.player, 87)
check(has(txt9, "attack missed!"), "Thunder accuracy is 50 in sun")
local st10, ad10 = battle({ moves = { 19 } }, { hp = 999, maxHp = 999 })
use(st10, ad10, st10.player, 19)
check(st10.player.semiInvulnerable == "ON_AIR", "Fly turn 1 sets ON_AIR")
local _, o10 = use(st10, ad10, st10.player, 33)
check(#o10._anim.hits == 1 and st10.player.semiInvulnerable == nil, "Fly turn 2 strikes and lands")
end
print("=== MINIMIZE / STOMP ===")
do
local st, ad = battle({ moves = { 23 } }, { moves = { 107 }, hp = 999, maxHp = 999 }, { rng = { ["0,99"] = 99 } })
local _, a = use(st, ad, st.player, 23)
local d0 = a._anim.hits[1].from - a._anim.hits[1].to
use(st, ad, st.enemy, 107)
check(st.enemy.minimized == true and st.enemy.stages.evasion == 1, "Minimize sets the minimized flag + evasion")
st.enemy.stages.evasion = 0
local _, b = use(st, ad, st.player, 23)
local d1 = b._anim.hits[1].from - b._anim.hits[1].to
check(d1 >= d0 * 2 - 1, "Stomp doubles vs a minimized target")
end
print("=== TELEPORT ===")
do
local st, ad = battle({ moves = { 100 } }, {})
local txt = use(st, ad, st.player, 100)
check(st.over and has(txt, "fled from\nbattle!"), "Teleport ends a wild battle")
local st2, ad2 = battle({ moves = { 100 } }, {}, { wild = false })
check(has((use(st2, ad2, st2.player, 100)), "But it failed!") and not st2.over, "Teleport fails vs trainers")
end
print("=== BEAT UP ===")
do
local st, ad = battle({ moves = { 251 } }, { hp = 999, maxHp = 999 },
{ bench = { { species = 4 }, { species = 7, status = "PSN" }, { species = 1, hp = 0 } } })
local txt, out = use(st, ad, st.player, 251)
check(#out._anim.hits == 2, "Beat Up strikes once per healthy, status-free party member")
check(has(txt, "'s attack!"), "Beat Up per-member message")
end
print("=== FAKE OUT ===")
do
local st, ad = battle({ moves = { 252 } }, { hp = 999, maxHp = 999 })
Engine.planTurnFromActions(st, ad, { kind = "move", move = 252, slot = 1 }, { kind = "move", move = 33, slot = 1 })
use(st, ad, st.player, 252)
check(st.enemy.flinched == true, "Fake Out flinches on the first turn")
Engine.planTurnFromActions(st, ad, { kind = "move", move = 252, slot = 1 }, { kind = "move", move = 33, slot = 1 })
local txt = use(st, ad, st.player, 252)
check(has(txt, "But it failed!"), "Fake Out fails after the first turn")
end
print("=== UPROAR ===")
do
local st, ad = battle({ moves = { 253 } }, { hp = 999, maxHp = 999, status = "SLP", moves = { 47 } }, { rng = { ["0,3"] = 1 } })
st.enemy.sleepTurns = 4
local txt = use(st, ad, st.player, 253)
check(st.player.expUproarTurns == 3 and has(txt, "caused\nan UPROAR!"), "Uproar starts for 2-5 turns")
local events = Engine.collectResidualEvents(st, ad)
local woke = false
for _, ev in ipairs(events) do for _, m in ipairs(ev.msgs) do if has(m, "woke up\nin the UPROAR!") then woke = true end end end
check(woke and ad:status(st.enemy) == nil, "Uproar wakes sleepers")
local txt2 = use(st, ad, st.enemy, 47)
check(ad:status(st.player) == nil and has(txt2, "can't\nsleep in an UPROAR!"), "sleep fails during an uproar")
end
print("=== STOCKPILE / SPIT UP ===")
do
local st, ad = battle({ moves = { 255 } }, { hp = 999, maxHp = 999 })
local txt = use(st, ad, st.player, 255)
check(has(txt, "failed to SPIT UP"), "Spit Up fails without stockpile")
use(st, ad, st.player, 254)
use(st, ad, st.player, 254)
check(st.player.expStockpile == 2 and st.player.stages.defense == 0, "Gen3 Stockpile raises no stats")
local _, out = use(st, ad, st.player, 255)
check(#out._anim.hits == 1 and st.player.expStockpile == 0, "Spit Up releases the stockpile")
end
print("=== FOLLOW ME / NATURE POWER / ASSIST / SECRET POWER ===")
do
local st, ad = battle({ moves = { 266 } }, {})
check(has((use(st, ad, st.player, 266)), "center of attention!"), "Follow Me succeeds in singles")
local st2, ad2 = battle({ moves = { 267 } }, { hp = 999, maxHp = 999 }, { terrain = 4 })
local txt2, out2 = use(st2, ad2, st2.player, 267)
check(has(txt2, "NATURE POWER turned into") and out2._anim.moveId == 57 and #out2._anim.hits == 1, "Nature Power on water calls Surf")
local st2b, ad2b = battle({ moves = { 267 } }, { hp = 999, maxHp = 999 }, { terrain = 8 })
local _, out2b = use(st2b, ad2b, st2b.player, 267)
check(out2b._anim.moveId == 129, "Nature Power indoors calls Swift")
local st3, ad3 = battle({ moves = { 274 } }, { hp = 999, maxHp = 999 }, { bench = { { moves = { 52, 118 } } } })
local _, out3 = use(st3, ad3, st3.player, 274)
check(out3._anim.moveId == 52, "Assist calls a bench move (never Metronome)")
local st4, ad4 = battle({ moves = { 290 } }, { hp = 999, maxHp = 999 }, { terrain = 8, rng = { ["0,99"] = 0 } })
use(st4, ad4, st4.player, 290)
check(ad4:status(st4.enemy) == "PAR", "Secret Power indoors paralyzes")
local st5, ad5 = battle({ moves = { 290 } }, { hp = 999, maxHp = 999 }, { terrain = 1, rng = { ["0,99"] = 0 } })
use(st5, ad5, st5.player, 290)
check(ad5:status(st5.enemy) == "SLP", "Secret Power in long grass sleeps")
end
print("=== BLAZE KICK / POISON FANG / POISON TAIL / SKY UPPERCUT crit + status ===")
do
check(Rules.crit.stage({}, Moves.get(299)) == 1, "Blaze Kick is high-crit")
check(Rules.crit.stage({}, Moves.get(342)) == 1, "Poison Tail is high-crit")
check(Rules.crit.stage({ expFocusEnergy = true }, Moves.get(33)) == 2, "Focus Energy adds 2 stages")
check(Rules.crit.stage({ item = 198, expFocusEnergy = true }, Moves.get(299)) == 4, "crit stage caps at 4")
local st, ad = battle({ moves = { 299 } }, { hp = 999, maxHp = 999 }, { rng = { ["0,99"] = 0 } })
use(st, ad, st.player, 299)
check(ad:status(st.enemy) == "BRN", "Blaze Kick burns")
local st2, ad2 = battle({ moves = { 305 } }, { hp = 999, maxHp = 999 }, { rng = { ["0,99"] = 0 } })
use(st2, ad2, st2.player, 305)
check(ad2:status(st2.enemy) == "TOX", "Poison Fang badly poisons")
local st3, ad3 = battle({ moves = { 342 } }, { hp = 999, maxHp = 999 }, { rng = { ["0,99"] = 0 } })
use(st3, ad3, st3.player, 342)
check(ad3:status(st3.enemy) == "PSN", "Poison Tail poisons")
local crits = 0
for r = 0, 7 do
if Rules.crit.roll({}, Moves.get(299), nil, function() return r end) then crits = crits + 1 end
end
check(crits == 1, "stage 1 crit chance is 1/8")
end
print("=== MORNING SUN weather fractions ===")
do
local function heal(weather)
local st, ad = battle({ moves = { 234 }, hp = 1, maxHp = 300 }, {})
st.weather = weather
use(st, ad, st.player, 234)
return ad:hp(st.player) - 1
end
check(heal(nil) == 150, "no weather heals 1/2")
check(heal("SUNNY") == 200, "sun heals 20/30")
check(heal("RAINY") == 75, "other weather heals 1/4")
end
print("=== accuracy / evasion stage table ===")
do
local st, ad = battle({ moves = { 33 } }, { hp = 999, maxHp = 999 }, { rng = { ["1,100"] = 72 } })
st.enemy.stages.evasion = 1
check(has((use(st, ad, st.player, 33)), "attack missed!"), "95 acc at -1 net stage misses a 72 roll (calc 71)")
local st2, ad2 = battle({ moves = { 33 } }, { hp = 999, maxHp = 999 }, { rng = { ["1,100"] = 71 } })
st2.enemy.stages.evasion = 1
check(not has((use(st2, ad2, st2.player, 33)), "attack missed!"), "…and hits a 71 roll")
local st3, ad3 = battle({ moves = { 129 } }, { hp = 999, maxHp = 999 }, { rng = { ["1,100"] = 100 } })
st3.enemy.stages.evasion = 6
local _, o3 = use(st3, ad3, st3.player, 129)
check(#o3._anim.hits == 1, "Swift (ALWAYS_HIT) ignores evasion")
end
print("=== Counter by type / Explosion defense halving ===")
do
local st, ad = battle({ moves = { 68 }, hp = 999, maxHp = 999 }, { moves = { 52 }, hp = 999, maxHp = 999 })
use(st, ad, st.enemy, 52)
check(has((use(st, ad, st.player, 68)), "But it failed!"), "Counter fails after a special-type hit")
local st2, ad2 = battle({ moves = { 68 }, hp = 999, maxHp = 999 }, { moves = { 33 }, hp = 999, maxHp = 999 })
use(st2, ad2, st2.enemy, 33)
local taken = 999 - ad2:hp(st2.player)
use(st2, ad2, st2.player, 68)
check(999 - ad2:hp(st2.enemy) == taken * 2, "Counter returns double physical damage")
local a = { mon = mon({ attack = 100 }), type1 = T.NORMAL, stages = {} }
local d = { mon = mon({ defense = 100 }), type1 = T.PSYCHIC, stages = {} }
local ex = Damage.base(a, d, Moves.get(153), {})
local plain = Damage.base(a, d, { power = 250, type = 0, effect = 0 }, {})
check(ex > plain, "Explosion halves the target's Defense")
end
print("=== toxic counter / confusion self-hit ===")
do
local st, ad = battle({ status = "TOX", maxHp = 160, hp = 160 }, {})
st.player.toxicCounter = 0
Engine.collectResidualEvents(st, ad)
Engine.collectResidualEvents(st, ad)
check(ad:hp(st.player) == 160 - 10 - 20, "toxic chip = floor(max/16) * counter")
local st2, ad2 = battle({ moves = { 33 } }, {}, { rng = { ["0,1"] = 0, ["85,100"] = 100 } })
st2.player.confusionTurns = 3
local txt = use(st2, ad2, st2.player, 33)
local expect = Damage.base(st2.player, st2.player, { power = 40, type = 0, effect = 0 }, { power = 40, moveType = 0 })
check(has(txt, "hurt itself") and ad2:hp(st2.player) == 100 - expect, "confusion self-hit = 40 power typeless")
end
print("=== adapter event contract ===")
do
local st, ad = battle({ moves = { 71 }, hp = 50 }, { hp = 999, maxHp = 999 })
local _, out = use(st, ad, st.player, 71)
local kinds = {}
for _, ev in ipairs(out._anim.events) do kinds[#kinds + 1] = ev.kind end
local seq = table.concat(kinds, ",")
check(seq:find("msg,move,hit", 1, true) ~= nil, "used msg -> move anim -> hit")
check(seq:find("hit,msg", 1, true) ~= nil or seq:find("hit,hp", 1, true) ~= nil, "drain follows the hit")
local hpEv
for _, ev in ipairs(out._anim.events) do if ev.kind == "hp" then hpEv = ev end end
check(hpEv and hpEv.side == "player" and hpEv.to > hpEv.from, "drain heal recorded as hp event")
check(#out._anim.heals >= 1, "legacy heals list still filled")
local st2, ad2 = battle({ moves = { 14 } }, {})
local _, out2 = use(st2, ad2, st2.player, 14)
local anim
for _, ev in ipairs(out2._anim.events) do if ev.kind == "anim" then anim = ev end end
check(anim and anim.anim == "general" and anim.name == "STATS_CHANGE" and anim.arg == 39, "Swords Dance STATS_CHANGE arg 39 (ATK +2)")
local st3, ad3 = battle({ moves = { 92 } }, {})
local _, out3 = use(st3, ad3, st3.player, 92)
local statusAnim, idxA, idxM
for i, ev in ipairs(out3._anim.events) do
if ev.kind == "anim" and ev.anim == "status" then statusAnim = ev; idxA = i end
if ev.kind == "msg" and ev.text:find("badly") then idxM = i end
end
check(statusAnim and statusAnim.name == "POISON" and idxA < idxM, "status anim precedes the status text")
end
print("=== Quick Attack / Vital Throw priority from ROM ===")
do
check(Moves.priority(98) == 1, "Quick Attack priority 1 from ROM")
local st, ad = battle({ speed = 1, moves = { 98 } }, { speed = 200 })
local acts = Engine.planTurnFromActions(st, ad, { kind = "move", move = 98, slot = 1 }, { kind = "move", move = 33, slot = 1 })
check(acts[1].user == st.player, "priority beats speed")
end
print("=== spikes on forced switch ===")
do
local st, ad = battle({ moves = { 18 } }, {}, { wild = false, foeBench = { { maxHp = 80, hp = 80 } } })
Hazards.set(st.enemySide, 1)
use(st, ad, st.player, 18)
check(st.enemy.partyIndex == 2 and ad:hp(st.enemy) == 70, "dragged-out mon takes 1/8 Spikes")
end
print(string.format("\n%d passed, %d failed", passed, failed))
if failed > 0 then
print("[FAIL] game3 battle move effects")
os.exit(1)
end
print("[ok] game3 battle move effects")