@pkmn/engine 0.1.0-dev.15f64810 → 0.1.0-dev.1aa30742

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.
Files changed (80) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +54 -6
  3. package/build/pkg/addon/browser.d.ts +3 -0
  4. package/build/pkg/addon/browser.js +47 -0
  5. package/build/pkg/addon/browser.js.map +1 -0
  6. package/build/pkg/addon/node.d.ts +3 -0
  7. package/build/pkg/addon/node.js +110 -0
  8. package/build/pkg/addon/node.js.map +1 -0
  9. package/build/pkg/addon/wasm.d.ts +2 -0
  10. package/build/pkg/addon/wasm.js +53 -0
  11. package/build/pkg/addon/wasm.js.map +1 -0
  12. package/build/pkg/addon.d.ts +25 -3
  13. package/build/pkg/addon.js +34 -65
  14. package/build/pkg/addon.js.map +1 -1
  15. package/build/pkg/data/layout.json +15 -2
  16. package/build/pkg/data/protocol.json +23 -9
  17. package/build/pkg/data.d.ts +23 -1
  18. package/build/pkg/data.js.map +1 -1
  19. package/build/pkg/gen1.d.ts +15 -18
  20. package/build/pkg/gen1.js +28 -19
  21. package/build/pkg/gen1.js.map +1 -1
  22. package/build/pkg/index.d.ts +40 -90
  23. package/build/pkg/index.js +23 -70
  24. package/build/pkg/index.js.map +1 -1
  25. package/build/pkg/protocol.d.ts +20 -1
  26. package/build/pkg/protocol.js +105 -103
  27. package/build/pkg/protocol.js.map +1 -1
  28. package/build/tools/debug.js +52 -76
  29. package/build/tools/debug.js.map +1 -1
  30. package/build/tools/display/debug.jsx +152 -0
  31. package/build/tools/display/debug.jsx.map +1 -0
  32. package/build/tools/display/index.js +152 -0
  33. package/build/tools/display/index.js.map +1 -0
  34. package/build/tools/display/ui.jsx +359 -0
  35. package/build/tools/display/ui.jsx.map +1 -0
  36. package/build/tools/display/util.js +33 -0
  37. package/build/tools/display/util.js.map +1 -0
  38. package/build.zig +330 -85
  39. package/package.json +54 -135
  40. package/src/bin/install-pkmn-engine +10 -7
  41. package/src/bin/pkmn-debug +1 -1
  42. package/src/lib/bindings/c.zig +255 -0
  43. package/src/lib/bindings/node.zig +121 -0
  44. package/src/lib/bindings/wasm.zig +59 -0
  45. package/src/lib/common/array.zig +77 -0
  46. package/src/lib/common/data.zig +8 -8
  47. package/src/lib/common/debug.zig +39 -8
  48. package/src/lib/common/js.zig +4 -2
  49. package/src/lib/common/optional.zig +13 -9
  50. package/src/lib/common/options.zig +4 -0
  51. package/src/lib/common/protocol.zig +45 -44
  52. package/src/lib/common/rational.zig +323 -157
  53. package/src/lib/common/rng.zig +5 -7
  54. package/src/lib/common/util.zig +65 -1
  55. package/src/lib/gen1/calc.zig +624 -191
  56. package/src/lib/gen1/chance.zig +366 -240
  57. package/src/lib/gen1/data/moves.zig +2 -4
  58. package/src/lib/gen1/data/species.zig +1 -3
  59. package/src/lib/gen1/data/types.zig +1 -2
  60. package/src/lib/gen1/data.zig +35 -38
  61. package/src/lib/gen1/helpers.zig +17 -560
  62. package/src/lib/gen1/mechanics.zig +291 -201
  63. package/src/lib/gen2/calc.zig +37 -27
  64. package/src/lib/gen2/chance.zig +90 -503
  65. package/src/lib/gen2/data/items.zig +1 -3
  66. package/src/lib/gen2/data/moves.zig +2 -4
  67. package/src/lib/gen2/data/species.zig +1 -3
  68. package/src/lib/gen2/data/types.zig +1 -2
  69. package/src/lib/gen2/data.zig +26 -29
  70. package/src/lib/gen2/generated.zig +18 -23
  71. package/src/lib/gen2/helpers.zig +9 -18
  72. package/src/lib/gen2/mechanics.zig +88 -115
  73. package/src/lib/node.zig +3 -116
  74. package/src/lib/pkmn.zig +14 -7
  75. package/src/lib/wasm.zig +13 -9
  76. package/src/tools/display/dom.ts +93 -0
  77. package/src/tools/display/ui.css +354 -0
  78. package/build/test/display.js +0 -400
  79. package/build/test/display.js.map +0 -1
  80. package/src/test/display/pkmn.html.tmpl +0 -368
@@ -1,37 +1,29 @@
1
- const std = @import("std");
2
-
3
- const pkmn = @import("../pkmn.zig");
1
+ const DEBUG = @import("../common/debug.zig").print;
4
2
 
3
+ const builtin = @import("builtin");
4
+ const chance = @import("chance.zig");
5
5
  const common = @import("../common/data.zig");
6
- const DEBUG = @import("../common/debug.zig").print;
6
+ const data = @import("data.zig");
7
+ const pkmn = @import("../pkmn.zig");
7
8
  const protocol = @import("../common/protocol.zig");
8
9
  const rng = @import("../common/rng.zig");
9
-
10
- const data = @import("data.zig");
10
+ const std = @import("std");
11
11
 
12
12
  const assert = std.debug.assert;
13
-
14
- const expectEqual = std.testing.expectEqual;
15
-
16
- const showdown = pkmn.options.showdown;
17
-
18
- const Choice = common.Choice;
19
- const ID = common.ID;
20
- const Player = common.Player;
21
- const Result = common.Result;
22
-
23
13
  const Boost = protocol.Boost;
14
+ const Choice = common.Choice;
24
15
  const Damage = protocol.Damage;
16
+ const Effectiveness = data.Effectiveness;
17
+ const expectEqual = std.testing.expectEqual;
25
18
  const Fail = protocol.Fail;
26
- const Heal = protocol.Heal;
27
-
28
19
  const Gen12 = rng.Gen12;
29
-
30
- const ActivePokemon = data.ActivePokemon;
31
- const Effectiveness = data.Effectiveness;
20
+ const Heal = protocol.Heal;
21
+ const ID = common.ID;
32
22
  const Move = data.Move;
33
23
  const MoveSlot = data.MoveSlot;
34
- const Pokemon = data.Pokemon;
24
+ const Player = common.Player;
25
+ const Result = common.Result;
26
+ const showdown = pkmn.options.showdown;
35
27
  const Side = data.Side;
36
28
  const Species = data.Species;
37
29
  const Stats = data.Stats;
@@ -174,7 +166,10 @@ fn selectMove(
174
166
  if (foe_choice.type == .Switch) {
175
167
  const last = side.active.move(battle.lastMove(player).index);
176
168
  if (last.id == .Metronome) side.last_selected_move = last.id;
177
- if (last.id == .MirrorMove) return Result.Error;
169
+ if (last.id == .MirrorMove) {
170
+ if (!pkmn.options.mod) return Result.Error;
171
+ side.last_selected_move = last.id;
172
+ }
178
173
  }
179
174
  }
180
175
  return null;
