-- Prize money for beating a trainer, plus what Mom does with her cut. -- ROM-free: -- luajit tests/gen2_prize_test.lua -- -- The numbers below are traceable to pokegold: Falkner's ¥900 is -- TrainerClassAttributes' 25 times his level 9 Pidgeotto times the four adds -- in WinTrainerBattle, and the Mom item table is data/items/mom_phone.asm -- verbatim. package.path = "./?.lua;./?/init.lua;" .. package.path local S = require("tests.harness").suite("gen2 prize money") local check, eq = S.check, S.eq local Prize = require("src.battle.gen2.Prize") local Battle = require("src.battle.gen2.Battle") local MomShopping = require("src.core.gen2.MomShopping") local Decorations = require("src.core.gen2.Decorations") local Events = require("src.world.gen2.Events") local Save = require("src.core.gen2.Save") local Trainers = require("src.world.gen2.Trainers") -- --------------------------------------------------------------- fixtures -- A save with nothing on it but the two money accounts the payout writes. local function record(money, saved, active, savingMoney) return { player = { name = "GOLD", money = money or 0 }, mom = { savedMoney = saved or 0, active = active or false, savingMoney = savingMoney or false }, } end -- ------------------------------------------------- ComputeTrainerReward -- engine/battle/read_trainer_party.asm: base reward times wCurPartyLevel, and -- wCurPartyLevel is whatever the LAST party row left behind. eq(Prize.reward(25, 9), 225, "Falkner's quarter is 25 x 9") eq(Prize.reward(4, 4), 16, "a YOUNGSTER's is 4 x 4") eq(Prize.reward(nil, 9), 0, "a class with no base reward pays nothing") eq(Prize.reward(25, nil), 0, "and neither does a party with no level") -- hProduct + 2 / + 3 with a zero on top: the product is kept modulo 65536. eq(Prize.reward(255, 300), (255 * 300) % 0x10000, "the product is truncated to wBattleReward's low two bytes") eq(Prize.rewardLevel({ { level = 7 }, { level = 9 } }), 9, "the level is the LAST roster row, not the highest") eq(Prize.rewardLevel({ { level = 40 }, { level = 3 } }), 3, "even when the last row is the weakest") eq(Prize.rewardLevel({}), 0, "and an empty party has none") -- ---------------------------------------------------- the four quarters -- WinTrainerBattle's `ld c, 4`: wBattleReward is added FOUR times, and only -- then doubled twice for the text. Falkner really does pay ¥900. local falkner = record(0) local award = Prize.award(falkner, { baseMoney = 25, level = 9 }) eq(award.quarter, 225, "one quarter of Falkner's prize") eq(award.total, 900, "and the figure the text prints") eq(falkner.player.money, 900, "all four quarters land in the wallet") eq(award.toMom, 0, "with Mom taking none of them") -- Mom is not saving yet: `active` alone is the bank conversation having -- happened, and MOM_SAVING_SOME_MONEY_F is a separate bit. local idle = record(0, 0, true, false) Prize.award(idle, { baseMoney = 25, level = 9 }) eq(idle.player.money, 900, "an active-but-not-saving Mom takes nothing") eq(idle.mom.savedMoney, 0, "and her account does not move") -- The standing 25%: one of the four adds goes to wMomsMoney. local saving = record(0, 0, true, true) local skimmed = Prize.award(saving, { baseMoney = 25, level = 9 }) eq(skimmed.toMom, 1, "MOM_SAVING_SOME_MONEY_F takes one quarter") eq(saving.mom.savedMoney, 225, "which is a quarter of the prize") eq(saving.player.money, 675, "and the wallet keeps the other three") eq(skimmed.total, 900, "while the text still names the whole figure") -- .CheckMaxedOutMomMoney: with her account at the cap the carry is clear, b -- is 0 and the KeepItAll text is chosen -- she stops skimming rather than -- throwing the quarter away. local maxed = record(0, Prize.MAX_MONEY, true, true) local kept = Prize.award(maxed, { baseMoney = 25, level = 9 }) eq(kept.toMom, 0, "a maxed-out Mom takes nothing") eq(kept.mode, 0, "and the text falls back to KeepItAll") eq(maxed.player.money, 900, "so the whole prize reaches the wallet") eq(maxed.mom.savedMoney, Prize.MAX_MONEY, "and her account is untouched") -- AddBattleMoneyToAccount clamps at MAX_MONEY rather than wrapping. local rich = record(Prize.MAX_MONEY - 100) Prize.award(rich, { baseMoney = 25, level = 9 }) eq(rich.player.money, Prize.MAX_MONEY, "the wallet clamps at the cap") local richMom = record(0, Prize.MAX_MONEY - 100, true, true) Prize.award(richMom, { baseMoney = 100, level = 100 }) eq(richMom.mom.savedMoney, Prize.MAX_MONEY, "and so does Mom's account") -- The Amulet Coin doubles wBattleReward BEFORE the split, so Mom's cut -- doubles with everything else. local coin = record(0, 0, true, true) local doubled = Prize.award(coin, { baseMoney = 25, level = 9, amuletCoin = true }) eq(doubled.total, 1800, "an AMULET COIN doubles the prize") eq(coin.mom.savedMoney, 450, "including the quarter Mom takes") eq(coin.player.money, 1350, "and the three the wallet keeps") -- The half and all settings are Crystal's -- BankOfMom never writes them in -- Gold -- but WinTrainerBattle's loop reads them, and 3 means FOUR quarters -- (the `inc a`), not three. local half = record(0, 0, true, 2) eq(Prize.award(half, { baseMoney = 25, level = 9 }).toMom, 2, "MOM_SAVING_HALF_MONEY_F takes two quarters") eq(half.mom.savedMoney, 450, "which really is half") local all = record(0, 0, true, 3) eq(Prize.award(all, { baseMoney = 25, level = 9 }).toMom, 4, "both bits together take all four") eq(all.player.money, 0, "so the wallet gets nothing") eq(all.mom.savedMoney, 900, "and Mom banks the lot") -- data/text/battle.asm. The half and all lines really do replace the money -- line rather than following it. local plain = Prize.message({ total = 900, mode = 0 }, "GOLD") check(plain:find("900", 1, true) ~= nil, "the money line names the figure") check(plain:find("MOM", 1, true) == nil, "and does not mention MOM") local some = Prize.message({ total = 900, mode = 1 }, "GOLD") check(some:find("900", 1, true) ~= nil, "SentSomeToMomText keeps the figure") check(some:find("Sent some to MOM!", 1, true) ~= nil, "and adds the line") eq(Prize.message({ total = 900, mode = 2 }, "GOLD"), "Sent half to MOM!", "SentHalfToMomText is the whole text") eq(Prize.message({ total = 900, mode = 3 }, "GOLD"), "Sent all to MOM!", "and so is SentAllToMomText") check(plain:find("\n", 1, true) == nil, "no line marker: Chrome.wrap breaks a battle message itself") -- ------------------------------------------------- reached from a battle -- The whole point of the file: a real Battle, beaten, pays the save. These -- fixtures are the shapes Trainers.party and Mon.new produce, cut down to -- what the faint path reads. local DATA = { pokemon = { RATTATA = { id = "RATTATA", name = "RATTATA", index = 19, baseExp = 57, growthRate = "MEDIUM_FAST", stats = { hp = 30, attack = 56, defense = 35, speed = 72, specialAttack = 25, specialDefense = 35 }, types = { "NORMAL" } }, PIDGEY = { id = "PIDGEY", name = "PIDGEY", index = 15, baseExp = 55, growthRate = "MEDIUM_SLOW", stats = { hp = 40, attack = 45, defense = 40, speed = 56, specialAttack = 35, specialDefense = 35 }, types = { "NORMAL", "FLYING" } }, }, moves = {}, } local function mon(species, level, hp, item) return { species = species, name = species, nickname = species, level = level, hp = hp, maxHp = 30, item = item, stats = { hp = 30, attack = 10, defense = 10, speed = 10, specialAttack = 10, specialDefense = 10 }, moves = {}, experience = 0, statExp = {}, dvs = {} } end local function beat(save, opts) opts = opts or {} local enemy = mon("PIDGEY", 7, 1) local last = mon("PIDGEY", 9, 0) local battle = Battle.new({ data = DATA, party = { mon("RATTATA", 10, 20, opts.playerItem) }, save = save, trainer = { name = "FALKNER", className = "LEADER", baseMoney = 25, party = { enemy, last } }, }) -- The last mon standing faints, which is the arm WinTrainerBattle sits in. battle.enemy = last battle.enemyIndex = 2 battle.enemyParty[1].hp = 0 last.hp = 0 battle:resolveFaints() return battle end local paid = record(0) local won = beat(paid) eq(won.outcome, "win", "the battle ends on a win") eq(paid.player.money, 900, "and the save is paid the cart's figure") check(won.prize ~= nil, "the battle keeps the award it made") -- The money line is an EVENT, so the battle screen shows it the same way it -- shows every other line: no new seam, no new phase. local sawMoney = nil for _, event in ipairs(won.events) do if event.kind == "money" then sawMoney = event end end check(sawMoney ~= nil, "a `money` event is emitted") check(sawMoney and sawMoney.text and sawMoney.text:find("900", 1, true) ~= nil, "carrying the line StdBattleTextbox would print") -- CheckAmuletCoin latches on the SEND-OUT, so the lead's held item counts. local coined = record(0) beat(coined, { playerItem = Prize.AMULET_COIN }) eq(coined.player.money, 1800, "a lead holding the AMULET COIN doubles it") -- A battle with no save (a headless turn-order test, or a link battle) pays -- nobody rather than throwing. local noSave = beat(nil) eq(noSave.outcome, "win", "a battle with no save still ends") -- A WILD battle pays nothing: WinTrainerBattle is the trainer arm and nothing -- else reaches it. local wildSave = record(0) local wild = Battle.new({ data = DATA, save = wildSave, party = { mon("RATTATA", 10, 20) }, wild = mon("PIDGEY", 3, 0) }) wild:resolveFaints() eq(wildSave.player.money, 0, "beating a wild mon pays nothing") -- --------------------------------------------- the class attribute row -- TrainerClassAttributes is SEVEN bytes and the base reward is the THIRD, so -- Trainers.lookup has to carry it through to the battle or the payout is -- always zero. local TRAINER_DATA = { classes = { FALKNER = { id = "FALKNER", index = 1, name = "FALKNER", baseMoney = 25, items = {}, trainers = { [1] = { id = "FALKNER1", name = "FALKNER", party = { { species = "PIDGEY", level = 7 } } } } } } } local looked = Trainers.lookup(TRAINER_DATA, 1, 1) eq(looked and looked.baseMoney, 25, "the class's base reward survives lookup") -- ------------------------------------------------------- Mom goes shopping local function momSave(saved) local save = Save.normalize({ mom = { savedMoney = saved, active = true, savingMoney = true }, party = {} }) return save end -- NewGame's seed, which is also what MomShopping.state fills in for a file -- that predates it. local seeded = MomShopping.state(momSave(0)) eq(seeded.whichItem, 0, "the ladder starts on its first rung") eq(seeded.triggerBalance, MomShopping.MOM_MONEY, "and the consolation threshold at MOM_MONEY") -- data/items/mom_phone.asm: the four dolls and their thresholds. These are -- the only way a Gold player gets any of them. local DOLLS = { { trigger = 10000, deco = 35, label = "CHARMANDER" }, { trigger = 30000, deco = 32, label = "CLEFAIRY" }, { trigger = 50000, deco = 30, label = "PIKACHU" }, { trigger = 100000, deco = 26, label = "BIG SNORLAX" }, } local dollRows = 0 for _, row in ipairs(MomShopping.ITEMS_2) do if row.kind == 2 then dollRows = dollRows + 1 end end eq(dollRows, 4, "MomItems_2 carries four dolls") for _, doll in ipairs(DOLLS) do local found = nil for _, row in ipairs(MomShopping.ITEMS_2) do if row.item == doll.deco then found = row end end check(found ~= nil, doll.label .. "'s doll is on the list") eq(found and found.trigger, doll.trigger, "and unlocks at its own savings threshold") eq(Decorations.attributes(doll.deco) ~= nil, true, "and its DECO id names a real decoration row") end -- Under the first rung and off a MOM_MONEY multiple: nothing happens. local quiet = momSave(500) check(MomShopping.tryBuy(quiet, { events = Events.new() }) == nil, "with too little saved Mom buys nothing") -- The ladder, one rung per won trainer battle. 10000 banked is past the -- first three triggers, and each buy comes out of the savings -- which is why -- the doll at 10000 does NOT follow them: three purchases have dropped the -- balance under its own trigger and it has to be earned back. local ladder = momSave(10000) local events = Events.new() local bought = {} for _ = 1, 4 do bought[#bought + 1] = MomShopping.tryBuy(ladder, { events = events, random = function() return 0 end }) end eq(bought[1] and bought[1].item, "SUPER_POTION", "rung one is a SUPER POTION") eq(bought[2] and bought[2].item, "REPEL", "rung two a REPEL") eq(bought[3] and bought[3].item, "SUPER_POTION", "rung three another") check(bought[4] == nil, "and the doll waits: the buying spent its trigger") eq(ladder.mom.whichItem, 3, "the ladder has moved on three rungs") eq(ladder.mom.savedMoney, 10000 - 600 - 270 - 600, "and every cost came out of her savings") eq(ladder.pcItems.SUPER_POTION, 2, "the two potions are in the PC") eq(ladder.pcItems.REPEL, 1, "and so is the REPEL") -- Saved back up to the trigger, the doll arrives -- and it is a decoration -- flag, not a PC item, because Mom_GiveItemOrDoll's doll arm goes through -- DecorationFlagAction_c. ladder.mom.savedMoney = 10000 local doll = MomShopping.tryBuy(ladder, { events = events }) eq(doll and doll.kind, "doll", "rung four is the doll") eq(doll and doll.item, 35, "the CHARMANDER doll") check(Decorations.owns(events, 35), "which the player now owns") eq(ladder.mom.savedMoney, 10000 - 1800, "and she paid 1800 for it") eq(ladder.pcItems[35], nil, "a doll does not go in the PC") -- MomItems_1: the consolation buy, which fires only when the LADDER has -- nothing to offer (the next rung's trigger is out of reach) and the savings -- land EXACTLY on a MOM_MONEY multiple the threshold has not passed. local function stalledSave(saved) local save = momSave(saved) MomShopping.state(save) -- Rung two wants 4000; anything below that leaves the ladder stalled. save.mom.whichItem = 1 return save end local exact = stalledSave(MomShopping.MOM_MONEY) local consolation = MomShopping.tryBuy(exact, { events = Events.new(), random = function() return 1 end }) eq(consolation and consolation.set, 1, "an exact 2300 buys off MomItems_1") eq(consolation and consolation.item, "ANTIDOTE", "the row the roll picked") eq(exact.mom.whichItem, 1, "and the ladder has NOT moved") eq(exact.mom.triggerBalance, MomShopping.MOM_MONEY * 2, "while the threshold has climbed a step") exact.mom.savedMoney = MomShopping.MOM_MONEY check(MomShopping.tryBuy(exact, { events = Events.new(), random = function() return 1 end }) == nil, "so the same 2300 cannot pay twice") -- Overshooting the threshold is `.less_than`: the balance is left where the -- walk pushed it and nothing is bought. local between = stalledSave(MomShopping.MOM_MONEY + 1) check(MomShopping.tryBuy(between, { events = Events.new() }) == nil, "a balance between two rungs buys nothing") eq(between.mom.triggerBalance, MomShopping.MOM_MONEY * 2, "and the threshold has walked past it") -- GetMapPhoneService: no reception, no call, and the balance is not even -- looked at -- so Mom tries again after the next trainer. local noService = stalledSave(MomShopping.MOM_MONEY) check(MomShopping.tryBuy(noService, { events = Events.new(), phoneService = false }) == nil, "a map with no phone service buys nothing") eq(noService.mom.triggerBalance, MomShopping.MOM_MONEY, "and leaves the threshold alone") -- Mom_GetScriptPointer's two scripts. local itemPages = MomShopping.pages({ kind = "item" }) local dollPages = MomShopping.pages({ kind = "doll" }) eq(#itemPages, 4, ".ItemScript is four writetexts") eq(#dollPages, 4, "and so is .DollScript") check(itemPages[4]:find("PC", 1, true) ~= nil, "the item ends up in the PC") check(dollPages[4]:find("room", 1, true) ~= nil, "and the doll in your room") check(itemPages[1] == dollPages[1], "both open on the same greeting") -- A doll cannot fail; an item into a full PC can, and then nothing is -- deducted (Mom_GiveItemOrDoll's no-carry return). local fullPc = momSave(10000) fullPc.pcItems = {} for i = 1, 50 do fullPc.pcItems["FILLER_" .. i] = 1 end local refused = MomShopping.tryBuy(fullPc, { events = Events.new() }) check(refused == nil, "a full PC refuses the purchase") eq(fullPc.mom.savedMoney, 10000, "and nothing is deducted") eq(fullPc.mom.whichItem, 0, "and the ladder does not move") -- ------------------------------------------------ the two World call sites -- -- A model with a green suite and no door is the failure this file exists to -- avoid, so both seams are driven through World:startBattle itself rather -- than called by hand: the battle screen is replaced through the screens -- REGISTRY (the same door a mod uses), which hands back the real `onDone` -- closure Script_reloadmapafterbattle's two arms live in. local World = require("src.world.gen2.World") local Screens = require("src.ui.Screens") local function battleWorld(save) local captured = {} -- Screens caches a factory by id alone, so a second world would otherwise -- be handed the first one's closure. Screens.invalidate() local game = { data = { pokemon = DATA.pokemon, moves = DATA.moves, screens = { Gen2BattleState = function(_, opts) captured.opts = opts return { screenId = "Gen2BattleState" } end, }, }, save = save, stack = { push = function() end, pop = function() end }, } local world = World.new(game) game.world = world world.map = { def = { id = "TEST_MAP", phoneService = true } } world.maps = { TEST_MAP = world.map.def } world.events = Events.new() -- The render and audio halves, which have no place in a headless run. world.playBattleMusic = function() end world.battleMusicContext = function() return nil end world.pushBattleTransition = function() return nil end world.restoreMapMusic = function() end world.healParty = function() end world.warpToSpawn = function() end return world, captured end local lossSave = Save.normalize({ party = {}, player = { name = "GOLD", money = 3001 }, mom = { savedMoney = 4000, active = true, savingMoney = true } }) local lossWorld, lossCaptured = battleWorld(lossSave) lossWorld:startBattle({ trainer = { name = "FALKNER", baseMoney = 25, party = { mon("PIDGEY", 9, 5) } } }) check(lossCaptured.opts ~= nil, "startBattle pushes the battle screen") check(lossCaptured.opts and lossCaptured.opts.save == lossSave, "handing it the save the payout writes") lossCaptured.opts.onDone("lose") eq(lossSave.player.money, 1500, "a lost battle halves the wallet") eq(lossSave.mom.savedMoney, 4000, "and leaves Mom's savings alone -- HalveMoney shifts wMoney only") -- The Bug Contest arm: `checkflag ENGINE_BUG_CONTEST_TIMER / iftrue -- .bug_contest` skips both callasms, so a wipe in the park is free. local parkSave = Save.normalize({ party = {}, player = { name = "GOLD", money = 3000 } }) parkSave.bugContest = { active = true, start = { day = 0, hour = 0, minute = 0, second = 0 } } local parkWorld, parkCaptured = battleWorld(parkSave) parkWorld:startBattle({ trainer = { name = "FALKNER", baseMoney = 25, party = { mon("PIDGEY", 9, 5) } } }) parkCaptured.opts.onDone("lose") eq(parkSave.player.money, 3000, "a wipe during the Bug Contest costs nothing") -- The win arm: MomTriesToBuySomething, queued the way LoadMemScript queues it -- so the four lines land after the trainer's own after-battle script. local shopSave = Save.normalize({ party = {}, player = { name = "GOLD", money = 0 }, mom = { savedMoney = 900, active = true, savingMoney = true } }) local shopWorld, shopCaptured = battleWorld(shopSave) shopWorld:startBattle({ trainer = { name = "FALKNER", baseMoney = 25, party = { mon("PIDGEY", 9, 5) } } }) check(shopWorld.queuedScript == nil, "nothing is queued before the battle") shopCaptured.opts.onDone("win") check(shopWorld.queuedScript ~= nil, "a won trainer battle lets Mom shop") eq(shopSave.mom.savedMoney, 900 - 600, "and she pays for what she bought") eq(shopSave.pcItems.SUPER_POTION, 1, "which is waiting in the PC") local sawRaw = false for _, row in ipairs(shopWorld.queuedScript or {}) do if row.op == "rawtext" then sawRaw = true end end check(sawRaw, "the queued script speaks the call's four lines") -- A won WILD battle does not: Script_reloadmapafterbattle's `.was_wild` arm -- goes to the box-full check instead. local wildWorld, wildCaptured = battleWorld(Save.normalize({ party = {}, player = { name = "GOLD", money = 0 }, mom = { savedMoney = 900, active = true, savingMoney = true } })) wildWorld.roamMonsAfterBattle = function() end wildWorld:startBattle({ wild = mon("PIDGEY", 3, 5) }) wildCaptured.opts.onDone("win") check(wildWorld.queuedScript == nil, "a wild battle gives Mom no chance") -- The registry cache is process-wide, so the fake screen must not outlive -- this file: tests/run_tests.lua dofiles every suite into one process. Screens.invalidate() return S.finish()