Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
<div align="center">
<h1>Espeonage</h1>
![Espeonage Logo](assets/logo.png)
<p><i>
“A Future Sight for your next matchup!”
</i>
Expand Down
Binary file added assets/logo.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
78 changes: 77 additions & 1 deletion espeonage/battle_simulator.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,65 @@
class BattleSimulator:
"""Simulates battle progress from replay logs"""

# Comprehensive list of non-attacking moves to filter out
NON_ATTACK_MOVES = {
# Status moves
'Toxic', 'Thunder Wave', 'Will-O-Wisp', 'Spore', 'Sleep Powder', 'Stun Spore',
'Hypnosis', 'Yawn', 'Poison Powder', 'Glare', 'Paralyze', 'Confuse Ray',
'Supersonic', 'Swagger', 'Sweet Kiss', 'Attract', 'Taunt', 'Torment',
'Encore', 'Disable', 'Heal Block', 'Embargo', 'Leech Seed',

# Entry hazards
'Spikes', 'Toxic Spikes', 'Stealth Rock', 'Sticky Web',

# Field effects
'Trick Room', 'Magic Room', 'Wonder Room', 'Gravity', 'Grassy Terrain',
'Misty Terrain', 'Electric Terrain', 'Psychic Terrain', 'Rain Dance',
'Sunny Day', 'Sandstorm', 'Hail', 'Snow', 'Tailwind', 'Light Screen',
'Reflect', 'Aurora Veil', 'Safeguard', 'Mist',

# Boosting/status moves
'Swords Dance', 'Dragon Dance', 'Nasty Plot', 'Calm Mind', 'Bulk Up',
'Agility', 'Rock Polish', 'Quiver Dance', 'Shift Gear', 'Coil',
'Curse', 'Iron Defense', 'Amnesia', 'Acid Armor', 'Barrier',
'Cosmic Power', 'Cotton Guard', 'Defend Order', 'Harden', 'Withdraw',
'Defense Curl', 'Stockpile', 'Charge', 'Focus Energy', 'Meditate',
'Sharpen', 'Acupressure', 'Howl', 'Work Up', 'Growth', 'Hone Claws',
'Shell Smash', 'Tail Glow', 'Geomancy', 'No Retreat',

# Recovery moves
'Recover', 'Roost', 'Slack Off', 'Soft-Boiled', 'Rest', 'Wish',
'Healing Wish', 'Lunar Dance', 'Heal Order', 'Milk Drink', 'Moonlight',
'Morning Sun', 'Synthesis', 'Heal Bell', 'Aromatherapy', 'Refresh',
'Purify', 'Life Dew', 'Shore Up', 'Swallow', 'Strength Sap',

# Protection moves
'Protect', 'Detect', 'Endure', 'King\'s Shield', 'Spiky Shield',
'Baneful Bunker', 'Obstruct', 'Silk Trap', 'Burning Bulwark',

# Switching/pivot moves (these don't directly deal KO damage)
'Teleport', 'Baton Pass', 'Parting Shot', 'Shed Shell',

# Support moves
'Substitute', 'Helping Hand', 'Follow Me', 'Rage Powder', 'Spotlight',
'Ally Switch', 'Trick', 'Switcheroo', 'Bestow', 'Instruct',
'Skill Swap', 'Role Play', 'Entrainment', 'Guard Split', 'Power Split',
'Speed Swap', 'Guard Swap', 'Power Swap', 'Heart Swap', 'Mimic',
'Transform', 'Copycat', 'Me First', 'Snatch', 'Recycle', 'Metronome',

# Weather/terrain setters (already listed above but including for clarity)

# Other non-damaging moves
'Splash', 'Celebrate', 'Hold Hands', 'Happy Hour', 'Conversion',
'Conversion 2', 'Camouflage', 'Nightmare', 'Perish Song', 'Mean Look',
'Block', 'Spider Web', 'Sand Tomb', 'Whirlpool', 'Bind', 'Wrap',
'Fire Spin', 'Magma Storm', 'Infestation', 'Clamp', 'Snore', 'Forest\'s Curse',
'Trick-or-Treat', 'Rototiller', 'Magnetic Flux', 'Gear Up', 'Electric Terrain',
'Flower Shield', 'Ion Deluge', 'Powder', 'Tearful Look', 'Baby-Doll Eyes',
'Play Nice', 'Venom Drench', 'Stockpile', 'Belly Drum', 'Psych Up',
'Power Trick', 'Guard Split', 'Power Split', 'Speed Swap',
}

