-- Gen 2 pack items used on a party mon outside battle: the ITEMMENU_PARTY -- half of engine/items/pack.asm UseItem, ported per item family from -- engine/items/item_effects.asm. -- -- Love-free on purpose, the same split src/core/gen2/Evolution.lua makes: -- every routine here is table math over a party record, so the whole family -- is assertable without a window. The screens (PackMenu -> PartyMenu -> -- MoveDeleter) only choose the target and print what comes back. -- -- Two contracts every entry point keeps, both from UseItem_SelectMon: -- - an EGG refuses with CantUseOnEggMessage before any effect runs -- (`cp EGG` is the routine's first test after the pick), and -- - a refusal costs nothing: the item is only removed by the caller when -- `used` comes back true, which is UseDisposableItem's own placement at -- the tail of each success path. local Happiness = require("src.core.gen2.Happiness") local Mon = require("src.battle.gen2.Mon") local ItemEffects = {} -- data/items/heal_hp.asm HealingHPAmounts. MAX_STAT_VALUE (999) is the -- table's own "everything" byte pair; nothing reaches it before the min() -- against the missing HP. ItemEffects.HEAL_HP = { FRESH_WATER = 50, SODA_POP = 60, LEMONADE = 80, HYPER_POTION = 200, SUPER_POTION = 50, POTION = 20, MAX_POTION = 999, FULL_RESTORE = 999, MOOMOO_MILK = 100, BERRY = 10, GOLD_BERRY = 30, ENERGYPOWDER = 50, ENERGY_ROOT = 200, RAGECANDYBAR = 20, BERRY_JUICE = 20, } -- data/items/heal_status.asm StatusHealingActions, folded to the status class -- each row's mask names. FULL_RESTORE is deliberately absent: its status -- half only runs from FullRestoreEffect's full-HP arm, handled in useOnMon. ItemEffects.HEAL_STATUS = { ANTIDOTE = "psn", BURN_HEAL = "brn", ICE_HEAL = "frz", AWAKENING = "slp", PARLYZ_HEAL = "par", FULL_HEAL = "all", HEAL_POWDER = "all", PSNCUREBERRY = "psn", PRZCUREBERRY = "par", BURNT_BERRY = "frz", ICE_BERRY = "brn", MINT_BERRY = "slp", MIRACLEBERRY = "all", } -- RevivePokemon's one split: `cp REVIVE / jr z, .revive_half_hp` -- only the -- plain REVIVE halves, MAX_REVIVE and REVIVAL_HERB both take ReviveFullHP. ItemEffects.REVIVE = { REVIVE = "half", MAX_REVIVE = "full", REVIVAL_HERB = "full", } -- RestorePP's per-item amounts: `ld c, 10` for the ETHER family, `ld c, 5` -- under `cp MYSTERYBERRY`, and the `.restore_all` arm for the MAX pair. -- `each` marks Elixer_RestorePPofAllMoves' loop over all four slots. ItemEffects.RESTORE_PP = { ETHER = { amount = 10 }, MAX_ETHER = { amount = "all" }, MYSTERYBERRY = { amount = 5 }, ELIXER = { amount = 10, each = true }, MAX_ELIXER = { amount = "all", each = true }, } -- EnergypowderEnergyRootCommon / HealPowderEffect: the herb items charge -- happiness for tasting bitter on top of their heal. local BITTER = { ENERGYPOWDER = "BITTERPOWDER", ENERGY_ROOT = "ENERGYROOT", HEAL_POWDER = "BITTERPOWDER", REVIVAL_HERB = "REVIVALHERB", } -- _ItemWontHaveEffectText / _ItemCantUseOnEggText (data/text/common_3.asm). ItemEffects.TEXT_NO_EFFECT = "It won't have any\neffect." ItemEffects.TEXT_CANT_USE_ON_EGG = "That can't be used\non an EGG." -- _PPRestoredText (data/text/common_3.asm). ItemEffects.TEXT_PP_RESTORED = "PP was restored." -- PrintPartyMenuActionText's .MenuActionTexts (engine/pokemon/party_menu.asm), -- keyed by the class GetItemHealingAction resolves. Each is the two rows the -- cart prints: the nickname line, then the fixed line. local STATUS_TEXT = { psn = "%s's\ncured of poison.", par = "%s's\nrid of paralysis.", brn = "%s's\nburn was healed.", frz = "%s\nwas defrosted.", slp = "%s\nwoke up.", all = "%s's\nhealth returned.", } -- The port's party records spell status several ways (the battle writes the -- long names, the party list reads both); fold them to the class letters the -- heal tables use. FNT is an HP fact, not a status, and is not here. -- -- Cross-file contract: every name src/battle/gen2/Battle.lua can write into -- mon.status (STATUS_EFFECTS, SECONDARY_EFFECTS, HELD_STATUS_CURES and the -- STATUS_TEXT lines beside them) must have a row here, or the cure for it -- refuses everywhere in the pack. tests/gen2_battle_pack_test.lua walks the -- battle's own tables against this one so the two cannot drift apart. ItemEffects.STATUS_CLASS = { psn = "psn", poison = "psn", toxic = "psn", brn = "brn", burn = "brn", frz = "frz", freeze = "frz", par = "par", paralysis = "par", paralyze = "par", slp = "slp", sleep = "slp", } local STATUS_CLASS = ItemEffects.STATUS_CLASS local function monName(mon) return (mon and (mon.nickname or mon.name or mon.species)) or "?" end local function maxHpOf(mon) return mon.maxHp or (mon.stats and mon.stats.hp) or 0 end local function fainted(mon) return (mon.hp or 0) <= 0 end -- HealStatus's field half: the status byte, the toxic counter and the turn -- counter all clear together (wPlayerSubStatus5's SUBSTATUS_TOXIC rides the -- same wipe on the cart; this port keeps that ramp on the mon record, so it -- goes with the byte rather than with the battler). local function clearStatus(mon) mon.status = nil mon.statusTurns = nil mon.toxicCounter = nil end local function bitterHappiness(itemId, mon) local event = BITTER[itemId] if event then Happiness.change(mon, event) end end -- ItemRestoreHP: fainted and full-HP targets refuse before anything is spent, -- then RestoreHealth adds the HealingHPAmounts row capped at max HP and -- PARTYMENUTEXT_HEAL_HP prints the delta ("\nrecovered NN HP!"). local function restoreHp(itemId, mon) local amount = ItemEffects.HEAL_HP[itemId] local maxHp = maxHpOf(mon) if fainted(mon) or (mon.hp or 0) >= maxHp then return { used = false, text = ItemEffects.TEXT_NO_EFFECT } end local healed = math.min(maxHp, (mon.hp or 0) + amount) local gained = healed - (mon.hp or 0) mon.hp = healed -- FullRestoreEffect's .FullRestore clears the status alongside the refill. if itemId == "FULL_RESTORE" then clearStatus(mon) end bitterHappiness(itemId, mon) return { used = true, text = ("%s\nrecovered %d HP!"):format(monName(mon), gained), } end -- Which StatusHealingActions class cures this status. The fold table above -- answers for every spelling the port writes; anything it does not know is a -- status some mod registered, and its own `statuses` record says which cure -- answers for it (src/battle/gen2/Battle.lua STATUSES, field `healClass`) -- -- read straight off the merged table so this module does not have to require -- the battle engine to cure a burn. function ItemEffects.healClassOf(status, data) local key = tostring(status or ""):lower() local class = STATUS_CLASS[key] if class then return class end local statuses = data and data.gen2Statuses local record = statuses and (statuses[status] or statuses[key]) return record and record.healClass or nil end -- UseStatusHealer: the status byte must intersect the item's mask ($ff for -- the HEAL_ALL family); a clean or fainted mon refuses. Field only -- the -- confusion arm reads wPlayerSubStatus3, which does not exist out of battle. local function healStatus(itemId, mon, class, data) if fainted(mon) then return { used = false, text = ItemEffects.TEXT_NO_EFFECT } end local have = ItemEffects.healClassOf(mon.status, data) if not have or (class ~= "all" and have ~= class) then return { used = false, text = ItemEffects.TEXT_NO_EFFECT } end clearStatus(mon) bitterHappiness(itemId, mon) local shape = STATUS_TEXT[class == "all" and "all" or have] return { used = true, text = shape:format(monName(mon)) } end -- RevivePokemon: only a fainted mon accepts; REVIVE stands it up at half max -- HP (ReviveHalfHP's `srl d / rr e`), the other two at full. local function revive(itemId, mon) if not fainted(mon) then return { used = false, text = ItemEffects.TEXT_NO_EFFECT } end local maxHp = maxHpOf(mon) mon.hp = (ItemEffects.REVIVE[itemId] == "half") and math.max(1, math.floor(maxHp / 2)) or maxHp clearStatus(mon) bitterHappiness(itemId, mon) return { used = true, text = ("%s\nis revitalized."):format(monName(mon)), } end -- RareCandyEffect: MAX_LEVEL refuses; otherwise the level goes up one, the -- experience is SET to CalcExpAtLevel's threshold, the stats recompute and -- the CURRENT HP gains the max-HP delta -- no clamp and no faint check, so a -- fainted mon stands up with the delta, exactly as the cart's arithmetic -- leaves it. `learned` is LearnLevelMoves' slice: the EvosAttacks rows at -- exactly the new level, for the caller to offer. local function rareCandy(mon, data) if (mon.level or 0) >= Mon.MAX_LEVEL then return { used = false, text = ItemEffects.TEXT_NO_EFFECT } end local def = data and data.pokemon and data.pokemon[mon.species] -- through Mon.growthFor, so a growth_rates record a mod registered is the -- curve a Rare Candy uses too; wiring only some of the six readers would let -- a mod curve drive battle EXP but not the candy local growth = Mon.growthFor(data, def and def.growthRate) local newLevel = (mon.level or 1) + 1 mon.level = newLevel mon.experience = Mon.experienceForLevel(growth, newLevel) local previousMax = maxHpOf(mon) if def and def.baseStats then mon.stats = Mon.stats(def.baseStats, mon.dvs, newLevel, mon.statExp) mon.maxHp = mon.stats.hp mon.hp = (mon.hp or previousMax) + (mon.maxHp - previousMax) end Happiness.change(mon, "GAINLEVEL") local learned = {} for _, entry in ipairs((def and def.levelMoves) or {}) do if entry.level == newLevel then learned[#learned + 1] = entry.move end end return { used = true, level = newLevel, learned = learned, text = ("%s grew to\nlevel %d!"):format(monName(mon), newLevel), } end -- --------------------------------------------------------- held attributes -- -- The `held_items` registry (src/mods/Schemas.lua), which is the last two -- columns of data/items/attributes.asm lifted out of the item record: -- ItemAttributes' HELD_* effect byte and its parameter. Gen 1 has neither, so -- this is one of the Gen 2-only registries -- gated under Gen 1, routed to -- data.gen2HeldItems under Gen 2. -- -- The table is a VIEW of data.items rather than a second source of truth: the -- extractor writes both columns onto the item record and -- src/battle/gen2/Battle.lua:itemDef reads them from there, so the registry -- has to end up back on data.items or a mod's write would land in a table the -- battle never opens. Hence the three routines below and the two calls in -- src/core/Game2.lua:load that use them: -- -- heldItemsFrom(items) builds the merge target, BEFORE mods:load, so the -- registry's base is the vanilla row and a mod's -- patch stacks on top of it (register collides, the -- way it does against any other seeded id) -- heldSnapshot(view) the same two bytes per id, kept aside -- applyHeldItems(...) AFTER the merge: writes back only the ids whose -- merged value differs from that snapshot -- -- The diff is the whole reason this is not a blind write-back. A mod may just -- as well reach an item's held columns through the shared `items` registry; -- that merge has already landed on data.items by the time this runs, and -- writing every row back would revert it to the vanilla view captured before -- the merge. Only what the held_items merge actually changed is written, so -- the two routes compose instead of racing. function ItemEffects.heldItemsFrom(items) local out = {} for id, def in pairs(items or {}) do if type(def) == "table" and def.heldEffect ~= nil then out[id] = { heldEffect = def.heldEffect, heldParameter = def.heldParameter or 0 } end end return out end function ItemEffects.heldSnapshot(view) local out = {} for id, row in pairs(view or {}) do if type(row) == "table" then out[id] = { heldEffect = row.heldEffect, heldParameter = row.heldParameter } end end return out end -- the merged `held_items` record for an item, the item's own columns when no -- loader ran function ItemEffects.heldItemFor(itemId, data) if itemId == nil then return nil end local merged = data and data.gen2HeldItems local row = merged and merged[itemId] if row then return row end local def = data and data.items and data.items[itemId] if type(def) ~= "table" or def.heldEffect == nil then return nil end return { heldEffect = def.heldEffect, heldParameter = def.heldParameter or 0 } end -- Returns the number of item records the merge changed. Zero on a mod-free -- boot, which is the parity claim: the seeded row IS the item's own two bytes, -- so nothing differs and nothing is written. function ItemEffects.applyHeldItems(data, snapshot) local items = data and data.items local merged = data and data.gen2HeldItems if not (items and merged) then return 0 end local applied = 0 for id, row in pairs(merged) do if type(row) == "table" then local was = (snapshot or {})[id] if not was or was.heldEffect ~= row.heldEffect or was.heldParameter ~= row.heldParameter then local def = items[id] if type(def) == "table" then def.heldEffect = row.heldEffect def.heldParameter = row.heldParameter or 0 applied = applied + 1 end end end end -- a tombstoned id (mod.content.held_items:remove) leaves the merged table -- without the row, which is the cart's own "holds nothing" for id, was in pairs(snapshot or {}) do if merged[id] == nil and type(items[id]) == "table" and was.heldEffect then items[id].heldEffect = nil items[id].heldParameter = nil applied = applied + 1 end end return applied end -- the merged `item_effects` record for an item id, the module's own when no -- loader ran; `data` is optional so the callers that only know an item id -- (src/core/Game2.lua asks partyAction before it has picked a mon) keep -- their signature and read the module records function ItemEffects.recordFor(itemId, data) if itemId == nil then return nil end local merged = data and data.gen2ItemEffects return (merged and merged[itemId]) or ItemEffects.RECORDS[itemId] end -- Which family a PACK item runs on a party mon, or nil for anything whose -- ITEMMENU_PARTY behaviour is not ported (vitamins, PP UP, evolution stones). -- FULL_RESTORE classifies as "heal"; its full-HP status arm lives inside the -- record's own `use` the way FullRestoreEffect keeps both halves in one -- routine. function ItemEffects.partyAction(itemId, data) local record = ItemEffects.recordFor(itemId, data) return record and record.action or nil end -- The one-call families (everything but PP, which needs a move pick first). -- Returns { used, text, learned?, level? }. function ItemEffects.useOnMon(itemId, mon, data) if not mon then return { used = false, text = ItemEffects.TEXT_NO_EFFECT } end if mon.isEgg then return { used = false, text = ItemEffects.TEXT_CANT_USE_ON_EGG } end local record = ItemEffects.recordFor(itemId, data) -- The PP family has its own entry point; reaching it here is the same -- "nothing happens" the unported items get. if not record or not record.use or record.action == "pp" then return { used = false, text = ItemEffects.TEXT_NO_EFFECT } end return record.use({ item = itemId, mon = mon, data = data }) end -- RestorePP over one move entry: a slot already at max refuses (`cp b / -- jr nc, .dont_restore`), "all" fills it, a number adds capped at max. local function restoreMove(move, amount) if type(move) ~= "table" or not move.id then return false end local maxPp = move.maxPp or move.pp or 0 if (move.pp or 0) >= maxPp then return false end if amount == "all" then move.pp = maxPp else move.pp = math.min(maxPp, (move.pp or 0) + amount) end return true end -- RestorePPEffect's two shapes: the ETHER family lands on one chosen slot, -- the ELIXER family (Elixer_RestorePPofAllMoves) walks every slot and counts -- -- one restored move is enough for the item to be spent. function ItemEffects.usePpItem(itemId, mon, slot, data) if not mon then return { used = false, text = ItemEffects.TEXT_NO_EFFECT } end if mon.isEgg then return { used = false, text = ItemEffects.TEXT_CANT_USE_ON_EGG } end local record = ItemEffects.recordFor(itemId, data) if not record or not record.use or record.action ~= "pp" then return { used = false, text = ItemEffects.TEXT_NO_EFFECT } end return record.use({ item = itemId, mon = mon, data = data, slot = slot }) end -- ------------------------------------------------------------- the registry -- -- The four tables above as records, in the shape src/mods/Schemas.lua's -- `item_effects` registry validates. Same registry NAME the Gen 1 catalog -- carries, and the same two Gen 1 fields where Gen 2 has a meaning for them: -- `use` (required) and `field`, which is true for every row here because this -- module is the ITEMMENU_PARTY half of pack.asm and nothing else. -- -- `use` is fn(ctx) -> { used, text, learned?, level? }, where ctx carries -- { item, mon, data, slot }. Gen 1 has no call site for its own item_effects -- records yet, so this is the first shape either generation gives them; it is -- the one Gold's two entry points already hand around. -- -- Two fields Gen 2 adds rather than renaming anything: `action`, the family -- src/core/Game2.lua and src/ui/gen2/BattleState.lua branch on before they -- know a target (it is what partyAction answers), and `needsTarget`, which -- the same screens read as "pick a party mon first". ItemEffects.RECORDS = {} local function record(itemId, action, use) ItemEffects.RECORDS[itemId] = { use = use, action = action, field = true, needsTarget = true, } end -- Built in reverse precedence order, so an id that appeared in two of the -- source tables would resolve the way partyAction's if-chain used to: heal -- first, then status, revive, candy and pp. Nothing overlaps today; the order -- is what keeps that true if something ever does. for itemId, row in pairs(ItemEffects.RESTORE_PP) do record(itemId, "pp", function(ctx) -- RestorePPEffect's two shapes: the ETHER family lands on the chosen slot, -- the ELIXER family (Elixer_RestorePPofAllMoves) walks every slot and -- counts -- one restored move is enough for the item to be spent. local moves = ctx.mon.moves or {} local any = false if row.each then for _, move in ipairs(moves) do if restoreMove(move, row.amount) then any = true end end else any = restoreMove(moves[ctx.slot], row.amount) end if not any then return { used = false, text = ItemEffects.TEXT_NO_EFFECT } end return { used = true, text = ItemEffects.TEXT_PP_RESTORED } end) end record("RARE_CANDY", "candy", function(ctx) return rareCandy(ctx.mon, ctx.data) end) for _, itemId in ipairs({ "SUN_STONE", "MOON_STONE", "FIRE_STONE", "THUNDERSTONE", "WATER_STONE", "LEAF_STONE" }) do record(itemId, "stone", function(ctx) if ctx.mon.item == "EVERSTONE" then return { used = false, text = ItemEffects.TEXT_NO_EFFECT } end local Evolution = require("src.core.gen2.Evolution") local entry = Evolution.checkMon(ctx.data, ctx.mon, { force = true, item = ctx.item }) if not entry then return { used = false, text = ItemEffects.TEXT_NO_EFFECT } end return { used = true, evolution = entry } end) end for itemId in pairs(ItemEffects.REVIVE) do record(itemId, "revive", function(ctx) return revive(ctx.item, ctx.mon) end) end for itemId, class in pairs(ItemEffects.HEAL_STATUS) do record(itemId, "status", function(ctx) return healStatus(ctx.item, ctx.mon, class, ctx.data) end) end for itemId in pairs(ItemEffects.HEAL_HP) do record(itemId, "heal", function(ctx) -- FullRestoreEffect: a full-HP target falls through to FullyHealStatus -- rather than refusing, so a paralyzed mon at full health is still cured. if ctx.item == "FULL_RESTORE" and not fainted(ctx.mon) and (ctx.mon.hp or 0) >= maxHpOf(ctx.mon) then return healStatus(ctx.item, ctx.mon, "all", ctx.data) end return restoreHp(ctx.item, ctx.mon) end) end -- 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 ItemEffects.registerInto(registry, _, owner) for id, entry in pairs(ItemEffects.RECORDS) do registry:register(id, entry, owner) end end return ItemEffects