@@ -180,16 +180,15 @@ def _stat_estimation(self, mon: Pokemon, stat: str):
180
180
181
181
def choose_move (self , battle : AbstractBattle ):
182
182
if isinstance (battle , DoubleBattle ):
183
- return self .choose_random_doubles_move (battle )
183
+ return self .choose_doubles_move (battle )
184
184
185
- # Main mons shortcuts
185
+ # Singles logic
186
186
active = battle .active_pokemon
187
187
opponent = battle .opponent_active_pokemon
188
188
189
189
if active is None or opponent is None :
190
190
return self .choose_random_move (battle )
191
191
192
- # Rough estimation of damage ratio
193
192
physical_ratio = self ._stat_estimation (active , "atk" ) / self ._stat_estimation (
194
193
opponent , "def"
195
194
)
@@ -270,3 +269,180 @@ def choose_move(self, battle: AbstractBattle):
270
269
)
271
270
272
271
return self .choose_random_move (battle )
272
+
273
+ def choose_doubles_move (self , battle : DoubleBattle ):
274
+ if any (battle .force_switch ):
275
+ return self .choose_random_doubles_move (battle )
276
+
277
+ orders : List [Optional [BattleOrder ]] = []
278
+ switched_in = None
279
+
280
+ # For convenience, alias opponent active Pokémon
281
+ opp1 = (
282
+ battle .opponent_active_pokemon [0 ]
283
+ if len (battle .opponent_active_pokemon ) > 0
284
+ else None
285
+ )
286
+ opp2 = (
287
+ battle .opponent_active_pokemon [1 ]
288
+ if len (battle .opponent_active_pokemon ) > 1
289
+ else None
290
+ )
291
+
292
+ for mon , moves , switches in zip (
293
+ battle .active_pokemon , battle .available_moves , battle .available_switches
294
+ ):
295
+ if mon is None or mon .fainted :
296
+ orders .append (None )
297
+ continue
298
+
299
+ filtered_switches = [s for s in switches if s != switched_in ]
300
+
301
+ # Compute worst-case matchup against both opponents
302
+ score1 = (
303
+ self ._estimate_matchup (mon , opp1 ) if opp1 and not opp1 .fainted else 0
304
+ )
305
+ score2 = (
306
+ self ._estimate_matchup (mon , opp2 ) if opp2 and not opp2 .fainted else 0
307
+ )
308
+ worst_matchup = min (score1 , score2 )
309
+
310
+ # If matchup is poor and there is a better switch option, switch out.
311
+ if filtered_switches and worst_matchup < self .SWITCH_OUT_MATCHUP_THRESHOLD :
312
+ best_switch = max (
313
+ filtered_switches ,
314
+ key = lambda s : min (
315
+ (
316
+ self ._estimate_matchup (s , opp1 )
317
+ if opp1 and not opp1 .fainted
318
+ else float ("-inf" )
319
+ ),
320
+ (
321
+ self ._estimate_matchup (s , opp2 )
322
+ if opp2 and not opp2 .fainted
323
+ else float ("-inf" )
324
+ ),
325
+ ),
326
+ )
327
+ orders .append (BattleOrder (best_switch ))
328
+ switched_in = best_switch
329
+ continue
330
+
331
+ # Evaluate moves with heuristics tuned for doubles:
332
+ if moves :
333
+ best_move = None
334
+ best_score = float ("-inf" )
335
+ for move in moves :
336
+ # Check if the move can hit both opponents
337
+ double_target = (
338
+ move .target in {Target .NORMAL , Target .ANY }
339
+ and opp1
340
+ and opp2
341
+ and not opp1 .fainted
342
+ and not opp2 .fainted
343
+ )
344
+ move_power = move .base_power * (1.5 if double_target else 1 )
345
+
346
+ # For damage ratios, compute estimates against both opponents and take the minimum.
347
+ if move .category == MoveCategory .PHYSICAL :
348
+ ratio1 = self ._stat_estimation (mon , "atk" ) / (
349
+ self ._stat_estimation (opp1 , "def" )
350
+ if opp1 and not opp1 .fainted
351
+ else 1
352
+ )
353
+ ratio2 = self ._stat_estimation (mon , "atk" ) / (
354
+ self ._stat_estimation (opp2 , "def" )
355
+ if opp2 and not opp2 .fainted
356
+ else 1
357
+ )
358
+ elif move .category == MoveCategory .SPECIAL :
359
+ ratio1 = self ._stat_estimation (mon , "spa" ) / (
360
+ self ._stat_estimation (opp1 , "spd" )
361
+ if opp1 and not opp1 .fainted
362
+ else 1
363
+ )
364
+ ratio2 = self ._stat_estimation (mon , "spa" ) / (
365
+ self ._stat_estimation (opp2 , "spd" )
366
+ if opp2 and not opp2 .fainted
367
+ else 1
368
+ )
369
+ else :
370
+ ratio1 = ratio2 = 1
371
+ move_ratio = min (ratio1 , ratio2 )
372
+
373
+ # Combine the factors: base power, ratio, accuracy, expected hits,
374
+ # and the worst-case damage multiplier against opponents.
375
+ base_score = (
376
+ move_power
377
+ * move_ratio
378
+ * move .accuracy
379
+ * move .expected_hits
380
+ * min (
381
+ (
382
+ opp1 .damage_multiplier (move )
383
+ if opp1 and not opp1 .fainted
384
+ else 1
385
+ ),
386
+ (
387
+ opp2 .damage_multiplier (move )
388
+ if opp2 and not opp2 .fainted
389
+ else 1
390
+ ),
391
+ )
392
+ )
393
+
394
+ # Bonus for setup moves that boost stats significantly.
395
+ if (
396
+ move .boosts
397
+ and sum (move .boosts .values ()) >= 2
398
+ and move .target == "self"
399
+ ):
400
+ base_score *= 1.2
401
+
402
+ if base_score > best_score :
403
+ best_score = base_score
404
+ best_move = move
405
+
406
+ if best_move :
407
+ targets = battle .get_possible_showdown_targets (best_move , mon )
408
+ if best_move .target in {Target .NORMAL , Target .ANY }:
409
+ possible_targets = [
410
+ t
411
+ for t in targets
412
+ if t
413
+ in {battle .OPPONENT_1_POSITION , battle .OPPONENT_2_POSITION }
414
+ ]
415
+ target = (
416
+ random .choice (possible_targets )
417
+ if possible_targets
418
+ else random .choice (targets )
419
+ )
420
+ else :
421
+ target = random .choice (targets )
422
+ orders .append (BattleOrder (best_move , move_target = target ))
423
+ continue
424
+
425
+ # Fallback: if no moves are available, switch if possible.
426
+ if filtered_switches :
427
+ best_switch = max (
428
+ filtered_switches ,
429
+ key = lambda s : min (
430
+ (
431
+ self ._estimate_matchup (s , opp1 )
432
+ if opp1 and not opp1 .fainted
433
+ else float ("-inf" )
434
+ ),
435
+ (
436
+ self ._estimate_matchup (s , opp2 )
437
+ if opp2 and not opp2 .fainted
438
+ else float ("-inf" )
439
+ ),
440
+ ),
441
+ )
442
+ orders .append (BattleOrder (best_switch ))
443
+ else :
444
+ orders .append (DefaultBattleOrder ())
445
+
446
+ if orders and len (orders ) >= 2 :
447
+ return DoubleBattleOrder (orders [0 ], orders [1 ])
448
+ return self .choose_random_doubles_move (battle )
0 commit comments