@pkmn/engine 0.1.0-dev.84cc1a02 → 0.1.0-dev.93b7cb0d

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.
@@ -56,30 +56,34 @@ const BOOSTS = &[_][2]u8{
56
56
 
57
57
  const MAX_STAT_VALUE = 999;
58
58
 
59
+ const DISTRIBUTION = [_]u3{ 2, 2, 2, 3, 3, 3, 4, 5 };
60
+
59
61
  pub fn update(battle: anytype, c1: Choice, c2: Choice, log: anytype) !Result {
60
62
  assert(c1.type != .Pass or c2.type != .Pass or battle.turn == 0);
61
63
  if (battle.turn == 0) return start(battle, log);
62
64
 
65
+ var s1 = false;
66
+ var s2 = false;
63
67
  var f1: ?Move = null;
64
68
  var f2: ?Move = null;
65
69
 
66
- if (selectMove(battle, .P1, c1, c2, &f1)) |r| return r;
67
- if (selectMove(battle, .P2, c2, c1, &f2)) |r| return r;
70
+ if (selectMove(battle, .P1, c1, c2, &s1, &f1)) |r| return r;
71
+ if (selectMove(battle, .P2, c2, c1, &s2, &f2)) |r| return r;
72
+
73
+ var p1 = battle.side(.P1);
74
+ var p2 = battle.side(.P2);
75
+
76
+ var r1 = showdown and p1.active.volatiles.Binding and c2.type == .Switch;
77
+ var r2 = showdown and p2.active.volatiles.Binding and c1.type == .Switch;
68
78
 
69
79
  if (turnOrder(battle, c1, c2) == .P1) {
70
- if (try doTurn(battle, .P1, c1, f1, .P2, c2, f2, log)) |r| return r;
80
+ if (try doTurn(battle, .P1, c1, r1, s1, f1, .P2, c2, r2, s2, f2, log)) |r| return r;
71
81
  } else {
72
- if (try doTurn(battle, .P2, c2, f2, .P1, c1, f1, log)) |r| return r;
82
+ if (try doTurn(battle, .P2, c2, r2, s2, f2, .P1, c1, r1, s1, f1, log)) |r| return r;
73
83
  }
74
84
 
75
- var p1 = battle.side(.P1);
76
- if (p1.active.volatiles.attacks == 0) {
77
- p1.active.volatiles.Binding = false;
78
- }
79
- var p2 = battle.side(.P2);
80
- if (p2.active.volatiles.attacks == 0) {
81
- p2.active.volatiles.Binding = false;
82
- }
85
+ if (p1.active.volatiles.attacks == 0) p1.active.volatiles.Binding = false;
86
+ if (p2.active.volatiles.attacks == 0) p2.active.volatiles.Binding = false;
83
87
 
84
88
  return endTurn(battle, log);
85
89
  }
@@ -112,6 +116,7 @@ fn selectMove(
112
116
  player: Player,
113
117
  choice: Choice,
114
118
  foe_choice: Choice,
119
+ skip_turn: *bool,
115
120
  from: *?Move,
116
121
  ) ?Result {
117
122
  if (choice.type == .Pass) return null;
@@ -124,17 +129,26 @@ fn selectMove(
124
129
  (choice.type == .Move and choice.data == @boolToInt(showdown)));
125
130
 
126
131
  // pre-battle menu
127
- if (volatiles.Recharging) return null;
132
+ if (volatiles.Recharging) {
133
+ if (showdown and battle.foe(player).active.volatiles.Binding) skip_turn.* = true;
134
+ return null;
135
+ }
128
136
  if (volatiles.Rage) {
129
137
  from.* = side.last_used_move;
130
- if (showdown) saveMove(battle, player, null);
138
+ if (showdown) {
139
+ if (battle.foe(player).active.volatiles.Binding) skip_turn.* = true;
140
+ saveMove(battle, player, null);
141
+ }
131
142
  return null;
132
143
  }
133
144
  // Pokémon Showdown removes Flinch at the end-of-turn in its residual handler
134
145
  if (!showdown) volatiles.Flinch = false;
135
146
  if (volatiles.Thrashing or volatiles.Charging) {
136
147
  from.* = side.last_selected_move;
137
- if (showdown) saveMove(battle, player, null);
148
+ if (showdown) {
149
+ if (battle.foe(player).active.volatiles.Binding) skip_turn.* = true;
150
+ saveMove(battle, player, null);
151
+ }
138
152
  return null;
139
153
  }
140
154
 
@@ -144,7 +158,10 @@ fn selectMove(
144
158
  // pre-move select
145
159
  if (Status.is(stored.status, .FRZ) or Status.is(stored.status, .SLP) or volatiles.Bide) {
146
160
  assert(showdown or choice.data == 0);
147
- if (showdown) saveMove(battle, player, choice);
161
+ if (showdown) {
162
+ if (volatiles.Bide and battle.foe(player).active.volatiles.Binding) skip_turn.* = true;
163
+ saveMove(battle, player, choice);
164
+ }
148
165
  return null;
149
166
  }
150
167
  if (volatiles.Binding) {
@@ -163,9 +180,9 @@ fn selectMove(
163
180
  // GLITCH: https://glitchcity.wiki/Partial_trapping_move_Mirror_Move_link_battle_glitch
164
181
  if (foe_choice.type == .Switch) {
165
182
  const slot = if (player == .P1)
166
- battle.last_selected_indexes.p1
183
+ battle.last_moves.p1_index
167
184
  else
168
- battle.last_selected_indexes.p2;
185
+ battle.last_moves.p2_index;
169
186
  const last = side.active.move(@intCast(u4, slot));
170
187
  if (last.id == .Metronome) side.last_selected_move = last.id;
171
188
  if (last.id == .MirrorMove) return Result.Error;
@@ -175,6 +192,7 @@ fn selectMove(
175
192
  }
176
193
 
177
194
  if (battle.foe(player).active.volatiles.Binding) {
195
+ skip_turn.* = true;
178
196
  if (showdown) {
179
197
  saveMove(battle, player, choice);
180
198
  } else {
@@ -185,6 +203,7 @@ fn selectMove(
185
203
  }
186
204
 
187
205
  // move select
206
+ volatiles.state = 0;
188
207
  if (choice.data == 0) {
189
208
  const struggle = ok: {
190
209
  for (side.active.moves, 0..) |move, i| {
@@ -218,9 +237,9 @@ fn saveMove(battle: anytype, player: Player, choice: ?Choice) void {
218
237
 
219
238
  side.last_selected_move = move.id;
220
239
  if (player == .P1) {
221
- battle.last_selected_indexes.p1 = @intCast(u4, c.data);
240
+ battle.last_moves.p1_index = @intCast(u3, c.data);
222
241
  } else {
223
- battle.last_selected_indexes.p2 = @intCast(u4, c.data);
242
+ battle.last_moves.p2_index = @intCast(u3, c.data);
224
243
  }
225
244
  }
226
245
  }
@@ -240,9 +259,9 @@ fn switchIn(battle: anytype, player: Player, slot: u8, initial: bool, log: anyty
240
259
  side.order[slot - 1] = out;
241
260
 
242
261
  if (player == .P1) {
243
- battle.last_selected_indexes.p1 = 1;
262
+ battle.last_moves.p1_index = 1;
244
263
  } else {
245
- battle.last_selected_indexes.p2 = 1;
264
+ battle.last_moves.p2_index = 1;
246
265
  }
247
266
 
248
267
  side.last_used_move = .None;
@@ -299,13 +318,11 @@ fn turnOrder(battle: anytype, c1: Choice, c2: Choice) Player {
299
318
  const spe2 = battle.side(.P2).active.stats.spe;
300
319
  if (spe1 == spe2) {
301
320
  // Pokémon Showdown's beforeTurnCallback shenanigans
302
- if (showdown and m1 == .Counter and m2 == .Counter) battle.rng.advance(1);
303
-
304
- const p1 = if (showdown)
305
- battle.rng.range(u8, 0, 2) == 0
306
- else
307
- battle.rng.next() < Gen12.percent(50) + 1;
321
+ if (showdown and !double_switch and m1 == .Counter and m2 == .Counter) {
322
+ battle.rng.advance(1);
323
+ }
308
324
 
325
+ const p1 = Rolls.speedTie(battle);
309
326
  if (!showdown) return if (p1) .P1 else .P2;
310
327
 
311
328
  // Pokémon Showdown's "lockedmove" volatile's onBeforeTurn uses BattleQueue#changeAction,
@@ -315,7 +332,7 @@ fn turnOrder(battle: anytype, c1: Choice, c2: Choice) Player {
315
332
  const t1 = battle.side(.P1).active.volatiles.Thrashing;
316
333
  const t2 = battle.side(.P2).active.volatiles.Thrashing;
317
334
  // If *both* sides are thrashing it really should be another speed tie, but we've patched
318
- // that out and enforce host ordering of events, so P1 just goes first reqgardless of who
335
+ // that out and enforce host ordering of events, so P1 just goes first regardless of who
319
336
  // won the original coin flip
320
337
  if (t1 and t2) return .P1;
321
338
  return if (p1) if (t1 and !t2) .P2 else .P1 else if (t2 and !t1) .P1 else .P2;
@@ -328,9 +345,13 @@ fn doTurn(
328
345
  battle: anytype,
329
346
  player: Player,
330
347
  player_choice: Choice,
348
+ player_rewrap: bool,
349
+ player_skip: bool,
331
350
  player_from: ?Move,
332
351
  foe_player: Player,
333
352
  foe_choice: Choice,
353
+ foe_rewrap: bool,
354
+ foe_skip: bool,
334
355
  foe_from: ?Move,
335
356
  log: anytype,
336
357
  ) !?Result {
@@ -338,7 +359,16 @@ fn doTurn(
338
359
 
339
360
  var residual = true;
340
361
  var replace = battle.side(player).stored().hp == 0;
341
- if (try executeMove(battle, player, player_choice, player_from, &residual, log)) |r| return r;
362
+ if (try executeMove(
363
+ battle,
364
+ player,
365
+ player_choice,
366
+ player_rewrap,
367
+ player_skip,
368
+ player_from,
369
+ &residual,
370
+ log,
371
+ )) |r| return r;
342
372
  if (!replace) {
343
373
  if (player_choice.type != .Switch) {
344
374
  if (try checkFaint(battle, foe_player, log)) |r| return r;
@@ -349,7 +379,16 @@ fn doTurn(
349
379
 
350
380
  residual = true;
351
381
  replace = battle.side(foe_player).stored().hp == 0;
352
- if (try executeMove(battle, foe_player, foe_choice, foe_from, &residual, log)) |r| return r;
382
+ if (try executeMove(
383
+ battle,
384
+ foe_player,
385
+ foe_choice,
386
+ foe_rewrap,
387
+ foe_skip,
388
+ foe_from,
389
+ &residual,
390
+ log,
391
+ )) |r| return r;
353
392
  if (!replace) {
354
393
  if (foe_choice.type != .Switch) {
355
394
  if (try checkFaint(battle, player, log)) |r| return r;
@@ -372,6 +411,8 @@ fn executeMove(
372
411
  battle: anytype,
373
412
  player: Player,
374
413
  choice: Choice,
414
+ rewrap: bool,
415
+ skip: bool,
375
416
  from: ?Move,
376
417
  residual: *bool,
377
418
  log: anytype,
@@ -383,9 +424,10 @@ fn executeMove(
383
424
  return null;
384
425
  }
385
426
 
427
+ // This is the correct place to check for SKIP_TURN and abort early, however since Pokémon
428
+ // Showdown overwrites the SKIP_TURN sentinel with its botched move select we need to add an
429
+ // additional skip boolean to accomplish the same thing in the Binding check of BeforeMove
386
430
  if (side.last_selected_move == .SKIP_TURN) {
387
- // Pokémon Showdown overwrites the SKIP_TURN sentinel with its botched move select,
388
- // Binding instead gets handled in beforeMove after sleep and freeze
389
431
  assert(!showdown);
390
432
  if (battle.foe(player).active.volatiles.Binding) {
391
433
  try log.cant(battle.active(player), .Bound);
@@ -404,9 +446,9 @@ fn executeMove(
404
446
  // choice.data == 0 only happens with Struggle on Pokémon Showdown
405
447
  assert(!showdown);
406
448
  mslot = @intCast(u4, if (player == .P1)
407
- battle.last_selected_indexes.p1
449
+ battle.last_moves.p1_index
408
450
  else
409
- battle.last_selected_indexes.p2);
451
+ battle.last_moves.p2_index);
410
452
  const stored = side.stored();
411
453
  // GLITCH: Struggle bypass PP underflow via Hyper Beam / Trapping-switch auto selection
412
454
  auto = side.last_selected_move == .HyperBeam or side.active.volatiles.Bide or
@@ -428,14 +470,14 @@ fn executeMove(
428
470
  // Incorrect mslot with Pokémon Showdown choice semantics so we need to recover from index
429
471
  assert(mslot == 1);
430
472
  mslot = @intCast(u4, if (player == .P1)
431
- battle.last_selected_indexes.p1
473
+ battle.last_moves.p1_index
432
474
  else
433
- battle.last_selected_indexes.p2);
475
+ battle.last_moves.p2_index);
434
476
  }
435
477
 
436
478
  var skip_can = false;
437
479
  var skip_pp = false;
438
- switch (try beforeMove(battle, player, from, residual, log)) {
480
+ switch (try beforeMove(battle, player, skip, from, residual, log)) {
439
481
  .done => return null,
440
482
  .skip_can => skip_can = true,
441
483
  .skip_pp => skip_pp = true,
@@ -447,14 +489,15 @@ fn executeMove(
447
489
  return null;
448
490
  }
449
491
 
450
- return doMove(battle, player, mslot, auto, residual, log);
492
+ return doMove(battle, player, mslot, rewrap, auto, residual, log);
451
493
  }
452
494
 
453
- const BeforeMove = union(enum) { done, skip_can, skip_pp, ok, err };
495
+ const BeforeMove = enum { done, skip_can, skip_pp, ok, err };
454
496
 
455
497
  fn beforeMove(
456
498
  battle: anytype,
457
499
  player: Player,
500
+ skip: bool,
458
501
  from: ?Move,
459
502
  residual: *bool,
460
503
  log: anytype,
@@ -486,13 +529,15 @@ fn beforeMove(
486
529
  return .done;
487
530
  }
488
531
 
489
- if (foe.active.volatiles.Binding) {
532
+ if (skip or foe.active.volatiles.Binding) {
490
533
  try log.cant(ident, .Bound);
491
534
  return .done;
492
535
  }
493
536
 
494
- if (!showdown and volatiles.Flinch) {
495
- volatiles.Flinch = false;
537
+ if (volatiles.Flinch) {
538
+ // Pokémon Showdown doesn't clear Flinch until its imaginary "residual" phase, meaning
539
+ // Pokémon can sometimes flinch multiple times from the same original hit
540
+ if (!showdown) volatiles.Flinch = false;
496
541
  try log.cant(ident, .Flinch);
497
542
  return .done;
498
543
  }
@@ -513,13 +558,6 @@ fn beforeMove(
513
558
  // Pokémon Showdown's disable condition has a single onBeforeMove handler
514
559
  if (showdown and try disabled(side, ident, log)) return .done;
515
560
 
516
- // Pokémon Showdown checks for Flinch *after* instead of before
517
- if (showdown and volatiles.Flinch) {
518
- volatiles.Flinch = false;
519
- try log.cant(ident, .Flinch);
520
- return .done;
521
- }
522
-
523
561
  // This can only happen if a Pokémon started the battle frozen/sleeping and was thawed/woken
524
562
  // before the side had a selected a move - we simply need to assume this leads to a desync
525
563
  if (side.last_selected_move == .None) {
@@ -537,12 +575,7 @@ fn beforeMove(
537
575
  } else {
538
576
  try log.activate(ident, .Confusion);
539
577
 
540
- const confused = if (showdown)
541
- !battle.rng.chance(u8, 128, 256)
542
- else
543
- battle.rng.next() >= Gen12.percent(50) + 1;
544
-
545
- if (confused) {
578
+ if (Rolls.confused(battle)) {
546
579
  assert(!volatiles.MultiHit);
547
580
  if (!volatiles.Rage) volatiles.state = 0;
548
581
  volatiles.Bide = false;
@@ -568,8 +601,12 @@ fn beforeMove(
568
601
  player
569
602
  else
570
603
  player.foe();
604
+
605
+ const uncapped = battle.last_damage;
571
606
  // Skipping adjustDamage / randomizeDamage / checkHit
572
607
  _ = try applyDamage(battle, player, target, .Confusion, log);
608
+ // Pokémon Showdown thinks that confusion damage is uncapped ¯\_(ツ)_/¯
609
+ if (showdown) battle.last_damage = uncapped;
573
610
 
574
611
  return .done;
575
612
  }
@@ -578,28 +615,28 @@ fn beforeMove(
578
615
 
579
616
  if (!showdown and try disabled(side, ident, log)) return .done;
580
617
 
581
- if (Status.is(stored.status, .PAR)) {
582
- const paralyzed = if (showdown)
583
- battle.rng.chance(u8, 63, 256)
584
- else
585
- battle.rng.next() < Gen12.percent(25);
586
-
587
- if (paralyzed) {
588
- if (!volatiles.Rage) volatiles.state = 0;
589
- volatiles.Bide = false;
590
- volatiles.Thrashing = false;
591
- volatiles.Charging = false;
592
- volatiles.Binding = false;
593
- // GLITCH: Invulnerable is not cleared, resulting in permanent Fly/Dig invulnerability
594
- try log.cant(ident, .Paralysis);
595
- return .done;
596
- }
618
+ if (Status.is(stored.status, .PAR) and Rolls.paralyzed(battle)) {
619
+ if (!volatiles.Rage) volatiles.state = 0;
620
+ volatiles.Bide = false;
621
+ volatiles.Thrashing = false;
622
+ volatiles.Charging = false;
623
+ volatiles.Binding = false;
624
+ // GLITCH: Invulnerable is not cleared, resulting in permanent Fly/Dig invulnerability
625
+ try log.cant(ident, .Paralysis);
626
+ return .done;
597
627
  }
598
628
 
599
629
  if (volatiles.Bide) {
600
630
  assert(!volatiles.Thrashing and !volatiles.Rage);
601
631
 
602
- volatiles.state +%= battle.last_damage;
632
+ if (showdown) {
633
+ // Pokémon Showdown doesn't implement Bide potentially overflowing in the event of
634
+ // OHKO-move damage, but we can fake this incorrect behavior by simply saturating the
635
+ // addition because 65535 is sufficient to faint any Pokémon anyway
636
+ volatiles.state +|= battle.last_damage;
637
+ } else {
638
+ volatiles.state +%= battle.last_damage;
639
+ }
603
640
 
604
641
  assert(volatiles.attacks > 0);
605
642
 
@@ -620,8 +657,9 @@ fn beforeMove(
620
657
  return .done;
621
658
  }
622
659
 
660
+ const sub = showdown and foe.active.volatiles.Substitute;
623
661
  _ = try applyDamage(battle, player.foe(), player.foe(), .None, log);
624
- if (foe.stored().hp > 0) try buildRage(battle, player.foe(), log);
662
+ if (foe.stored().hp > 0 and !sub) try buildRage(battle, player.foe(), log);
625
663
 
626
664
  // For reasons passing understanding, Pokémon Showdown still inflicts residual damage to
627
665
  // Bide's user even if the above damage has caused the foe to faint. It's simpler to always
@@ -642,8 +680,13 @@ fn beforeMove(
642
680
  try log.start(battle.active(player), .ConfusionSilent);
643
681
  }
644
682
  try log.move(ident, side.last_selected_move, battle.active(player.foe()), from);
645
- if (showdown and handleThrashing(battle, active)) {
646
- try log.start(battle.active(player), .ConfusionSilent);
683
+ if (showdown) {
684
+ // This shouldn't actually set last_used_move, but Pokémon Showdown sets last
685
+ // used in useMove and doesn't have the notion of skipping canMove semantics
686
+ side.last_used_move = side.last_selected_move;
687
+ if (handleThrashing(battle, active)) {
688
+ try log.start(battle.active(player), .ConfusionSilent);
689
+ }
647
690
  }
648
691
  return .skip_can;
649
692
  }
@@ -658,7 +701,11 @@ fn beforeMove(
658
701
  side.last_selected_move,
659
702
  );
660
703
  if (showdown or battle.last_damage != 0) {
704
+ const sub = showdown and foe.active.volatiles.Substitute;
661
705
  _ = try applyDamage(battle, player.foe(), player.foe(), .None, log);
706
+ if (battle.foe(player).stored().hp > 0 and !sub) {
707
+ try buildRage(battle, player.foe(), log);
708
+ }
662
709
  }
663
710
  return .done;
664
711
  }
@@ -690,6 +737,7 @@ fn canMove(
690
737
  side.active.volatiles.Invulnerable = false;
691
738
  } else if (move.effect == .Charge) {
692
739
  try log.move(player_ident, side.last_selected_move, .{}, from);
740
+ setCounterable(battle, player, side, move);
693
741
  try Effects.charge(battle, player, log);
694
742
  return false;
695
743
  }
@@ -706,6 +754,7 @@ fn canMove(
706
754
  // regrettable, but this information is more "nice to have" than required
707
755
  const f = if (from != null and from.? == .None) null else from;
708
756
  try log.move(player_ident, side.last_selected_move, battle.active(target), f);
757
+ setCounterable(battle, player, side, move);
709
758
 
710
759
  if (move.effect.onBegin()) {
711
760
  try onBegin(battle, player, move, mslot, residual, log);
@@ -716,12 +765,27 @@ fn canMove(
716
765
  Effects.thrashing(battle, player);
717
766
  } else if (!showdown and move.effect == .Binding) {
718
767
  // Pokémon Showdown handles this after hit/miss checks and damage calculation
719
- Effects.binding(battle, player);
768
+ Effects.binding(battle, player, false);
720
769
  }
721
770
 
722
771
  return true;
723
772
  }
724
773
 
774
+ fn setCounterable(battle: anytype, player: Player, side: *Side, move: Move.Data) void {
775
+ // The Counter desync is caused by the cartridge not calling GetCurrentMove until now, meaning
776
+ // in cases where an early return happens the data for a players last selected move does not get
777
+ // reloaded and HandleCounterMove actually bases its success/failure off of stale information.
778
+ // This boolean state we track here doesn't exist on the cartridge because it instead manifests
779
+ // as the desync results from actually having two separate battle states that subtly disagree
780
+ const counterable = side.last_selected_move != .Counter and move.bp > 0 and
781
+ (move.type == .Normal or move.type == .Fighting);
782
+ if (player == .P1) {
783
+ battle.last_moves.p1_counterable = @boolToInt(counterable);
784
+ } else {
785
+ battle.last_moves.p2_counterable = @boolToInt(counterable);
786
+ }
787
+ }
788
+
725
789
  fn decrementPP(side: *Side, mslot: u4, auto: bool) void {
726
790
  if (side.last_selected_move == .Struggle) return;
727
791
 
@@ -757,6 +821,7 @@ fn doMove(
757
821
  battle: anytype,
758
822
  player: Player,
759
823
  mslot: u4,
824
+ rewrap: bool,
760
825
  auto: bool,
761
826
  residual: *bool,
762
827
  log: anytype,
@@ -787,12 +852,15 @@ fn doMove(
787
852
  (@enumToInt(move.type.effectiveness(foe.active.types.type1)) == 0 or
788
853
  @enumToInt(move.type.effectiveness(foe.active.types.type2)) == 0);
789
854
  if (immune and move.effect != .Binding) break :miss true;
790
- if (move.effect == .OHKO and side.active.stats.spe < foe.active.stats.spe) break :miss true;
855
+ if (move.effect == .OHKO and side.active.stats.spe < foe.active.stats.spe) {
856
+ battle.last_damage = 0;
857
+ break :miss true;
858
+ }
791
859
  break :miss move.target != .Self and !moveHit(battle, player, move, &immune, &mist);
792
860
  };
793
861
  assert(!immune or miss or (showdown and move.effect == .Binding));
794
862
 
795
- var late = showdown;
863
+ var late = showdown and move.effect != .Explode;
796
864
  const skip = status or immune;
797
865
  if ((!showdown or (!skip or counter)) and !miss) blk: {
798
866
  if (showdown and move.effect.isMulti()) {
@@ -845,9 +913,9 @@ fn doMove(
845
913
 
846
914
  if (!showdown or !miss) {
847
915
  if (move.effect == .MirrorMove) {
848
- return mirrorMove(battle, player, mslot, auto, residual, log);
916
+ return mirrorMove(battle, player, mslot, rewrap, auto, residual, log);
849
917
  } else if (move.effect == .Metronome) {
850
- return metronome(battle, player, mslot, auto, residual, log);
918
+ return metronome(battle, player, mslot, rewrap, auto, residual, log);
851
919
  } else if (move.effect.onEnd()) {
852
920
  try onEnd(battle, player, move, log);
853
921
  return null;
@@ -862,7 +930,7 @@ fn doMove(
862
930
  move.effect == .OHKO and side.active.stats.spe < foe.active.stats.spe;
863
931
  if (ohko) {
864
932
  try log.immune(foe_ident, .OHKO);
865
- } else if (immune and !invulnerable) {
933
+ } else if (immune and !invulnerable and (!showdown or move.effect != .Binding)) {
866
934
  try log.immune(foe_ident, .None);
867
935
  } else if (mist) {
868
936
  if (!foe.active.volatiles.Substitute) try log.activate(foe_ident, .Mist);
@@ -894,6 +962,9 @@ fn doMove(
894
962
  hits = side.active.volatiles.attacks;
895
963
  }
896
964
 
965
+ // Pokémon Showdown only builds Rage for Disable/Explosion (hit/miss) when attacking into a sub
966
+ const sub = showdown and foe.active.volatiles.Substitute;
967
+
897
968
  var nullified = false;
898
969
  var hit: u4 = 0;
899
970
  while (hit < hits) {
@@ -909,7 +980,7 @@ fn doMove(
909
980
  if (hit == 0 and ohko) try log.ohko();
910
981
  hit += 1;
911
982
  if (foe.stored().hp == 0) break;
912
- if (!late) try buildRage(battle, player.foe(), log);
983
+ if (!late and (!sub or move.effect == .Explode)) try buildRage(battle, player.foe(), log);
913
984
  // If the substitute breaks during a multi-hit attack, the attack ends
914
985
  if (nullified) break;
915
986
  }
@@ -925,11 +996,17 @@ fn doMove(
925
996
  } else if (showdown) {
926
997
  // This should be handled much earlier but Pokémon Showdown does it here... ¯\_(ツ)_/¯
927
998
  if (move.effect == .Binding) {
928
- Effects.binding(battle, player);
999
+ Effects.binding(battle, player, rewrap);
929
1000
  if (immune) {
930
1001
  battle.last_damage = 0;
1002
+ assert(foe.stored().hp > 0);
931
1003
  // Pokémon Showdown logs |-damage| here instead of |-immune| because logic...
932
- try log.damage(battle.active(player.foe()), foe.stored(), .None);
1004
+ if (sub) {
1005
+ try log.activate(battle.active(player.foe()), .Substitute);
1006
+ } else {
1007
+ try log.damage(battle.active(player.foe()), foe.stored(), .None);
1008
+ try buildRage(battle, player.foe(), log);
1009
+ }
933
1010
  return null;
934
1011
  }
935
1012
  }
@@ -946,8 +1023,8 @@ fn doMove(
946
1023
 
947
1024
  if (foe.stored().hp == 0) return null;
948
1025
 
949
- // Pokémon Showdown builds Rage at the wrong time for non-MultiHit moves
950
- if (late and move.effect != .Disable) try buildRage(battle, player.foe(), log);
1026
+ // Pokémon Showdown builds Rage at the wrong time for non-MultiHit move
1027
+ if (late and !sub and move.effect != .Disable) try buildRage(battle, player.foe(), log);
951
1028
 
952
1029
  if (!move.effect.isSpecial()) {
953
1030
  // On the cartridge Rage is not considered to be "special" and thus gets executed for a
@@ -980,8 +1057,7 @@ fn checkCriticalHit(battle: anytype, player: Player, move: Move.Data) bool {
980
1057
  chance = if (side.active.volatiles.FocusEnergy) chance / 2 else @min(chance * 2, 255);
981
1058
  chance = if (move.effect == .HighCritical) @min(chance * 4, 255) else chance / 2;
982
1059
 
983
- if (showdown) return battle.rng.chance(u8, @intCast(u8, chance), 256);
984
- return std.math.rotl(u8, battle.rng.next(), 3) < chance;
1060
+ return Rolls.criticalHit(battle, chance);
985
1061
  }
986
1062
 
987
1063
  fn calcDamage(
@@ -1085,50 +1161,36 @@ fn adjustDamage(battle: anytype, player: Player) u16 {
1085
1161
 
1086
1162
  fn randomizeDamage(battle: anytype) void {
1087
1163
  if (battle.last_damage <= 1) return;
1088
-
1089
- const random = if (showdown)
1090
- battle.rng.range(u8, 217, 256)
1091
- else loop: {
1092
- while (true) {
1093
- const r = std.math.rotr(u8, battle.rng.next(), 1);
1094
- if (r >= 217) break :loop r;
1095
- }
1096
- };
1097
-
1164
+ const random = Rolls.damage(battle);
1098
1165
  battle.last_damage = @intCast(u16, @as(u32, battle.last_damage) *% random / 255);
1099
1166
  }
1100
1167
 
1101
1168
  fn specialDamage(battle: anytype, player: Player, move: Move.Data, log: anytype) !?Result {
1102
1169
  const side = battle.side(player);
1170
+ const foe = battle.foe(player);
1103
1171
 
1104
1172
  if (!try checkHit(battle, player, move, log)) return null;
1105
1173
 
1106
1174
  battle.last_damage = switch (side.last_selected_move) {
1107
- .SuperFang => @max(battle.foe(player).stored().hp / 2, 1),
1175
+ .SuperFang => @max(foe.stored().hp / 2, 1),
1108
1176
  .SeismicToss, .NightShade => side.stored().level,
1109
1177
  .SonicBoom => 20,
1110
1178
  .DragonRage => 40,
1111
1179
  // GLITCH: if power = 0 then a desync occurs (or a miss on Pokémon Showdown)
1112
1180
  .Psywave => power: {
1113
1181
  const max = @intCast(u8, @as(u16, side.stored().level) * 3 / 2);
1114
- if (showdown) {
1115
- break :power battle.rng.range(u8, 0, max);
1116
- } else {
1117
- // GLITCH: Psywave infinite glitch loop
1118
- if (max <= 1) return Result.Error;
1119
- while (true) {
1120
- const r = battle.rng.next();
1121
- if (r < max) break :power r;
1122
- }
1123
- }
1182
+ // GLITCH: Psywave infinite glitch loop
1183
+ if (!showdown and max <= 1) return Result.Error;
1184
+ break :power Rolls.psywave(battle, max);
1124
1185
  },
1125
1186
  else => unreachable,
1126
1187
  };
1127
1188
 
1128
1189
  if (battle.last_damage == 0) return if (showdown) null else Result.Error;
1129
1190
 
1191
+ const sub = showdown and foe.active.volatiles.Substitute;
1130
1192
  _ = try applyDamage(battle, player.foe(), player.foe(), .None, log);
1131
- if (battle.foe(player).stored().hp > 0) try buildRage(battle, player.foe(), log);
1193
+ if (battle.foe(player).stored().hp > 0 and !sub) try buildRage(battle, player.foe(), log);
1132
1194
 
1133
1195
  return null;
1134
1196
  }
@@ -1141,19 +1203,17 @@ fn counterDamage(battle: anytype, player: Player, move: Move.Data, log: anytype)
1141
1203
  return null;
1142
1204
  }
1143
1205
 
1144
- // Pretend Counter was used as a stand-in when no move has been used to fail below with 0 BP
1145
- const foe_last_used_move =
1146
- Move.get(if (foe.last_used_move == .None) .Counter else foe.last_used_move);
1206
+ // Pretend Splash was used as a stand-in when no move has been used to fail below with 0 BP
1147
1207
  const foe_last_selected_move =
1148
1208
  Move.get(if (foe.last_selected_move == .None or foe.last_selected_move == .SKIP_TURN)
1149
- .Counter
1209
+ .Splash
1150
1210
  else
1151
1211
  foe.last_selected_move);
1152
1212
 
1153
- const used = foe_last_used_move.bp > 0 and
1154
- foe.last_used_move != .Counter and
1155
- (foe_last_used_move.type == .Normal or
1156
- foe_last_used_move.type == .Fighting);
1213
+ const used = (if (player.foe() == .P1)
1214
+ battle.last_moves.p1_counterable
1215
+ else
1216
+ battle.last_moves.p2_counterable) != 0;
1157
1217
 
1158
1218
  const selected = foe_last_selected_move.bp > 0 and
1159
1219
  foe.last_selected_move != .Counter and
@@ -1172,12 +1232,14 @@ fn counterDamage(battle: anytype, player: Player, move: Move.Data, log: anytype)
1172
1232
  return null;
1173
1233
  }
1174
1234
 
1175
- battle.last_damage = if (battle.last_damage > 0x7FFF) 0xFFFF else battle.last_damage * 2;
1235
+ battle.last_damage *|= 2;
1176
1236
 
1177
1237
  // Pokémon Showdown calls checkHit before Counter
1178
1238
  if (!showdown and !try checkHit(battle, player, move, log)) return null;
1179
1239
 
1240
+ const sub = showdown and foe.active.volatiles.Substitute;
1180
1241
  _ = try applyDamage(battle, player.foe(), player.foe(), .None, log);
1242
+ if (battle.foe(player).stored().hp > 0 and !sub) try buildRage(battle, player.foe(), log);
1181
1243
  return null;
1182
1244
  }
1183
1245
 
@@ -1221,6 +1283,7 @@ fn mirrorMove(
1221
1283
  battle: anytype,
1222
1284
  player: Player,
1223
1285
  mslot: u4,
1286
+ rewrap: bool,
1224
1287
  auto: bool,
1225
1288
  residual: *bool,
1226
1289
  log: anytype,
@@ -1233,41 +1296,30 @@ fn mirrorMove(
1233
1296
  if (foe.last_used_move == .None or foe.last_used_move == .MirrorMove) {
1234
1297
  try log.fail(battle.active(player), .None);
1235
1298
  return null;
1299
+ } else if (!showdown or foe.last_used_move != .Struggle) {
1300
+ incrementPP(side, mslot);
1236
1301
  }
1237
1302
 
1238
- incrementPP(side, mslot);
1239
-
1240
1303
  if (!try canMove(battle, player, mslot, auto, false, .MirrorMove, residual, log)) return null;
1241
- return doMove(battle, player, mslot, auto, residual, log);
1304
+ return doMove(battle, player, mslot, rewrap, auto, residual, log);
1242
1305
  }
1243
1306
 
1244
1307
  fn metronome(
1245
1308
  battle: anytype,
1246
1309
  player: Player,
1247
1310
  mslot: u4,
1311
+ rewrap: bool,
1248
1312
  auto: bool,
1249
1313
  residual: *bool,
1250
1314
  log: anytype,
1251
1315
  ) !?Result {
1252
1316
  var side = battle.side(player);
1253
1317
 
1254
- side.last_selected_move = if (showdown) blk: {
1255
- const r = battle.rng.range(u8, 0, @enumToInt(Move.Struggle) - 2);
1256
- const mod = @as(u2, (if (r < @enumToInt(Move.Metronome) - 1) 1 else 2));
1257
- break :blk @intToEnum(Move, r + mod);
1258
- } else loop: {
1259
- while (true) {
1260
- const r = battle.rng.next();
1261
- if (r == 0 or r == @enumToInt(Move.Metronome)) continue;
1262
- if (r >= @enumToInt(Move.Struggle)) continue;
1263
- break :loop @intToEnum(Move, r);
1264
- }
1265
- };
1266
-
1318
+ side.last_selected_move = Rolls.metronome(battle);
1267
1319
  incrementPP(side, mslot);
1268
1320
 
1269
1321
  if (!try canMove(battle, player, mslot, auto, false, .Metronome, residual, log)) return null;
1270
- return doMove(battle, player, mslot, auto, residual, log);
1322
+ return doMove(battle, player, mslot, rewrap, auto, residual, log);
1271
1323
  }
1272
1324
 
1273
1325
  fn checkHit(battle: anytype, player: Player, move: Move.Data, log: anytype) !bool {
@@ -1321,23 +1373,24 @@ fn moveHit(battle: anytype, player: Player, move: Move.Data, immune: *bool, mist
1321
1373
  }
1322
1374
 
1323
1375
  // GLITCH: Thrash / Petal Dance / Rage get their accuracy overwritten on subsequent hits
1324
- var state = &side.active.volatiles.state;
1325
- const overwritten = (move.effect == .Thrashing or move.effect == .Rage) and state.* > 0;
1326
- assert(!overwritten or (0 < state.* and state.* <= 255 and !side.active.volatiles.Bide));
1327
- var accuracy = if (overwritten) state.* else @as(u16, Gen12.percent(move.accuracy));
1376
+ const state = side.active.volatiles.state;
1377
+ var overwrite = move.effect == .Rage or move.effect == .Thrashing;
1378
+ const overwritten = overwrite and state > 0;
1379
+ assert(!overwritten or (0 < state and state <= 255 and !side.active.volatiles.Bide));
1380
+
1381
+ var accuracy = if (overwritten) state else @as(u16, Gen12.percent(move.accuracy));
1328
1382
  var boost = BOOSTS[@intCast(u4, @as(i8, side.active.boosts.accuracy) + 6)];
1329
1383
  accuracy = accuracy * boost[0] / boost[1];
1330
1384
  boost = BOOSTS[@intCast(u4, @as(i8, -foe.active.boosts.evasion) + 6)];
1331
1385
  accuracy = accuracy * boost[0] / boost[1];
1332
1386
  accuracy = @min(255, @max(1, accuracy));
1333
1387
 
1334
- if (move.effect == .Thrashing or move.effect == .Rage) state.* = accuracy;
1388
+ // Pokémon Showdown only overwrites if the volatile is present
1389
+ if (showdown) overwrite = side.active.volatiles.Rage or side.active.volatiles.Thrashing;
1390
+ if (overwrite) side.active.volatiles.state = accuracy;
1335
1391
 
1336
1392
  // GLITCH: max accuracy is 255 so 1/256 chance of miss
1337
- break :miss if (showdown)
1338
- !battle.rng.chance(u8, @intCast(u8, accuracy), 256)
1339
- else
1340
- battle.rng.next() >= accuracy;
1393
+ break :miss !Rolls.hit(battle, accuracy);
1341
1394
  };
1342
1395
 
1343
1396
  // Pokémon Showdown reports miss instead of fail for moves blocked by Mist that 1/256 miss
@@ -1469,9 +1522,6 @@ fn handleResidual(battle: anytype, player: Player, log: anytype) !void {
1469
1522
  damage *= volatiles.toxic;
1470
1523
  }
1471
1524
 
1472
- // Pokémon Showdown erroneously updates last damage with uncapped Leech Seed damage
1473
- if (showdown) battle.last_damage = damage;
1474
-
1475
1525
  const amount = @min(damage, stored.hp);
1476
1526
  stored.hp -= amount;
1477
1527
 
@@ -1490,13 +1540,13 @@ fn endTurn(battle: anytype, log: anytype) @TypeOf(log).Error!Result {
1490
1540
  assert(!battle.side(.P1).active.volatiles.MultiHit);
1491
1541
  assert(!battle.side(.P2).active.volatiles.MultiHit);
1492
1542
 
1543
+ battle.turn += 1;
1544
+
1493
1545
  if (showdown and options.ebc and checkEBC(battle)) {
1494
1546
  try log.tie();
1495
1547
  return Result.Tie;
1496
1548
  }
1497
1549
 
1498
- battle.turn += 1;
1499
-
1500
1550
  if (showdown and battle.turn >= 1000) {
1501
1551
  try log.tie();
1502
1552
  return Result.Tie;
@@ -1563,6 +1613,42 @@ fn checkEBC(battle: anytype) bool {
1563
1613
  return false;
1564
1614
  }
1565
1615
 
1616
+ fn disabled(side: *Side, ident: ID, log: anytype) !bool {
1617
+ if (side.active.volatiles.disabled_move != 0) {
1618
+ // A Pokémon that transforms after being disabled may end up with less move slots
1619
+ const m = side.active.moves[side.active.volatiles.disabled_move - 1].id;
1620
+ // side.last_selected_move can be Struggle here on Pokemon Showdown we need an extra check
1621
+ const last = if (showdown and m == .Bide and side.active.volatiles.Bide)
1622
+ m
1623
+ else
1624
+ side.last_selected_move;
1625
+ if (m != .None and m == last) {
1626
+ side.active.volatiles.Charging = false;
1627
+ try log.disabled(ident, last);
1628
+ return true;
1629
+ }
1630
+ }
1631
+ return false;
1632
+ }
1633
+
1634
+ inline fn buildRage(battle: anytype, who: Player, log: anytype) !void {
1635
+ const side = battle.side(who);
1636
+ if (side.active.volatiles.Rage and side.active.boosts.atk < 6) {
1637
+ try Effects.boost(battle, who, Move.get(.Rage), log);
1638
+ }
1639
+ }
1640
+
1641
+ fn handleThrashing(battle: anytype, active: *ActivePokemon) bool {
1642
+ var volatiles = &active.volatiles;
1643
+ assert(volatiles.Thrashing);
1644
+ if (volatiles.attacks > 0) return false;
1645
+
1646
+ volatiles.Thrashing = false;
1647
+ volatiles.Confusion = true;
1648
+ volatiles.confusion = Rolls.confusionDuration(battle);
1649
+ return true;
1650
+ }
1651
+
1566
1652
  inline fn onBegin(
1567
1653
  battle: anytype,
1568
1654
  player: Player,
@@ -1653,10 +1739,7 @@ pub const Effects = struct {
1653
1739
  side.active.volatiles.Bide = true;
1654
1740
  assert(!side.active.volatiles.Thrashing and !side.active.volatiles.Rage);
1655
1741
  side.active.volatiles.state = 0;
1656
- side.active.volatiles.attacks = @intCast(u3, if (showdown)
1657
- battle.rng.range(u4, 2, 4)
1658
- else
1659
- (battle.rng.next() & 1) + 2);
1742
+ side.active.volatiles.attacks = Rolls.bideThrashDuration(battle);
1660
1743
 
1661
1744
  try log.start(battle.active(player), .Bide);
1662
1745
  }
@@ -1678,14 +1761,8 @@ pub const Effects = struct {
1678
1761
  return;
1679
1762
  }
1680
1763
 
1681
- const chance = !foe.active.types.includes(move.type) and if (showdown)
1682
- battle.rng.chance(u8, @as(u8, if (move.effect == .BurnChance1) 26 else 77), 256)
1683
- else
1684
- battle.rng.next() < 1 + (if (move.effect == .BurnChance1)
1685
- Gen12.percent(10)
1686
- else
1687
- Gen12.percent(30));
1688
- if (!chance) return;
1764
+ if (foe.active.types.includes(move.type)) return;
1765
+ if (!Rolls.secondaryChance(battle, move.effect == .BurnChance1)) return;
1689
1766
 
1690
1767
  foe_stored.status = Status.init(.BRN);
1691
1768
  foe.active.stats.atk = @max(foe.active.stats.atk / 2, 1);
@@ -1709,11 +1786,7 @@ pub const Effects = struct {
1709
1786
  const sub = foe.active.volatiles.Substitute;
1710
1787
 
1711
1788
  if (move.effect == .ConfusionChance) {
1712
- const chance = if (showdown)
1713
- battle.rng.chance(u8, 25, 256)
1714
- else
1715
- battle.rng.next() < Gen12.percent(10);
1716
- if (!chance) return;
1789
+ if (!Rolls.confusionChance(battle)) return;
1717
1790
  } else {
1718
1791
  if (showdown) {
1719
1792
  if (!try checkHit(battle, player, move, log)) {
@@ -1732,10 +1805,7 @@ pub const Effects = struct {
1732
1805
 
1733
1806
  if (foe.active.volatiles.Confusion) return;
1734
1807
  foe.active.volatiles.Confusion = true;
1735
- foe.active.volatiles.confusion = @intCast(u3, if (showdown)
1736
- battle.rng.range(u8, 2, 6)
1737
- else
1738
- (battle.rng.next() & 3) + 2);
1808
+ foe.active.volatiles.confusion = Rolls.confusionDuration(battle);
1739
1809
 
1740
1810
  try log.start(battle.active(player.foe()), .Confusion);
1741
1811
  }
@@ -1785,11 +1855,8 @@ pub const Effects = struct {
1785
1855
  return Result.Error;
1786
1856
  }
1787
1857
 
1788
- volatiles.disabled_move = @intCast(u3, randomMoveSlot(&battle.rng, &foe.active.moves, n));
1789
- volatiles.disabled_duration = @intCast(u4, if (showdown)
1790
- battle.rng.range(u8, 1, 9)
1791
- else
1792
- (battle.rng.next() & 7) + 1);
1858
+ volatiles.disabled_move = @intCast(u3, Rolls.moveSlot(battle, &foe.active.moves, n));
1859
+ volatiles.disabled_duration = Rolls.disableDuration(battle);
1793
1860
 
1794
1861
  try log.startEffect(foe_ident, .Disable, foe.active.move(volatiles.disabled_move).id);
1795
1862
  return null;
@@ -1801,7 +1868,7 @@ pub const Effects = struct {
1801
1868
 
1802
1869
  if (battle.last_damage == 0) {
1803
1870
  assert(showdown);
1804
- return;
1871
+ if (!battle.foe(player).active.volatiles.Substitute) return;
1805
1872
  }
1806
1873
 
1807
1874
  const drain = @max(battle.last_damage / 2, 1);
@@ -1828,15 +1895,7 @@ pub const Effects = struct {
1828
1895
  var volatiles = &battle.foe(player).active.volatiles;
1829
1896
 
1830
1897
  if (volatiles.Substitute) return;
1831
-
1832
- const chance = if (showdown)
1833
- battle.rng.chance(u8, @as(u8, if (move.effect == .FlinchChance1) 26 else 77), 256)
1834
- else
1835
- battle.rng.next() < 1 + (if (move.effect == .FlinchChance1)
1836
- Gen12.percent(10)
1837
- else
1838
- Gen12.percent(30));
1839
- if (!chance) return;
1898
+ if (!Rolls.secondaryChance(battle, move.effect == .FlinchChance1)) return;
1840
1899
 
1841
1900
  volatiles.Flinch = true;
1842
1901
  volatiles.Recharging = false;
@@ -1862,12 +1921,8 @@ pub const Effects = struct {
1862
1921
  return if (showdown and !foe.active.types.includes(move.type)) battle.rng.advance(1);
1863
1922
  }
1864
1923
 
1865
- const chance = !foe.active.types.includes(move.type) and if (showdown)
1866
- battle.rng.chance(u8, 26, 256)
1867
- else
1868
- battle.rng.next() < 1 + Gen12.percent(10);
1869
- if (!chance) return;
1870
-
1924
+ if (foe.active.types.includes(move.type)) return;
1925
+ if (!Rolls.secondaryChance(battle, true)) return;
1871
1926
  // Freeze Clause Mod
1872
1927
  if (showdown) for (foe.pokemon) |p| if (Status.is(p.status, .FRZ)) return;
1873
1928
 
@@ -2004,15 +2059,15 @@ pub const Effects = struct {
2004
2059
  }
2005
2060
  break :has_mimic false;
2006
2061
  };
2007
- // If the foe is Invulnerable we still want to fall through to checkHit to be able to
2008
- // trigger |-miss| instead of |-fail|
2009
- if (!has_mimic and !foe.active.volatiles.Invulnerable) {
2062
+ if (!has_mimic) {
2063
+ // Invulnerable foes or 1/256 miss can trigger |-miss| instead of |-fail|
2064
+ if (!try checkHit(battle, player, move, log)) return;
2010
2065
  return try log.fail(battle.active(player.foe()), .None);
2011
2066
  }
2012
2067
  }
2013
2068
  if (!try checkHit(battle, player, move, log)) return;
2014
2069
 
2015
- const rslot = randomMoveSlot(&battle.rng, &foe.active.moves, 0);
2070
+ const rslot = Rolls.moveSlot(battle, &foe.active.moves, 0);
2016
2071
  side.active.move(oslot).id = foe.active.move(rslot).id;
2017
2072
 
2018
2073
  try log.startEffect(battle.active(player), .Mimic, side.active.move(oslot).id);
@@ -2033,7 +2088,8 @@ pub const Effects = struct {
2033
2088
  assert(!side.active.volatiles.MultiHit);
2034
2089
  side.active.volatiles.MultiHit = true;
2035
2090
 
2036
- side.active.volatiles.attacks = if (move.effect == .MultiHit) distribution(battle) else 2;
2091
+ side.active.volatiles.attacks =
2092
+ if (move.effect == .MultiHit) Rolls.distribution(battle) else 2;
2037
2093
  }
2038
2094
 
2039
2095
  fn paralyze(battle: anytype, player: Player, move: Move.Data, log: anytype) !void {
@@ -2077,14 +2133,8 @@ pub const Effects = struct {
2077
2133
  }
2078
2134
 
2079
2135
  // Body Slam can't paralyze a Normal type Pokémon
2080
- const chance = !foe.active.types.includes(move.type) and if (showdown)
2081
- battle.rng.chance(u8, @as(u8, if (move.effect == .ParalyzeChance1) 26 else 77), 256)
2082
- else
2083
- battle.rng.next() < 1 + (if (move.effect == .ParalyzeChance1)
2084
- Gen12.percent(10)
2085
- else
2086
- Gen12.percent(30));
2087
- if (!chance) return;
2136
+ if (foe.active.types.includes(move.type)) return;
2137
+ if (!Rolls.secondaryChance(battle, move.effect == .ParalyzeChance1)) return;
2088
2138
 
2089
2139
  foe_stored.status = Status.init(.PAR);
2090
2140
  foe.active.stats.spe = @max(foe.active.stats.spe / 4, 1);
@@ -2127,16 +2177,7 @@ pub const Effects = struct {
2127
2177
 
2128
2178
  if (move.effect == .Poison) {
2129
2179
  if (!showdown and !try checkHit(battle, player, move, log)) return;
2130
- } else {
2131
- const chance = if (showdown)
2132
- battle.rng.chance(u8, @as(u8, if (move.effect == .PoisonChance1) 52 else 103), 256)
2133
- else
2134
- battle.rng.next() < 1 + (if (move.effect == .PoisonChance1)
2135
- Gen12.percent(20)
2136
- else
2137
- Gen12.percent(40));
2138
- if (!chance) return;
2139
- }
2180
+ } else if (!Rolls.poisonChance(battle, move.effect == .PoisonChance1)) return;
2140
2181
 
2141
2182
  foe_stored.status = Status.init(.PSN);
2142
2183
  if (toxic) {
@@ -2150,16 +2191,17 @@ pub const Effects = struct {
2150
2191
 
2151
2192
  fn rage(battle: anytype, player: Player) !void {
2152
2193
  var volatiles = &battle.side(player).active.volatiles;
2153
- if (volatiles.Rage) return;
2154
- volatiles.Rage = true;
2155
2194
  assert(!volatiles.Bide);
2156
- volatiles.state = 0;
2195
+ volatiles.Rage = true;
2157
2196
  }
2158
2197
 
2159
2198
  fn recoil(battle: anytype, player: Player, residual: *bool, log: anytype) !void {
2160
2199
  var side = battle.side(player);
2161
2200
  var stored = side.stored();
2162
2201
 
2202
+ assert(showdown or battle.last_damage > 0);
2203
+ if (showdown and battle.last_damage == 0) return;
2204
+
2163
2205
  const damage = @intCast(i16, @max(battle.last_damage /
2164
2206
  @as(u8, if (side.last_selected_move == .Struggle) 2 else 4), 1));
2165
2207
  stored.hp = @intCast(u16, @max(@intCast(i16, stored.hp) - damage, 0));
@@ -2205,16 +2247,7 @@ pub const Effects = struct {
2205
2247
  }
2206
2248
  foe.active.volatiles.Recharging = false;
2207
2249
 
2208
- const duration = @intCast(u3, if (showdown)
2209
- battle.rng.range(u8, 1, 8)
2210
- else loop: {
2211
- while (true) {
2212
- const r = battle.rng.next() & 7;
2213
- if (r != 0) break :loop r;
2214
- }
2215
- });
2216
-
2217
- foe_stored.status = Status.slp(duration);
2250
+ foe_stored.status = Status.slp(Rolls.sleepDuration(battle));
2218
2251
  try log.statusFrom(foe_ident, foe_stored.status, battle.side(player).last_selected_move);
2219
2252
  }
2220
2253
 
@@ -2267,11 +2300,7 @@ pub const Effects = struct {
2267
2300
  assert(!volatiles.Bide);
2268
2301
 
2269
2302
  volatiles.Thrashing = true;
2270
- volatiles.state = 0;
2271
- volatiles.attacks = @intCast(u3, if (showdown)
2272
- battle.rng.range(u8, 2, 4)
2273
- else
2274
- (battle.rng.next() & 1) + 2);
2303
+ volatiles.attacks = Rolls.bideThrashDuration(battle);
2275
2304
  }
2276
2305
 
2277
2306
  fn transform(battle: anytype, player: Player, log: anytype) !void {
@@ -2303,7 +2332,7 @@ pub const Effects = struct {
2303
2332
  try log.transform(battle.active(player), foe_ident);
2304
2333
  }
2305
2334
 
2306
- fn binding(battle: anytype, player: Player) void {
2335
+ fn binding(battle: anytype, player: Player, rewrap: bool) void {
2307
2336
  var side = battle.side(player);
2308
2337
  var foe = battle.foe(player);
2309
2338
 
@@ -2311,9 +2340,11 @@ pub const Effects = struct {
2311
2340
  side.active.volatiles.Binding = true;
2312
2341
  // GLITCH: Hyper Beam automatic selection glitch if Recharging gets cleared on miss
2313
2342
  // (Pokémon Showdown unitentionally patches this glitch, preventing automatic selection)
2314
- if (!showdown) foe.active.volatiles.Recharging = false;
2343
+ if (!showdown) {
2344
+ foe.active.volatiles.Recharging = false;
2345
+ } else if (foe.stored().hp == 0) return if (!rewrap) battle.rng.advance(1);
2315
2346
 
2316
- side.active.volatiles.attacks = distribution(battle) - 1;
2347
+ side.active.volatiles.attacks = Rolls.distribution(battle) - 1;
2317
2348
  }
2318
2349
 
2319
2350
  fn boost(battle: anytype, player: Player, move: Move.Data, log: anytype) !void {
@@ -2340,9 +2371,11 @@ pub const Effects = struct {
2340
2371
  return try log.fail(ident, .None);
2341
2372
  }
2342
2373
  var mod = BOOSTS[@intCast(u4, @as(i8, boosts.atk) + 6)];
2343
- const stat = unmodifiedStats(battle, player).atk;
2374
+ const stat = unmodifiedStats(battle, side).atk;
2344
2375
  stats.atk = @min(MAX_STAT_VALUE, stat * mod[0] / mod[1]);
2345
2376
  try log.boost(ident, reason, n);
2377
+ // Pokémon Showdown doesn't re-apply status modifiers after Rage boosts
2378
+ if (showdown and move.effect == .Rage) return;
2346
2379
  },
2347
2380
  .DefenseUp1, .DefenseUp2 => {
2348
2381
  assert(boosts.def >= -6 and boosts.def <= 6);
@@ -2358,7 +2391,7 @@ pub const Effects = struct {
2358
2391
  return try log.fail(ident, .None);
2359
2392
  }
2360
2393
  var mod = BOOSTS[@intCast(u4, @as(i8, boosts.def) + 6)];
2361
- const stat = unmodifiedStats(battle, player).def;
2394
+ const stat = unmodifiedStats(battle, side).def;
2362
2395
  stats.def = @min(MAX_STAT_VALUE, stat * mod[0] / mod[1]);
2363
2396
  try log.boost(ident, .Defense, n);
2364
2397
  },
@@ -2375,7 +2408,7 @@ pub const Effects = struct {
2375
2408
  return try log.fail(ident, .None);
2376
2409
  }
2377
2410
  var mod = BOOSTS[@intCast(u4, @as(i8, boosts.spe) + 6)];
2378
- const stat = unmodifiedStats(battle, player).spe;
2411
+ const stat = unmodifiedStats(battle, side).spe;
2379
2412
  stats.spe = @min(MAX_STAT_VALUE, stat * mod[0] / mod[1]);
2380
2413
  try log.boost(ident, .Speed, 2);
2381
2414
  },
@@ -2395,7 +2428,7 @@ pub const Effects = struct {
2395
2428
  return try log.fail(ident, .None);
2396
2429
  }
2397
2430
  var mod = BOOSTS[@intCast(u4, @as(i8, boosts.spc) + 6)];
2398
- const stat = unmodifiedStats(battle, player).spc;
2431
+ const stat = unmodifiedStats(battle, side).spc;
2399
2432
  stats.spc = @min(MAX_STAT_VALUE, stat * mod[0] / mod[1]);
2400
2433
  try log.boost(ident, .SpecialAttack, n);
2401
2434
  try log.boost(ident, .SpecialDefense, n);
@@ -2422,11 +2455,7 @@ pub const Effects = struct {
2422
2455
  }
2423
2456
 
2424
2457
  if (move.effect.isStatDownChance()) {
2425
- const chance = if (showdown)
2426
- battle.rng.chance(u8, 85, 256)
2427
- else
2428
- battle.rng.next() < Gen12.percent(33) + 1;
2429
- if (!chance or foe.active.volatiles.Invulnerable) return;
2458
+ if (!Rolls.unboost(battle) or foe.active.volatiles.Invulnerable) return;
2430
2459
  } else if (!showdown and !try checkHit(battle, player, move, log)) {
2431
2460
  return; // checkHit already checks for Invulnerable
2432
2461
  }
@@ -2448,7 +2477,7 @@ pub const Effects = struct {
2448
2477
  return try log.fail(foe_ident, .None);
2449
2478
  }
2450
2479
  var mod = BOOSTS[@intCast(u4, @as(i8, boosts.atk) + 6)];
2451
- const stat = unmodifiedStats(battle, player.foe()).atk;
2480
+ const stat = unmodifiedStats(battle, foe).atk;
2452
2481
  stats.atk = @max(1, stat * mod[0] / mod[1]);
2453
2482
  try log.boost(foe_ident, .Attack, -1);
2454
2483
  },
@@ -2466,7 +2495,7 @@ pub const Effects = struct {
2466
2495
  return try log.fail(foe_ident, .None);
2467
2496
  }
2468
2497
  var mod = BOOSTS[@intCast(u4, @as(i8, boosts.def) + 6)];
2469
- const stat = unmodifiedStats(battle, player.foe()).def;
2498
+ const stat = unmodifiedStats(battle, foe).def;
2470
2499
  stats.def = @max(1, stat * mod[0] / mod[1]);
2471
2500
  try log.boost(foe_ident, .Defense, -@as(i8, n));
2472
2501
  },
@@ -2483,7 +2512,7 @@ pub const Effects = struct {
2483
2512
  return try log.fail(foe_ident, .None);
2484
2513
  }
2485
2514
  var mod = BOOSTS[@intCast(u4, @as(i8, boosts.spe) + 6)];
2486
- const stat = unmodifiedStats(battle, player.foe()).spe;
2515
+ const stat = unmodifiedStats(battle, foe).spe;
2487
2516
  stats.spe = @max(1, stat * mod[0] / mod[1]);
2488
2517
  try log.boost(foe_ident, .Speed, -1);
2489
2518
  assert(boosts.spe >= -6);
@@ -2503,7 +2532,7 @@ pub const Effects = struct {
2503
2532
  return try log.fail(foe_ident, .None);
2504
2533
  }
2505
2534
  var mod = BOOSTS[@intCast(u4, @as(i8, boosts.spc) + 6)];
2506
- const stat = unmodifiedStats(battle, player.foe()).spc;
2535
+ const stat = unmodifiedStats(battle, foe).spc;
2507
2536
  stats.spc = @max(1, stat * mod[0] / mod[1]);
2508
2537
  try log.boost(foe_ident, .SpecialAttack, -1);
2509
2538
  try log.boost(foe_ident, .SpecialDefense, -1);
@@ -2522,8 +2551,7 @@ pub const Effects = struct {
2522
2551
  }
2523
2552
  };
2524
2553
 
2525
- fn unmodifiedStats(battle: anytype, who: Player) *Stats(u16) {
2526
- const side = battle.side(who);
2554
+ fn unmodifiedStats(battle: anytype, side: *Side) *Stats(u16) {
2527
2555
  if (!side.active.volatiles.Transform) return &side.stored().stats;
2528
2556
  const id = ID.from(side.active.volatiles.transform);
2529
2557
  return &battle.side(id.player).pokemon[id.id - 1].stats;
@@ -2589,75 +2617,140 @@ fn clearVolatiles(battle: anytype, who: Player, log: anytype) !void {
2589
2617
  }
2590
2618
  }
2591
2619
 
2592
- fn disabled(side: *Side, ident: ID, log: anytype) !bool {
2593
- if (side.active.volatiles.disabled_move != 0) {
2594
- // A Pokémon that transforms after being disabled may end up with less move slots
2595
- const m = side.active.moves[side.active.volatiles.disabled_move - 1].id;
2596
- if (m != .None and m == side.last_selected_move) {
2597
- side.active.volatiles.Charging = false;
2598
- try log.disabled(ident, side.last_selected_move);
2599
- return true;
2620
+ pub const Rolls = struct {
2621
+ inline fn speedTie(battle: anytype) bool {
2622
+ if (showdown) return battle.rng.range(u8, 0, 2) == 0;
2623
+ return battle.rng.next() < Gen12.percent(50) + 1;
2624
+ }
2625
+
2626
+ inline fn criticalHit(battle: anytype, chance: u16) bool {
2627
+ if (showdown) return battle.rng.chance(u8, @intCast(u8, chance), 256);
2628
+ return std.math.rotl(u8, battle.rng.next(), 3) < chance;
2629
+ }
2630
+
2631
+ inline fn damage(battle: anytype) u8 {
2632
+ if (showdown) return battle.rng.range(u8, 217, 256);
2633
+ while (true) {
2634
+ const r = std.math.rotr(u8, battle.rng.next(), 1);
2635
+ if (r >= 217) return r;
2600
2636
  }
2601
2637
  }
2602
- return false;
2603
- }
2604
2638
 
2605
- inline fn buildRage(battle: anytype, who: Player, log: anytype) !void {
2606
- const side = battle.side(who);
2607
- if (side.active.volatiles.Rage and side.active.boosts.atk < 6) {
2608
- try Effects.boost(battle, who, Move.get(.Rage), log);
2639
+ inline fn hit(battle: anytype, accuracy: u16) bool {
2640
+ if (showdown) return battle.rng.chance(u8, @intCast(u8, accuracy), 256);
2641
+ return battle.rng.next() < accuracy;
2609
2642
  }
2610
- }
2611
2643
 
2612
- fn handleThrashing(battle: anytype, active: *ActivePokemon) bool {
2613
- var volatiles = &active.volatiles;
2614
- assert(volatiles.Thrashing);
2615
- if (volatiles.attacks > 0) return false;
2644
+ inline fn confusionDuration(battle: anytype) u3 {
2645
+ if (showdown) return @intCast(u3, battle.rng.range(u8, 2, 6));
2646
+ return @intCast(u3, (battle.rng.next() & 3) + 2);
2647
+ }
2616
2648
 
2617
- volatiles.Thrashing = false;
2618
- volatiles.Confusion = true;
2619
- volatiles.confusion = @intCast(u3, if (showdown)
2620
- battle.rng.range(u8, 2, 6)
2621
- else
2622
- (battle.rng.next() & 3) + 2);
2649
+ inline fn confused(battle: anytype) bool {
2650
+ if (showdown) return !battle.rng.chance(u8, 128, 256);
2651
+ return battle.rng.next() >= Gen12.percent(50) + 1;
2652
+ }
2623
2653
 
2624
- return true;
2625
- }
2654
+ inline fn paralyzed(battle: anytype) bool {
2655
+ if (showdown) return battle.rng.chance(u8, 63, 256);
2656
+ return battle.rng.next() < Gen12.percent(25);
2657
+ }
2626
2658
 
2627
- const DISTRIBUTION = [_]u3{ 2, 2, 2, 3, 3, 3, 4, 5 };
2659
+ inline fn confusionChance(battle: anytype) bool {
2660
+ if (showdown) return battle.rng.chance(u8, 25, 256);
2661
+ return battle.rng.next() < Gen12.percent(10);
2662
+ }
2628
2663
 
2629
- fn distribution(battle: anytype) u3 {
2630
- if (showdown) return DISTRIBUTION[battle.rng.range(u8, 0, DISTRIBUTION.len)];
2631
- const r = (battle.rng.next() & 3);
2632
- return @intCast(u3, (if (r < 2) r else battle.rng.next() & 3) + 2);
2633
- }
2664
+ inline fn secondaryChance(battle: anytype, low: bool) bool {
2665
+ if (showdown) return battle.rng.chance(u8, @as(u8, if (low) 26 else 77), 256);
2666
+ return battle.rng.next() < 1 + (if (low) Gen12.percent(10) else Gen12.percent(30));
2667
+ }
2634
2668
 
2635
- fn randomMoveSlot(rand: anytype, moves: []MoveSlot, check_pp: u4) u4 {
2636
- if (showdown) {
2637
- if (check_pp == 0) {
2638
- var i: usize = moves.len;
2639
- while (i > 0) {
2640
- i -= 1;
2641
- if (moves[i].id != .None) return rand.range(u4, 0, @intCast(u4, i + 1)) + 1;
2642
- }
2643
- } else {
2644
- var r = rand.range(u4, 0, @intCast(u4, check_pp)) + 1;
2645
- var i: usize = 0;
2646
- while (i < moves.len and r > 0) : (i += 1) {
2647
- if (moves[i].pp > 0) {
2648
- r -= 1;
2649
- if (r == 0) break;
2669
+ inline fn poisonChance(battle: anytype, low: bool) bool {
2670
+ if (showdown) return battle.rng.chance(u8, @as(u8, if (low) 52 else 103), 256);
2671
+ return battle.rng.next() < 1 + (if (low) Gen12.percent(20) else Gen12.percent(40));
2672
+ }
2673
+
2674
+ inline fn sleepDuration(battle: anytype) u3 {
2675
+ if (showdown) return @intCast(u3, battle.rng.range(u8, 1, 8));
2676
+ while (true) {
2677
+ const r = battle.rng.next() & 7;
2678
+ if (r != 0) return @intCast(u3, r);
2679
+ }
2680
+ }
2681
+
2682
+ inline fn bideThrashDuration(battle: anytype) u3 {
2683
+ if (showdown) return @intCast(u3, battle.rng.range(u4, 2, 4));
2684
+ return @intCast(u3, (battle.rng.next() & 1) + 2);
2685
+ }
2686
+
2687
+ fn moveSlot(battle: anytype, moves: []MoveSlot, check_pp: u4) u4 {
2688
+ if (showdown) {
2689
+ if (check_pp == 0) {
2690
+ var i: usize = moves.len;
2691
+ while (i > 0) {
2692
+ i -= 1;
2693
+ if (moves[i].id != .None) {
2694
+ return battle.rng.range(u4, 0, @intCast(u4, i + 1)) + 1;
2695
+ }
2650
2696
  }
2697
+ } else {
2698
+ var r = battle.rng.range(u4, 0, @intCast(u4, check_pp)) + 1;
2699
+ var i: usize = 0;
2700
+ while (i < moves.len and r > 0) : (i += 1) {
2701
+ if (moves[i].pp > 0) {
2702
+ r -= 1;
2703
+ if (r == 0) break;
2704
+ }
2705
+ }
2706
+ return @intCast(u4, i + 1);
2651
2707
  }
2652
- return @intCast(u4, i + 1);
2708
+ }
2709
+
2710
+ while (true) {
2711
+ const r = @intCast(u4, battle.rng.next() & 3);
2712
+ if (moves[r].id != .None and (check_pp == 0 or moves[r].pp > 0)) return r + 1;
2653
2713
  }
2654
2714
  }
2655
2715
 
2656
- while (true) {
2657
- const r = @intCast(u4, rand.next() & 3);
2658
- if (moves[r].id != .None and (check_pp == 0 or moves[r].pp > 0)) return r + 1;
2716
+ inline fn disableDuration(battle: anytype) u4 {
2717
+ if (showdown) return @intCast(u4, battle.rng.range(u8, 1, 9));
2718
+ return @intCast(u4, (battle.rng.next() & 7) + 1);
2659
2719
  }
2660
- }
2720
+
2721
+ inline fn metronome(battle: anytype) Move {
2722
+ if (showdown) {
2723
+ const r = battle.rng.range(u8, 0, @enumToInt(Move.Struggle) - 2);
2724
+ const mod = @as(u2, (if (r < @enumToInt(Move.Metronome) - 1) 1 else 2));
2725
+ return @intToEnum(Move, r + mod);
2726
+ }
2727
+ while (true) {
2728
+ const r = battle.rng.next();
2729
+ if (r == 0 or r == @enumToInt(Move.Metronome)) continue;
2730
+ if (r >= @enumToInt(Move.Struggle)) continue;
2731
+ return @intToEnum(Move, r);
2732
+ }
2733
+ }
2734
+
2735
+ inline fn psywave(battle: anytype, max: u8) u8 {
2736
+ if (showdown) return battle.rng.range(u8, 0, max);
2737
+ while (true) {
2738
+ const r = battle.rng.next();
2739
+ if (r < max) return r;
2740
+ }
2741
+ }
2742
+
2743
+ inline fn distribution(battle: anytype) u3 {
2744
+ if (showdown) return DISTRIBUTION[battle.rng.range(u8, 0, DISTRIBUTION.len)];
2745
+ const r = (battle.rng.next() & 3);
2746
+ return @intCast(u3, (if (r < 2) r else battle.rng.next() & 3) + 2);
2747
+ }
2748
+
2749
+ inline fn unboost(battle: anytype) bool {
2750
+ if (showdown) return battle.rng.chance(u8, 85, 256);
2751
+ return battle.rng.next() < Gen12.percent(33) + 1;
2752
+ }
2753
+ };
2661
2754
 
2662
2755
  test "RNG agreement" {
2663
2756
  if (!showdown) return;
@@ -2757,7 +2850,7 @@ pub fn choices(battle: anytype, player: Player, request: Choice.Type, out: []Cho
2757
2850
  while (slot <= 4) : (slot += 1) {
2758
2851
  const m = active.moves[slot - 1];
2759
2852
  if (m.id == .None) break;
2760
- if (m.id == side.last_selected_move) {
2853
+ if (m.id == if (active.volatiles.Bide) .Bide else side.last_selected_move) {
2761
2854
  // Pokémon Showdown displays Struggle if limited to Bide but unable to pick
2762
2855
  const struggle =
2763
2856
  m.id == .Bide and (m.pp == 0 or active.volatiles.disabled_move == slot);