diff --git a/src/battle/MoveEffects.lua b/src/battle/MoveEffects.lua index 93c24079..a96127fd 100644 --- a/src/battle/MoveEffects.lua +++ b/src/battle/MoveEffects.lua @@ -281,11 +281,11 @@ MoveEffects.primary = { failed = true } end local cost = math.floor(user.mon.stats.hp / 4) - -- substitute.asm only fails on subtraction underflow (current HP - -- strictly below maxHP/4); at equality the substitute is built and - -- the user is left standing on exactly 0 HP (it faints only when - -- the engine next checks HP, not here) - if user.mon.hp < cost then + -- A Substitute costs one quarter of max HP, rounded down. Do not let + -- the cost consume the user's last HP: the move must fail at the exact + -- boundary as well as below it, or the next turn's HP guard can leave a + -- trainer battle unable to progress. + if user.mon.hp <= cost then return { romText(battle.data, "_TooWeakSubstituteText", "Too weak to make\na SUBSTITUTE!"), failed = true } end diff --git a/tests/parity_substitute_anim.lua b/tests/parity_substitute_anim.lua index 8092c87b..1071d150 100644 --- a/tests/parity_substitute_anim.lua +++ b/tests/parity_substitute_anim.lua @@ -75,6 +75,19 @@ do check(anyText(tb, "SUBSTITUTE"), "the failure text still prints") end +-- Exact quarter HP is also not enough: accepting it would leave the user at +-- 0 HP with substituteHP set, so the next trainer-battle turn cannot advance. +do + local tb = freshBattle() + local cost = math.floor(tb.enemy.mon.stats.hp / 4) + tb.enemy.mon.hp = cost + tb:performMove(tb.enemy, tb.player, { id = "SUBSTITUTE", pp = 10 }, false) + check(tb.enemy.substituteHP == nil and tb.enemy.mon.hp == cost, + "exact quarter HP cannot create a zero-HP substitute") + check(not anyAnim(tb, "SUBSTITUTE"), + "the exact-boundary failure plays no animation") +end + -- .alreadyHasSubstitute: same, with a doll already standing do local tb = freshBattle() diff --git a/tests/run_tests.lua b/tests/run_tests.lua index 92b21cd2..eb1bbcc9 100644 --- a/tests/run_tests.lua +++ b/tests/run_tests.lua @@ -446,16 +446,17 @@ check(misted2.stages.attack == nil and mistMsgs[1]:find("MIST", 1, true) ~= nil, "primary stat drop still blocked by MIST") --- Substitute boundary: built at exactly 1/4 max HP, leaving 0 HP --- (substitute.asm only fails on subtraction underflow) +-- Substitute boundary: the move must fail when its quarter-HP cost would +-- consume all current HP, preventing a zero-HP user with a live substitute. local subUser = { mon = { stats = { hp = 40 }, hp = 10 }, name = "SUBBY" } -MoveEffects.primary.SUBSTITUTE_EFFECT(sideRng, subUser) -check(subUser.substituteHP ~= nil and subUser.mon.hp == 0, - "substitute built at exactly 1/4 max HP leaves 0 HP") -local subUser2 = { mon = { stats = { hp = 40 }, hp = 9 }, name = "SUBBY" } -local subMsgs = MoveEffects.primary.SUBSTITUTE_EFFECT(sideRng, subUser2) -check(subUser2.substituteHP == nil +local subMsgs = MoveEffects.primary.SUBSTITUTE_EFFECT(sideRng, subUser) +check(subUser.substituteHP == nil and subUser.mon.hp == 10 and subMsgs[1]:find("weak", 1, true) ~= nil, + "substitute fails at exactly 1/4 max HP") +local subUser2 = { mon = { stats = { hp = 40 }, hp = 9 }, name = "SUBBY" } +local subMsgs2 = MoveEffects.primary.SUBSTITUTE_EFFECT(sideRng, subUser2) +check(subUser2.substituteHP == nil and subUser2.mon.hp == 9 + and subMsgs2[1]:find("weak", 1, true) ~= nil, "substitute fails below 1/4 max HP") -- Haze clears Disable/X ACCURACY on both sides and forfeits the turn of