def __init__(self):
self.tracker = PokemonTracker()
self.calculator = DamageCalculator()
Expand Down Expand Up @@ -96,6 +155,18 @@ def _parse_hp(self, hp_str: str) -> tuple:

return None, None, status

def _is_attack_move(self, move: str) -> bool:
"""
Determine if a move is a direct attacking move

Args:
move: Move name

Returns:
True if the move is a direct attack, False otherwise
"""
return move not in self.NON_ATTACK_MOVES

def _handle_player(self, args: List[str]):
"""Handle player command"""
pass
Expand Down Expand Up @@ -184,7 +255,12 @@ def _handle_faint(self, args: List[str]):
opponent = 'p1' if player == 'p2' else 'p2'
if opponent in self.last_move:
attacker_id = self.last_move[opponent]['pokemon']
self.tracker.track_knockout(attacker_id)
move = self.last_move[opponent]['move']

# Only attribute kill to move if it's an attacking move
if self._is_attack_move(move):
self.tracker.track_knockout(attacker_id)
self.tracker.track_move_kill(attacker_id, move)

def _handle_ability(self, args: List[str]):
"""Handle ability reveal"""
Expand Down
8 changes: 8 additions & 0 deletions espeonage/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,14 @@ def format_text_output(results: dict) -> str:
if data['moves']:
lines.append(f" Moves: {', '.join(data['moves'])}")

# Display move kills if any
if data.get('move_kills') and any(kills > 0 for kills in data['move_kills'].values()):
lines.append(f" Move Kills:")
for move, kills in sorted(data['move_kills'].items()):
if kills > 0:
ko_text = "KO" if kills == 1 else "KOs"
lines.append(f" {move}: {kills} {ko_text}")

lines.append(f" Stats:")
lines.append(f" K/D Ratio: {data['kd_ratio']:.2f} ({data['knockouts']}/{data['deaths']})")
lines.append(f" Damage Dealt: {data['damage_dealt']}")
Expand Down
15 changes: 15 additions & 0 deletions espeonage/pokemon_tracker.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ class PokemonData:
deaths: int = 0
damage_dealt: int = 0
damage_taken: int = 0
move_kills: Dict[str, int] = field(default_factory=dict)

# EV/IV inference data
observed_stats: Dict[str, List[int]] = field(default_factory=dict)
Expand Down Expand Up @@ -187,6 +188,19 @@ def track_knockout(self, pokemon: str):
if pokemon in self.pokemon:
self.pokemon[pokemon].add_knockout()

def track_move_kill(self, pokemon: str, move: str):
"""
Track when a Pokémon gets a knockout with a specific move

Args:
pokemon: Pokémon identifier
move: Move name that got the kill
"""
if pokemon in self.pokemon:
if move not in self.pokemon[pokemon].move_kills:
self.pokemon[pokemon].move_kills[move] = 0
self.pokemon[pokemon].move_kills[move] += 1

