Repository: pmariglia/foul-play
Pull request #242: Add support for Shed Tail
Author: login: jpf4e
Created: 2025-04-06T23:15:00Z | Merged: 2025-04-12T13:30:45Z

Introduced `shed_tailing` attribute to manage the state of the Shed Tail move. Consolidated volatile status handling into a single `switch_keep_volatiles` list used for both Baton Pass and Shed Tail scenarios. Updated logging to reflect the changes in volatile status preservation on switching.


Diff:
diff --git a/constants.py b/constants.py
index 67acce10..d6218baf 100644
--- a/constants.py
+++ b/constants.py
@@ -183,6 +183,7 @@
     "teleport",
     "flipturn",
     "chillyreception",
+    "shedtail",
 }
 
 # volatile statuses
diff --git a/fp/battle.py b/fp/battle.py
index 02377039..ce39eac1 100644
--- a/fp/battle.py
+++ b/fp/battle.py
@@ -189,6 +189,7 @@ def __init__(self):
         self.name = None
         self.trapped = False
         self.baton_passing = False
+        self.shed_tailing = False
         self.wish = (0, 0)
         self.future_sight = (0, "")
 
diff --git a/fp/battle_bots/poke_engine_helpers.py b/fp/battle_bots/poke_engine_helpers.py
index 7748a17e..d83e2c3b 100644
--- a/fp/battle_bots/poke_engine_helpers.py
+++ b/fp/battle_bots/poke_engine_helpers.py
@@ -146,6 +146,7 @@ def battler_to_poke_engine_side(
     side = PokeEngineSide(
         active_index="0",
         baton_passing=battler.baton_passing,
+        shed_tailing=battler.shed_tailing,
         pokemon=[pokemon_to_poke_engine_pkmn(battler.active)]
         + [pokemon_to_poke_engine_pkmn(p) for p in battler.reserve],
         side_conditions=PokeEngineSideConditions(
diff --git a/fp/battle_modifier.py b/fp/battle_modifier.py
index c61460b7..2ce0c6da 100644
--- a/fp/battle_modifier.py
+++ b/fp/battle_modifier.py
@@ -293,7 +293,7 @@ def switch_or_drag(battle, split_msg, switch_or_drag="switch"):
         side.side_conditions[constants.TOXIC_COUNT] = 0
 
     baton_passed_boosts = None
-    baton_passed_volatiles = []
+    switch_keep_volatiles = []
     if side.active is not None:
         # set the pkmn's types back to their original value if the types were changed
         # if the pkmn is terastallized, this does not happen
@@ -341,10 +341,16 @@ def switch_or_drag(battle, split_msg, switch_or_drag="switch"):
 
             if constants.SUBSTITUTE in side.active.volatile_statuses:
                 logger.info("Baton passing, preserving substitute")
-                baton_passed_volatiles.append(constants.SUBSTITUTE)
+                switch_keep_volatiles.append(constants.SUBSTITUTE)
             if constants.LEECH_SEED in side.active.volatile_statuses:
                 logger.info("Baton passing, preserving leechseed")
-                baton_passed_volatiles.append(constants.LEECH_SEED)
+                switch_keep_volatiles.append(constants.LEECH_SEED)
+        elif split_msg[-1] == "[from] Shed Tail":
+            side.shed_tailing = False
+
+            if constants.SUBSTITUTE in side.active.volatile_statuses:
+                logger.info("Shed tailing, preserving substitute")
+                switch_keep_volatiles.append(constants.SUBSTITUTE)
 
         # gen5 rest turns are reset upon switching
         if battle.generation == "gen5" and side.active.status == constants.SLEEP:
@@ -539,8 +545,8 @@ def switch_or_drag(battle, split_msg, switch_or_drag="switch"):
             )
         )
         side.active.boosts = baton_passed_boosts
-    for volatile in baton_passed_volatiles:
-        logger.info("Baton passing volatile: {}".format(volatile))
+    for volatile in switch_keep_volatiles:
+        logger.info("Keeping volatile on switch: {}".format(volatile))
         side.active.volatile_statuses.append(volatile)
 
 
@@ -1215,6 +1221,13 @@ def start_volatile_status(battle, split_msg):
         pkmn.volatile_statuses.append(volatile_status)
 
     if volatile_status == constants.SUBSTITUTE:
+        if len(split_msg) >= 5 and split_msg[4] == "[from] move: Shed Tail":
+            logger.info(
+                "{} started a substitute from shed tail - setting shed_tailing to True".format(
+                    pkmn.name
+                )
+            )
+            side.shed_tailing = True
         logger.info(
             "{} started a substitute - setting substitute_hit to False".format(
                 pkmn.name
diff --git a/requirements.txt b/requirements.txt
index ac5ba154..d71941c2 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -2,4 +2,4 @@ requests==2.32.0
 environs==11.0.0
 websockets==14.1
 python-dateutil==2.8.0
-poke-engine==0.0.40 --config-settings="build-args=--features poke-engine/terastallization --no-default-features"
+poke-engine==0.0.41 --config-settings="build-args=--features poke-engine/terastallization --no-default-features"
diff --git a/tests/test_battle_modifiers.py b/tests/test_battle_modifiers.py
index 9b2be8c9..0d565ef8 100644
--- a/tests/test_battle_modifiers.py
+++ b/tests/test_battle_modifiers.py
@@ -852,6 +852,45 @@ def test_ditto_switching_resets_types(self):
 
         self.assertEqual(["normal"], ditto.types)
 
+    def test_shed_tail_switching_in_gets_shed_tailing_flag_set_to_false(self):
+        self.battle.user.shed_tailing = True
+
+        split_msg = [
+            "",
+            "switch",
+            "p1a: Pikachu",
+            "Pikachu, L100, M",
+            "100/100",
+            "[from] Shed Tail",
+        ]
+        switch_or_drag(self.battle, split_msg)
+
+        self.assertFalse(self.battle.user.shed_tailing)
+
+    def test_shed_tail_switching_in_only_keeps_substitute(self):
+        self.battle.user.active.volatile_statuses = [
+            constants.SUBSTITUTE,
+            constants.LEECH_SEED,
+        ]
+        self.battle.user.active.boosts[constants.SPEED] = 1
+        self.battle.user.active.boosts[constants.ATTACK] = -2
+
+        split_msg = [
+            "",
+            "switch",
+            "p1a: Pikachu",
+            "Pikachu, L100, M",
+            "100/100",
+            "[from] Shed Tail",
+        ]
+        switch_or_drag(self.battle, split_msg)
+
+        self.assertEqual(0, self.battle.user.active.boosts[constants.SPEED])
+        self.assertEqual(0, self.battle.user.active.boosts[constants.ATTACK])
+        self.assertEqual(
+            [constants.SUBSTITUTE], self.battle.user.active.volatile_statuses
+        )
+
 
 class TestHealOrDamage(unittest.TestCase):
     def setUp(self):
@@ -2639,6 +2678,24 @@ def test_substitute_gets_substitute_hit_flag_set_to_false(self):
         start_volatile_status(self.battle, split_msg)
         self.assertFalse(self.battle.opponent.active.substitute_hit)
 
+    def test_substitute_gets_shed_tailing_flag_set_to_true(self):
+        self.battle.user.active.max_hp = 100
+        self.battle.user.active.hp = 100
+
+        self.battle.opponent.active.hp = 100
+        self.battle.user.active.hp = 100
+
+        messages = [
+            "|move|p1a: Cyclizar|Shed Tail|p1a: Cyclizar",
+            "|-start|p1a: Cyclizar|Substitute|[from] move: Shed Tail",
+            "|-damage|p1a: Cyclizar|50/100",  # damage from sub should not be caught
+        ]
+
+        split_msg = messages[1].split("|")
+
+        start_volatile_status(self.battle, split_msg)
+        self.assertTrue(self.battle.user.shed_tailing)
+
     def test_flashfire_sets_ability_on_opponent(self):
         split_msg = ["", "-start", "p2a: Caterpie", "ability: Flash Fire"]
         start_volatile_status(self.battle, split_msg)


Reviews:
user: login: pmariglia
body: Awesome, looks pretty straight forward.

Will properly review after https://github.com/pmariglia/poke-engine/pull/3 is merged.

Some tests in `test_battle_modifiers.py` would be great just to make sure the `shed_tailing` flag is set and unset as expected.
state: COMMENTED
author_association: OWNER
submitted_at: 2025-04-08T21:44:14Z
user: login: pmariglia
state: COMMENTED
author_association: OWNER
submitted_at: 2025-04-11T20:56:42Z
user: login: jpf4e
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-11T23:09:27Z
user: login: jpf4e
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-11T23:14:33Z
user: login: pmariglia
body: be sure to run `make fmt` as well to make ruff happy.
state: CHANGES_REQUESTED
author_association: OWNER
submitted_at: 2025-04-11T23:35:52Z
user: login: pmariglia
state: COMMENTED
author_association: OWNER
submitted_at: 2025-04-11T23:37:35Z
user: login: pmariglia
state: COMMENTED
author_association: OWNER
submitted_at: 2025-04-12T00:05:05Z
user: login: jpf4e
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-12T00:13:22Z

Review comments:
diff_hunk: @@ -959,6 +965,9 @@ def move(battle, split_msg):
     if move_name == "batonpass":
         side.baton_passing = True
 
+    if move_name == "shedtail":
+        side.shed_tailing = True
+
path: fp/battle_modifier.py
user: login: pmariglia
body: Shed Tail Success:
```
|move|p1a: Cyclizar|Shed Tail|p1a: Cyclizar
|-start|p1a: Cyclizar|Substitute|[from] move: Shed Tail
|-damage|p1a: Cyclizar|126/252
```

Shed Tail Failure:
```
|move|p1a: Cyclizar|Shed Tail|p1a: Cyclizar
|-fail|p1a: Cyclizar|move: Shed Tail|[weak]
```

This flag should be set when `|-start` comes through, not `|switch|`. What you have would incorrectly set the shed-tail flag when it fails. Please add tests for each of these situations.

Thanks!
created_at: 2025-04-11T20:56:42Z
author_association: OWNER
diff_hunk: @@ -959,6 +965,9 @@ def move(battle, split_msg):
     if move_name == "batonpass":
         side.baton_passing = True
 
+    if move_name == "shedtail":
+        side.shed_tailing = True
+
path: fp/battle_modifier.py
user: login: jpf4e
body: Yes, I left the PR in draft as it wasn't finished.
But I added a few tests and the change you mentioned.
I didn't add a "-fail" test, though. How do you suggest adding it?
created_at: 2025-04-11T23:09:27Z
author_association: CONTRIBUTOR
diff_hunk: @@ -959,6 +965,9 @@ def move(battle, split_msg):
     if move_name == "batonpass":
         side.baton_passing = True
 
+    if move_name == "shedtail":
+        side.shed_tailing = True
+
path: fp/battle_modifier.py
user: login: jpf4e
body: Also, shouldn't we add all the switch-out moves to this list here:
https://github.com/jpf4e/foul-play/blob/51d4c3aec1cd93f0b213e6b130cf9bb65002ebeb/fp/battle_bots/mcts_parallel/standard_battles.py#L86-L93
```
if all_move_json[mv][constants.CATEGORY] not in logical_moves and mv not in [
    "trick",
    "switcheroo",
    "flipturn",
    "uturn",
    "voltswitch",
]:
    num_illogical_moves += 1
```
```
SWITCH_OUT_MOVES = {
    "uturn",
    "voltswitch",
    "partingshot",
    "teleport",
    "flipturn",
    "chillyreception",
    "shedtail",
}
```
created_at: 2025-04-11T23:14:33Z
author_association: CONTRIBUTOR
diff_hunk: @@ -2639,6 +2659,24 @@ def test_substitute_gets_substitute_hit_flag_set_to_false(self):
         start_volatile_status(self.battle, split_msg)
         self.assertFalse(self.battle.opponent.active.substitute_hit)
 
+    def test_substitute_gets_shed_tailing_flag_set_to_true(self):
+        self.battle.user.active.max_hp = 100
+        self.battle.user.active.hp = 100
+
+        self.battle.opponent.active.hp = 100
+        self.battle.user.active.hp = 100
+
+        messages = [
+            "|move|p1a: Cyclizar|Shed Tail|p1a: Cyclizar",
+            "|-start|p1a: Cyclizar|Substitute|[from] move: Shed Tail",
+            "|-damage|p1a: Cyclizar|50/100",  # damage from sub should not be caught
+        ]
path: tests/test_battle_modifiers.py
user: login: pmariglia
body: I have no idea why I made 3 message lines in the above test and then only operate on one of them :sweat_smile: 
created_at: 2025-04-11T23:31:15Z
author_association: OWNER
diff_hunk: @@ -1215,6 +1221,13 @@ def start_volatile_status(battle, split_msg):
         pkmn.volatile_statuses.append(volatile_status)
 
     if volatile_status == constants.SUBSTITUTE:
+        if split_msg[4] == "[from] move: Shed Tail":
path: fp/battle_modifier.py
user: login: pmariglia
body: ```suggestion
        if len(split_msg) >= 5 and split_msg[4] == "[from] move: Shed Tail":
```

Because of the way I've written this, all checks like this need to verify the length of the incoming messages. This probably fails some existing tests
created_at: 2025-04-11T23:33:22Z
author_association: OWNER
diff_hunk: @@ -959,6 +965,9 @@ def move(battle, split_msg):
     if move_name == "batonpass":
         side.baton_passing = True
 
+    if move_name == "shedtail":
+        side.shed_tailing = True
+
path: fp/battle_modifier.py
user: login: pmariglia
body: Possibly. I don't really know what the final form of that package is going to be so lets just leave it for now. I mostly use it for experimentation.
created_at: 2025-04-11T23:37:35Z
author_association: OWNER
diff_hunk: @@ -959,6 +965,9 @@ def move(battle, split_msg):
     if move_name == "batonpass":
         side.baton_passing = True
 
+    if move_name == "shedtail":
+        side.shed_tailing = True
+
path: fp/battle_modifier.py
user: login: pmariglia
body: > But I added a few tests and the change you mentioned. I didn't add a "-fail" test, though. How do you suggest adding it?

I don't think there is any action that the client needs to take when it fails. I don't think anything needs to be done
created_at: 2025-04-12T00:05:05Z
author_association: OWNER
diff_hunk: @@ -2639,6 +2659,24 @@ def test_substitute_gets_substitute_hit_flag_set_to_false(self):
         start_volatile_status(self.battle, split_msg)
         self.assertFalse(self.battle.opponent.active.substitute_hit)
 
+    def test_substitute_gets_shed_tailing_flag_set_to_true(self):
+        self.battle.user.active.max_hp = 100
+        self.battle.user.active.hp = 100
+
+        self.battle.opponent.active.hp = 100
+        self.battle.user.active.hp = 100
+
+        messages = [
+            "|move|p1a: Cyclizar|Shed Tail|p1a: Cyclizar",
+            "|-start|p1a: Cyclizar|Substitute|[from] move: Shed Tail",
+            "|-damage|p1a: Cyclizar|50/100",  # damage from sub should not be caught
+        ]
path: tests/test_battle_modifiers.py
user: login: jpf4e
body: It could be for context of the whole move interaction. I don't blame you.
Actually I like it as it gives an idea of what happens when that move is run.
created_at: 2025-04-12T00:13:22Z
author_association: CONTRIBUTOR

PR comments:
user: login: pmariglia
created_at: 2025-04-11T21:01:31Z
body: I've released poke-engine 0.0.41 which has the breaking change for shed-tail. requirements.txt will need to be updated.
author_association: OWNER
user: login: pmariglia
created_at: 2025-04-12T13:30:50Z
body: Thank you!
author_association: OWNER