-- M6 battle extensibility: effect-record coverage and parity, statuses/ -- balls/rulesets/ai_classes consumption, move-field promotion, the battle -- hooks and events, and the side/field substrate. Self-contained like the -- other mod suites: own bootstrap, assert-based checks, error() on failure. package.path = "./?.lua;./?/init.lua;" .. package.path love = love or require("tests.love_stub") local Data = require("src.core.Data") if not Data.maps then Data:load() end local Font = require("src.render.Font") Font.load(Data) local BattleState = require("src.battle.BattleState") local PaletteFX = require("src.render.PaletteFX") local Catching = require("src.battle.Catching") local Damage = require("src.battle.Damage") local Events = require("src.mods.Events") local Evolution = require("src.pokemon.Evolution") local Experience = require("src.battle.Experience") local Growth = require("src.pokemon.Growth") local Hooks = require("src.mods.Hooks") local MoveEffects = require("src.battle.MoveEffects") local Pokemon = require("src.pokemon.Pokemon") local Runtime = require("src.mods.Runtime") local SaveData = require("src.core.SaveData") local Status = require("src.battle.Status") local TrainerAI = require("src.battle.TrainerAI") local TurnOrder = require("src.battle.TurnOrder") local TypeChart = require("src.battle.TypeChart") TypeChart.load(Data) local ruleset = require("src.battle.rulesets.gen1_faithful") local S = require("tests.harness").suite("mod battle") local check = S.check local function mkseq(vals) -- scripted rng: pops vals, then max rolls local i = 0 return function(a, b) i = i + 1 return vals[i] ~= nil and vals[i] or b end end -- a stub stack keeps the queue pump self-contained (no UI rows in these -- probes, so top() never has to return the battle) local function makeGame(party) local save = SaveData.newGame() save.party = party local stack = { states = {} } function stack:push(state) self.states[#self.states + 1] = state end function stack:pop() return table.remove(self.states) end function stack:top() return self.states[#self.states] end return { data = Data, save = save, stack = stack, input = { wasPressed = function() return true end } } end local function pump(battle, limit) local steps = 0 while steps < (limit or 6000) do steps = steps + 1 if not battle:updateQueue() then break end end end local function hasText(battle, fragment) for _, item in ipairs(battle.queue) do if item.text and item.text:find(fragment, 1, true) then return true end end return false end -- fresh buses for the hook/event sections; restored at the bottom local savedEvents, savedHooks = Runtime.events, Runtime.hooks local events, hooks = Events.new(), Hooks.new() Runtime.install(events, hooks) -- ------- every vanilla move's effect resolves to a registered record local effectCount, fullCount = 0, 0 for _, move in pairs(Data.moves) do if move.effect then local record = MoveEffects.RECORDS[move.effect] check(record ~= nil, "effect record exists for " .. move.effect) check(record.kind == "primary" or record.kind == "secondary" or record.kind == "full", "effect record kind valid for " .. move.effect) end end for _, record in pairs(MoveEffects.RECORDS) do effectCount = effectCount + 1 if record.kind == "full" then fullCount = fullCount + 1 end end check(fullCount >= 25, "the 25 inline effects registered as full records") check(effectCount >= 60, "primary/secondary/full records all registered") check(MoveEffects.RECORDS.SWIFT_EFFECT.neverMiss == true, "Swift record never misses") check(MoveEffects.RECORDS.OHKO_EFFECT.gate ~= nil, "OHKO record carries its gate") check(MoveEffects.RECORDS.TWINEEDLE_EFFECT.kind == "full" and MoveEffects.RECORDS.TWINEEDLE_EFFECT.run ~= nil, "Twineedle is a full record with its secondary run") check(MoveEffects.RECORDS.SLEEP_EFFECT.accuracyChecked == true and MoveEffects.RECORDS.ATTACK_UP1_EFFECT.accuracyChecked == nil, "accuracyChecked marks the MoveHitTest primaries") -- ------- category-vs-isSpecial equivalence local typeCount = 0 for id, record in pairs(TypeChart.TYPES) do typeCount = typeCount + 1 check(Damage.isSpecial(id) == (record.category == "special"), "category matches isSpecial for " .. id) end check(typeCount == 15, "all 15 vanilla types checked") check(TypeChart.displayName("PSYCHIC_TYPE") == "PSYCHIC", "type display name") -- ------- record-driven parity probes do -- fixed damage through the SPECIAL_DAMAGE record local game = makeGame({ Pokemon.new(Data, "BULBASAUR", 20) }) local battle = BattleState.newWild(game, "SNORLAX", 30) battle.rng = mkseq({ 0 }) -- accuracy only: SONICBOOM rolls nothing else local before = battle.enemy.mon.hp battle:performMove(battle.player, battle.enemy, { id = "SONICBOOM", pp = 10 }) check(before - battle.enemy.mon.hp == 20, "SONICBOOM deals a fixed 20") -- OHKO gate fails against a faster target local game2 = makeGame({ Pokemon.new(Data, "BULBASAUR", 5) }) local slow = BattleState.newWild(game2, "RATTATA", 30) slow.rng = mkseq({}) slow:performMove(slow.player, slow.enemy, { id = "FISSURE", pp = 5 }) check(hasText(slow, "But, it failed!"), "OHKO fails against a faster target") check(slow.enemy.mon.hp == slow.enemy.mon.stats.hp, "no damage through a failed gate") end -- ------- a mod-registered move effect drives a battle do local landed = false local effects = {} for id, record in pairs(MoveEffects.RECORDS) do effects[id] = record end effects.TEST_SIDE_EFFECT = { kind = "secondary", run = function(ctx) landed = true return { "It tingles!" } end } Data.move_effects = effects Data.moves.TEST_STRIKE = { id = "TEST_STRIKE", name = "TEST STRIKE", type = "NORMAL", power = 40, accuracy = 100, pp = 10, effect = "TEST_SIDE_EFFECT" } local game = makeGame({ Pokemon.new(Data, "BULBASAUR", 20) }) local battle = BattleState.newWild(game, "SNORLAX", 30) battle.rng = mkseq({ 0, 255, 255 }) battle:performMove(battle.player, battle.enemy, { id = "TEST_STRIKE", pp = 10 }) check(landed, "a registered move effect runs post-damage") check(hasText(battle, "It tingles!"), "the effect's message is queued") check(battle.enemy.mon.hp < battle.enemy.mon.stats.hp, "an unknown-kind move still deals its damage") Data.move_effects = nil Data.moves.TEST_STRIKE = nil end -- ------- statuses registry: gauntlet, residual, HUD, catch bonus do local statuses = {} for id, record in pairs(Status.RECORDS) do statuses[id] = record end statuses.FBT = { id = "FBT", label = "FBT", hudLabel = "FBT", catchBonus = 20, shakeBonus = 30, beforeMovePriority = 40, beforeMove = function(battler) return false, { battler.name .. "\nis frostbitten!" } end, residual = function(battler) battler.mon.hp = math.max(0, battler.mon.hp - 3) return { "The frostbite\nhurts!" } end, } Data.statuses = statuses local game = makeGame({ Pokemon.new(Data, "BULBASAUR", 20) }) local battle = BattleState.newWild(game, "SNORLAX", 30) battle.enemy.mon.status = "FBT" local canMove, msgs = Status.beforeMove(battle.enemy, battle.rng, battle) check(canMove == false and msgs[1]:find("frostbitten", 1, true), "a registered status joins the beforeMove gauntlet") local hp = battle.enemy.mon.hp local residualMsgs = Status.residual(battle.enemy, battle.player, battle) check(battle.enemy.mon.hp == hp - 3 and #residualMsgs == 1, "a registered status joins the residual sweep") check(battle:statusLabel(battle.enemy.mon) == "FBT", "the HUD reads the registered hudLabel") -- the catch roll subtracts the registered catchBonus local mon = { status = "FBT", stats = { hp = 100 }, hp = 100 } local caught = Catching.attempt("POKE_BALL", mon, { catchRate = 0 }, mkseq({ 19 }), nil, { statuses = statuses }) check(caught == true, "registered catchBonus underflows the catch roll") local uncaught = Catching.attempt("POKE_BALL", mon, { catchRate = 0 }, mkseq({ 19 }), nil, nil) check(uncaught == false, "an unknown status grants no catch bonus") -- the failure wobble adds the registered shakeBonus (fallback +5) local _, wobbles = Catching.attempt("POKE_BALL", mon, { catchRate = 50 }, mkseq({ 100 }), nil, { statuses = statuses }) check(wobbles == 2, "registered shakeBonus feeds the wobble math") local _, plain = Catching.attempt("POKE_BALL", mon, { catchRate = 50 }, mkseq({ 100 }), nil, nil) check(plain == 1, "an unknown status falls back to the stock wobble bonus") Data.statuses = nil -- vanilla gauntlet parity without a battle on hand local sleeper = { mon = { status = "SLP" }, sleepTurns = 2, name = "SLEEPY" } local slpMove, slpMsgs = Status.beforeMove(sleeper, mkseq({})) check(slpMove == false and slpMsgs[1]:find("fast asleep", 1, true), "vanilla sleep runs through its record") local par = { mon = { status = "PAR" }, name = "ZAPPED" } local parMove = Status.beforeMove(par, mkseq({ 62 })) check(parMove == false, "full paralysis on a low roll") local parFree = Status.beforeMove(par, mkseq({ 63 })) check(parFree == true, "paralysis clears on a high roll") end -- ------- balls registry: record fields and the attempt override do local calls = {} local rng = function(a, b) calls[#calls + 1] = { a, b } return b end local mon = { status = nil, stats = { hp = 100 }, hp = 100 } Catching.attempt("MOD_BALL", mon, { catchRate = 100 }, rng, nil, { ballDef = { randMax = 100, hpFactor = 12, wobbleFactor = 150 } }) check(calls[1][2] == 100, "a registered ball's randMax bounds the roll") local auto = Catching.attempt("MOD_BALL", mon, { catchRate = 0 }, function() error("autoCatch must not roll") end, nil, { ballDef = { randMax = 0, autoCatch = true } }) check(auto == true, "autoCatch skips every roll") -- an attempt override doubles the rate then falls through to the math local caught = Catching.attempt("MOD_BALL", mon, { catchRate = 100 }, mkseq({ 100, 85 }), nil, { ballDef = { randMax = 255, hpFactor = 12, wobbleFactor = 150, attempt = function(ctx) ctx.rateOverride = math.min(255, ctx.targetDef.catchRate * 2) return ctx.vanillaAttempt() end } }) check(caught == true, "an attempt override rewrites the rate and delegates") -- toss/flicker resolve from the records local game = makeGame({ Pokemon.new(Data, "BULBASAUR", 20) }) local battle = BattleState.newWild(game, "RATTATA", 5) check(battle:tossAnimFor("GREAT_BALL") == "GREATTOSS_ANIM", "toss arc from record") check(battle:ballFlicker("ULTRA_BALL") == true, "Ultra flickers") check(battle:ballFlicker("POKE_BALL") == false, "Poke ball does not flicker") end -- ------- rulesets from the merged registry do Data.rulesets = { gen1_faithful = ruleset, test_rules = { name = "test_rules", oneIn256Miss = false, critUsesBaseSpeed = true, critIgnoresStages = true, randMin = 255, randMax = 255, focusEnergyBug = true }, } local game = makeGame({ Pokemon.new(Data, "BULBASAUR", 20) }) game.save.options = { ruleset = "test_rules" } local battle = BattleState.newWild(game, "RATTATA", 5) check(battle.ruleset.name == "test_rules", "battle picks the registered ruleset") game.save.options = { ruleset = "no_such_rules" } local fallback = BattleState.newWild(game, "RATTATA", 5) check(fallback.ruleset == ruleset, "unknown ruleset falls back to the default") Data.rulesets = nil end -- ------- options menu cycles the merged ruleset registry do local OptionsMenu = require("src.ui.OptionsMenu") Data.rulesets = { gen1_faithful = ruleset, modern_clean = require("src.battle.rulesets.modern_clean"), aaa_rules = { name = "aaa rules" }, zz_hidden = { name = "hidden rules", hidden = true }, } local pressed = {} local game = { data = Data, save = SaveData.newGame(), input = { wasPressed = function(_, key) return pressed[key] or false end }, stack = { pop = function() end } } local menu = OptionsMenu.new(game) for i, row in ipairs(menu.rows) do if row.id == "ruleset" then menu.index = i end end local function press(key) pressed = { [key] = true } menu:update(1 / 60) pressed = {} end check(game.save.options.ruleset == "gen1_faithful", "new saves start on the default ruleset") press("right") check(game.save.options.ruleset == "modern_clean", "right steps to the next sorted id") press("right") check(game.save.options.ruleset == "aaa_rules", "a mod-registered ruleset is selectable") press("right") check(game.save.options.ruleset == "gen1_faithful", "the cycle wraps and never offers the hidden record") press("left") check(game.save.options.ruleset == "aaa_rules", "left steps backwards") local drawn = {} local savedDraw = Font.draw Font.draw = function(text) drawn[#drawn + 1] = text end menu:draw() Font.draw = savedDraw local shown = false for _, text in ipairs(drawn) do if text == "aaa rules" then shown = true end end check(shown, "the row displays the record's name, not the id") Data.rulesets = nil end -- ------- ai_classes: brains and layer records -- ------- custom trainer portraits: palette sources and base portraits do local oldMode = PaletteFX.mode PaletteFX.mode = "redpp" local custom = { id = "TEST_TRAINER", paletteSource = "ROM:SpriteSheetPointerTable[21]" } local pal = BattleState.trainerPalette(Data, custom) local expected = PaletteFX.spriteObp({ paletteSource = custom.paletteSource }, custom.id) PaletteFX.mode = oldMode check(pal and expected and pal.colors[3][1] == expected[3][1] and pal.colors[3][2] == expected[3][2] and pal.colors[3][3] == expected[3][3], "a custom trainer portrait resolves its Advanced OBJ palette source") check(BattleState.trainerPicPath(Data, { basePic = "OPP_ENGINEER" }) == Data.trainers.OPP_ENGINEER.pic, "a custom trainer can reuse a base trainer portrait by id") end do Data.ai_classes = { OPP_YOUNGSTER = { brain = function(battle) return { id = "SPLASH", pp = 1, brained = true } end } } local game = makeGame({ Pokemon.new(Data, "BULBASAUR", 20) }) local battle = BattleState.newTrainer(game, "OPP_YOUNGSTER", 1) local action = battle:enemyAction() check(action.brained == true, "a registered brain chooses the enemy action") Data.ai_classes = { LAYER_1 = { score = function(view, moveDef, score) if moveDef and moveDef.id == "TACKLE" then return score + 50 end return score end } } local aiMon = { curMoves = { { id = "TACKLE", pp = 10 }, { id = "GROWL", pp = 10 } } } local aiBattle = { enemyAIMods = { 1 }, data = Data, player = { mon = {}, curTypes = { "NORMAL" } } } local pick = TrainerAI.chooseMove(aiMon, mkseq({}), aiBattle) check(pick.id == "GROWL", "a registered layer record rescores the choice") Data.ai_classes = nil local brock = TrainerAI.classFor({ trainer = { id = "OPP_BROCK" }, data = Data }) check(brock and brock.item == "FULL_HEAL", "class lookup falls back to the data file") end -- ------- move-field promotion do -- priority beats speed local slow = { curStats = { speed = 5 }, stages = {}, mon = {} } local fast = { curStats = { speed = 99 }, stages = {}, mon = {} } check(TurnOrder.firstMover(slow, { id = "X", priority = 1 }, fast, { id = "TACKLE" }, mkseq({})) == true, "move.priority wins the turn order") check(TurnOrder.firstMover(slow, { id = "QUICK_ATTACK" }, fast, { id = "TACKLE" }, mkseq({})) == true, "legacy priority ids keep resolving") -- highCrit matches the legacy table's boosted rate local battler = { def = { baseStats = { speed = 128 } } } local function critCount(moveId, highCrit) local n = 0 for i = 0, 255 do if Damage.critRoll(ruleset, battler, moveId, function() return i end, highCrit) then n = n + 1 end end return n end check(critCount("TACKLE", true) == critCount("SLASH", nil), "highCrit = true matches the legacy high-crit list") check(critCount("TACKLE", nil) == 64, "an unmarked move keeps the normal rate") -- fixedDamage field through the SPECIAL_DAMAGE record Data.moves.TEST_FIX = { id = "TEST_FIX", name = "TEST FIX", type = "NORMAL", power = 1, accuracy = 100, pp = 10, effect = "SPECIAL_DAMAGE_EFFECT", fixedDamage = 15 } local game = makeGame({ Pokemon.new(Data, "BULBASAUR", 20) }) local battle = BattleState.newWild(game, "SNORLAX", 30) battle.rng = mkseq({ 0 }) local before = battle.enemy.mon.hp battle:performMove(battle.player, battle.enemy, { id = "TEST_FIX", pp = 10 }) check(before - battle.enemy.mon.hp == 15, "fixedDamage field sets the damage") Data.moves.TEST_FIX = nil -- chargeText and semiInvulnerable fields Data.moves.TEST_CHARGE = { id = "TEST_CHARGE", name = "TEST CHARGE", type = "NORMAL", power = 40, accuracy = 100, pp = 10, effect = "CHARGE_EFFECT", chargeText = "%s\nis winding up!", semiInvulnerable = true } local cb = BattleState.newWild(makeGame({ Pokemon.new(Data, "BULBASAUR", 20) }), "RATTATA", 5) cb.rng = mkseq({}) local inst = { id = "TEST_CHARGE", pp = 10 } cb:performMove(cb.player, cb.enemy, inst) check(cb.player.charging == inst, "charge record starts the charge turn") check(cb.player.invulnerable == true, "semiInvulnerable field goes invulnerable") check(hasText(cb, "is winding up!"), "chargeText field picks the text") Data.moves.TEST_CHARGE = nil -- counterable field replaces the Normal/Fighting whitelist local counterGame = makeGame({ Pokemon.new(Data, "BULBASAUR", 20) }) local counter = BattleState.newWild(counterGame, "SNORLAX", 30) counter.rng = mkseq({ 0 }) counter.lastDamage = 30 counter.enemy.lastMove = "WATER_GUN" counter:performMove(counter.player, counter.enemy, { id = "COUNTER", pp = 10 }) check(hasText(counter, "attack missed!"), "a Water move is not counterable") Data.moves.WATER_GUN.counterable = true local counter2 = BattleState.newWild(counterGame, "SNORLAX", 30) counter2.rng = mkseq({ 0 }) counter2.lastDamage = 30 counter2.enemy.lastMove = "WATER_GUN" local hp = counter2.enemy.mon.hp counter2:performMove(counter2.player, counter2.enemy, { id = "COUNTER", pp = 10 }) check(hp - counter2.enemy.mon.hp == 60, "counterable = true doubles the last damage") Data.moves.WATER_GUN.counterable = nil -- multiHit field: a plain count consumes no distribution roll Data.moves.TEST_MULTI = { id = "TEST_MULTI", name = "TEST MULTI", type = "NORMAL", power = 15, accuracy = 100, pp = 10, effect = "TWO_TO_FIVE_ATTACKS_EFFECT", multiHit = 2 } local mh = BattleState.newWild(makeGame({ Pokemon.new(Data, "BULBASAUR", 20) }), "SNORLAX", 30) mh.rng = mkseq({ 0, 255, 255 }) mh:performMove(mh.player, mh.enemy, { id = "TEST_MULTI", pp = 10 }) check(hasText(mh, "Hit the enemy\n2 times!"), "multiHit = 2 lands two hits") Data.moves.TEST_MULTI = nil end -- ------- constants: badge boosts and exp tuning do local function plain(badges, boosts) return { curStats = { attack = 10, defense = 10, speed = 10, special = 10 }, stages = {}, curTypes = {}, badges = badges, badgeBoosts = boosts, name = "TEST", mon = { level = 10 }, def = { baseStats = { speed = 10 } } } end local physTest = { id = "PHYS", power = 100, type = "NORMAL", accuracy = 100 } local maxRoll = { rng = function() return 255 end, forceCrit = false } local rows = { { badge = "ZAPBADGE", stat = "attack", num = 2, den = 1 } } local boosted = Damage.compute(ruleset, plain({ ZAPBADGE = true }, rows), plain(nil), physTest, maxRoll) check(boosted == 26, "a registered badge boost row rescales the stat") local speedRows = { { badge = "ZAPBADGE", stat = "speed", num = 2, den = 1 } } check(TurnOrder.effectiveSpeed(plain({ ZAPBADGE = true }, speedRows)) == 20, "a registered speed boost row reaches TurnOrder") local rat = Data.pokemon.RATTATA check(Experience.gainFor(rat, 10, false, 1, false, { exp = { divisor = 14 } }) == math.floor(rat.baseExp * 10 / 14), "constants.exp.divisor retunes the exp formula") check(Experience.gainFor(rat, 10, false, 1, false) == math.floor(rat.baseExp * 10 / 7), "no constants keeps the /7 formula") check(Growth.levelForExp("MEDIUM_FAST", 100000000, 50) == 50, "levelForExp honors the level cap") end -- ------- growth rates registry do local rates = { TESTCURVE = { expForLevel = function(n) return n * 100 end } } check(Growth.expForLevel("TESTCURVE", 3, rates) == 300, "a registered curve resolves through the rates table") check(Growth.levelForExp("TESTCURVE", 500, 100, rates) == 5, "levelForExp walks a registered curve") check(Growth.expForLevel("MEDIUM_FAST", 10) == 1000, "vanilla curves unchanged without a rates table") end -- ------- evolution methods and the evolution.check hook do local egame = { data = { pokemon = { TESTMON = { evolutions = { { method = "LEVEL", level = 5, species = "RAICHU" } } } }, } } local mon = { species = "TESTMON", level = 10 } local species = Evolution.pendingFor(egame, mon, { kind = "levelup" }) check(species == "RAICHU", "LEVEL method fires through pendingFor") check(Evolution.pendingFor(egame, mon, { kind = "trade" }) == nil, "a trade trigger does not fire LEVEL") local unsub = hooks:wrap("evolution.check", function() return false end) check(Evolution.pendingFor(egame, mon, { kind = "levelup" }) == nil, "evolution.check can cancel an evolution") unsub() check(Evolution.pendingFor(egame, mon, { kind = "levelup" }) == "RAICHU", "unhooked dispatch is vanilla again") local fgame = { data = { pokemon = { TESTMON = { evolutions = { { method = "FRIENDSHIP", species = "RAICHU" } } } }, evolution_methods = { FRIENDSHIP = { check = function(g, m, evo, trigger) return trigger.kind == "levelup" and (m.friendship or 0) >= 200 end } }, } } local buddy = { species = "TESTMON", level = 5, friendship = 250 } check(Evolution.pendingFor(fgame, buddy, { kind = "levelup" }) == "RAICHU", "a registered evolution method fires") buddy.friendship = 0 check(Evolution.pendingFor(fgame, buddy, { kind = "levelup" }) == nil, "the registered method's own gate holds") end -- ------- checkParty only offers evolutions for mons that leveled (#213) do local caterpie = Pokemon.new(Data, "CATERPIE", 7) -- at LEVEL evo threshold local bench = Pokemon.new(Data, "PIDGEY", 5) local save = SaveData.newGame() save.party = { caterpie, bench } local stack = { states = {} } function stack:push(state) self.states[#self.states + 1] = state end function stack:pop() return table.remove(self.states) end function stack:top() return self.states[#self.states] end local game = { data = Data, save = save, stack = stack } local offered = {} local origEvolve = Evolution.evolve Evolution.evolve = function(_, mon, to, onDone) offered[#offered + 1] = { mon = mon, to = to } if onDone then onDone() end end check(Evolution.checkParty(game) == 0, "checkParty with no leveledUp set offers nothing") check(#offered == 0, "no evolve calls without leveledUp") check(Evolution.checkParty(game, nil, {}) == 0, "checkParty with empty leveledUp offers nothing") check(#offered == 0, "cancel-at-threshold does not re-offer next battle") check(Evolution.checkParty(game, nil, { [bench] = true }) == 0, "a leveled mon with no pending evo is skipped") check(#offered == 0, "bench level-up alone does not evolve CATERPIE") check(Evolution.checkParty(game, nil, { [caterpie] = true }) == 1, "checkParty offers evo for the mon that leveled") check(#offered == 1 and offered[1].mon == caterpie and offered[1].to == "METAPOD", "the leveled CATERPIE is queued for METAPOD") Evolution.evolve = origEvolve end -- ------- the rare-candy flow runs the hook-wrapped dispatch do local Bag = require("src.inventory.Bag") local BagMenu = require("src.ui.BagMenu") local pressed = {} local function uiGame(party) local save = SaveData.newGame() save.party = party local stack = { states = {} } function stack:push(state) self.states[#self.states + 1] = state end function stack:pop() return table.remove(self.states) end function stack:top() return self.states[#self.states] end return { data = Data, save = save, stack = stack, input = { wasPressed = function(_, key) return pressed[key] or false end, isDown = function() return false end } } end -- feed one candy through the real bag UI: press A through the item -- list, USE, the party pick, the level text, the stat box and any -- evolution text until every state has popped local function candyFlow() local mon = Pokemon.new(Data, "CHARMANDER", 15) local game = uiGame({ mon }) Bag.add(game.save, "RARE_CANDY", 1) game.stack:push(BagMenu.new(game)) for _ = 1, 600 do local top = game.stack:top() if not top then break end pressed = { a = true } top:update(1) pressed = {} end check(game.stack:top() == nil, "the candy flow runs to completion") return mon end local fed = candyFlow() check(fed.level == 16, "the candy levels the mon") check(fed.species == "CHARMELEON", "the level evolution fires afterwards") local unsub = hooks:wrap("evolution.check", function() return false end) local blocked = candyFlow() check(blocked.level == 16, "the cancel hook leaves the level gain alone") check(blocked.species == "CHARMANDER", "evolution.check gates the rare-candy evolution") unsub() end -- ------- battle hooks: pass-through, transform, isolation do local function tackleProbe() local game = makeGame({ Pokemon.new(Data, "BULBASAUR", 10) }) local battle = BattleState.newWild(game, "SNORLAX", 30) battle.rng = mkseq({ 0, 255, 255 }) local before = battle.enemy.mon.hp battle:performMove(battle.player, battle.enemy, { id = "TACKLE", pp = 10 }) return before - battle.enemy.mon.hp end local baseline = tackleProbe() check(baseline > 0, "baseline tackle deals damage") check(tackleProbe() == baseline, "unhooked damage is deterministic") local unsub = hooks:wrap("battle.damage", function(nextFn, ctx) local dmg, info = nextFn(ctx) return dmg * 2, info end) check(tackleProbe() == baseline * 2, "battle.damage hook doubles the damage") unsub() check(tackleProbe() == baseline, "unwrapped damage is vanilla again") unsub = hooks:wrap("battle.damage", function() error("boom") end) check(tackleProbe() == baseline, "a throwing damage wrapper is skipped") unsub() unsub = hooks:wrap("battle.crit", function() return true end) local game = makeGame({ Pokemon.new(Data, "BULBASAUR", 10) }) local critBattle = BattleState.newWild(game, "SNORLAX", 30) critBattle.rng = mkseq({ 0, 255 }) -- accuracy, damage; the hook owns the crit critBattle:performMove(critBattle.player, critBattle.enemy, { id = "TACKLE", pp = 10 }) check(hasText(critBattle, "Critical hit!"), "battle.crit hook forces a crit") unsub() unsub = hooks:wrap("battle.accuracy", function() return false end) local missBattle = BattleState.newWild(game, "SNORLAX", 30) missBattle.rng = mkseq({}) missBattle:performMove(missBattle.player, missBattle.enemy, { id = "TACKLE", pp = 10 }) check(hasText(missBattle, "attack missed!"), "battle.accuracy hook forces a miss") unsub() unsub = hooks:wrap("catch.rate", function() return true, 3 end) local catchBattle = BattleState.newWild(game, "SNORLAX", 30) catchBattle.rng = mkseq({}) local caught, shakes = catchBattle:catchAttempt("POKE_BALL") check(caught == true and shakes == 3, "catch.rate hook decides the catch") unsub() unsub = hooks:wrap("exp.gain", function(nextFn, ctx) return nextFn(ctx) * 2 end) local mon = Pokemon.new(Data, "BULBASAUR", 10) local expBefore = mon.exp local _, gained = Experience.apply(Data, mon, Data.pokemon.RATTATA, 10, false, 1, false) check(gained == Experience.gainFor(Data.pokemon.RATTATA, 10, false, 1, false) * 2, "exp.gain hook doubles the award") check(mon.exp == expBefore + gained, "the doubled award is what lands") unsub() local sawOrder = false unsub = hooks:wrap("battle.turn_order", function(nextFn, a, aMove, b, bMove, ctx) sawOrder = true return nextFn(a, aMove, b, bMove, ctx) end) local orderGame = makeGame({ Pokemon.new(Data, "BULBASAUR", 10) }) local orderBattle = BattleState.newWild(orderGame, "RATTATA", 5) orderBattle.rng = mkseq({}) orderBattle:resolveTurn(orderBattle.player.curMoves[1]) check(sawOrder, "battle.turn_order hook wraps the order roll") unsub() unsub = hooks:wrap("battle.run", function() return true end) local runGame = makeGame({ Pokemon.new(Data, "CATERPIE", 3) }) local runBattle = BattleState.newWild(runGame, "RATTATA", 30) runBattle.rng = mkseq({ 0 }) runBattle:tryRun() check(runBattle.result == "run", "battle.run hook forces the escape") unsub() unsub = hooks:wrap("battle.enemy_action", function() return { id = "TACKLE", pp = 1, hooked = true } end) local actGame = makeGame({ Pokemon.new(Data, "BULBASAUR", 10) }) local actBattle = BattleState.newWild(actGame, "RATTATA", 5) check(actBattle:enemyAction().hooked == true, "battle.enemy_action hook rewrites the choice") unsub() -- battle.catch_exp: vanilla catches never grant exp; a mod can flip that unsub = hooks:wrap("battle.catch_exp", function() return true end) local catchExpParty = { Pokemon.new(Data, "BULBASAUR", 10) } local catchExpGame = makeGame(catchExpParty) local catchExpBattle = BattleState.newWild(catchExpGame, "RATTATA", 3) catchExpBattle.enemy.mon = Pokemon.new(Data, "RATTATA", 3) local expBeforeCatch = catchExpParty[1].exp catchExpBattle:storeCaughtMon() check(catchExpParty[1].exp > expBeforeCatch, "battle.catch_exp hook pays out exp on a catch") unsub() -- battle.exp_award: a mod can replace the participant/EXP.ALL split -- wholesale via ctx.applyShare unsub = hooks:wrap("battle.exp_award", function(nextFn, ctx) ctx.applyShare(ctx.alive[1], 999, "flatShare") end) local awardParty = { Pokemon.new(Data, "BULBASAUR", 10) } local awardGame = makeGame(awardParty) local awardBattle = BattleState.newWild(awardGame, "RATTATA", 3) local expBeforeAward = awardParty[1].exp awardBattle:awardExp() check(awardParty[1].exp > expBeforeAward, "battle.exp_award hook replaces the award split") unsub() end -- ------- battle.low_health_alarm hook: mirrors the siren toggle do local Sound = require("src.core.Sound") local calls = {} local origStart, origStop = Sound.startLoop, Sound.stopLoop Sound.startLoop = function(data, name) calls[#calls + 1] = { "start", name } end Sound.stopLoop = function(name) calls[#calls + 1] = { "stop", name } end local game = makeGame({ Pokemon.new(Data, "BULBASAUR", 20) }) local battle = BattleState.newWild(game, "RATTATA", 5) battle.player.mon.hp = 1 battle.player.shownHP = 1 local seenOn = nil local unsub = hooks:wrap("battle.low_health_alarm", function(nextFn, ctx) seenOn = ctx.on return nextFn(ctx) end) battle:updateFx() check(seenOn == true, "battle.low_health_alarm hook sees the alarm toggle on") check(calls[#calls][1] == "start" and calls[#calls][2] == "Low_Health_Alarm", "an unmodified hook still starts the siren loop") unsub() unsub = hooks:wrap("battle.low_health_alarm", function(nextFn, ctx) ctx.on = false return nextFn(ctx) end) battle:updateFx() check(calls[#calls][1] == "stop", "a mod can force the alarm off before vanilla acts") unsub() Sound.startLoop, Sound.stopLoop = origStart, origStop end -- ------- battle events: the scripted sequence do local log = {} local function listen(name) events:on(name, function(payload) log[#log + 1] = { name = name, payload = payload } end) end for _, name in ipairs({ "battle.started", "battle.turn_started", "battle.turn_ended", "battle.move_used", "battle.damage_dealt", "battle.fainted", "battle.exp_gained", "battle.ended", "battle.status_inflicted", "battle.ball_thrown", "pokemon.caught", "battle.battler_switched" }) do listen(name) end local function indexOf(name) for i, entry in ipairs(log) do if entry.name == name then return i end end return nil end local game = makeGame({ Pokemon.new(Data, "BULBASAUR", 50) }) local battle = BattleState.newWild(game, "RATTATA", 2) battle.onFinish = function() end battle:enter() check(indexOf("battle.started") ~= nil, "battle.started fires on enter") check(log[indexOf("battle.started")].payload.kind == "wild", "battle.started carries the kind") battle.rng = function(a) return a end -- min rolls: the move always hits battle:resolveTurn(battle.player.curMoves[1]) pump(battle) battle:finish() check(indexOf("battle.turn_started") ~= nil, "battle.turn_started fires") check(indexOf("battle.move_used") ~= nil, "battle.move_used fires") check(indexOf("battle.damage_dealt") ~= nil, "battle.damage_dealt fires") check(indexOf("battle.fainted") ~= nil, "battle.fainted fires") check(indexOf("battle.turn_ended") ~= nil, "battle.turn_ended fires") check(indexOf("battle.exp_gained") ~= nil, "battle.exp_gained fires") check(indexOf("battle.ended") ~= nil, "battle.ended fires") check(indexOf("battle.started") < indexOf("battle.turn_started") and indexOf("battle.turn_started") < indexOf("battle.move_used") and indexOf("battle.move_used") < indexOf("battle.damage_dealt") and indexOf("battle.damage_dealt") < indexOf("battle.fainted") and indexOf("battle.fainted") < indexOf("battle.ended"), "the battle events fire in order") -- status_inflicted on a landing Thunder Wave local waveGame = makeGame({ Pokemon.new(Data, "BULBASAUR", 10) }) local wave = BattleState.newWild(waveGame, "RATTATA", 5) wave.rng = mkseq({ 254 }) wave:performMove(wave.player, wave.enemy, { id = "THUNDER_WAVE", pp = 10 }) local inflicted = indexOf("battle.status_inflicted") check(inflicted ~= nil and log[inflicted].payload.status == "PAR", "battle.status_inflicted carries the status") -- ball_thrown + pokemon.caught (box destination, no UI rows) local party = {} for _ = 1, 6 do party[#party + 1] = Pokemon.new(Data, "PIDGEY", 5) end local catchGame = makeGame(party) catchGame.save.pokedex.owned.RATTATA = true local catchBattle = BattleState.newWild(catchGame, "RATTATA", 3) catchBattle.onFinish = function() end catchBattle.rng = function(a) return a end catchBattle.queue = {} catchBattle:throwBall("POKE_BALL") pump(catchBattle) local thrown = indexOf("battle.ball_thrown") check(thrown ~= nil and log[thrown].payload.caught == true, "battle.ball_thrown reports the outcome") local caughtIdx = indexOf("pokemon.caught") check(caughtIdx ~= nil, "pokemon.caught fires") check(log[caughtIdx].payload.ball == "POKE_BALL" and log[caughtIdx].payload.destination == "box", "pokemon.caught carries ball and destination") -- battler_switched on a mid-battle switch local swGame = makeGame({ Pokemon.new(Data, "BULBASAUR", 20), Pokemon.new(Data, "PIDGEY", 20) }) local swBattle = BattleState.newWild(swGame, "RATTATA", 5) swBattle.rng = mkseq({}) swBattle:resolveSwitch(swGame.save.party[2]) pump(swBattle) local switched = indexOf("battle.battler_switched") check(switched ~= nil and log[switched].payload.side.index == 1, "battle.battler_switched names the side") end -- ------- side/field substrate do local game = makeGame({ Pokemon.new(Data, "BULBASAUR", 20) }) local battle = BattleState.newWild(game, "RATTATA", 5) battle:syncSides() check(battle.sides[1].battlers[1] == battle.player and battle.sides[2].battlers[1] == battle.enemy, "sides mirror the singles battlers") check(battle:sideOf(battle.enemy).index == 2, "sideOf maps by side") check(battle.field.sides == battle.sides and battle.field.weather == nil, "the field substrate starts empty") local residuals, expired = 0, false table.insert(battle.sides[2].tokens, { id = "test", turns = 2, onResidual = function() residuals = residuals + 1 end, onExpire = function() expired = true end }) table.insert(battle.field.tokens, { id = "haze", turns = 1, onExpire = function() end }) battle.rng = mkseq({}) battle:endOfTurn() check(residuals == 1 and not expired, "side tokens tick each end of turn") check(#battle.field.tokens == 0, "an expired field token is removed") battle:endOfTurn() check(expired and #battle.sides[2].tokens == 0, "a side token expires after its turns run out") end Runtime.install(savedEvents, savedHooks) S.finish()