def track_damage(self, attacker: str, defender: str, damage: int):
"""
Track damage dealt and taken
Expand Down Expand Up @@ -229,5 +243,6 @@ def get_summary(self) -> Dict:
'kd_ratio': data.get_kd_ratio(),
'damage_dealt': data.damage_dealt,
'damage_taken': data.damage_taken,
'move_kills': data.move_kills,
}
return summary
205 changes: 205 additions & 0 deletions tests/test_move_kills.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,205 @@
"""Tests for move kill tracking functionality."""

import unittest
from espeonage.battle_simulator import BattleSimulator
from espeonage.replay_parser import ReplayParser


class TestMoveKills(unittest.TestCase):
"""Test cases for move kill tracking."""

def test_attack_move_classification(self):
"""Test that attack moves are correctly identified."""
simulator = BattleSimulator()

# Test attacking moves
self.assertTrue(simulator._is_attack_move('Earthquake'))
self.assertTrue(simulator._is_attack_move('Thunderbolt'))
self.assertTrue(simulator._is_attack_move('Hydro Pump'))
self.assertTrue(simulator._is_attack_move('Flamethrower'))
self.assertTrue(simulator._is_attack_move('Ice Beam'))
self.assertTrue(simulator._is_attack_move('Dragon Claw'))

# Test non-attacking moves
self.assertFalse(simulator._is_attack_move('Stealth Rock'))
self.assertFalse(simulator._is_attack_move('Toxic'))
self.assertFalse(simulator._is_attack_move('Recover'))
self.assertFalse(simulator._is_attack_move('Swords Dance'))
self.assertFalse(simulator._is_attack_move('Will-O-Wisp'))
self.assertFalse(simulator._is_attack_move('Thunder Wave'))
self.assertFalse(simulator._is_attack_move('Leech Seed'))
self.assertFalse(simulator._is_attack_move('Spikes'))

def test_move_kills_tracked_for_attacks(self):
"""Test that kills are tracked for direct attacking moves."""
parser = ReplayParser()
log = (
"|player|p1|Player1|\n"
"|player|p2|Player2|\n"
"|switch|p1a: Pikachu|Pikachu, L50|150/150\n"
"|switch|p2a: Charizard|Charizard, L50|200/200\n"
"|turn|1\n"
"|move|p1a: Pikachu|Thunderbolt|p2a: Charizard\n"
"|-damage|p2a: Charizard|0 fnt\n"
"|faint|p2a: Charizard\n"
"|win|Player1\n"
)

result = parser.parse_raw_log(log)
simulator = BattleSimulator()
battle_result = simulator.process_battle_log(result['battle_log'])

pikachu_data = battle_result['pokemon']['p1:Pikachu']

# Pikachu should have 1 knockout
self.assertEqual(pikachu_data['knockouts'], 1)

# Pikachu should have 1 kill with Thunderbolt
self.assertIn('Thunderbolt', pikachu_data['move_kills'])
self.assertEqual(pikachu_data['move_kills']['Thunderbolt'], 1)

def test_move_kills_not_tracked_for_status(self):
"""Test that kills from status effects are not tracked."""
parser = ReplayParser()
log = (
"|player|p1|Player1|\n"
"|player|p2|Player2|\n"
"|switch|p1a: Toxapex|Toxapex, L50|150/150\n"
"|switch|p2a: Charizard|Charizard, L50|200/200\n"
"|turn|1\n"
"|move|p1a: Toxapex|Toxic|p2a: Charizard\n"
"|-status|p2a: Charizard|tox\n"
"|move|p2a: Charizard|Flamethrower|p1a: Toxapex\n"
"|-damage|p1a: Toxapex|100/150\n"
"|turn|2\n"
"|move|p1a: Toxapex|Recover|p1a: Toxapex\n"
"|-heal|p1a: Toxapex|150/150\n"
"|move|p2a: Charizard|Flamethrower|p1a: Toxapex\n"
"|-damage|p1a: Toxapex|100/150\n"
"|-damage|p2a: Charizard|180/200 tox|[from] psn\n"
"|turn|3\n"
"|move|p1a: Toxapex|Recover|p1a: Toxapex\n"
"|-heal|p1a: Toxapex|150/150\n"
"|-damage|p2a: Charizard|0 fnt|[from] psn\n"
"|faint|p2a: Charizard\n"
"|win|Player1\n"
)

result = parser.parse_raw_log(log)
simulator = BattleSimulator()
battle_result = simulator.process_battle_log(result['battle_log'])

toxapex_data = battle_result['pokemon']['p1:Toxapex']

# Toxapex should have 0 knockouts because the kill was from status
# (Recover was the last move used, which is non-attacking)
self.assertEqual(toxapex_data['knockouts'], 0)

# Toxapex should have no move kills
self.assertEqual(len(toxapex_data['move_kills']), 0)

def test_multiple_kills_same_move(self):
"""Test that multiple kills with the same move are tracked correctly."""
parser = ReplayParser()
log = (
"|player|p1|Player1|\n"
"|player|p2|Player2|\n"
"|switch|p1a: Pikachu|Pikachu, L50|150/150\n"
"|switch|p2a: Charizard|Charizard, L50|200/200\n"
"|turn|1\n"
"|move|p1a: Pikachu|Thunderbolt|p2a: Charizard\n"
"|-damage|p2a: Charizard|0 fnt\n"
"|faint|p2a: Charizard\n"
"|switch|p2a: Blastoise|Blastoise, L50|180/180\n"
"|turn|2\n"
"|move|p1a: Pikachu|Thunderbolt|p2a: Blastoise\n"
"|-damage|p2a: Blastoise|0 fnt\n"
"|faint|p2a: Blastoise\n"
"|win|Player1\n"
)

result = parser.parse_raw_log(log)
simulator = BattleSimulator()
battle_result = simulator.process_battle_log(result['battle_log'])

pikachu_data = battle_result['pokemon']['p1:Pikachu']

# Pikachu should have 2 knockouts
self.assertEqual(pikachu_data['knockouts'], 2)

# Pikachu should have 2 kills with Thunderbolt
self.assertIn('Thunderbolt', pikachu_data['move_kills'])
self.assertEqual(pikachu_data['move_kills']['Thunderbolt'], 2)

def test_multiple_kills_different_moves(self):
"""Test that kills from different moves are tracked separately."""
parser = ReplayParser()
log = (
"|player|p1|Player1|\n"
"|player|p2|Player2|\n"
"|switch|p1a: Pikachu|Pikachu, L50|150/150\n"
"|switch|p2a: Charizard|Charizard, L50|200/200\n"
"|turn|1\n"
"|move|p1a: Pikachu|Thunderbolt|p2a: Charizard\n"
"|-damage|p2a: Charizard|0 fnt\n"
"|faint|p2a: Charizard\n"
"|switch|p2a: Pidgeot|Pidgeot, L50|160/160\n"
"|turn|2\n"
"|move|p1a: Pikachu|Iron Tail|p2a: Pidgeot\n"
"|-damage|p2a: Pidgeot|0 fnt\n"
"|faint|p2a: Pidgeot\n"
"|win|Player1\n"
)

result = parser.parse_raw_log(log)
simulator = BattleSimulator()
battle_result = simulator.process_battle_log(result['battle_log'])

pikachu_data = battle_result['pokemon']['p1:Pikachu']

# Pikachu should have 2 knockouts
self.assertEqual(pikachu_data['knockouts'], 2)

# Pikachu should have 1 kill with Thunderbolt
self.assertIn('Thunderbolt', pikachu_data['move_kills'])
self.assertEqual(pikachu_data['move_kills']['Thunderbolt'], 1)

# Pikachu should have 1 kill with Iron Tail
self.assertIn('Iron Tail', pikachu_data['move_kills'])
self.assertEqual(pikachu_data['move_kills']['Iron Tail'], 1)

def test_hazard_kill_not_attributed(self):
"""Test that kills from hazards like Stealth Rock are not attributed."""
parser = ReplayParser()
log = (
"|player|p1|Player1|\n"
"|player|p2|Player2|\n"
"|switch|p1a: Ferrothorn|Ferrothorn, L50|150/150\n"
"|switch|p2a: Charizard|Charizard, L50|200/200\n"
"|turn|1\n"
"|move|p1a: Ferrothorn|Stealth Rock|p2a: Charizard\n"
"|-sidestart|p2: Player2|move: Stealth Rock\n"
"|move|p2a: Charizard|Fire Blast|p1a: Ferrothorn\n"
"|-damage|p1a: Ferrothorn|100/150\n"
"|turn|2\n"
"|switch|p2a: Moltres|Moltres, L50|180/180\n"
"|-damage|p2a: Moltres|0 fnt|[from] Stealth Rock\n"
"|faint|p2a: Moltres\n"
"|win|Player1\n"
)

result = parser.parse_raw_log(log)
simulator = BattleSimulator()
battle_result = simulator.process_battle_log(result['battle_log'])

ferrothorn_data = battle_result['pokemon']['p1:Ferrothorn']

# Ferrothorn should have 0 knockouts (hazard kills don't count)
self.assertEqual(ferrothorn_data['knockouts'], 0)

# Ferrothorn should have no move kills
self.assertEqual(len(ferrothorn_data['move_kills']), 0)


if __name__ == '__main__':
unittest.main()
Loading