@@ -253,11 +248,11 @@ fn switchIn(battle: anytype, player: Player, slot: u8, initial: bool, options: a
253
248
  active.moves = incoming.moves;
254
249
 
255
250
  statusModify(incoming.status, &active.stats);
251
+ options.chance.switched(player, slot);
256
252
 
257
253
  foe.active.volatiles.Binding = false;
258
254
 
259
255
  try options.log.switched(.{ battle.active(player), incoming });
260
- options.chance.switched(player, side.order[0], out);
261
256
 
262
257
  if (showdown and incoming.status == Status.TOX) {
263
258
  incoming.status = Status.init(.PSN);
@@ -347,13 +342,13 @@ fn doTurn(
347
342
  )) |r| return r;
348
343
  if (!replace) {
349
344
  if (player_choice.type != .Switch) {
350
- if (try checkFaint(battle, foe_player, options)) |r| return r;
345
+ if (try checkFaint(battle, foe_player, true, options)) |r| return r;
351
346
  }
352
347
  if (residual) try handleResidual(battle, player, options);
353
- if (try checkFaint(battle, player, options)) |r| return r;
348
+ if (try checkFaint(battle, player, false, options)) |r| return r;
354
349
  } else if (foe_choice.type == .Pass) return null;
355
350
 
356
- options.chance.clearPending();
351
+ if (!showdown) options.chance.clearPending();
357
352
 
358
353
  residual = true;
359
354
  replace = battle.side(foe_player).stored().hp == 0;
@@ -369,10 +364,10 @@ fn doTurn(
369
364
  )) |r| return r;
370
365
  if (!replace) {
371
366
  if (!calc and foe_choice.type != .Switch) {
372
- if (try checkFaint(battle, player, options)) |r| return r;
367
+ if (try checkFaint(battle, player, true, options)) |r| return r;
373
368
  }
374
369
  if (residual) try handleResidual(battle, foe_player, options);
375
- if (try checkFaint(battle, foe_player, options)) |r| return r;
370
+ if (try checkFaint(battle, foe_player, false, options)) |r| return r;
376
371
  }
377
372
 
378
373
  // Flinch is bugged on Pokémon Showdown because it gets implemented with a duration which causes
