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.
- localdex/__init__.py +6 -0
- localdex/core.py +45 -4
- localdex/data/items/crystal-cluster.json +3 -0
- localdex/name_normalizer.py +12 -1
- localdex/stat_guesser.py +928 -0
- localdex-0.3.1.dist-info/METADATA +135 -0
- {localdex-0.3.0.dist-info → localdex-0.3.1.dist-info}/RECORD +11 -10
- localdex/data/items/cyrstal-cluster.json +0 -3
- localdex-0.3.0.dist-info/METADATA +0 -196
- {localdex-0.3.0.dist-info → localdex-0.3.1.dist-info}/WHEEL +0 -0
- {localdex-0.3.0.dist-info → localdex-0.3.1.dist-info}/entry_points.txt +0 -0
- {localdex-0.3.0.dist-info → localdex-0.3.1.dist-info}/licenses/LICENSE +0 -0
- {localdex-0.3.0.dist-info → localdex-0.3.1.dist-info}/top_level.txt +0 -0
localdex/stat_guesser.py
ADDED
@@ -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
|