CLOSES #1396, CLOSES #1398, CLOSES #1400, CLOSES #1401, CLOSES #1406, CLOSES #1407, CLOSES #1411, CLOSES #1413, CLOSES #1415, CLOSES #1416, CLOSES #1417, CLOSES #1419, CLOSES #1421, CLOSES #1422, CLOSES #1423, CLOSES #1424, CLOSES #1425, CLOSES #1427, CLOSES #1428, CLOSES #1429, CLOSES #1431, CLOSES #1432, CLOSES #1433, CLOSES #1435, CLOSES #1437, CLOSES #1440, CLOSES #1441, CLOSES #1442, CLOSES #1443, CLOSES #1444, CLOSES #1447, CLOSES #1449, CLOSES #1456, CLOSES #1461, CLOSES #1464, CLOSES #1465, CLOSES #1466, CLOSES #1468, CLOSES #1469, CLOSES #1470

This commit is contained in:
bryanthaboi
2026-08-17 10:27:14 -04:00
parent 45519ad550
commit 8c9af95598
11 changed files with 186 additions and 10 deletions
+2 -2
View File
@@ -4111,8 +4111,8 @@ function BattleState:awardExp()
end
local function applyShare(mon, split, announce)
local playerId = self.game.save.player and self.game.save.player.id
local traded = mon.otId ~= nil and playerId ~= nil
and mon.otId ~= playerId or mon.traded == true and mon.otId == nil
local traded = mon.traded == true
or (mon.otId ~= nil and playerId ~= nil and mon.otId ~= playerId)
local levels, gained = Experience.apply(self.data, mon, self.enemy.def,
self.enemy.mon.level, self.kind == "trainer",
split, traded)
+29 -2
View File
@@ -1868,7 +1868,11 @@ function Battle:useMove(attacker, defender, moveId)
if status and (def.power or 0) == 0 then
-- A refused primary status is a failed move (effect_commands.asm:3748,
-- :6656); a refused secondary already animated and stays unmarked (:3752).
if Battle.AI_FAIL_STATUSES[status]
if self:statusRefusedByType(defender, def.type, status) then
self:markMissed()
self:emit({ kind = "message",
text = "It doesn't affect " .. self:monName(defender) .. "..." })
elseif Battle.AI_FAIL_STATUSES[status]
and self:aiRandomFail(attacker, defender) then
self:markMissed()
self:emit({ kind = "message", text = "But it failed!" })
@@ -1880,7 +1884,8 @@ function Battle:useMove(attacker, defender, moveId)
and record.status or nil
-- engine/battle/effect_commands.asm:6325
if secondary and (defender.hp or 0) > 0
and not self:safeguarded(defender) then
and not self:safeguarded(defender)
and not self:statusRefusedByType(defender, def.type, secondary) then
local chance = def.effectChance or 0
if chance > 0 and rand(self.random, 100) < chance then
self:applyStatus(defender, secondary, attacker)
@@ -2941,6 +2946,27 @@ end
-- `source` is the battler that inflicted it, carried only so
-- battle.status_inflicted can name it the way Gen 1's does.
-- BattleCommand_Paralyze and BattleCommand_Poison refuse on a zero matchup,
-- and the poison pair also refuses a POISON-type target: effect_commands.asm
-- :5788 (paralyze), :3671 (poison), :3646 / :4019 (the secondary arms).
-- Sleep, confusion and stat changes are deliberately not gated.
function Battle:statusRefusedByType(defender, moveType, status)
if not (status == "paralyze" or status == "poison" or status == "toxic") then
return false
end
local types = (self:speciesDef(defender) or {}).types or defender.types or {}
if moveType then
local matchups = self.data.type_chart and self.data.type_chart.matchups
if Damage.typeMultiplier(moveType, types, matchups) == 0 then return true end
end
if status == "poison" or status == "toxic" then
for _, t in ipairs(types) do
if t == "POISON" then return true end
end
end
return false
end
function Battle:applyStatus(mon, status, source)
if (mon.hp or 0) <= 0 then return false end
-- Confusion is SUBSTATUS_CONFUSED on the cart, not a status byte: it lives
@@ -3202,6 +3228,7 @@ end
-- player's own.
function Battle:isOutsider(mon)
local playerId = self.save and self.save.player and self.save.player.id
if mon.traded == true then return true end
if mon.otId == nil or playerId == nil then return false end
return mon.otId ~= playerId
end
+2 -1
View File
@@ -388,7 +388,8 @@ function Mon.stampOT(save, mon)
mon.ot = mon.ot or player.name
-- NpcTrade.lua:150: `ot` is what Breeding reads, `otName` what the summary prints.
mon.otName = mon.otName or mon.ot
mon.otId = mon.otId or player.id
-- engine/battle/experience.asm:69: a traded mon keeps its own OT id
if not mon.traded then mon.otId = mon.otId or player.id end
return mon
end
+1
View File
@@ -1233,6 +1233,7 @@ function Game:restoreSave(loaded, recovered, opts)
self:applyOptions(loaded.options)
-- saves from before OT/ID stamping: backfill with the player's (after
-- the scrub, so every mon the stamp loop sees is known)
SaveData.repairTradedOtIds(loaded)
local stamp = require("src.battle.BattleState").stampOT
for _, mon in ipairs(loaded.party or {}) do stamp(loaded, mon) end
for _, box in ipairs(loaded.boxes or {}) do
+20
View File
@@ -1923,6 +1923,26 @@ function SaveData.applyPostGameHome(save, boot)
return heal
end
-- 0.1.82-0.1.9x loads stamped the player's own id onto traded mons and saved
-- it, which reads back as home-caught. A caught mon can never carry
-- traded=true, so clearing that pair is safe on every load. #1461
function SaveData.repairTradedOtIds(save)
local playerId = save and save.player and save.player.id
if playerId == nil then return 0 end
local fixed = 0
local function scrub(mon)
if mon and mon.traded == true and mon.otId == playerId then
mon.otId = nil
fixed = fixed + 1
end
end
for _, mon in ipairs(save.party or {}) do scrub(mon) end
for _, box in ipairs(save.boxes or {}) do
for _, mon in ipairs(box) do scrub(mon) end
end
return fixed
end
-- Softlocked 0.1.11 saves: still standing in HALL_OF_FAME after credits,
-- with lastOutdoor on Indigo. One-shot rescue on CONTINUE.
function SaveData.needsPostGameRescue(save)
@@ -46,4 +46,30 @@ do
T.eq(tradedWithId.otId, 777, "a recorded foreign OT id is never overwritten")
end
-- #1461: saves that passed through 0.1.82-0.1.9x already have the player's
-- own id written onto traded mons, so stamping correctly from now on does
-- not help them. The repair runs on every load, not behind a format gate.
do
local SaveData = require("src.core.SaveData")
local save = newSave()
local poisoned = { traded = true, otId = 12345 }
local foreign = { traded = true, otId = 777 }
local caught = { otId = 12345 }
save.party = { poisoned, foreign, caught }
save.boxes = { { { traded = true, otId = 12345 } } }
T.eq(SaveData.repairTradedOtIds(save), 2, "both poisoned mons are repaired")
T.eq(poisoned.otId, nil, "a traded mon carrying the player id is cleared")
T.eq(save.boxes[1][1].otId, nil, "boxed mons are repaired too")
T.eq(foreign.otId, 777, "a real foreign OT id is left alone")
T.eq(caught.otId, 12345, "a caught mon keeps the player id")
T.eq(caught.traded, nil, "and is never marked traded")
T.eq(SaveData.repairTradedOtIds(save), 0, "the repair is idempotent")
-- and after the repair the stamp loop must not re-adopt it
BattleState.stampOT(save, poisoned)
T.eq(poisoned.otId, nil, "the stamp loop does not undo the repair")
end
T.finish("exp traded ot survives reload bug 1265")
@@ -0,0 +1,62 @@
-- engine/battle/effect_commands.asm:5788, :3671, :3646, :4019
package.path = "./?.lua;./?/init.lua;" .. package.path
love = require("tests.love_stub")
local T = require("tests.harness")
local Battle = require("src.battle.gen2.Battle")
local MATCHUPS = {
{ attacker = "ELECTRIC", defender = "GROUND", multiplier = 0 },
{ attacker = "POISON", defender = "STEEL", multiplier = 0 },
{ attacker = "POISON", defender = "POISON", multiplier = 5 },
{ attacker = "NORMAL", defender = "GHOST", multiplier = 0 },
{ attacker = "PSYCHIC_TYPE", defender = "GROUND", multiplier = 10 },
}
local POKEMON = {
GEODUDE = { types = { "ROCK", "GROUND" } },
MAGNEMITE = { types = { "STEEL", "ELECTRIC" } },
ZUBAT = { types = { "POISON", "FLYING" } },
GASTLY = { types = { "GHOST", "POISON" } },
PIDGEY = { types = { "NORMAL", "FLYING" } },
}
local battle = setmetatable({
data = { type_chart = { matchups = MATCHUPS }, pokemon = POKEMON },
}, { __index = Battle })
local function mon(species) return { species = species, hp = 20 } end
local function refused(species, moveType, status)
return battle:statusRefusedByType(mon(species), moveType, status)
end
-- The two repros the reporter filed, neither of which the #1318 fix touched.
T.eq(refused("GEODUDE", "ELECTRIC", "paralyze"), true,
"Thunder Wave does not affect a GROUND type")
T.eq(refused("MAGNEMITE", "POISON", "poison"), true,
"Poison Gas does not affect a STEEL type")
-- Poison vs POISON is 0.5x, not an immunity, so only CheckIfTargetIsPoisonType
-- refuses it: this is the Poison Sting on Zubat case.
T.eq(refused("ZUBAT", "POISON", "poison"), true,
"a POISON type cannot be poisoned even on a resisted, landed hit")
T.eq(refused("GASTLY", "POISON", "toxic"), true,
"Toxic is refused by the same check")
T.eq(refused("GASTLY", "NORMAL", "paralyze"), true,
"Glare does not affect a GHOST type")
-- Everything the cart deliberately leaves ungated stays ungated.
T.eq(refused("GEODUDE", "PSYCHIC_TYPE", "sleep"), false,
"Hypnosis still lands on a GROUND type")
T.eq(refused("GEODUDE", "PSYCHIC_TYPE", "confuse"), false,
"confusion is never type-gated")
T.eq(refused("PIDGEY", "ELECTRIC", "paralyze"), false,
"Thunder Wave still paralyses a non-immune target")
T.eq(refused("PIDGEY", "POISON", "poison"), false,
"a non-POISON target is still poisonable")
T.eq(refused("GEODUDE", nil, "paralyze"), false,
"a status with no move type behind it is not refused")
T.finish("gen2 status type immunity bug 1444")
+27
View File
@@ -132,6 +132,33 @@ do
check(cat.species[1] < cat.species[2], "species sorted")
end
-- RomExtractorGen2 stamps generation/source beside the id-keyed records, and
-- they sort last because lowercase follows uppercase. #1466
do
local gold = {
pokemon = { generation = 2, source = "ROM", CHIKORITA = { name = "CHIKORITA" } },
items = { generation = 2, source = "ROM", POTION = { name = "POTION" } },
moves = {
generation = 2, source = "ROM:Moves + MoveNames",
TACKLE = { pp = 35 }, ZAP_CANNON = { pp = 5 },
},
}
local cat = Catalog.build(gold)
eq(#cat.moves, 2, "gold move catalog holds only real moves")
eq(cat.moves[#cat.moves], "ZAP_CANNON", "and the last entry is a move, not a scalar")
for _, list in pairs(cat) do
for _, id in ipairs(list) do
check(id ~= "generation" and id ~= "source",
"no provenance scalar reached a catalog: " .. tostring(id))
end
end
local S = { data = gold, cat = cat }
local mon = { moves = { { id = "ZAP_CANNON", pp = 5 } } }
check(require("Ops").cycleMove(S, mon, 1), "cycling off the last move succeeds")
eq(mon.moves[1].id, "TACKLE", "and wraps to the first move instead of a scalar")
end
do
local events = Catalog.scrapeEvents("data/scripts", "data/generated/trainer_headers.lua")
check(#events > 50, "scraped events")
+15 -3
View File
@@ -9,11 +9,23 @@ local function sortedKeys(t)
return keys
end
-- Gen 2's generated tables carry provenance scalars (generation, source)
-- beside the id-keyed records, so a wheel built from every key would offer
-- them as pickable entries. #1466
local function sortedRecordKeys(t)
local keys = {}
for k, v in pairs(t or {}) do
if type(v) == "table" then table.insert(keys, k) end
end
table.sort(keys)
return keys
end
function Catalog.build(data)
return {
species = sortedKeys(data.pokemon),
items = sortedKeys(data.items),
moves = sortedKeys(data.moves),
species = sortedRecordKeys(data.pokemon),
items = sortedRecordKeys(data.items),
moves = sortedRecordKeys(data.moves),
}
end
+1 -1
View File
@@ -43,7 +43,7 @@ end
function MonOps.setMove(data, mon, slot, moveId)
assert(slot >= 1 and slot <= 4)
local mdef = data.moves[moveId]
assert(mdef, "unknown move")
assert(type(mdef) == "table", "unknown move")
mon.moves = mon.moves or {}
mon.moves[slot] = {
id = moveId,
+1 -1
View File
@@ -441,7 +441,7 @@ function Ops.cycleMove(S, mon, slot)
end
for step = 1, #moves do
local nextId = moves[((idx + step - 1) % #moves) + 1]
if S.data and S.data.moves and S.data.moves[nextId] then
if S.data and S.data.moves and type(S.data.moves[nextId]) == "table" then
MonOps.setMove(S.data, mon, slot, nextId)
return Ops.mark(S, ("Move %d set to %s"):format(slot, nextId))
end