localdex 0.3.0__py3-none-any.whl → 0.3.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,928 @@
1
+ """
2
+ Stat guessing functionality for LocalDex.
3
+
4
+ This module provides functionality for guessing Pokemon stats and optimizing EV spreads,
5
+ replicating the functionality from stat_utils.ts.
6
+ """
7
+
8
+ from typing import Dict, List, Optional, Tuple, Any, Union
9
+ from dataclasses import dataclass
10
+ from enum import Enum
11
+
12
+ from .models import Pokemon, Move, Ability, Item, BaseStats
13
+ from .stat_calculator import StatCalculator
14
+
15
+
16
+ class StatName(str, Enum):
17
+ """Pokemon stat names."""
18
+ HP = "hp"
19
+ ATK = "atk"
20
+ DEF = "def"
21
+ SPA = "spa"
22
+ SPD = "spd"
23
+ SPE = "spe"
24
+
25
+
26
+ class MoveCategory(str, Enum):
27
+ """Move categories."""
28
+ PHYSICAL = "Physical"
29
+ SPECIAL = "Special"
30
+ STATUS = "Status"
31
+
32
+
33
+ # Nature modifier system
34
+ NATURE_MODIFIERS = {
35
+ "Hardy": {"plus": None, "minus": None},
36
+ "Lonely": {"plus": "attack", "minus": "defense"},
37
+ "Brave": {"plus": "attack", "minus": "speed"},
38
+ "Adamant": {"plus": "attack", "minus": "special_attack"},
39
+ "Naughty": {"plus": "attack", "minus": "special_defense"},
40
+ "Bold": {"plus": "defense", "minus": "attack"},
41
+ "Docile": {"plus": None, "minus": None},
42
+ "Relaxed": {"plus": "defense", "minus": "speed"},
43
+ "Impish": {"plus": "defense", "minus": "special_attack"},
44
+ "Lax": {"plus": "defense", "minus": "special_defense"},
45
+ "Timid": {"plus": "speed", "minus": "attack"},
46
+ "Hasty": {"plus": "speed", "minus": "defense"},
47
+ "Serious": {"plus": None, "minus": None},
48
+ "Jolly": {"plus": "speed", "minus": "special_attack"},
49
+ "Naive": {"plus": "speed", "minus": "special_defense"},
50
+ "Modest": {"plus": "special_attack", "minus": "attack"},
51
+ "Mild": {"plus": "special_attack", "minus": "defense"},
52
+ "Quiet": {"plus": "special_attack", "minus": "speed"},
53
+ "Bashful": {"plus": None, "minus": None},
54
+ "Rash": {"plus": "special_attack", "minus": "special_defense"},
55
+ "Calm": {"plus": "special_defense", "minus": "attack"},
56
+ "Gentle": {"plus": "special_defense", "minus": "defense"},
57
+ "Sassy": {"plus": "special_defense", "minus": "speed"},
58
+ "Careful": {"plus": "special_defense", "minus": "special_attack"},
59
+ "Quirky": {"plus": None, "minus": None},
60
+ }
61
+
62
+
63
+ @dataclass
64
+ class PokemonSet:
65
+ """Represents a Pokemon set with moves, items, abilities, etc."""
66
+ species: str
67
+ name: Optional[str] = None
68
+ item: Optional[str] = None
69
+ ability: Optional[str] = None
70
+ nature: Optional[str] = None
71
+ level: int = 100
72
+ moves: List[str] = None
73
+ evs: Optional[Dict[str, int]] = None
74
+ ivs: Optional[Dict[str, int]] = None
75
+
76
+ def __post_init__(self):
77
+ if self.moves is None:
78
+ self.moves = []
79
+ if self.evs is None:
80
+ self.evs = {}
81
+ if self.ivs is None:
82
+ self.ivs = {}
83
+
84
+
85
+ class BattleStatGuesser:
86
+ """
87
+ A class for guessing Pokemon stats and roles based on moves, items, and abilities.
88
+
89
+ This replicates the functionality from the TypeScript BattleStatGuesser class.
90
+ """
91
+
92
+ def __init__(self, format_id: str, localdex):
93
+ """
94
+ Initialize the BattleStatGuesser.
95
+
96
+ Args:
97
+ format_id: The format ID (e.g., "gen9ou")
98
+ localdex: LocalDex instance for accessing Pokemon data
99
+ """
100
+ self.format_id = format_id
101
+ self.localdex = localdex
102
+ self.stat_calculator = StatCalculator(localdex)
103
+ self.move_count = None
104
+ self.has_move = None
105
+
106
+ # Format-specific settings
107
+ self.ignore_ev_limits = self._should_ignore_ev_limits(format_id)
108
+ self.supports_evs = "letsgo" not in format_id.lower()
109
+ self.supports_avs = "letsgo" in format_id.lower()
110
+
111
+ def _should_ignore_ev_limits(self, format_id: str) -> bool:
112
+ """Determine if EV limits should be ignored for this format."""
113
+ format_lower = format_id.lower()
114
+ return (
115
+ format_lower.startswith("gen1") or format_lower.startswith("gen2") or
116
+ format_lower.endswith("hackmons") or format_lower.endswith("bh") or
117
+ "metronomebattle" in format_lower or format_lower.endswith("norestrictions")
118
+ )
119
+
120
+ def guess(self, pokemon_set: PokemonSet) -> Dict[str, Any]:
121
+ """
122
+ Guess the role and EV spread for a Pokemon set.
123
+
124
+ Args:
125
+ pokemon_set: The Pokemon set to analyze
126
+
127
+ Returns:
128
+ Dictionary containing role, EVs, plus/minus stats, and move information
129
+ """
130
+ role = self.guess_role(pokemon_set)
131
+ combo_evs = self.guess_evs(pokemon_set, role)
132
+
133
+ evs = {stat.value: 0 for stat in StatName}
134
+ for stat in evs:
135
+ evs[stat] = combo_evs.get(stat, 0)
136
+
137
+ plus_stat = combo_evs.get('plusStat', '')
138
+ minus_stat = combo_evs.get('minusStat', '')
139
+
140
+ return {
141
+ 'role': role,
142
+ 'evs': evs,
143
+ 'plusStat': plus_stat,
144
+ 'minusStat': minus_stat,
145
+ 'moveCount': self.move_count,
146
+ 'hasMove': self.has_move
147
+ }
148
+
149
+ def guess_role(self, pokemon_set: PokemonSet) -> str:
150
+ """
151
+ Guess the role of a Pokemon based on its moves, stats, and items.
152
+
153
+ Args:
154
+ pokemon_set: The Pokemon set to analyze
155
+
156
+ Returns:
157
+ The guessed role as a string
158
+ """
159
+ if not pokemon_set or not pokemon_set.moves:
160
+ return '?'
161
+
162
+ # Initialize move counters
163
+ move_count = {
164
+ 'Physical': 0,
165
+ 'Special': 0,
166
+ 'PhysicalAttack': 0,
167
+ 'SpecialAttack': 0,
168
+ 'PhysicalSetup': 0,
169
+ 'SpecialSetup': 0,
170
+ 'Support': 0,
171
+ 'Setup': 0,
172
+ 'Restoration': 0,
173
+ 'Offense': 0,
174
+ 'Stall': 0,
175
+ 'SpecialStall': 0,
176
+ 'PhysicalStall': 0,
177
+ 'Fast': 0,
178
+ 'Ultrafast': 0,
179
+ 'bulk': 0,
180
+ 'specialBulk': 0,
181
+ 'physicalBulk': 0,
182
+ }
183
+
184
+ has_move = {}
185
+ item_id = self._to_id(pokemon_set.item) if pokemon_set.item else ""
186
+ ability_id = self._to_id(pokemon_set.ability) if pokemon_set.ability else ""
187
+
188
+ try:
189
+ species = self.localdex.get_pokemon(pokemon_set.species)
190
+ except:
191
+ return '?'
192
+
193
+ # Handle mega evolution
194
+ if pokemon_set.item:
195
+ try:
196
+ item = self.localdex.get_item(pokemon_set.item)
197
+ if hasattr(item, 'mega_evolves') and item.mega_evolves == species.name:
198
+ species = self.localdex.get_pokemon(item.mega_stone)
199
+ except:
200
+ pass
201
+
202
+ stats = species.base_stats
203
+
204
+ if len(pokemon_set.moves) < 1:
205
+ return '?'
206
+
207
+ needs_four_moves = species.name.lower() not in ['unown', 'ditto']
208
+ has_four_valid_moves = len(pokemon_set.moves) >= 4 and all(move for move in pokemon_set.moves)
209
+ move_ids = [self._to_id(move) for move in pokemon_set.moves]
210
+
211
+ if 'lastresort' in move_ids:
212
+ needs_four_moves = False
213
+
214
+ if not has_four_valid_moves and needs_four_moves and 'metronomebattle' not in self.format_id:
215
+ return '?'
216
+
217
+ # Analyze moves
218
+ for move_name in pokemon_set.moves:
219
+ try:
220
+ move = self.localdex.get_move(move_name)
221
+ has_move[move.name.lower()] = 1
222
+
223
+ if move.category == MoveCategory.STATUS:
224
+ self._analyze_status_move(move, move_count, has_move)
225
+ else:
226
+ self._analyze_attack_move(move, move_count, has_move)
227
+
228
+ except:
229
+ continue
230
+
231
+ # Post-process move counts
232
+ if has_move.get('batonpass'):
233
+ move_count['Support'] += move_count['Setup']
234
+
235
+ move_count['PhysicalAttack'] = move_count['Physical']
236
+ move_count['Physical'] += move_count['PhysicalSetup']
237
+ move_count['SpecialAttack'] = move_count['Special']
238
+ move_count['Special'] += move_count['SpecialSetup']
239
+
240
+ if has_move.get('dragondance') or has_move.get('quiverdance'):
241
+ move_count['Ultrafast'] = 1
242
+
243
+ # Calculate bulk and speed characteristics
244
+ is_fast = stats.speed >= 80
245
+ physical_bulk = (stats.hp + 75) * (stats.defense + 87)
246
+ special_bulk = (stats.hp + 75) * (stats.special_defense + 87)
247
+
248
+ # Apply bulk modifiers based on moves and abilities
249
+ physical_bulk, special_bulk = self._apply_bulk_modifiers(
250
+ physical_bulk, special_bulk, has_move, ability_id, item_id, move_count
251
+ )
252
+
253
+ bulk = physical_bulk + special_bulk
254
+ if bulk < 46000 and stats.speed >= 70:
255
+ is_fast = True
256
+ if has_move.get('trickroom'):
257
+ is_fast = False
258
+
259
+ move_count['bulk'] = bulk
260
+ move_count['physicalBulk'] = physical_bulk
261
+ move_count['specialBulk'] = special_bulk
262
+
263
+ # Determine speed characteristics
264
+ is_fast = self._determine_speed_characteristics(
265
+ is_fast, has_move, ability_id, item_id, move_count
266
+ )
267
+
268
+ self.move_count = move_count
269
+ self.has_move = has_move
270
+
271
+ # Special cases
272
+ if species.name.lower() == 'ditto':
273
+ return 'Physically Defensive' if ability_id == 'imposter' else 'Fast Bulky Support'
274
+ if species.name.lower() == 'shedinja':
275
+ return 'Fast Physical Sweeper'
276
+
277
+ # Determine role based on item and move patterns
278
+ role = self._determine_role_from_patterns(
279
+ item_id, move_count, is_fast, stats, has_move, ability_id, physical_bulk, special_bulk
280
+ )
281
+
282
+ return role
283
+
284
+ def _analyze_status_move(self, move, move_count, has_move):
285
+ """Analyze a status move and update move counts."""
286
+ move_id = move.name.lower()
287
+
288
+ if move_id in ['batonpass', 'healingwish', 'lunardance']:
289
+ move_count['Support'] += 1
290
+ elif move_id in ['metronome', 'assist', 'copycat', 'mefirst', 'photongeyser', 'shellsidearm']:
291
+ move_count['Physical'] += 0.5
292
+ move_count['Special'] += 0.5
293
+ elif move_id == 'naturepower':
294
+ move_count['Special'] += 1
295
+ elif move_id in ['protect', 'detect', 'spikyshield', 'kingsshield']:
296
+ move_count['Stall'] += 1
297
+ elif move_id == 'wish':
298
+ move_count['Restoration'] += 1
299
+ move_count['Stall'] += 1
300
+ move_count['Support'] += 1
301
+ elif move.drain and move.drain[0] > 0: # Use drain instead of heal
302
+ move_count['Restoration'] += 1
303
+ move_count['Stall'] += 1
304
+ elif move.target == 'self': # target is always available
305
+ if move_id in ['agility', 'rockpolish', 'shellsmash', 'growth', 'workup']:
306
+ move_count['PhysicalSetup'] += 1
307
+ move_count['SpecialSetup'] += 1
308
+ elif move_id in ['dragondance', 'swordsdance', 'coil', 'bulkup', 'curse', 'bellydrum']:
309
+ move_count['PhysicalSetup'] += 1
310
+ elif move_id in ['nastyplot', 'tailglow', 'quiverdance', 'calmmind', 'geomancy']:
311
+ move_count['SpecialSetup'] += 1
312
+
313
+ if move_id == 'substitute':
314
+ move_count['Stall'] += 1
315
+ move_count['Setup'] += 1
316
+ else:
317
+ if move_id in ['toxic', 'leechseed', 'willowisp']:
318
+ move_count['Stall'] += 1
319
+ move_count['Support'] += 1
320
+
321
+ def _analyze_attack_move(self, move, move_count, has_move):
322
+ """Analyze an attack move and update move counts."""
323
+ move_id = move.name.lower()
324
+
325
+ if move_id in ['counter', 'endeavor', 'metalburst', 'mirrorcoat', 'rapidspin']:
326
+ move_count['Support'] += 1
327
+ elif move_id in [
328
+ 'nightshade', 'seismictoss', 'psywave', 'superfang', 'naturesmadness',
329
+ 'foulplay', 'endeavor', 'finalgambit', 'bodypress'
330
+ ]:
331
+ move_count['Offense'] += 1
332
+ elif move_id == 'fellstinger':
333
+ move_count['PhysicalSetup'] += 1
334
+ move_count['Setup'] += 1
335
+ else:
336
+ move_count[move.category] += 1
337
+ move_count['Offense'] += 1
338
+
339
+ if move_id == 'knockoff':
340
+ move_count['Support'] += 1
341
+
342
+ if move_id in ['scald', 'voltswitch', 'uturn', 'flipturn']:
343
+ move_count[move.category] -= 0.2
344
+
345
+ def _apply_bulk_modifiers(self, physical_bulk, special_bulk, has_move, ability_id, item_id, move_count):
346
+ """Apply bulk modifiers based on moves, abilities, and items."""
347
+ # Physical bulk modifiers
348
+ if has_move.get('willowisp') or has_move.get('acidarmor') or has_move.get('irondefense') or has_move.get('cottonguard'):
349
+ physical_bulk *= 1.6
350
+ move_count['PhysicalStall'] += 1
351
+ elif has_move.get('scald') or has_move.get('bulkup') or has_move.get('coil') or has_move.get('cosmicpower'):
352
+ physical_bulk *= 1.3
353
+ if has_move.get('scald'):
354
+ move_count['SpecialStall'] += 1
355
+ else:
356
+ move_count['PhysicalStall'] += 1
357
+
358
+ if ability_id == 'flamebody':
359
+ physical_bulk *= 1.1
360
+
361
+ # Special bulk modifiers
362
+ if has_move.get('calmmind') or has_move.get('quiverdance') or has_move.get('geomancy'):
363
+ special_bulk *= 1.3
364
+ move_count['SpecialStall'] += 1
365
+
366
+ # Item and ability modifiers
367
+ if item_id in ['leftovers', 'blacksludge']:
368
+ modifier = 1 + 0.1 * (1 + move_count['Stall'] / 1.5)
369
+ physical_bulk *= modifier
370
+ special_bulk *= modifier
371
+
372
+ if has_move.get('leechseed'):
373
+ modifier = 1 + 0.1 * (1 + move_count['Stall'] / 1.5)
374
+ physical_bulk *= modifier
375
+ special_bulk *= modifier
376
+
377
+ if item_id in ['flameorb', 'toxicorb'] and ability_id != 'magicguard':
378
+ if item_id == 'toxicorb' and ability_id == 'poisonheal':
379
+ modifier = 1 + 0.1 * (2 + move_count['Stall'])
380
+ physical_bulk *= modifier
381
+ special_bulk *= modifier
382
+ else:
383
+ physical_bulk *= 0.8
384
+ special_bulk *= 0.8
385
+
386
+ if item_id == 'lifeorb':
387
+ physical_bulk *= 0.7
388
+ special_bulk *= 0.7
389
+
390
+ if ability_id in ['multiscale', 'magicguard', 'regenerator']:
391
+ physical_bulk *= 1.4
392
+ special_bulk *= 1.4
393
+
394
+ if item_id == 'eviolite':
395
+ physical_bulk *= 1.5
396
+ special_bulk *= 1.5
397
+
398
+ if item_id == 'assaultvest':
399
+ special_bulk *= 1.5
400
+
401
+ return physical_bulk, special_bulk
402
+
403
+ def _determine_speed_characteristics(self, is_fast, has_move, ability_id, item_id, move_count):
404
+ """Determine speed characteristics based on moves and abilities."""
405
+ if (has_move.get('agility') or has_move.get('dragondance') or has_move.get('quiverdance') or
406
+ has_move.get('rockpolish') or has_move.get('shellsmash') or has_move.get('flamecharge')):
407
+ is_fast = True
408
+ elif ability_id in ['unburden', 'speedboost', 'motordrive']:
409
+ is_fast = True
410
+ move_count['Ultrafast'] = 1
411
+ elif ability_id in ['chlorophyll', 'swiftswim', 'sandrush']:
412
+ is_fast = True
413
+ move_count['Ultrafast'] = 2
414
+ elif item_id == 'salacberry':
415
+ is_fast = True
416
+
417
+ ultrafast = (has_move.get('agility') or has_move.get('shellsmash') or
418
+ has_move.get('autotomize') or has_move.get('shiftgear') or has_move.get('rockpolish'))
419
+ if ultrafast:
420
+ move_count['Ultrafast'] = 2
421
+
422
+ move_count['Fast'] = 1 if is_fast else 0
423
+ return is_fast
424
+
425
+ def _determine_role_from_patterns(self, item_id, move_count, is_fast, stats, has_move, ability_id, physical_bulk, special_bulk):
426
+ """Determine the role based on patterns in moves, items, and stats."""
427
+ # Choice item patterns
428
+ if item_id == 'choiceband' and move_count['PhysicalAttack'] >= 2:
429
+ return 'Bulky Band' if not is_fast else 'Fast Band'
430
+ elif item_id == 'choicespecs' and move_count['SpecialAttack'] >= 2:
431
+ return 'Bulky Specs' if not is_fast else 'Fast Specs'
432
+ elif item_id == 'choicescarf':
433
+ if move_count['PhysicalAttack'] == 0:
434
+ return 'Special Scarf'
435
+ elif move_count['SpecialAttack'] == 0:
436
+ return 'Physical Scarf'
437
+ elif move_count['PhysicalAttack'] > move_count['SpecialAttack']:
438
+ return 'Physical Biased Mixed Scarf'
439
+ elif move_count['PhysicalAttack'] < move_count['SpecialAttack']:
440
+ return 'Special Biased Mixed Scarf'
441
+ elif stats.special_attack < stats.attack:
442
+ return 'Special Biased Mixed Scarf'
443
+ else:
444
+ return 'Physical Biased Mixed Scarf'
445
+
446
+ # Special cases
447
+ if has_move.get('unown'):
448
+ return 'Fast Special Sweeper'
449
+
450
+ # Defensive patterns
451
+ if move_count['PhysicalStall'] and move_count['Restoration']:
452
+ return 'Fast Bulky Support' if stats.speed > 110 and ability_id != 'prankster' else 'Specially Defensive'
453
+ if move_count['SpecialStall'] and move_count['Restoration'] and item_id != 'lifeorb':
454
+ return 'Fast Bulky Support' if stats.speed > 110 and ability_id != 'prankster' else 'Physically Defensive'
455
+
456
+ # Offensive bias
457
+ offense_bias = 'Physical'
458
+ if stats.special_attack > stats.attack and move_count['Special'] > 1:
459
+ offense_bias = 'Special'
460
+ elif stats.attack > stats.special_attack and move_count['Physical'] > 1:
461
+ offense_bias = 'Physical'
462
+ elif move_count['Special'] > move_count['Physical']:
463
+ offense_bias = 'Special'
464
+
465
+ # Sweeper patterns
466
+ if (move_count['Stall'] + move_count['Support'] / 2 <= 2 and
467
+ move_count['bulk'] < 135000 and move_count[offense_bias] >= 1.5):
468
+ if is_fast:
469
+ if move_count['bulk'] > 80000 and not move_count['Ultrafast']:
470
+ return f'Bulky {offense_bias} Sweeper'
471
+ return f'Fast {offense_bias} Sweeper'
472
+ else:
473
+ if move_count[offense_bias] >= 3 or move_count['Stall'] <= 0:
474
+ return f'Bulky {offense_bias} Sweeper'
475
+
476
+ # Support patterns
477
+ if is_fast and ability_id != 'prankster':
478
+ if stats.speed > 100 or move_count['bulk'] < 55000 or move_count['Ultrafast']:
479
+ return 'Fast Bulky Support'
480
+
481
+ # Defensive patterns
482
+ if move_count['SpecialStall']:
483
+ return 'Physically Defensive'
484
+ if move_count['PhysicalStall']:
485
+ return 'Specially Defensive'
486
+
487
+ # Default defensive role
488
+ if special_bulk >= physical_bulk:
489
+ return 'Specially Defensive'
490
+ return 'Physically Defensive'
491
+
492
+ def guess_evs(self, pokemon_set: PokemonSet, role: str) -> Dict[str, Any]:
493
+ """
494
+ Guess EV spread for a Pokemon based on its role.
495
+
496
+ Args:
497
+ pokemon_set: The Pokemon set
498
+ role: The guessed role
499
+
500
+ Returns:
501
+ Dictionary containing EV spread and nature information
502
+ """
503
+ if not pokemon_set or role == '?':
504
+ return {}
505
+
506
+ try:
507
+ species = self.localdex.get_pokemon(pokemon_set.species)
508
+ except:
509
+ return {}
510
+
511
+ stats = species.base_stats
512
+ has_move = self.has_move
513
+ move_count = self.move_count
514
+
515
+ evs = {stat.value: 0 for stat in StatName}
516
+ plus_stat = None
517
+ minus_stat = None
518
+
519
+ # Role-based stat priorities
520
+ stat_chart = {
521
+ 'Bulky Band': ['atk', 'hp'],
522
+ 'Fast Band': ['spe', 'atk'],
523
+ 'Bulky Specs': ['spa', 'hp'],
524
+ 'Fast Specs': ['spe', 'spa'],
525
+ 'Physical Scarf': ['spe', 'atk'],
526
+ 'Special Scarf': ['spe', 'spa'],
527
+ 'Physical Biased Mixed Scarf': ['spe', 'atk'],
528
+ 'Special Biased Mixed Scarf': ['spe', 'spa'],
529
+ 'Fast Physical Sweeper': ['spe', 'atk'],
530
+ 'Fast Special Sweeper': ['spe', 'spa'],
531
+ 'Bulky Physical Sweeper': ['atk', 'hp'],
532
+ 'Bulky Special Sweeper': ['spa', 'hp'],
533
+ 'Fast Bulky Support': ['spe', 'hp'],
534
+ 'Physically Defensive': ['def', 'hp'],
535
+ 'Specially Defensive': ['spd', 'hp'],
536
+ }
537
+
538
+ if role not in stat_chart:
539
+ return {}
540
+
541
+ plus_stat = stat_chart[role][0]
542
+ if role == 'Fast Bulky Support':
543
+ move_count['Ultrafast'] = 0
544
+
545
+ if plus_stat == 'spe' and move_count.get('Ultrafast'):
546
+ if stat_chart[role][1] in ['atk', 'spa']:
547
+ plus_stat = stat_chart[role][1]
548
+ elif move_count.get('Physical', 0) >= 3:
549
+ plus_stat = 'atk'
550
+ elif stats.special_defense > stats.defense:
551
+ plus_stat = 'spd'
552
+ else:
553
+ plus_stat = 'def'
554
+
555
+ # Handle different EV systems
556
+ if self.supports_avs:
557
+ # Let's Go, AVs enabled
558
+ evs = {stat.value: 200 for stat in StatName}
559
+ if not move_count.get('PhysicalAttack'):
560
+ evs['atk'] = 0
561
+ if not move_count.get('SpecialAttack'):
562
+ evs['spa'] = 0
563
+ if has_move.get('gyroball') or has_move.get('trickroom'):
564
+ evs['spe'] = 0
565
+ elif not self.supports_evs:
566
+ # Let's Go, AVs disabled
567
+ pass
568
+ elif self.ignore_ev_limits:
569
+ # Gen 1-2, hackable EVs
570
+ evs = {stat.value: 252 for stat in StatName}
571
+ if not move_count.get('PhysicalAttack'):
572
+ evs['atk'] = 0
573
+ if not move_count.get('SpecialAttack'):
574
+ evs['spa'] = 0
575
+ if has_move.get('gyroball') or has_move.get('trickroom'):
576
+ evs['spe'] = 0
577
+ else:
578
+ # Normal Gen 3+ EV system
579
+ evs = self._calculate_normal_evs(pokemon_set, role, stat_chart, plus_stat, stats, has_move, move_count)
580
+
581
+ # Determine minus stat
582
+ minus_stat = self._determine_minus_stat(has_move, move_count, stats, evs, plus_stat)
583
+
584
+ return {
585
+ **evs,
586
+ 'plusStat': plus_stat,
587
+ 'minusStat': minus_stat
588
+ }
589
+
590
+ def _calculate_normal_evs(self, pokemon_set, role, stat_chart, plus_stat, stats, has_move, move_count):
591
+ """Calculate EVs for normal Gen 3+ system."""
592
+ evs = {stat.value: 0 for stat in StatName}
593
+ ev_total = 0
594
+
595
+ # Primary stat
596
+ primary_stat = stat_chart[role][0]
597
+ evs[primary_stat] = 252
598
+ ev_total += 252
599
+
600
+ # Secondary stat
601
+ secondary_stat = stat_chart[role][1]
602
+ if secondary_stat == 'hp' and pokemon_set.level and pokemon_set.level < 20:
603
+ secondary_stat = 'spd'
604
+ evs[secondary_stat] = 252
605
+ ev_total += 252
606
+
607
+ # HP optimization
608
+ evs = self._optimize_hp_evs(pokemon_set, evs, ev_total, has_move, stats)
609
+
610
+ # Special cases for specific Pokemon
611
+ evs = self._apply_special_pokemon_evs(pokemon_set, evs, stats)
612
+
613
+ # Distribute remaining EVs
614
+ evs = self._distribute_remaining_evs(pokemon_set, evs, move_count, stats)
615
+
616
+ return evs
617
+
618
+ def _optimize_hp_evs(self, pokemon_set, evs, ev_total, has_move, stats):
619
+ """Optimize HP EVs based on various factors."""
620
+ # Stealth Rock weaknesses and resistances
621
+ sr_weaknesses = ['Fire', 'Flying', 'Bug', 'Ice']
622
+ sr_resistances = ['Ground', 'Steel', 'Fighting']
623
+
624
+ try:
625
+ species = self.localdex.get_pokemon(pokemon_set.species)
626
+ sr_weak = 0
627
+
628
+ if pokemon_set.ability not in ['Magic Guard', 'Mountaineer']:
629
+ for pokemon_type in species.types:
630
+ if pokemon_type in sr_weaknesses:
631
+ sr_weak += 1
632
+ elif pokemon_type in sr_resistances:
633
+ sr_weak -= 1
634
+
635
+ # Determine HP divisibility requirements
636
+ hp_divisibility = 0
637
+ hp_should_be_divisible = False
638
+ hp = evs.get('hp', 0)
639
+
640
+ # Check for Leftovers + Substitute
641
+ if (pokemon_set.item in ['Leftovers', 'Black Sludge'] and
642
+ has_move.get('substitute')):
643
+ hp_divisibility = 4
644
+
645
+ # Check for Berry + Belly Drum
646
+ elif (has_move.get('bellydrum') and
647
+ pokemon_set.item and pokemon_set.item.endswith('Berry')):
648
+ hp_divisibility = 2
649
+ hp_should_be_divisible = True
650
+
651
+ # Check for Berry + Substitute
652
+ elif (has_move.get('substitute') and
653
+ pokemon_set.item and pokemon_set.item.endswith('Berry')):
654
+ hp_divisibility = 4
655
+ hp_should_be_divisible = True
656
+
657
+ # Check for Stealth Rock weakness or Belly Drum
658
+ elif sr_weak >= 2 or has_move.get('bellydrum'):
659
+ hp_divisibility = 2
660
+
661
+ # Check for Stealth Rock weakness or Substitute
662
+ elif sr_weak >= 1 or has_move.get('substitute') or has_move.get('transform'):
663
+ hp_divisibility = 4
664
+
665
+ # Default for other cases
666
+ elif pokemon_set.ability != 'Magic Guard':
667
+ hp_divisibility = 8
668
+
669
+ # Optimize HP EVs based on divisibility
670
+ if hp_divisibility:
671
+ current_hp = self.get_stat('hp', pokemon_set, hp, 1.0)
672
+
673
+ # Add EVs until we reach the right divisibility
674
+ while (hp < 252 and ev_total < 508 and
675
+ (current_hp % hp_divisibility == 0) != hp_should_be_divisible):
676
+ hp += 4
677
+ current_hp = self.get_stat('hp', pokemon_set, hp, 1.0)
678
+ ev_total += 4
679
+
680
+ # Remove EVs if we overshot
681
+ while (hp > 0 and
682
+ (current_hp % hp_divisibility == 0) != hp_should_be_divisible):
683
+ hp -= 4
684
+ current_hp = self.get_stat('hp', pokemon_set, hp, 1.0)
685
+ ev_total -= 4
686
+
687
+ # Remove redundant EVs
688
+ while (hp > 0 and
689
+ current_hp == self.get_stat('hp', pokemon_set, hp - 4, 1.0)):
690
+ hp -= 4
691
+ ev_total -= 4
692
+
693
+ if hp > 0 or evs.get('hp'):
694
+ evs['hp'] = hp
695
+
696
+ except:
697
+ pass
698
+
699
+ return evs
700
+
701
+ def _apply_special_pokemon_evs(self, pokemon_set, evs, stats):
702
+ """Apply special EV requirements for specific Pokemon."""
703
+ try:
704
+ species = self.localdex.get_pokemon(pokemon_set.species)
705
+ species_id = species.name.lower()
706
+
707
+ # Special cases for specific Pokemon
708
+ if species_id == 'tentacruel':
709
+ evs = self._ensure_min_evs(evs, 'spe', 16)
710
+ elif species_id == 'skarmory':
711
+ evs = self._ensure_min_evs(evs, 'spe', 24)
712
+ elif species_id == 'jirachi':
713
+ evs = self._ensure_min_evs(evs, 'spe', 32)
714
+ elif species_id == 'celebi':
715
+ evs = self._ensure_min_evs(evs, 'spe', 36)
716
+ elif species_id == 'volcarona':
717
+ evs = self._ensure_min_evs(evs, 'spe', 52)
718
+ elif species_id == 'gliscor':
719
+ evs = self._ensure_min_evs(evs, 'spe', 72)
720
+ elif species_id == 'dragonite' and evs.get('hp'):
721
+ evs = self._ensure_max_evs(evs, 'spe', 220)
722
+
723
+ except:
724
+ pass
725
+
726
+ return evs
727
+
728
+ def _distribute_remaining_evs(self, pokemon_set, evs, move_count, stats):
729
+ """Distribute any remaining EVs."""
730
+ ev_total = sum(evs.values())
731
+
732
+ if ev_total < 508:
733
+ remaining = 508 - ev_total
734
+ if remaining > 252:
735
+ remaining = 252
736
+
737
+ # Determine secondary stat to invest in
738
+ secondary_stat = None
739
+
740
+ if not evs.get('atk') and move_count.get('PhysicalAttack', 0) >= 1:
741
+ secondary_stat = 'atk'
742
+ elif not evs.get('spa') and move_count.get('SpecialAttack', 0) >= 1:
743
+ secondary_stat = 'spa'
744
+ elif stats.hp == 1 and not evs.get('def'):
745
+ secondary_stat = 'def'
746
+ elif stats.defense == stats.special_defense and not evs.get('spd'):
747
+ secondary_stat = 'spd'
748
+ elif not evs.get('spd'):
749
+ secondary_stat = 'spd'
750
+ elif not evs.get('def'):
751
+ secondary_stat = 'def'
752
+
753
+ if secondary_stat:
754
+ ev = remaining
755
+ stat_value = self.get_stat(secondary_stat, pokemon_set, ev)
756
+
757
+ # Reduce EVs until we get a different stat value
758
+ while ev > 0 and stat_value == self.get_stat(secondary_stat, pokemon_set, ev - 4):
759
+ ev -= 4
760
+
761
+ if ev > 0:
762
+ evs[secondary_stat] = ev
763
+ remaining -= ev
764
+
765
+ # Distribute any remaining EVs to speed
766
+ if remaining > 0 and not evs.get('spe'):
767
+ ev = remaining
768
+ stat_value = self.get_stat('spe', pokemon_set, ev)
769
+
770
+ while ev > 0 and stat_value == self.get_stat('spe', pokemon_set, ev - 4):
771
+ ev -= 4
772
+
773
+ if ev > 0:
774
+ evs['spe'] = ev
775
+
776
+ return evs
777
+
778
+ def _ensure_min_evs(self, evs, stat, min_evs):
779
+ """Ensure a stat has at least the minimum EVs."""
780
+ if not evs.get(stat):
781
+ evs[stat] = 0
782
+
783
+ diff = min_evs - evs[stat]
784
+ if diff <= 0:
785
+ return evs
786
+
787
+ ev_total = sum(evs.values())
788
+
789
+ if ev_total <= 504:
790
+ change = min(508 - ev_total, diff)
791
+ ev_total += change
792
+ evs[stat] += change
793
+ diff -= change
794
+
795
+ if diff <= 0:
796
+ return evs
797
+
798
+ # Try to take EVs from other stats
799
+ ev_priority = {'def': 1, 'spd': 1, 'hp': 1, 'atk': 1, 'spa': 1, 'spe': 1}
800
+
801
+ for prio_stat in ev_priority:
802
+ if prio_stat == stat:
803
+ continue
804
+ if evs.get(prio_stat, 0) > 128:
805
+ evs[prio_stat] -= diff
806
+ evs[stat] += diff
807
+ break
808
+
809
+ return evs
810
+
811
+ def _ensure_max_evs(self, evs, stat, max_evs):
812
+ """Ensure a stat has at most the maximum EVs."""
813
+ if not evs.get(stat):
814
+ evs[stat] = 0
815
+
816
+ diff = evs[stat] - max_evs
817
+ if diff <= 0:
818
+ return evs
819
+
820
+ evs[stat] -= diff
821
+ return evs
822
+
823
+ def _determine_minus_stat(self, has_move, move_count, stats, evs, plus_stat):
824
+ """Determine which stat should be reduced by nature."""
825
+ if has_move.get('gyroball') or has_move.get('trickroom'):
826
+ return 'spe'
827
+ elif not move_count.get('PhysicalAttack'):
828
+ return 'atk'
829
+ elif move_count.get('SpecialAttack', 0) < 1 and not evs.get('spa'):
830
+ if move_count.get('SpecialAttack', 0) < move_count.get('PhysicalAttack', 0):
831
+ return 'spa'
832
+ elif not evs.get('atk'):
833
+ return 'atk'
834
+ elif move_count.get('PhysicalAttack', 0) < 1 and not evs.get('atk'):
835
+ return 'atk'
836
+ elif stats.defense > stats.speed and stats.special_defense > stats.speed and not evs.get('spe'):
837
+ return 'spe'
838
+ elif stats.defense > stats.special_defense:
839
+ return 'spd'
840
+ else:
841
+ return 'def'
842
+
843
+ def get_stat(self, stat: str, pokemon_set: PokemonSet, ev_override: Optional[int] = None, nature_override: Optional[float] = None) -> int:
844
+ """
845
+ Calculate a Pokemon's stat value.
846
+
847
+ Args:
848
+ stat: The stat to calculate
849
+ pokemon_set: The Pokemon set
850
+ ev_override: Override EV value
851
+ nature_override: Override nature modifier
852
+
853
+ Returns:
854
+ The calculated stat value
855
+ """
856
+ try:
857
+ species = self.localdex.get_pokemon(pokemon_set.species)
858
+ except:
859
+ return 0
860
+
861
+ level = pokemon_set.level or 100
862
+
863
+ # Map stat names to base_stats attributes
864
+ stat_mapping = {
865
+ 'hp': 'hp',
866
+ 'atk': 'attack',
867
+ 'def': 'defense',
868
+ 'spa': 'special_attack',
869
+ 'spd': 'special_defense',
870
+ 'spe': 'speed'
871
+ }
872
+
873
+ base_stat_name = stat_mapping.get(stat, stat)
874
+ base_stat = getattr(species.base_stats, base_stat_name, 0)
875
+
876
+ # Get IV
877
+ iv = pokemon_set.ivs.get(stat, 31)
878
+
879
+ # Get EV
880
+ ev = pokemon_set.evs.get(stat, 0)
881
+ if ev_override is not None:
882
+ ev = ev_override
883
+
884
+ # Calculate stat using StatCalculator
885
+ if stat == 'hp':
886
+ return self.stat_calculator.calculate_hp(base_stat, iv, ev, level)
887
+ else:
888
+ # Apply nature modifier
889
+ nature_modifier = 1.0
890
+ if nature_override:
891
+ nature_modifier = nature_override
892
+ elif pokemon_set.nature:
893
+ nature_mod = NATURE_MODIFIERS.get(pokemon_set.nature, {})
894
+ if nature_mod.get('plus') == base_stat_name:
895
+ nature_modifier = 1.1
896
+ elif nature_mod.get('minus') == base_stat_name:
897
+ nature_modifier = 0.9
898
+
899
+ return self.stat_calculator.calculate_other_stat(base_stat, iv, ev, level, nature_modifier)
900
+
901
+ def _to_id(self, text: Optional[str]) -> str:
902
+ """Convert text to ID format (lowercase, no spaces)."""
903
+ if not text:
904
+ return ""
905
+ return text.lower().replace(' ', '').replace('-', '')
906
+
907
+
908
+ def battle_stat_optimizer(pokemon_set: PokemonSet, format_id: str, localdex) -> Optional[Dict[str, Any]]:
909
+ """
910
+ Optimize a Pokemon's EV spread and nature.
911
+
912
+ This replicates the functionality from the TypeScript BattleStatOptimizer function.
913
+
914
+ Args:
915
+ pokemon_set: The Pokemon set to optimize
916
+ format_id: The format ID
917
+ localdex: LocalDex instance
918
+
919
+ Returns:
920
+ Optimized spread or None if no optimization is possible
921
+ """
922
+ if not pokemon_set.evs:
923
+ return None
924
+
925
+ # This is a placeholder - the full implementation would be quite complex
926
+ # and would need to implement the full optimization logic from the TypeScript version
927
+
928
+ return None