@@ -425,7 +420,7 @@ fn executeMove(
425
420
  mslot = @intCast(battle.lastMove(player).index);
426
421
  const stored = side.stored();
427
422
  // GLITCH: Struggle bypass PP underflow via Hyper Beam / Trapping-switch auto selection
428
- auto = isForced(&side.active) or
423
+ auto = pkmn.options.mod or isForced(&side.active) or
429
424
  side.active.volatiles.Binding or side.active.volatiles.Bide or
430
425
  side.last_selected_move == .HyperBeam or
431
426
  Status.is(stored.status, .FRZ) or Status.is(stored.status, .SLP);
@@ -458,6 +453,25 @@ fn executeMove(
458
453
  .err => return @as(?Result, Result.Error),
459
454
  }
460
455
 
456
+ // Pokémon Showdown incorrectly implements PP deduction when handling the Hyper Beam automatic
457
+ // selection glitch - if the move was proced due to Metronome / Mirror Move it either subtracts
458
+ // from the Hyper Beam slot if present or skips PP deduction entirely...
459
+ if (showdown and !skip_pp and side.last_selected_move == .HyperBeam and
460
+ side.last_selected_move != side.active.move(mslot).id)
461
+ {
462
+ assert(mslot == 1);
463
+ const has_beam = has_beam: {
464
+ for (side.active.moves, 0..) |m, i| {
465
+ if (m.id == .HyperBeam) {
466
+ mslot = @intCast(i + 1);
467
+ break :has_beam true;
468
+ }
469
+ }
470
+ break :has_beam false;
471
+ };
472
+ if (!has_beam) skip_pp = true;
473
+ }
474
+
461
475
  const can = skip_can or
462
476
  try canMove(battle, player, mslot, auto, skip_pp, .None, residual, options);
463
477
  if (!can) return null;
@@ -484,17 +498,22 @@ fn beforeMove(
484
498
 
485
499
  if (Status.is(stored.status, .SLP)) {
486
500
  const before = stored.status;
501
+ const slf = Status.is(stored.status, .EXT);
487
502
  // Even if the EXT bit is set this will still correctly modify the sleep duration
488
- if (options.calc.modify(player, .sleep)) |extend| {
489
- if (!extend) stored.status = 0;
503
+ if (options.calc.overridden(player, .sleep)) |obs| switch (obs) {
504
+ .started, .ended => stored.status = 0,
505
+ .continuing => if (Status.duration(stored.status) > 1) {
506
+ stored.status -= 1;
507
+ },
508
+ else => unreachable,
490
509
  } else {
491
510
  stored.status -= 1;
492
511
  }
493
512
 
494
513
  const duration = Status.duration(stored.status);
495
- if (!Status.is(stored.status, .EXT)) {
496
- try options.chance.sleep(player, duration);
497
- }
514
+ try options.chance.sleep(player, if (slf)
515
+ .None
516
+ else if (duration == 0) .ended else .continuing);
498
517
 
499
518
  if (duration == 0) {
500
519
  try log.curestatus(.{ ident, before, .Message });
@@ -532,12 +551,12 @@ fn beforeMove(
532
551
  }
533
552
 
534
553
  if (volatiles.disable_duration > 0) {
535
- if (options.calc.modify(player, .disable)) |extend| {
536
- if (!extend) volatiles.disable_duration = 0;
537
- } else {
538
- volatiles.disable_duration -= 1;
539
- }
540
- try options.chance.disable(player, volatiles.disable_duration);
554
+ volatiles.disable_duration =
555
+ decrement(.disable, player, options, volatiles.disable_duration);
556
+ try options.chance.disable(
557
+ player,
558
+ if (volatiles.disable_duration == 0) .ended else .continuing,
559
+ );
541
560
 
542
561
  if (volatiles.disable_duration == 0) {
543
562
  volatiles.disable_move = 0;
@@ -550,19 +569,24 @@ fn beforeMove(
550
569
  // This can only happen if a Pokémon started the battle frozen/sleeping and was thawed/woken
551
570
  // before the side had a selected a move - we simply need to assume this leads to a desync
552
571
  if (side.last_selected_move == .None) {
553
- assert(!showdown);
572
+ assert(!pkmn.options.mod);
554
573
  return .err;
555
574
  }
556
575
 
557
576
  if (volatiles.Confusion) {
558
577
  assert(volatiles.confusion > 0);
559
-
560
- if (options.calc.modify(player, .confusion)) |extend| {
561
- if (!extend) volatiles.confusion = 0;
578
+ if (options.calc.overridden(player, .confusion)) |obs| switch (obs) {
579
+ .started, .ended => volatiles.confusion = 0,
580
+ .continuing => if (volatiles.confusion > 1) {
581
+ volatiles.confusion -= 1;
582
+ },
583
+ .overwritten => {},
584
+ else => unreachable,
562
585
  } else {
563
586
  volatiles.confusion -= 1;
564
587
  }
565
- try options.chance.confusion(player, volatiles.confusion);
588
+
589
+ try options.chance.confusion(player, if (volatiles.confusion == 0) .ended else .continuing);
566
590
 
567
591
  if (volatiles.confusion == 0) {
568
592
  volatiles.Confusion = false;
@@ -580,7 +604,9 @@ fn beforeMove(
580
604
  volatiles.Charging = false;
581
605
  volatiles.Binding = false;
582
606
  volatiles.Invulnerable = false;
583
- options.chance.clearDurations(player, null);
607
+
608
+ options.chance.observe(.attacking, player, .None);
609
+ options.chance.observe(.binding, player, .None);
584
610
  {
585
611
  // This feels (and is) disgusting but the cartridge literally just overwrites
586
612
  // the opponent's defense with the user's defense and resets it after. As a
@@ -588,19 +614,14 @@ fn beforeMove(
588
614
  const def = foe.active.stats.def;
589
615
  foe.active.stats.def = active.stats.def;
590
616
  defer foe.active.stats.def = def;
591
- if (!calcDamage(battle, player, player.foe(), null, false)) return .err;
617
+ if (!calcDamage(battle, player, player.foe(), null, false, options)) {
618
+ return .err;
619
+ }
592
620
  }
593
- // Pokémon Showdown incorrectly changes the "target" of the confusion self-hit based
594
- // on the targeting behavior of the confused Pokémon's selected move which results
595
- // in the wrong behavior with respect to the Substitute + Confusion glitch
596
- const target = if (showdown and Move.get(side.last_selected_move).target == .Self)
597
- player
598
- else
599
- player.foe();
600
621
 
601
622
  const uncapped = battle.last_damage;
602
623
  // Skipping adjustDamage / randomizeDamage / checkHit
603
- _ = try applyDamage(battle, player, target, .Confusion, options);
624
+ _ = try applyDamage(battle, player, player.foe(), .Confusion, options);
604
625
  // Pokémon Showdown thinks that confusion damage is uncapped ¯\_(ツ)_/¯
605
626
  if (showdown) battle.last_damage = uncapped;
606
627
 
@@ -617,7 +638,10 @@ fn beforeMove(
617
638
  volatiles.Thrashing = false;
618
639
  volatiles.Charging = false;
619
640
  volatiles.Binding = false;
620
- options.chance.clearDurations(player, null);
641
+
642
+ options.chance.observe(.attacking, player, .None);
643
+ options.chance.observe(.binding, player, .None);
644
+
621
645
  // GLITCH: Invulnerable is not cleared, resulting in permanent Fly/Dig invulnerability
622
646
  try log.cant(.{ ident, .Paralysis });
623
647
  return .done;
@@ -635,12 +659,8 @@ fn beforeMove(
635
659
  volatiles.state +%= battle.last_damage;
636
660
  }
637
661
 
638
- if (options.calc.modify(player, .attacking)) |extend| {
639
- if (!extend) volatiles.attacks = 0;
640
- } else {
641
- volatiles.attacks -= 1;
642
- }
643
- try options.chance.attacking(player, volatiles.attacks);
662
+ volatiles.attacks = decrement(.attacking, player, options, volatiles.attacks);
663
+ try options.chance.attacking(player, if (volatiles.attacks == 0) .ended else .continuing);
644
664
 
645
665
  if (volatiles.attacks != 0) {
646
666
  try log.activate(.{ ident, .Bide });
@@ -674,35 +694,40 @@ fn beforeMove(
674
694
  }
675
695
 
676
696
  if (volatiles.Thrashing) {
677
- if (options.calc.modify(player, .attacking)) |extend| {
678
- if (!extend) volatiles.attacks = 0;
679
- } else {
680
- volatiles.attacks -= 1;
681
- }
682
- try options.chance.attacking(player, volatiles.attacks);
697
+ try log.move(.{ ident, side.last_selected_move, battle.active(player.foe()) });
698
+
699
+ volatiles.attacks = decrement(.attacking, player, options, volatiles.attacks);
700
+ try options.chance.attacking(player, if (volatiles.attacks == 0) .ended else .continuing);
701
+
702
+ if (volatiles.attacks == 0) {
703
+ const overwritten = volatiles.Confusion;
704
+
705
+ volatiles.Thrashing = false;
706
+ volatiles.Confusion = true;
707
+ volatiles.confusion = Rolls.confusionDuration(battle, player, options);
683
708
 
684
- if (!showdown and handleThrashing(battle, active, player, options)) {
709
+ // On Pokémon Showdown this could leak information to the opponent that thrashing has
710
+ // ended (the engine's protocol is always from the omniscient perspective and clients
711
+ // can be expected to recognize this pattern and pass the information only to the
712
+ // correct player)
685
713
  try log.start(.{ battle.active(player), .ConfusionSilent });
714
+ options.chance.observe(.confusion, player, if (overwritten)
715
+ if (pkmn.options.overwrite) .overwritten else .continuing
716
+ else
717
+ .started);
718
+ if (!overwritten) options.calc.confusion(player);
686
719
  }
687
- try log.move(.{ ident, side.last_selected_move, battle.active(player.foe()) });
688
- if (showdown) {
689
- // This shouldn't actually set last_used_move, but Pokémon Showdown sets last
690
- // used in useMove and doesn't have the notion of skipping canMove semantics
691
- side.last_used_move = side.last_selected_move;
692
- if (handleThrashing(battle, active, player, options)) {
693
- try log.start(.{ battle.active(player), .ConfusionSilent });
694
- }
695
- }
720
+
721
+ // This shouldn't actually set last_used_move, but Pokémon Showdown sets last
722
+ // used in useMove and doesn't have the notion of skipping canMove semantics
723
+ if (showdown) side.last_used_move = side.last_selected_move;
724
+
696
725
  return .skip_can;
697
726
  }
698
727
 
699
728
  if (volatiles.Binding) {
700
- if (options.calc.modify(player, .binding)) |extend| {
701
- if (!extend) volatiles.attacks = 0;
702
- } else {
703
- volatiles.attacks -= 1;
704
- }
705
- try options.chance.binding(player, volatiles.attacks);
729
+ volatiles.attacks = decrement(.binding, player, options, volatiles.attacks);
730
+ try options.chance.binding(player, if (volatiles.attacks == 0) .ended else .continuing);
706
731
 
707
732
  try log.move(.{ ident, side.last_selected_move, battle.active(player.foe()) });
708
733
  if (showdown or battle.last_damage != 0) {
@@ -756,9 +781,14 @@ fn canMove(
756
781
 
757
782
  if (move.effect == .Thrashing) {
758
783
  Effects.thrashing(battle, player, options);
759
- } else if (!showdown and move.effect == .Binding) {
760
- // Pokémon Showdown handles this after hit/miss checks and damage calculation
761
- try Effects.binding(battle, player, false, options);
784
+ } else if (move.effect == .Binding) {
785
+ // Pokémon Showdown handles this after hit/miss checks and damage calculation, though clears
786
+ // Recharging in onTryMove (required for the Hyper Beam automatic selection glitch)
787
+ if (showdown) {
788
+ battle.foe(player).active.volatiles.Recharging = false;
789
+ } else {
790
+ try Effects.binding(battle, player, false, options);
791
+ }
762
792
  }
763
793
 
764
794
  return true;
@@ -795,7 +825,7 @@ fn decrementPP(side: *Side, mslot: u4, auto: bool) void {
795
825
  assert(active.move(mslot).pp == side.stored().move(mslot).pp);
796
826
  }
797
827
 
798
- fn incrementPP(side: *Side, mslot: u4) void {
828
+ fn incrementPP(side: *Side, player: Player, mslot: u4, options: anytype) void {
799
829
  var active = &side.active;
800
830
  const volatiles = &active.volatiles;
801
831
 
@@ -803,8 +833,11 @@ fn incrementPP(side: *Side, mslot: u4) void {
803
833
  // GLITCH: No check for Transform means an empty/incorrect stored slot can get incremented
804
834
  if (showdown and volatiles.Transform) return;
805
835
 
836
+ const n = options.calc.overridden(player, .pp) orelse 1;
837
+ if (volatiles.Transform and !options.chance.pp(player, n)) return;
838
+
806
839
  assert(mslot > 0 and mslot <= 4);
807
- side.stored().moves[mslot - 1].pp = @as(u6, @intCast(side.stored().moves[mslot - 1].pp)) +% 1;
840
+ side.stored().moves[mslot - 1].pp = @as(u6, @intCast(side.stored().moves[mslot - 1].pp)) +% n;
808
841
  }
809
842
 
810
843
  // Pokémon Showdown does hit/multi/crit/damage instead of crit/damage/hit/multi
@@ -858,7 +891,7 @@ fn doMove(
858
891
 
859
892
  var late = showdown and move.effect != .Explode;
860
893
  const skip = status or immune;
861
- if ((!showdown or (!skip or counter)) and !miss) blk: {
894
+ if ((!showdown or (!skip or counter)) and !miss) damage: {
862
895
  if (showdown and move.effect.isMulti()) {
863
896
  try Effects.multiHit(battle, player, move, options);
864
897
  hits = side.active.volatiles.attacks;
@@ -880,8 +913,9 @@ fn doMove(
880
913
  ohko = if (!showdown) side.active.stats.spe >= foe.active.stats.spe else true;
881
914
  // This can overflow after adjustDamage, but will still be sufficient to OHKO
882
915
  battle.last_damage = if (ohko) 65535 else 0;
883
- if (showdown) break :blk; // skip adjustDamage / randomizeDamage
884
- } else if (!calcDamage(battle, player, player.foe(), move, crit)) {
916
+ if (showdown) break :damage; // skip adjustDamage / randomizeDamage
917
+ } else if (!calcDamage(battle, player, player.foe(), move, crit, options)) {
918
+ if (!showdown) try options.chance.commit(player, .err);
885
919
  return @as(?Result, Result.Error);
886
920
  }
887
921
  if (battle.last_damage == 0) {
@@ -895,11 +929,12 @@ fn doMove(
895
929
  }
896
930
  }
897
931
 
932
+ var missed: bool = undefined;
898
933
  const zero = battle.last_damage == 0;
899
- miss = if (showdown or skip)
900
- miss
901
- else
902
- (!try moveHit(battle, player, move, &immune, &mist, options) or zero);
934
+ if (!showdown and !skip) {
935
+ missed = !try moveHit(battle, player, move, &immune, &mist, options);
936
+ miss = missed or zero;
937
+ }
903
938
 
904
939
  assert(showdown or miss or battle.last_damage > 0 or skip);
905
940
  assert((!showdown and miss) or !(ohko and immune));
@@ -926,11 +961,32 @@ fn doMove(
926
961
  try log.immune(.{ foe_ident, .OHKO });
927
962
  } else if (immune and !invulnerable and (!showdown or move.effect != .Binding)) {
928
963
  try log.immune(.{ foe_ident, .None });
964
+ if (!showdown) {
965
+ if (move.effect == .Binding) {
966
+ // Rather obnoxiously, the cartridge sets the Binding volatile in canMove and
967
+ // then simply clears it later on if the move happens to miss in moveHit.
968
+ // However, moveHit can return true and we'll still end up in the outer miss
969
+ // handling block because attacking into an immune Pokemon does zero damage
970
+ // (considered missing)
971
+ assert(zero);
972
+ // Thus we still need to actually save the hit result (but not the crit roll).
973
+ // Using .miss for this commit would accomplish what we want, but is somewhat
974
+ // misleading as its entirely possible the move actually did hit (and the target
975
+ // is now trapped) so we use the .binding value instead which is effectively
976
+ // identical, just doesn't claim to know about the actual results of the hit
977
+ // roll)
978
+ try options.chance.commit(player, .binding);
979
+ } else {
980
+ // The .glitch option allows for conditionally committing if we are in a
981
+ // division-by-zero freeze alternative timeline scenario
982
+ try options.chance.commit(player, .glitch);
983
+ }
984
+ }
929
985
  } else if (mist) {
930
986
  if (!foe.active.volatiles.Substitute) try log.activate(.{ foe_ident, .Mist });
931
987
  try log.fail(.{ foe_ident, .None });
932
988
  } else {
933
- if (!showdown and !zero) try options.chance.commit(player, .miss);
989
+ if (!showdown) try options.chance.commit(player, if (zero and !missed) .hit else .miss);
934
990
  try log.lastmiss(.{});
935
991
  try log.miss(.{battle.active(player)});
936
992
  }
@@ -945,8 +1001,6 @@ fn doMove(
945
1001
  try buildRage(battle, player.foe(), options);
946
1002
  } else if (showdown and move.effect == .Disable) {
947
1003
  try buildRage(battle, player.foe(), options);
948
- } else if (!showdown and immune and move.effect == .Binding) {
949
- try options.chance.commit(player, .binding);
950
1004
  }
951
1005
  return null;
952
1006
  } else if (!showdown) {
@@ -994,22 +1048,20 @@ fn doMove(
994
1048
  try Effects.poison(battle, player, Move.get(.PoisonSting), options);
995
1049
  }
996
1050
  try log.hitcount(.{ battle.active(player.foe()), hit });
997
- } else if (showdown) {
1051
+ } else if (showdown and move.effect == .Binding) {
998
1052
  // This should be handled much earlier but Pokémon Showdown does it here... ¯\_(ツ)_/¯
999
- if (move.effect == .Binding) {
1000
- try Effects.binding(battle, player, rewrap, options);
1001
- if (immune) {
1002
- battle.last_damage = 0;
1003
- assert(foe.stored().hp > 0);
1004
- // Pokémon Showdown logs |-damage| here instead of |-immune| because logic...
1005
- if (sub) {
1006
- try log.activate(.{ battle.active(player.foe()), .Substitute });
1007
- } else {
1008
- try log.damage(.{ battle.active(player.foe()), foe.stored(), .None });
1009
- try buildRage(battle, player.foe(), options);
1010
- }
1011
- return null;
1053
+ try Effects.binding(battle, player, rewrap, options);
1054
+ if (immune) {
1055
+ battle.last_damage = 0;
1056
+ assert(foe.stored().hp > 0);
1057
+ // Pokémon Showdown logs |-damage| here instead of |-immune| because logic...
1058
+ if (sub) {
1059
+ try log.activate(.{ battle.active(player.foe()), .Substitute });
1060
+ } else {
1061
+ try log.damage(.{ battle.active(player.foe()), foe.stored(), .None });
1062
+ try buildRage(battle, player.foe(), options);
1012
1063
  }
1064
+ return null;
1013
1065
  }
1014
1066
  }
1015
1067
 
@@ -1069,6 +1121,7 @@ fn calcDamage(
1069
1121
  target_player: Player,
1070
1122
  m: ?Move.Data,
1071
1123
  crit: bool,
1124
+ options: anytype,
1072
1125
  ) bool {
1073
1126
  // Confusion (indicated when m == null) just needs a 40 BP physical move
1074
1127
  const cfz = m == null;
@@ -1080,28 +1133,8 @@ fn calcDamage(
1080
1133
 
1081
1134
  const special = move.type.special();
1082
1135
 
1083
- // zig fmt: off
1084
- var atk: u32 =
1085
- if (crit)
1086
- if (special) side.stored().stats.spc
1087
- else side.stored().stats.atk
1088
- else
1089
- if (special) side.active.stats.spc
1090
- else side.active.stats.atk;
1091
- var def: u32 =
1092
- if (crit)
1093
- if (special) target.stored().stats.spc
1094
- else target.stored().stats.def
1095
- else
1096
- // GLITCH: not capped to MAX_STAT_VALUE, can be 999 * 2 = 1998
1097
- if (special)
1098
- target.active.stats.spc *
1099
- @as(u2, if (target.active.volatiles.LightScreen) 2 else 1)
1100
- // Pokémon Showdown doesn't apply the opponent's Reflect to confusion's self-hit
1101
- else
1102
- target.active.stats.def *
1103
- @as(u2, if ((!showdown or !cfz) and target.active.volatiles.Reflect) 2 else 1);
1104
- // zig fmt: on
1136
+ var atk = attack(side, crit, special);
1137
+ var def = defense(target, crit, special, cfz);
1105
1138
 
1106
1139
  // Pokémon Showdown erroneously skips this for confusion's self-hit damage, but thankfully we
1107
1140
  // will not overflow because the hit is only 40 BP and unboosted (the highest legal unboosted
@@ -1109,7 +1142,7 @@ fn calcDamage(
1109
1142
  if ((!showdown or !cfz) and (atk > 255 or def > 255)) {
1110
1143
  atk = @max((atk / 4) & 255, 1);
1111
1144
  // GLITCH: not adjusted to be a min of 1 on cartridge (can lead to division-by-zero freeze)
1112
- def = @max((def / 4) & 255, if (showdown) 1 else 0);
1145
+ def = @max((def / 4) & 255, if (pkmn.options.mod) 1 else 0);
1113
1146
  }
1114
1147
 
1115
1148
  const lvl: u32 = side.stored().level * @as(u2, if (crit) 2 else 1);
@@ -1126,11 +1159,41 @@ fn calcDamage(
1126
1159
  d = @min(997, d);
1127
1160
  d += 2;
1128
1161
 
1162
+ // Whether or not a critical hit occurred can change whether a division-by-zero freeze is
1163
+ // encountered so we need to make sure that in the event of a miss we commit the status of the
1164
+ // crit roll. The alternative to doing this extra work is to *always* commit the crit roll but
1165
+ // that results in counter intuitive probabilities in the common case
1166
+ if (pkmn.options.chance and !pkmn.options.mod and move.effect != .Explode) {
1167
+ atk = attack(side, !crit, special);
1168
+ def = defense(target, !crit, special, false);
1169
+ if ((atk > 255 or def > 255) and (def / 4) & 255 == 0) options.chance.glitch();
1170
+ }
1171
+
1129
1172
  battle.last_damage = @intCast(d);
1130
1173
 
1131
1174
  return true;
1132
1175
  }
1133
1176
 
1177
+ fn attack(side: *Side, crit: bool, special: bool) u32 {
1178
+ return if (crit)
1179
+ if (special) side.stored().stats.spc else side.stored().stats.atk
1180
+ else if (special) side.active.stats.spc else side.active.stats.atk;
1181
+ }
1182
+
1183
+ fn defense(target: *Side, crit: bool, special: bool, cfz: bool) u32 {
1184
+ return if (crit)
1185
+ if (special) target.stored().stats.spc else target.stored().stats.def
1186
+ else
1187
+ // GLITCH: not capped to MAX_STAT_VALUE, can be 999 * 2 = 1998
1188
+ if (special)
1189
+ target.active.stats.spc *
1190
+ @as(u2, if (target.active.volatiles.LightScreen) 2 else 1)
1191
+ else
1192
+ // Pokémon Showdown doesn't apply the opponent's Reflect to confusion's self-hit
1193
+ target.active.stats.def *
1194
+ @as(u2, if ((!showdown or !cfz) and target.active.volatiles.Reflect) 2 else 1);
1195
+ }
1196
+
1134
1197
  fn adjustDamage(battle: anytype, player: Player) u16 {
1135
1198
  const side = battle.side(player);
1136
1199
  const foe = battle.foe(player);
@@ -1185,13 +1248,13 @@ fn specialDamage(battle: anytype, player: Player, move: Move.Data, options: anyt
1185
1248
  .Psywave => power: {
1186
1249
  const max: u8 = @intCast(@as(u16, side.stored().level) * 3 / 2);
1187
1250
  // GLITCH: Psywave infinite glitch loop
1188
- if (!showdown and max <= 1) return Result.Error;
1251
+ if (!pkmn.options.mod and max <= 1) return Result.Error;
1189
1252
  break :power try Rolls.psywave(battle, player, max, options);
1190
1253
  },
1191
1254
  else => unreachable,
1192
1255
  };
1193
1256
 
1194
- if (battle.last_damage == 0) return if (showdown) null else Result.Error;
1257
+ if (battle.last_damage == 0) return if (pkmn.options.mod) null else Result.Error;
1195
1258
 
1196
1259
  const sub = showdown and foe.active.volatiles.Substitute;
1197
1260
  _ = try applyDamage(battle, player.foe(), player.foe(), .None, options);
@@ -1228,12 +1291,16 @@ fn counterDamage(battle: anytype, player: Player, move: Move.Data, options: anyt
1228
1291
 
1229
1292
  if (!used or !selected) {
1230
1293
  // GLITCH: Counter desync (covered by Desync Clause Mod on Pokémon Showdown)
1231
- if (!showdown) return Result.Error;
1294
+ if (!pkmn.options.mod) return Result.Error;
1232
1295
  try options.log.fail(.{ battle.active(player), .None });
1233
1296
  return null;
1234
1297
  }
1235
1298
 
1236
- battle.last_damage *|= 2;
1299
+ // NOTE: llvm/llvm-project#58557
1300
+ battle.last_damage = if (comptime builtin.target.cpu.arch.isWasm())
1301
+ @max(battle.last_damage *% 2, std.math.maxInt(u16))
1302
+ else
1303
+ battle.last_damage *| 2;
1237
1304
 
1238
1305
  // Pokémon Showdown calls moveHit before Counter
1239
1306
  if (!showdown and !try checkHit(battle, player, move, options)) return null;
@@ -1265,7 +1332,7 @@ fn applyDamage(
1265
1332
  subbed.active.volatiles.Substitute = false;
1266
1333
  // battle.last_damage is not updated with the amount of HP the Substitute had
1267
1334
  try options.log.end(.{ battle.active(sub_player), .Substitute });
1268
- options.calc.capped(target_player);
1335
+ if (reason != .Confusion) options.calc.capped(sub_player);
1269
1336
  return true;
1270
1337
  } else {
1271
1338
  // Safe to truncate since less than subbed.volatiles.substitute which is a u8
@@ -1299,7 +1366,7 @@ fn mirrorMove(
1299
1366
  try options.log.fail(.{ battle.active(player), .None });
1300
1367
  return null;
1301
1368
  } else if (!showdown or foe.last_used_move != .Struggle) {
1302
- incrementPP(side, mslot);
1369
+ incrementPP(side, player, mslot, options);
1303
1370
  }
1304
1371
 
1305
1372
  if (!try canMove(battle, player, mslot, auto, false, .MirrorMove, residual, options)) {
@@ -1320,7 +1387,7 @@ fn metronome(
1320
1387
  var side = battle.side(player);
1321
1388
 
1322
1389
  side.last_selected_move = try Rolls.metronome(battle, player, options);
1323
- incrementPP(side, mslot);
1390
+ incrementPP(side, player, mslot, options);
1324
1391
 
1325
1392
  if (!try canMove(battle, player, mslot, auto, false, .Metronome, residual, options)) {
1326
1393
  return null;
@@ -1421,13 +1488,17 @@ fn moveHit(
1421
1488
 
1422
1489
  if (!miss) return true;
1423
1490
  if (!showdown or !foe.active.volatiles.Invulnerable) battle.last_damage = 0;
1491
+
1424
1492
  side.active.volatiles.Binding = false;
1493
+ options.chance.observe(.binding, player, .None);
1494
+
1425
1495
  return false;
1426
1496
  }
1427
1497
 
1428
1498
  fn checkFaint(
1429
1499
  battle: anytype,
1430
1500
  player: Player,
1501
+ cap: bool,
1431
1502
  options: anytype,
1432
1503
  ) @TypeOf(options.log).Error!?Result {
1433
1504
  const side = battle.side(player);
@@ -1440,8 +1511,8 @@ fn checkFaint(
1440
1511
  const foe_out = foe_fainted and findFirstAlive(foe) == 0;
1441
1512
  const more = player_out or foe_out;
1442
1513
 
1443
- if (try faint(battle, player, !(more or foe_fainted), options)) |r| return r;
1444
- if (foe_fainted) if (try faint(battle, player.foe(), !more, options)) |r| return r;
1514
+ if (try faint(battle, player, !(more or foe_fainted), cap, options)) |r| return r;
1515
+ if (foe_fainted) if (try faint(battle, player.foe(), !more, false, options)) |r| return r;
1445
1516
 
1446
1517
  assert(!side.active.volatiles.MultiHit);
1447
1518
  assert(!foe.active.volatiles.MultiHit);
@@ -1462,7 +1533,7 @@ fn checkFaint(
1462
1533
  return .{ .p1 = foe_choice, .p2 = .Switch };
1463
1534
  }
1464
1535
 
1465
- fn faint(battle: anytype, player: Player, done: bool, options: anytype) !?Result {
1536
+ fn faint(battle: anytype, player: Player, done: bool, cap: bool, options: anytype) !?Result {
1466
1537
  var side = battle.side(player);
1467
1538
  var foe = battle.foe(player);
1468
1539
  assert(side.stored().hp == 0);
@@ -1471,7 +1542,7 @@ fn faint(battle: anytype, player: Player, done: bool, options: anytype) !?Result
1471
1542
  assert(!foe_volatiles.MultiHit);
1472
1543
  if (foe_volatiles.Bide) {
1473
1544
  assert(!foe_volatiles.Thrashing and !foe_volatiles.Rage);
1474
- foe_volatiles.state = if (showdown) 0 else foe_volatiles.state & 255;
1545
+ foe_volatiles.state = if (pkmn.options.mod) 0 else foe_volatiles.state & 255;
1475
1546
  if (foe_volatiles.state != 0) return Result.Error;
1476
1547
  }
1477
1548
 
@@ -1496,7 +1567,9 @@ fn faint(battle: anytype, player: Player, done: bool, options: anytype) !?Result
1496
1567
  }
1497
1568
 
1498
1569
  try options.log.faint(.{ battle.active(player), done });
1499
- options.calc.capped(player);
1570
+ // We can only safely set capped if the faint occured as the direct result of an opponent's
1571
+ // move (as opposed to due to residual damage, recoil, Explosion, etc)
1572
+ if (cap) options.calc.capped(player);
1500
1573
  return null;
1501
1574
  }
1502
1575
 
@@ -1560,12 +1633,12 @@ fn endTurn(battle: anytype, options: anytype) @TypeOf(options.log).Error!Result
1560
1633
 
1561
1634
  battle.turn += 1;
1562
1635
 
1563
- if (showdown and pkmn.options.ebc and checkEBC(battle)) {
1636
+ if (pkmn.options.mod and pkmn.options.ebc and checkEBC(battle)) {
1564
1637
  try options.log.tie(.{});
1565
1638
  return Result.Tie;
1566
1639
  }
1567
1640
 
1568
- if (showdown and battle.turn >= 1000) {
1641
+ if (pkmn.options.mod and battle.turn >= 1000) {
1569
1642
  try options.log.tie(.{});
1570
1643
  return Result.Tie;
1571
1644
  } else if (battle.turn >= 65535) {
@@ -1656,17 +1729,6 @@ fn buildRage(battle: anytype, who: Player, options: anytype) !void {
1656
1729
  }
1657
1730
  }
1658
1731
 
1659
- fn handleThrashing(battle: anytype, active: *ActivePokemon, player: Player, options: anytype) bool {
1660
- var volatiles = &active.volatiles;
1661
- assert(volatiles.Thrashing);
1662
- if (volatiles.attacks > 0) return false;
1663
-
1664
- volatiles.Thrashing = false;
1665
- volatiles.Confusion = true;
1666
- volatiles.confusion = Rolls.confusionDuration(battle, player, true, options);
1667
- return true;
1668
- }
1669
-
1670
1732
  fn onBegin(
1671
1733
  battle: anytype,
1672
1734
  player: Player,
@@ -1753,8 +1815,10 @@ pub const Effects = struct {
1753
1815
 
1754
1816
  side.active.volatiles.Bide = true;
1755
1817
  assert(!side.active.volatiles.Thrashing and !side.active.volatiles.Rage);
1818
+
1756
1819
  side.active.volatiles.state = 0;
1757
1820
  side.active.volatiles.attacks = Rolls.attackingDuration(battle, player, options);
1821
+ options.chance.observe(.attacking, player, .started);
1758
1822
 
1759
1823
  try options.log.start(.{ battle.active(player), .Bide });
1760
1824
  }
@@ -1820,7 +1884,10 @@ pub const Effects = struct {
1820
1884
 
1821
1885
  if (foe.active.volatiles.Confusion) return;
1822
1886
  foe.active.volatiles.Confusion = true;
1823
- foe.active.volatiles.confusion = Rolls.confusionDuration(battle, player, false, options);
1887
+
1888
+ foe.active.volatiles.confusion = Rolls.confusionDuration(battle, player, options);
1889
+ options.chance.observe(.confusion, player.foe(), .started);
1890
+ options.calc.confusion(player.foe());
1824
1891
 
1825
1892
  try options.log.start(.{ battle.active(player.foe()), .Confusion });
1826
1893
  }
@@ -1866,18 +1933,18 @@ pub const Effects = struct {
1866
1933
 
1867
1934
  // Technically this is still considered simply a "miss" on the cartridge,
1868
1935
  // but diverging from Pokémon Showdown here would mostly just be pedantic
1869
- if (n == 0) {
1936
+ if (n == 0 or (pkmn.options.mod and err)) {
1870
1937
  try options.log.fail(.{ foe_ident, .None });
1871
1938
  return null;
1872
1939
  } else if (err) {
1873
1940
  // GLITCH: Transform + Mirror Move / Metronome PP softlock
1874
- assert(!showdown);
1875
1941
  return Result.Error;
1876
1942
  }
1877
1943
 
1878
1944
  volatiles.disable_move =
1879
1945
  @intCast(try Rolls.moveSlot(battle, player, &foe.active.moves, n, options));
1880
1946
  volatiles.disable_duration = Rolls.disableDuration(battle, player, options);
1947
+ options.chance.observe(.disable, player.foe(), .started);
1881
1948
 
1882
1949
  const id = foe.active.move(volatiles.disable_move).id;
1883
1950
  try options.log.start(.{ foe_ident, .Disable, id });
@@ -1948,7 +2015,7 @@ pub const Effects = struct {
1948
2015
  if (foe.active.types.includes(move.type)) return;
1949
2016
  if (!try Rolls.secondaryChance(battle, player, true, options)) return;
1950
2017
  // Freeze Clause Mod
1951
- if (showdown) for (foe.pokemon) |p| if (Status.is(p.status, .FRZ)) return;
2018
+ if (pkmn.options.mod) for (foe.pokemon) |p| if (Status.is(p.status, .FRZ)) return;
1952
2019
 
1953
2020
  foe_stored.status = Status.init(.FRZ);
1954
2021
  // GLITCH: Hyper Beam recharging status is not cleared
@@ -1984,22 +2051,24 @@ pub const Effects = struct {
1984
2051
  if (p != player and Status.any(s.stored().status)) {
1985
2052
  try log.curestatus(.{ foe_ident, foe_stored.status, .Silent });
1986
2053
  s.stored().status = 0;
2054
+ options.chance.observe(.sleep, p, .None);
1987
2055
  } else if (showdown and s.stored().status == Status.TOX) {
1988
2056
  s.stored().status = Status.init(.PSN);
1989
2057
  try log.status(.{ battle.active(p), s.stored().status, .None });
1990
2058
  }
1991
- try clearVolatiles(battle, p, p != player, options);
2059
+ try clearVolatiles(battle, p, options);
1992
2060
  }
1993
2061
  } else {
1994
2062
  if (Status.any(foe_stored.status)) {
1995
2063
  if (Status.is(foe_stored.status, .FRZ) or Status.is(foe_stored.status, .SLP)) {
1996
2064
  foe.last_selected_move = .SKIP_TURN;
2065
+ options.chance.observe(.sleep, player.foe(), .None);
1997
2066
  }
1998
2067
  try log.curestatus(.{ foe_ident, foe_stored.status, .Silent });
1999
2068
  foe_stored.status = 0;
2000
2069
  }
2001
- try clearVolatiles(battle, player, false, options);
2002
- try clearVolatiles(battle, player.foe(), true, options);
2070
+ try clearVolatiles(battle, player, options);
2071
+ try clearVolatiles(battle, player.foe(), options);
2003
2072
  }
2004
2073
  }
2005
2074
 
@@ -2287,7 +2356,7 @@ pub const Effects = struct {
2287
2356
  }
2288
2357
 
2289
2358
  // Sleep Clause Mod
2290
- if (showdown) {
2359
+ if (pkmn.options.mod) {
2291
2360
  for (foe.pokemon) |p| {
2292
2361
  if (Status.is(p.status, .SLP) and !Status.is(p.status, .EXT)) return;
2293
2362
  }
@@ -2295,6 +2364,8 @@ pub const Effects = struct {
2295
2364
  foe.active.volatiles.Recharging = false;
2296
2365
 
2297
2366
  foe_stored.status = Status.slp(Rolls.sleepDuration(battle, player, options));
2367
+ options.chance.observe(.sleep, player.foe(), .started);
2368
+
2298
2369
  const last = battle.side(player).last_selected_move;
2299
2370
  try options.log.status(.{ foe_ident, foe_stored.status, .From, last });
2300
2371
  }
@@ -2351,6 +2422,8 @@ pub const Effects = struct {
2351
2422
 
2352
2423
  volatiles.Thrashing = true;
2353
2424
  volatiles.attacks = Rolls.attackingDuration(battle, player, options);
2425
+
2426
+ options.chance.observe(.attacking, player, .started);
2354
2427
  }
2355
2428
 
2356
2429
  fn transform(battle: anytype, player: Player, options: anytype) !void {
@@ -2389,13 +2462,14 @@ pub const Effects = struct {
2389
2462
  if (side.active.volatiles.Binding) return;
2390
2463
  side.active.volatiles.Binding = true;
2391
2464
  // GLITCH: Hyper Beam automatic selection glitch if Recharging gets cleared on miss
2392
- // (Pokémon Showdown unitentionally patches this glitch, preventing automatic selection)
2465
+ // (Pokémon Showdown clears Recharging in onTryMove instead)
2393
2466
  if (!showdown) {
2394
2467
  foe.active.volatiles.Recharging = false;
2395
2468
  } else if (foe.stored().hp == 0) return if (!rewrap) battle.rng.advance(1);
2396
2469
 
2397
2470
  side.active.volatiles.attacks =
2398
2471
  try Rolls.distribution(battle, .Binding, player, options) - 1;
2472
+ options.chance.observe(.binding, player, .started);
2399
2473
  }
2400
2474
 
2401
2475
  fn boost(battle: anytype, player: Player, move: Move.Data, options: anytype) !void {
@@ -2625,22 +2699,22 @@ fn isForced(active: anytype) bool {
2625
2699
  active.volatiles.Thrashing or active.volatiles.Charging;
2626
2700
  }
2627
2701
 
2628
- fn clearVolatiles(battle: anytype, who: Player, clear: bool, options: anytype) !void {
2702
+ fn clearVolatiles(battle: anytype, who: Player, options: anytype) !void {
2629
2703
  var log = options.log;
2630
2704
  var side = battle.side(who);
2631
2705
  var volatiles = &side.active.volatiles;
2632
2706
  const ident = battle.active(who);
2633
2707
 
2634
- options.chance.clearDurations(who, clear);
2635
-
2636
2708
  if (volatiles.disable_move != 0) {
2637
2709
  volatiles.disable_move = 0;
2638
2710
  volatiles.disable_duration = 0;
2711
+ options.chance.observe(.disable, who, .None);
2639
2712
  try log.end(.{ ident, .DisableSilent });
2640
2713
  }
2641
2714
  if (volatiles.Confusion) {
2642
2715
  // volatiles.confusion is left unchanged
2643
2716
  volatiles.Confusion = false;
2717
+ options.chance.observe(.confusion, who, .None);
2644
2718
  try log.end(.{ ident, .ConfusionSilent });
2645
2719
  }
2646
2720
  if (volatiles.Mist) {
@@ -2675,6 +2749,19 @@ fn clearVolatiles(battle: anytype, who: Player, clear: bool, options: anytype) !
2675
2749
  }
2676
2750
  }
2677
2751
 
2752
+ fn decrement(
2753
+ comptime field: chance.Action.Field,
2754
+ player: Player,
2755
+ options: anytype,
2756
+ n: anytype,
2757
+ ) @TypeOf(n) {
2758
+ return if (options.calc.overridden(player, field)) |obs| switch (obs) {
2759
+ .started, .ended => 0,
2760
+ .continuing => if (n > 1) n - 1 else n,
2761
+ else => unreachable,
2762
+ } else n - 1;
2763
+ }
2764
+
2678
2765
  pub const Rolls = struct {
2679
2766
  fn speedTie(battle: anytype, options: anytype) !bool {
2680
2767
  const p1 = if (options.calc.overridden(.P1, .speed_tie)) |player|
@@ -2872,9 +2959,10 @@ pub const Rolls = struct {
2872
2959
  }
2873
2960
 
2874
2961
  fn sleepDuration(battle: anytype, player: Player, options: anytype) u3 {
2875
- const duration: u3 = if (options.calc.overridden(player, .duration)) |val|
2876
- @intCast(val)
2877
- else if (@hasDecl(@TypeOf(battle.rng), "sleepDuration"))
2962
+ const duration: u3 = (if (options.calc.overridden(player, .duration)) |val|
2963
+ if (val >= 1 and val <= 7) @as(u3, @intCast(val)) else null
2964
+ else
2965
+ null) orelse if (@hasDecl(@TypeOf(battle.rng), "sleepDuration"))
2878
2966
  battle.rng.sleepDuration(player)
2879
2967
  else if (showdown)
2880
2968
  battle.rng.range(u3, 1, 8)
@@ -2886,14 +2974,15 @@ pub const Rolls = struct {
2886
2974
  };
2887
2975
 
2888
2976
  assert(duration >= 1 and duration <= 7);
2889
- options.chance.duration(.sleep, player, player.foe(), duration);
2977
+ options.chance.duration(player, duration);
2890
2978
  return duration;
2891
2979
  }
2892
2980
 
2893
2981
  fn disableDuration(battle: anytype, player: Player, options: anytype) u4 {
2894
- const duration: u4 = if (options.calc.overridden(player, .duration)) |val|
2895
- val
2896
- else if (@hasDecl(@TypeOf(battle.rng), "disableDuration"))
2982
+ const duration: u4 = (if (options.calc.overridden(player, .duration)) |val|
2983
+ if (val >= 1 and val <= 8) val else null
2984
+ else
2985
+ null) orelse if (@hasDecl(@TypeOf(battle.rng), "disableDuration"))
2897
2986
  battle.rng.disableDuration(player)
2898
2987
  else if (showdown)
2899
2988
  battle.rng.range(u4, 1, 9)
@@ -2901,29 +2990,31 @@ pub const Rolls = struct {
2901
2990
  @intCast((battle.rng.next() & 7) + 1);
2902
2991
 
2903
2992
  assert(duration >= 1 and duration <= 8);
2904
- options.chance.duration(.disable, player, player.foe(), duration);
2993
+ options.chance.duration(player, duration);
2905
2994
  return duration;
2906
2995
  }
2907
2996
 
2908
- fn confusionDuration(battle: anytype, player: Player, self: bool, options: anytype) u3 {
2909
- const duration: u3 = if (options.calc.overridden(player, .duration)) |val|
2910
- @intCast(val)
2911
- else if (@hasDecl(@TypeOf(battle.rng), "confusionDuration"))
2912
- battle.rng.confusionDuration(player, self)
2997
+ fn confusionDuration(battle: anytype, player: Player, options: anytype) u3 {
2998
+ const duration: u3 = (if (options.calc.overridden(player, .duration)) |val|
2999
+ if (val >= 2 and val <= 5) @as(u3, @intCast(val)) else null
3000
+ else
3001
+ null) orelse if (@hasDecl(@TypeOf(battle.rng), "confusionDuration"))
3002
+ battle.rng.confusionDuration(player)
2913
3003
  else if (showdown)
2914
3004
  battle.rng.range(u3, 2, 6)
2915
3005
  else
2916
3006
  @intCast((battle.rng.next() & 3) + 2);
2917
3007
 
2918
3008
  assert(duration >= 2 and duration <= 5);
2919
- options.chance.duration(.confusion, player, if (self) player else player.foe(), duration);
3009
+ options.chance.duration(player, duration);
2920
3010
  return duration;
2921
3011
  }
2922
3012
 
2923
3013
  fn attackingDuration(battle: anytype, player: Player, options: anytype) u3 {
2924
- const duration: u3 = if (options.calc.overridden(player, .duration)) |val|
2925
- @intCast(val)
2926
- else if (@hasDecl(@TypeOf(battle.rng), "attackingDuration"))
3014
+ const duration: u3 = (if (options.calc.overridden(player, .duration)) |val|
3015
+ if (val >= 2 and val <= 3) @as(u3, @intCast(val)) else null
3016
+ else
3017
+ null) orelse if (@hasDecl(@TypeOf(battle.rng), "attackingDuration"))
2927
3018
  battle.rng.attackingDuration(player)
2928
3019
  else if (showdown)
2929
3020
  battle.rng.range(u3, 2, 4)
@@ -2931,7 +3022,7 @@ pub const Rolls = struct {
2931
3022
  @intCast((battle.rng.next() & 1) + 2);
2932
3023
 
2933
3024
  assert(duration >= 2 and duration <= 3);
2934
- options.chance.duration(.attacking, player, player, duration);
3025
+ options.chance.duration(player, duration);
2935
3026
  return duration;
2936
3027
  }
2937
3028
 
@@ -2944,9 +3035,10 @@ pub const Rolls = struct {
2944
3035
  options: anytype,
2945
3036
  ) !u3 {
2946
3037
  const roll = if (effect == .Binding) .duration else .multi_hit;
2947
- const n: u3 = if (options.calc.overridden(player, roll)) |val|
2948
- @intCast(val)
2949
- else if (@hasDecl(@TypeOf(battle.rng), "distribution"))
3038
+ const n: u3 = (if (options.calc.overridden(player, roll)) |val|
3039
+ if (val >= 2 and val <= 5) @as(u3, @intCast(val)) else null
3040
+ else
3041
+ null) orelse if (@hasDecl(@TypeOf(battle.rng), "distribution"))
2950
3042
  battle.rng.distribution(player)
2951
3043
  else if (showdown)
2952
3044
  DISTRIBUTION[battle.rng.range(u3, 0, DISTRIBUTION.len)]
@@ -2957,7 +3049,7 @@ pub const Rolls = struct {
2957
3049
 
2958
3050
  assert(n >= 2 and n <= 5);
2959
3051
  if (effect == .Binding) {
2960
- options.chance.duration(.binding, player, player, n);
3052
+ options.chance.duration(player, n);
2961
3053
  } else {
2962
3054
  try options.chance.multiHit(player, n);
2963
3055
  }
@@ -2972,15 +3064,13 @@ pub const Rolls = struct {
2972
3064
  options: anytype,
2973
3065
  ) !u4 {
2974
3066
  // TODO: consider throwing error instead of rerolling?
2975
- const overridden = if (options.calc.overridden(player, .move_slot)) |val|
3067
+ const slot: u4 = (if (options.calc.overridden(player, .move_slot)) |val|
2976
3068
  if (moves[val - 1].id != .None and (check_pp == 0 or moves[val - 1].pp > 0))
2977
3069
  val
2978
3070
  else
2979
3071
  null
2980
3072
  else
2981
- null;
2982
-
2983
- const slot: u4 = overridden orelse if (@hasDecl(@TypeOf(battle.rng), "moveSlot"))
3073
+ null) orelse if (@hasDecl(@TypeOf(battle.rng), "moveSlot"))
2984
3074
  battle.rng.moveSlot(player, moves, check_pp)
2985
3075
  else slot: {
2986
3076
  if (showdown) {
@@ -3138,7 +3228,7 @@ pub fn choices(battle: anytype, player: Player, request: Choice.Type, out: []Cho
3138
3228
  // Struggle (Pokémon Showdown would use 'move 1' here)
3139
3229
  if (n == before) {
3140
3230
  // GLITCH: Transform + Mirror Move / Metronome PP softlock
3141
- if (!showdown) {
3231
+ if (!pkmn.options.mod) {
3142
3232
  while (slot <= 4) : (slot += 1) {
3143
3233
  if (active.moves[slot - 1].pp != 0) return n;
3144
3234
  }