-- Per-turn status/volatile condition handling (Gen 1 semantics). -- -- The persistent conditions live in Status.RECORDS; a battle passes its -- merged Data.statuses so mod statuses join the same beforeMove gauntlet -- and residual sweep. Callers without a battle (pure-module tests) fall -- back to the vanilla records, which is bit-identical behavior. local Strings = require("src.core.Strings") local romText = require("src.core.RomText") local Status = {} -- pokered's / text macros print "Enemy " before the enemy -- mon's nickname; these records only know the raw name -- BattleState -- splices the prefix in (prefixEnemy), same as always local function name(battler) return battler.name end -- statuses with beforeMovePriority above this run before the engine's -- held/disable/confusion volatiles; at or below, after (sleep 40 and -- freeze 30 come first, paralysis 10 comes last, like the original -- CheckPlayerStatusConditions order) local VOLATILE_PRIORITY = 20 local function hasType(battler, wanted) for _, t in ipairs(battler.curTypes or {}) do if t == wanted then return true end end return false end -- shared PSN/BRN residual: 1/16 max HP, multiplied (and advanced) by the -- Toxic counter (HandlePoisonBurnLeechSeed). The caller passes the whole -- sentence rather than the noun: "hurt by poison" and "hurt by the burn" -- decline differently once translated, so a shared fragment cannot be the -- translatable unit. local function damageOverTime(label, template) return function(battler, _, battle) local mon = battler.mon local base = math.max(1, math.floor(mon.stats.hp / 16)) local dmg = base if battler.toxicCounter then dmg = base * battler.toxicCounter battler.toxicCounter = battler.toxicCounter + 1 end mon.hp = math.max(0, mon.hp - dmg) return { romText(battle and battle.data, label, template, name(battler)) } end end -- Labels stay plain literals on purpose: this table is built at require -- time, before Strings.load has a catalog, so a Strings() here would -- freeze the English. They are already translatable through the -- statuses registry (mod.content.statuses:patch(id, { label = ... })). -- -- The five persistent conditions as records: the beforeMove gauntlet, the -- residual sweep, the inflict text/immunities (StatusRegistry.inflict), -- the catch/wobble bonuses (Catching.attempt), the HUD label, and the -- burn/paralysis stat cut (Damage.compute, TurnOrder.effectiveSpeed) all -- read these fields, so a mod's sixth status plugs into every consumer. Status.RECORDS = { SLP = { id = "SLP", label = "SLP", hudLabel = "SLP", catchBonus = 25, shakeBonus = 10, beforeMovePriority = 40, beforeMove = function(battler, _, battle) battler.sleepTurns = (battler.sleepTurns or 1) - 1 if battler.sleepTurns <= 0 then battler.mon.status = nil -- wakes, loses the turn return false, { romText(battle and battle.data, "_WokeUpText", "%s\nwoke up!", name(battler)) } end return false, { romText(battle and battle.data, "_FastAsleepText", "%s\nis fast asleep!", name(battler)) } end, onInflict = function(battle, target, opts, display) target.sleepTurns = battle.rng(1, 7) return { romText(battle.data, "_FellAsleepText", "%s\nfell asleep!", display) } end, }, FRZ = { id = "FRZ", label = "FRZ", hudLabel = "FRZ", catchBonus = 25, shakeBonus = 10, beforeMovePriority = 30, beforeMove = function(battler, _, battle) return false, { romText(battle and battle.data, "_IsFrozenText", "%s\nis frozen solid!", name(battler)) } end, canInflict = function(target) return not hasType(target, "ICE") end, onInflict = function(battle, _, _, display) return { romText(battle and battle.data, "_FrozenText", "%s\nwas frozen solid!", display) } end, }, PSN = { id = "PSN", label = "PSN", hudLabel = "PSN", catchBonus = 12, shakeBonus = 5, residual = damageOverTime("_HurtByPoisonText", Strings.source("%s's\nhurt by poison!")), canInflict = function(target) return not hasType(target, "POISON") end, onInflict = function(battle, target, opts, display) if opts.toxic then target.toxicCounter = 1 return { romText(battle and battle.data, "_BadlyPoisonedText", "%s's\nbadly poisoned!", display) } end return { romText(battle and battle.data, "_PoisonedText", "%s\nwas poisoned!", display) } end, }, BRN = { id = "BRN", label = "BRN", hudLabel = "BRN", catchBonus = 12, shakeBonus = 5, statPenalty = { stat = "attack", div = 2 }, residual = damageOverTime("_HurtByBurnText", Strings.source("%s's\nhurt by the burn!")), canInflict = function(target) return not hasType(target, "FIRE") end, onInflict = function(battle, _, _, display) return { romText(battle and battle.data, "_BurnedText", "%s\nwas burned!", display) } end, }, PAR = { id = "PAR", label = "PAR", hudLabel = "PAR", catchBonus = 12, shakeBonus = 5, statPenalty = { stat = "speed", div = 4 }, beforeMovePriority = 10, beforeMove = function(battler, rng, battle) -- cp 25 percent / jr nc: fully paralyzed on rand < 63 (63/256) if rng(0, 255) < 63 then return false, { romText(battle and battle.data, "_FullyParalyzedText", "%s's\nfully paralyzed!", name(battler)) } end return true, {} end, canInflict = function(target, opts) -- ParalyzeEffect_: Electric-type moves can't paralyze Ground-types return not (opts.moveType == "ELECTRIC" and hasType(target, "GROUND")) end, onInflict = function(battle, _, _, display) -- primary and secondary paralysis share this line return { romText(battle and battle.data, "_ParalyzedMayNotAttackText", "%s's\nparalyzed! It may\nnot attack!", display) } end, }, } function Status.registerInto(registry, _, owner) for id, record in pairs(Status.RECORDS) do registry:register(id, record, owner) end end -- the merged view when a battle is on hand, the vanilla records otherwise function Status.recordFor(statuses, id) if id == nil then return nil end return (statuses or Status.RECORDS)[id] end local function battleStatuses(battle) return battle and battle.data and battle.data.statuses end -- Returns canMove, messages, selfHit (true -> hurt itself in confusion). -- The active status record's beforeMove runs at its priority slot: above -- VOLATILE_PRIORITY before the held/disable/confusion block (sleep, -- freeze), at or below after it (paralysis) -- the original's order. function Status.beforeMove(battler, rng, battle, selectedMoveId) local mon = battler.mon -- Haze curing this mon's sleep/freeze forfeits its pending move for -- the turn, silently (haze.asm writes $ff/CANNOT_MOVE to the selected -- move; ExecuteMove returns immediately without a message) if battler.skipMove then battler.skipMove = nil return false, {} end if battler.flinched then battler.flinched = false return false, { romText(battle and battle.data, "_FlinchedText", "%s\nflinched!", name(battler)) } end local record = Status.recordFor(battleStatuses(battle), mon.status) local handler = record and record.beforeMove local priority = handler and (record.beforeMovePriority or 0) local msgs = {} local function runStatus() local canMove, statusMsgs, selfHit = handler(battler, rng, battle) for _, m in ipairs(statusMsgs or {}) do msgs[#msgs + 1] = m end return canMove, selfHit end if handler and priority > VOLATILE_PRIORITY then local canMove, selfHit = runStatus() if not canMove or selfHit then return canMove, msgs, selfHit end handler = nil end if battler.boundTurns and battler.boundTurns > 0 then battler.boundTurns = battler.boundTurns - 1 msgs[#msgs + 1] = romText(battle and battle.data, "_CantMoveText", "%s\ncan't move!", name(battler)) return false, msgs end if battler.disabledTurns then battler.disabledTurns = battler.disabledTurns - 1 if battler.disabledTurns <= 0 then battler.disabledTurns, battler.disabledSlot = nil, nil table.insert(msgs, romText(battle and battle.data, "_DisabledNoMoreText", "%s's\ndisabled no more!", name(battler))) end end if battler.confusedTurns then battler.confusedTurns = battler.confusedTurns - 1 if battler.confusedTurns <= 0 then battler.confusedTurns = nil table.insert(msgs, romText(battle and battle.data, "_ConfusedNoMoreText", "%s\nsnapped out of\nconfusion!", name(battler))) else table.insert(msgs, romText(battle and battle.data, "_IsConfusedText", "%s\nis confused!", name(battler))) -- cp 50 percent + 1 / jr c: hurt itself on rand >= 128 (128/256) if rng(0, 255) < 128 then return false, msgs, true -- hurt itself end end end -- .TriedToUseDisabledMoveCheck (engine/battle/core.asm, and the enemy -- copy .checkIfTriedToUseDisabledMove): the disabled-move test runs at -- EXECUTION time, comparing wPlayerDisabledMoveNumber against the -- already SELECTED move, so a Disable that lands earlier in the same -- turn still blocks the slower mon's move (#860). It sits after the -- confusion block and before the paralysis roll, so a confusion self-hit -- still pre-empts it and the paralysis roll is never spent on a turn the -- disable eats. PrintMoveIsDisabledText clears CHARGING_UP before -- printing, so a disabled charge move drops its stored turn instead of -- releasing later. if selectedMoveId and battler.disabledSlot then local disabled = (battler.curMoves or {})[battler.disabledSlot] if disabled and disabled.id == selectedMoveId then battler.charging, battler.chargeReady = nil, nil local moves = battle and battle.data and battle.data.moves local shown = moves and moves[selectedMoveId] and moves[selectedMoveId].name or tostring(selectedMoveId) table.insert(msgs, romText(battle and battle.data, "_MoveIsDisabledText", "%s's\n%s is\ndisabled!", name(battler), shown)) return false, msgs end end if handler then local canMove, selfHit = runStatus() if not canMove or selfHit then return canMove, msgs, selfHit end end return true, msgs end -- End-of-turn residual damage; opponent is needed for Leech Seed. -- Returns messages. function Status.residual(battler, opponent, battle) local msgs = {} local mon = battler.mon -- the Haze move-forfeit only covers the turn Haze was used; if this -- mon had already moved, drop the flag before it leaks into next turn battler.skipMove = nil if mon.hp <= 0 then return msgs end local record = Status.recordFor(battleStatuses(battle), mon.status) if record and record.residual then for _, m in ipairs(record.residual(battler, opponent, battle) or {}) do msgs[#msgs + 1] = m end end if battler.leechSeeded and mon.hp > 0 and opponent.mon.hp > 0 then -- the shared Toxic counter multiplies (and advances on) the seed -- drain too -- the Gen 1 Leech Seed glitch -- (HandlePoisonBurnLeechSeed_DecreaseOwnHP) local dmg = math.max(1, math.floor(mon.stats.hp / 16)) if battler.toxicCounter then dmg = dmg * battler.toxicCounter battler.toxicCounter = battler.toxicCounter + 1 end dmg = math.min(dmg, mon.hp) mon.hp = mon.hp - dmg opponent.mon.hp = math.min(opponent.mon.stats.hp, opponent.mon.hp + dmg) table.insert(msgs, romText(battle and battle.data, "_HurtByLeechSeedText", "LEECH SEED saps\n%s!", name(battler))) end return msgs end return Status