@pkmn/engine 0.1.0-dev.a0167a9d → 0.1.0-dev.a0447192

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 (60) hide show
  1. package/README.md +4 -2
  2. package/build/pkg/addon/browser.d.ts +3 -0
  3. package/build/pkg/addon/browser.js +43 -0
  4. package/build/pkg/addon/browser.js.map +1 -0
  5. package/build/pkg/addon/node.d.ts +3 -0
  6. package/build/pkg/addon/node.js +96 -0
  7. package/build/pkg/addon/node.js.map +1 -0
  8. package/build/pkg/addon.d.ts +2 -0
  9. package/build/pkg/addon.js +29 -51
  10. package/build/pkg/addon.js.map +1 -1
  11. package/build/pkg/data.d.ts +23 -1
  12. package/build/pkg/data.js.map +1 -1
  13. package/build/pkg/gen1.js +1 -1
  14. package/build/pkg/gen1.js.map +1 -1
  15. package/build/pkg/index.d.ts +22 -9
  16. package/build/pkg/index.js +15 -10
  17. package/build/pkg/index.js.map +1 -1
  18. package/build/pkg/protocol.d.ts +20 -1
  19. package/build/pkg/protocol.js +105 -103
  20. package/build/pkg/protocol.js.map +1 -1
  21. package/build/tools/debug.js +35 -68
  22. package/build/tools/debug.js.map +1 -1
  23. package/build/tools/display/debug.jsx +141 -0
  24. package/build/tools/display/debug.jsx.map +1 -0
  25. package/build/tools/display/index.js +132 -0
  26. package/build/tools/display/index.js.map +1 -0
  27. package/build/tools/display/ui.jsx +349 -0
  28. package/build/tools/display/ui.jsx.map +1 -0
  29. package/build/tools/display/util.js +18 -0
  30. package/build/tools/display/util.js.map +1 -0
  31. package/build.zig +199 -66
  32. package/package.json +46 -29
  33. package/src/bin/install-pkmn-engine +7 -5
  34. package/src/bin/pkmn-debug +1 -1
  35. package/src/lib/common/array.zig +77 -0
  36. package/src/lib/common/data.zig +8 -8
  37. package/src/lib/common/debug.zig +39 -8
  38. package/src/lib/common/js.zig +4 -2
  39. package/src/lib/common/optional.zig +13 -9
  40. package/src/lib/common/options.zig +2 -0
  41. package/src/lib/common/protocol.zig +41 -36
  42. package/src/lib/common/rational.zig +53 -51
  43. package/src/lib/common/rng.zig +4 -4
  44. package/src/lib/common/util.zig +50 -1
  45. package/src/lib/gen1/calc.zig +508 -127
  46. package/src/lib/gen1/chance.zig +316 -173
  47. package/src/lib/gen1/data.zig +28 -27
  48. package/src/lib/gen1/helpers.zig +15 -450
  49. package/src/lib/gen1/mechanics.zig +216 -145
  50. package/src/lib/gen2/calc.zig +34 -16
  51. package/src/lib/gen2/chance.zig +90 -592
  52. package/src/lib/gen2/data.zig +15 -14
  53. package/src/lib/gen2/helpers.zig +4 -6
  54. package/src/lib/gen2/mechanics.zig +47 -77
  55. package/src/lib/pkmn.zig +7 -5
  56. package/src/tools/display/dom.ts +96 -0
  57. package/src/tools/display/ui.css +359 -0
  58. package/build/test/display.js +0 -400
  59. package/build/test/display.js.map +0 -1
  60. package/src/test/display/pkmn.html.tmpl +0 -368
@@ -8,6 +8,7 @@ const protocol = @import("../common/protocol.zig");
8
8
  const rng = @import("../common/rng.zig");
9
9
 
10
10
  const data = @import("data.zig");
11
+ const chance = @import("chance.zig");
11
12
 
12
13
  const assert = std.debug.assert;
13
14
 
@@ -27,11 +28,9 @@ const Heal = protocol.Heal;
27
28
 
28
29
  const Gen12 = rng.Gen12;
29
30
 
30
- const ActivePokemon = data.ActivePokemon;
31
31
  const Effectiveness = data.Effectiveness;
32
32
  const Move = data.Move;
33
33
  const MoveSlot = data.MoveSlot;
34
- const Pokemon = data.Pokemon;
35
34
  const Side = data.Side;
36
35
  const Species = data.Species;
37
36
  const Stats = data.Stats;
@@ -253,11 +252,11 @@ fn switchIn(battle: anytype, player: Player, slot: u8, initial: bool, options: a
253
252
  active.moves = incoming.moves;
254
253
 
255
254
  statusModify(incoming.status, &active.stats);
255
+ options.chance.switched(player, slot);
256
256
 
257
257
  foe.active.volatiles.Binding = false;
258
258
 
259
259
  try options.log.switched(.{ battle.active(player), incoming });
260
- options.chance.switched(player, side.order[0], out);
261
260
 
262
261
  if (showdown and incoming.status == Status.TOX) {
263
262
  incoming.status = Status.init(.PSN);
@@ -353,7 +352,7 @@ fn doTurn(
353
352
  if (try checkFaint(battle, player, false, options)) |r| return r;
354
353
  } else if (foe_choice.type == .Pass) return null;
355
354
 
356
- options.chance.clearPending();
355
+ if (!showdown) options.chance.clearPending();
357
356
 
358
357
  residual = true;
359
358
  replace = battle.side(foe_player).stored().hp == 0;
@@ -484,17 +483,22 @@ fn beforeMove(
484
483
 
485
484
  if (Status.is(stored.status, .SLP)) {
486
485
  const before = stored.status;
486
+ const slf = Status.is(stored.status, .EXT);
487
487
  // 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;
488
+ if (options.calc.overridden(player, .sleep)) |obs| switch (obs) {
489
+ .started, .ended => stored.status = 0,
490
+ .continuing => if (Status.duration(stored.status) > 1) {
491
+ stored.status -= 1;
492
+ },
493
+ else => unreachable,
490
494
  } else {
491
495
  stored.status -= 1;
492
496
  }
493
497
 
494
498
  const duration = Status.duration(stored.status);
495
- if (!Status.is(stored.status, .EXT)) {
496
- try options.chance.sleep(player, duration);
497
- }
499
+ try options.chance.sleep(player, if (slf)
500
+ .None
501
+ else if (duration == 0) .ended else .continuing);
498
502
 
499
503
  if (duration == 0) {
500
504
  try log.curestatus(.{ ident, before, .Message });
@@ -532,12 +536,12 @@ fn beforeMove(
532
536
  }
533
537
 
534
538
  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);
539
+ volatiles.disable_duration =
540
+ decrement(.disable, player, options, volatiles.disable_duration);
541
+ try options.chance.disable(
542
+ player,
543
+ if (volatiles.disable_duration == 0) .ended else .continuing,
544
+ );
541
545
 
542
546
  if (volatiles.disable_duration == 0) {
543
547
  volatiles.disable_move = 0;
@@ -556,13 +560,18 @@ fn beforeMove(
556
560
 
557
561
  if (volatiles.Confusion) {
558
562
  assert(volatiles.confusion > 0);
559
-
560
- if (options.calc.modify(player, .confusion)) |extend| {
561
- if (!extend) volatiles.confusion = 0;
563
+ if (options.calc.overridden(player, .confusion)) |obs| switch (obs) {
564
+ .started, .ended => volatiles.confusion = 0,
565
+ .continuing => if (volatiles.confusion > 1) {
566
+ volatiles.confusion -= 1;
567
+ },
568
+ .overwritten => {},
569
+ else => unreachable,
562
570
  } else {
563
571
  volatiles.confusion -= 1;
564
572
  }
565
- try options.chance.confusion(player, volatiles.confusion);
573
+
574
+ try options.chance.confusion(player, if (volatiles.confusion == 0) .ended else .continuing);
566
575
 
567
576
  if (volatiles.confusion == 0) {
568
577
  volatiles.Confusion = false;
@@ -580,7 +589,9 @@ fn beforeMove(
580
589
  volatiles.Charging = false;
581
590
  volatiles.Binding = false;
582
591
  volatiles.Invulnerable = false;
583
- options.chance.clearDurations(player, null);
592
+
593
+ options.chance.observe(.attacking, player, .None);
594
+ options.chance.observe(.binding, player, .None);
584
595
  {
585
596
  // This feels (and is) disgusting but the cartridge literally just overwrites
586
597
  // the opponent's defense with the user's defense and resets it after. As a
@@ -588,7 +599,9 @@ fn beforeMove(
588
599
  const def = foe.active.stats.def;
589
600
  foe.active.stats.def = active.stats.def;
590
601
  defer foe.active.stats.def = def;
591
- if (!calcDamage(battle, player, player.foe(), null, false)) return .err;
602
+ if (!calcDamage(battle, player, player.foe(), null, false, options)) {
603
+ return .err;
604
+ }
592
605
  }
593
606
  // Pokémon Showdown incorrectly changes the "target" of the confusion self-hit based
594
607
  // on the targeting behavior of the confused Pokémon's selected move which results
@@ -617,7 +630,10 @@ fn beforeMove(
617
630
  volatiles.Thrashing = false;
618
631
  volatiles.Charging = false;
619
632
  volatiles.Binding = false;
620
- options.chance.clearDurations(player, null);
633
+
634
+ options.chance.observe(.attacking, player, .None);
635
+ options.chance.observe(.binding, player, .None);
636
+
621
637
  // GLITCH: Invulnerable is not cleared, resulting in permanent Fly/Dig invulnerability
622
638
  try log.cant(.{ ident, .Paralysis });
623
639
  return .done;
@@ -635,12 +651,8 @@ fn beforeMove(
635
651
  volatiles.state +%= battle.last_damage;
636
652
  }
637
653
 
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);
654
+ volatiles.attacks = decrement(.attacking, player, options, volatiles.attacks);
655
+ try options.chance.attacking(player, if (volatiles.attacks == 0) .ended else .continuing);
644
656
 
645
657
  if (volatiles.attacks != 0) {
646
658
  try log.activate(.{ ident, .Bide });
@@ -674,35 +686,40 @@ fn beforeMove(
674
686
  }
675
687
 
676
688
  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);
689
+ try log.move(.{ ident, side.last_selected_move, battle.active(player.foe()) });
690
+
691
+ volatiles.attacks = decrement(.attacking, player, options, volatiles.attacks);
692
+ try options.chance.attacking(player, if (volatiles.attacks == 0) .ended else .continuing);
693
+
694
+ if (volatiles.attacks == 0) {
695
+ const overwritten = volatiles.Confusion;
683
696
 
684
- if (!showdown and handleThrashing(battle, active, player, options)) {
697
+ volatiles.Thrashing = false;
698
+ volatiles.Confusion = true;
699
+ volatiles.confusion = Rolls.confusionDuration(battle, player, options);
700
+
701
+ // On Pokémon Showdown this could leak information to the opponent that thrashing has
702
+ // ended (the engine's protocol is always from the omniscient perspective and clients
703
+ // can be expected to recognize this pattern and pass the information only to the
704
+ // correct player)
685
705
  try log.start(.{ battle.active(player), .ConfusionSilent });
706
+ options.chance.observe(.confusion, player, if (overwritten)
707
+ if (pkmn.options.overwrite) .overwritten else .continuing
708
+ else
709
+ .started);
710
+ if (!overwritten) options.calc.confusion(player);
686
711
  }
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
- }
712
+
713
+ // This shouldn't actually set last_used_move, but Pokémon Showdown sets last
714
+ // used in useMove and doesn't have the notion of skipping canMove semantics
715
+ if (showdown) side.last_used_move = side.last_selected_move;
716
+
696
717
  return .skip_can;
697
718
  }
698
719
 
699
720
  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);
721
+ volatiles.attacks = decrement(.binding, player, options, volatiles.attacks);
722
+ try options.chance.binding(player, if (volatiles.attacks == 0) .ended else .continuing);
706
723
 
707
724
  try log.move(.{ ident, side.last_selected_move, battle.active(player.foe()) });
708
725
  if (showdown or battle.last_damage != 0) {
@@ -795,7 +812,7 @@ fn decrementPP(side: *Side, mslot: u4, auto: bool) void {
795
812
  assert(active.move(mslot).pp == side.stored().move(mslot).pp);
796
813
  }
797
814
 
798
- fn incrementPP(side: *Side, mslot: u4) void {
815
+ fn incrementPP(side: *Side, player: Player, mslot: u4, options: anytype) void {
799
816
  var active = &side.active;
800
817
  const volatiles = &active.volatiles;
801
818
 
@@ -803,8 +820,11 @@ fn incrementPP(side: *Side, mslot: u4) void {
803
820
  // GLITCH: No check for Transform means an empty/incorrect stored slot can get incremented
804
821
  if (showdown and volatiles.Transform) return;
805
822
 
823
+ const n = options.calc.overridden(player, .pp) orelse 1;
824
+ if (volatiles.Transform and !options.chance.pp(player, n)) return;
825
+
806
826
  assert(mslot > 0 and mslot <= 4);
807
- side.stored().moves[mslot - 1].pp = @as(u6, @intCast(side.stored().moves[mslot - 1].pp)) +% 1;
827
+ side.stored().moves[mslot - 1].pp = @as(u6, @intCast(side.stored().moves[mslot - 1].pp)) +% n;
808
828
  }
809
829
 
810
830
  // Pokémon Showdown does hit/multi/crit/damage instead of crit/damage/hit/multi
@@ -858,7 +878,7 @@ fn doMove(
858
878
 
859
879
  var late = showdown and move.effect != .Explode;
860
880
  const skip = status or immune;
861
- if ((!showdown or (!skip or counter)) and !miss) blk: {
881
+ if ((!showdown or (!skip or counter)) and !miss) damage: {
862
882
  if (showdown and move.effect.isMulti()) {
863
883
  try Effects.multiHit(battle, player, move, options);
864
884
  hits = side.active.volatiles.attacks;
@@ -880,8 +900,9 @@ fn doMove(
880
900
  ohko = if (!showdown) side.active.stats.spe >= foe.active.stats.spe else true;
881
901
  // This can overflow after adjustDamage, but will still be sufficient to OHKO
882
902
  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)) {
903
+ if (showdown) break :damage; // skip adjustDamage / randomizeDamage
904
+ } else if (!calcDamage(battle, player, player.foe(), move, crit, options)) {
905
+ if (!showdown) try options.chance.commit(player, .err);
885
906
  return @as(?Result, Result.Error);
886
907
  }
887
908
  if (battle.last_damage == 0) {
@@ -895,11 +916,12 @@ fn doMove(
895
916
  }
896
917
  }
897
918
 
919
+ var missed: bool = undefined;
898
920
  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);
921
+ if (!showdown and !skip) {
922
+ missed = !try moveHit(battle, player, move, &immune, &mist, options);
923
+ miss = missed or zero;
924
+ }
903
925
 
904
926
  assert(showdown or miss or battle.last_damage > 0 or skip);
905
927
  assert((!showdown and miss) or !(ohko and immune));
@@ -926,11 +948,32 @@ fn doMove(
926
948
  try log.immune(.{ foe_ident, .OHKO });
927
949
  } else if (immune and !invulnerable and (!showdown or move.effect != .Binding)) {
928
950
  try log.immune(.{ foe_ident, .None });
951
+ if (!showdown) {
952
+ if (move.effect == .Binding) {
953
+ // Rather obnoxiously, the cartridge sets the Binding volatile in canMove and
954
+ // then simply clears it later on if the move happens to miss in moveHit.
955
+ // However, moveHit can return true and we'll still end up in the outer miss
956
+ // handling block because attacking into an immune Pokemon does zero damage
957
+ // (considered missing)
958
+ assert(zero);
959
+ // Thus we still need to actually save the hit result (but not the crit roll).
960
+ // Using .miss for this commit would accomplish what we want, but is somewhat
961
+ // misleading as its entirely possible the move actually did hit (and the target
962
+ // is now trapped) so we use the .binding value instead which is effectively
963
+ // identical, just doesn't claim to know about the actual results of the hit
964
+ // roll)
965
+ try options.chance.commit(player, .binding);
966
+ } else {
967
+ // The .glitch option allows for conditionally committing if we are in a
968
+ // division-by-zero freeze alternative timeline scenario
969
+ try options.chance.commit(player, .glitch);
970
+ }
971
+ }
929
972
  } else if (mist) {
930
973
  if (!foe.active.volatiles.Substitute) try log.activate(.{ foe_ident, .Mist });
931
974
  try log.fail(.{ foe_ident, .None });
932
975
  } else {
933
- if (!showdown and !zero) try options.chance.commit(player, .miss);
976
+ if (!showdown) try options.chance.commit(player, if (zero and !missed) .hit else .miss);
934
977
  try log.lastmiss(.{});
935
978
  try log.miss(.{battle.active(player)});
936
979
  }
@@ -945,8 +988,6 @@ fn doMove(
945
988
  try buildRage(battle, player.foe(), options);
946
989
  } else if (showdown and move.effect == .Disable) {
947
990
  try buildRage(battle, player.foe(), options);
948
- } else if (!showdown and immune and move.effect == .Binding) {
949
- try options.chance.commit(player, .binding);
950
991
  }
951
992
  return null;
952
993
  } else if (!showdown) {
@@ -994,22 +1035,20 @@ fn doMove(
994
1035
  try Effects.poison(battle, player, Move.get(.PoisonSting), options);
995
1036
  }
996
1037
  try log.hitcount(.{ battle.active(player.foe()), hit });
997
- } else if (showdown) {
1038
+ } else if (showdown and move.effect == .Binding) {
998
1039
  // 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;
1040
+ try Effects.binding(battle, player, rewrap, options);
1041
+ if (immune) {
1042
+ battle.last_damage = 0;
1043
+ assert(foe.stored().hp > 0);
1044
+ // Pokémon Showdown logs |-damage| here instead of |-immune| because logic...
1045
+ if (sub) {
1046
+ try log.activate(.{ battle.active(player.foe()), .Substitute });
1047
+ } else {
1048
+ try log.damage(.{ battle.active(player.foe()), foe.stored(), .None });
1049
+ try buildRage(battle, player.foe(), options);
1012
1050
  }
1051
+ return null;
1013
1052
  }
1014
1053
  }
1015
1054
 
@@ -1069,6 +1108,7 @@ fn calcDamage(
1069
1108
  target_player: Player,
1070
1109
  m: ?Move.Data,
1071
1110
  crit: bool,
1111
+ options: anytype,
1072
1112
  ) bool {
1073
1113
  // Confusion (indicated when m == null) just needs a 40 BP physical move
1074
1114
  const cfz = m == null;
@@ -1080,28 +1120,8 @@ fn calcDamage(
1080
1120
 
1081
1121
  const special = move.type.special();
1082
1122
 
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
1123
+ var atk = attack(side, crit, special);
1124
+ var def = defense(target, crit, special, cfz);
1105
1125
 
1106
1126
  // Pokémon Showdown erroneously skips this for confusion's self-hit damage, but thankfully we
1107
1127
  // will not overflow because the hit is only 40 BP and unboosted (the highest legal unboosted
@@ -1126,11 +1146,41 @@ fn calcDamage(
1126
1146
  d = @min(997, d);
1127
1147
  d += 2;
1128
1148
 
1149
+ // Whether or not a critical hit occurred can change whether a division-by-zero freeze is
1150
+ // encountered so we need to make sure that in the event of a miss we commit the status of the
1151
+ // crit roll. The alternative to doing this extra work is to *always* commit the crit roll but
1152
+ // that results in counter intuitive probabilities in the common case
1153
+ if (pkmn.options.chance and !showdown) {
1154
+ atk = attack(side, !crit, special);
1155
+ def = defense(target, !crit, special, false);
1156
+ if ((atk > 255 or def > 255) and (def / 4) & 255 == 0) options.chance.glitch();
1157
+ }
1158
+
1129
1159
  battle.last_damage = @intCast(d);
1130
1160
 
1131
1161
  return true;
1132
1162
  }
1133
1163
 
1164
+ fn attack(side: *Side, crit: bool, special: bool) u32 {
1165
+ return if (crit)
1166
+ if (special) side.stored().stats.spc else side.stored().stats.atk
1167
+ else if (special) side.active.stats.spc else side.active.stats.atk;
1168
+ }
1169
+
1170
+ fn defense(target: *Side, crit: bool, special: bool, cfz: bool) u32 {
1171
+ return if (crit)
1172
+ if (special) target.stored().stats.spc else target.stored().stats.def
1173
+ else
1174
+ // GLITCH: not capped to MAX_STAT_VALUE, can be 999 * 2 = 1998
1175
+ if (special)
1176
+ target.active.stats.spc *
1177
+ @as(u2, if (target.active.volatiles.LightScreen) 2 else 1)
1178
+ else
1179
+ // Pokémon Showdown doesn't apply the opponent's Reflect to confusion's self-hit
1180
+ target.active.stats.def *
1181
+ @as(u2, if ((!showdown or !cfz) and target.active.volatiles.Reflect) 2 else 1);
1182
+ }
1183
+
1134
1184
  fn adjustDamage(battle: anytype, player: Player) u16 {
1135
1185
  const side = battle.side(player);
1136
1186
  const foe = battle.foe(player);
@@ -1265,7 +1315,7 @@ fn applyDamage(
1265
1315
  subbed.active.volatiles.Substitute = false;
1266
1316
  // battle.last_damage is not updated with the amount of HP the Substitute had
1267
1317
  try options.log.end(.{ battle.active(sub_player), .Substitute });
1268
- options.calc.capped(sub_player);
1318
+ if (reason != .Confusion) options.calc.capped(sub_player);
1269
1319
  return true;
1270
1320
  } else {
1271
1321
  // Safe to truncate since less than subbed.volatiles.substitute which is a u8
@@ -1299,7 +1349,7 @@ fn mirrorMove(
1299
1349
  try options.log.fail(.{ battle.active(player), .None });
1300
1350
  return null;
1301
1351
  } else if (!showdown or foe.last_used_move != .Struggle) {
1302
- incrementPP(side, mslot);
1352
+ incrementPP(side, player, mslot, options);
1303
1353
  }
1304
1354
 
1305
1355
  if (!try canMove(battle, player, mslot, auto, false, .MirrorMove, residual, options)) {
@@ -1320,7 +1370,7 @@ fn metronome(
1320
1370
  var side = battle.side(player);
1321
1371
 
1322
1372
  side.last_selected_move = try Rolls.metronome(battle, player, options);
1323
- incrementPP(side, mslot);
1373
+ incrementPP(side, player, mslot, options);
1324
1374
 
1325
1375
  if (!try canMove(battle, player, mslot, auto, false, .Metronome, residual, options)) {
1326
1376
  return null;
@@ -1421,7 +1471,10 @@ fn moveHit(
1421
1471
 
1422
1472
  if (!miss) return true;
1423
1473
  if (!showdown or !foe.active.volatiles.Invulnerable) battle.last_damage = 0;
1474
+
1424
1475
  side.active.volatiles.Binding = false;
1476
+ options.chance.observe(.binding, player, .None);
1477
+
1425
1478
  return false;
1426
1479
  }
1427
1480
 
@@ -1659,17 +1712,6 @@ fn buildRage(battle: anytype, who: Player, options: anytype) !void {
1659
1712
  }
1660
1713
  }
1661
1714
 
1662
- fn handleThrashing(battle: anytype, active: *ActivePokemon, player: Player, options: anytype) bool {
1663
- var volatiles = &active.volatiles;
1664
- assert(volatiles.Thrashing);
1665
- if (volatiles.attacks > 0) return false;
1666
-
1667
- volatiles.Thrashing = false;
1668
- volatiles.Confusion = true;
1669
- volatiles.confusion = Rolls.confusionDuration(battle, player, true, options);
1670
- return true;
1671
- }
1672
-
1673
1715
  fn onBegin(
1674
1716
  battle: anytype,
1675
1717
  player: Player,
@@ -1756,8 +1798,10 @@ pub const Effects = struct {
1756
1798
 
1757
1799
  side.active.volatiles.Bide = true;
1758
1800
  assert(!side.active.volatiles.Thrashing and !side.active.volatiles.Rage);
1801
+
1759
1802
  side.active.volatiles.state = 0;
1760
1803
  side.active.volatiles.attacks = Rolls.attackingDuration(battle, player, options);
1804
+ options.chance.observe(.attacking, player, .started);
1761
1805
 
1762
1806
  try options.log.start(.{ battle.active(player), .Bide });
1763
1807
  }
@@ -1823,7 +1867,10 @@ pub const Effects = struct {
1823
1867
 
1824
1868
  if (foe.active.volatiles.Confusion) return;
1825
1869
  foe.active.volatiles.Confusion = true;
1826
- foe.active.volatiles.confusion = Rolls.confusionDuration(battle, player, false, options);
1870
+
1871
+ foe.active.volatiles.confusion = Rolls.confusionDuration(battle, player, options);
1872
+ options.chance.observe(.confusion, player.foe(), .started);
1873
+ options.calc.confusion(player.foe());
1827
1874
 
1828
1875
  try options.log.start(.{ battle.active(player.foe()), .Confusion });
1829
1876
  }
@@ -1881,6 +1928,7 @@ pub const Effects = struct {
1881
1928
  volatiles.disable_move =
1882
1929
  @intCast(try Rolls.moveSlot(battle, player, &foe.active.moves, n, options));
1883
1930
  volatiles.disable_duration = Rolls.disableDuration(battle, player, options);
1931
+ options.chance.observe(.disable, player.foe(), .started);
1884
1932
 
1885
1933
  const id = foe.active.move(volatiles.disable_move).id;
1886
1934
  try options.log.start(.{ foe_ident, .Disable, id });
@@ -1987,22 +2035,24 @@ pub const Effects = struct {
1987
2035
  if (p != player and Status.any(s.stored().status)) {
1988
2036
  try log.curestatus(.{ foe_ident, foe_stored.status, .Silent });
1989
2037
  s.stored().status = 0;
2038
+ options.chance.observe(.sleep, p, .None);
1990
2039
  } else if (showdown and s.stored().status == Status.TOX) {
1991
2040
  s.stored().status = Status.init(.PSN);
1992
2041
  try log.status(.{ battle.active(p), s.stored().status, .None });
1993
2042
  }
1994
- try clearVolatiles(battle, p, p != player, options);
2043
+ try clearVolatiles(battle, p, options);
1995
2044
  }
1996
2045
  } else {
1997
2046
  if (Status.any(foe_stored.status)) {
1998
2047
  if (Status.is(foe_stored.status, .FRZ) or Status.is(foe_stored.status, .SLP)) {
1999
2048
  foe.last_selected_move = .SKIP_TURN;
2049
+ options.chance.observe(.sleep, player.foe(), .None);
2000
2050
  }
2001
2051
  try log.curestatus(.{ foe_ident, foe_stored.status, .Silent });
2002
2052
  foe_stored.status = 0;
2003
2053
  }
2004
- try clearVolatiles(battle, player, false, options);
2005
- try clearVolatiles(battle, player.foe(), true, options);
2054
+ try clearVolatiles(battle, player, options);
2055
+ try clearVolatiles(battle, player.foe(), options);
2006
2056
  }
2007
2057
  }
2008
2058
 
@@ -2298,6 +2348,8 @@ pub const Effects = struct {
2298
2348
  foe.active.volatiles.Recharging = false;
2299
2349
 
2300
2350
  foe_stored.status = Status.slp(Rolls.sleepDuration(battle, player, options));
2351
+ options.chance.observe(.sleep, player.foe(), .started);
2352
+
2301
2353
  const last = battle.side(player).last_selected_move;
2302
2354
  try options.log.status(.{ foe_ident, foe_stored.status, .From, last });
2303
2355
  }
@@ -2354,6 +2406,8 @@ pub const Effects = struct {
2354
2406
 
2355
2407
  volatiles.Thrashing = true;
2356
2408
  volatiles.attacks = Rolls.attackingDuration(battle, player, options);
2409
+
2410
+ options.chance.observe(.attacking, player, .started);
2357
2411
  }
2358
2412
 
2359
2413
  fn transform(battle: anytype, player: Player, options: anytype) !void {
@@ -2399,6 +2453,7 @@ pub const Effects = struct {
2399
2453
 
2400
2454
  side.active.volatiles.attacks =
2401
2455
  try Rolls.distribution(battle, .Binding, player, options) - 1;
2456
+ options.chance.observe(.binding, player, .started);
2402
2457
  }
2403
2458
 
2404
2459
  fn boost(battle: anytype, player: Player, move: Move.Data, options: anytype) !void {
@@ -2628,22 +2683,22 @@ fn isForced(active: anytype) bool {
2628
2683
  active.volatiles.Thrashing or active.volatiles.Charging;
2629
2684
  }
2630
2685
 
2631
- fn clearVolatiles(battle: anytype, who: Player, clear: bool, options: anytype) !void {
2686
+ fn clearVolatiles(battle: anytype, who: Player, options: anytype) !void {
2632
2687
  var log = options.log;
2633
2688
  var side = battle.side(who);
2634
2689
  var volatiles = &side.active.volatiles;
2635
2690
  const ident = battle.active(who);
2636
2691
 
2637
- options.chance.clearDurations(who, clear);
2638
-
2639
2692
  if (volatiles.disable_move != 0) {
2640
2693
  volatiles.disable_move = 0;
2641
2694
  volatiles.disable_duration = 0;
2695
+ options.chance.observe(.disable, who, .None);
2642
2696
  try log.end(.{ ident, .DisableSilent });
2643
2697
  }
2644
2698
  if (volatiles.Confusion) {
2645
2699
  // volatiles.confusion is left unchanged
2646
2700
  volatiles.Confusion = false;
2701
+ options.chance.observe(.confusion, who, .None);
2647
2702
  try log.end(.{ ident, .ConfusionSilent });
2648
2703
  }
2649
2704
  if (volatiles.Mist) {
@@ -2678,6 +2733,19 @@ fn clearVolatiles(battle: anytype, who: Player, clear: bool, options: anytype) !
2678
2733
  }
2679
2734
  }
2680
2735
 
2736
+ fn decrement(
2737
+ comptime field: chance.Action.Field,
2738
+ player: Player,
2739
+ options: anytype,
2740
+ n: anytype,
2741
+ ) @TypeOf(n) {
2742
+ return if (options.calc.overridden(player, field)) |obs| switch (obs) {
2743
+ .started, .ended => 0,
2744
+ .continuing => if (n > 1) n - 1 else n,
2745
+ else => unreachable,
2746
+ } else n - 1;
2747
+ }
2748
+
2681
2749
  pub const Rolls = struct {
2682
2750
  fn speedTie(battle: anytype, options: anytype) !bool {
2683
2751
  const p1 = if (options.calc.overridden(.P1, .speed_tie)) |player|
@@ -2875,9 +2943,10 @@ pub const Rolls = struct {
2875
2943
  }
2876
2944
 
2877
2945
  fn sleepDuration(battle: anytype, player: Player, options: anytype) u3 {
2878
- const duration: u3 = if (options.calc.overridden(player, .duration)) |val|
2879
- @intCast(val)
2880
- else if (@hasDecl(@TypeOf(battle.rng), "sleepDuration"))
2946
+ const duration: u3 = (if (options.calc.overridden(player, .duration)) |val|
2947
+ if (val >= 1 and val <= 7) @as(u3, @intCast(val)) else null
2948
+ else
2949
+ null) orelse if (@hasDecl(@TypeOf(battle.rng), "sleepDuration"))
2881
2950
  battle.rng.sleepDuration(player)
2882
2951
  else if (showdown)
2883
2952
  battle.rng.range(u3, 1, 8)
@@ -2889,14 +2958,15 @@ pub const Rolls = struct {
2889
2958
  };
2890
2959
 
2891
2960
  assert(duration >= 1 and duration <= 7);
2892
- options.chance.duration(.sleep, player, player.foe(), duration);
2961
+ options.chance.duration(player, duration);
2893
2962
  return duration;
2894
2963
  }
2895
2964
 
2896
2965
  fn disableDuration(battle: anytype, player: Player, options: anytype) u4 {
2897
- const duration: u4 = if (options.calc.overridden(player, .duration)) |val|
2898
- val
2899
- else if (@hasDecl(@TypeOf(battle.rng), "disableDuration"))
2966
+ const duration: u4 = (if (options.calc.overridden(player, .duration)) |val|
2967
+ if (val >= 1 and val <= 8) val else null
2968
+ else
2969
+ null) orelse if (@hasDecl(@TypeOf(battle.rng), "disableDuration"))
2900
2970
  battle.rng.disableDuration(player)
2901
2971
  else if (showdown)
2902
2972
  battle.rng.range(u4, 1, 9)
@@ -2904,29 +2974,31 @@ pub const Rolls = struct {
2904
2974
  @intCast((battle.rng.next() & 7) + 1);
2905
2975
 
2906
2976
  assert(duration >= 1 and duration <= 8);
2907
- options.chance.duration(.disable, player, player.foe(), duration);
2977
+ options.chance.duration(player, duration);
2908
2978
  return duration;
2909
2979
  }
2910
2980
 
2911
- fn confusionDuration(battle: anytype, player: Player, self: bool, options: anytype) u3 {
2912
- const duration: u3 = if (options.calc.overridden(player, .duration)) |val|
2913
- @intCast(val)
2914
- else if (@hasDecl(@TypeOf(battle.rng), "confusionDuration"))
2915
- battle.rng.confusionDuration(player, self)
2981
+ fn confusionDuration(battle: anytype, player: Player, options: anytype) u3 {
2982
+ const duration: u3 = (if (options.calc.overridden(player, .duration)) |val|
2983
+ if (val >= 2 and val <= 5) @as(u3, @intCast(val)) else null
2984
+ else
2985
+ null) orelse if (@hasDecl(@TypeOf(battle.rng), "confusionDuration"))
2986
+ battle.rng.confusionDuration(player)
2916
2987
  else if (showdown)
2917
2988
  battle.rng.range(u3, 2, 6)
2918
2989
  else
2919
2990
  @intCast((battle.rng.next() & 3) + 2);
2920
2991
 
2921
2992
  assert(duration >= 2 and duration <= 5);
2922
- options.chance.duration(.confusion, player, if (self) player else player.foe(), duration);
2993
+ options.chance.duration(player, duration);
2923
2994
  return duration;
2924
2995
  }
2925
2996
 
2926
2997
  fn attackingDuration(battle: anytype, player: Player, options: anytype) u3 {
2927
- const duration: u3 = if (options.calc.overridden(player, .duration)) |val|
2928
- @intCast(val)
2929
- else if (@hasDecl(@TypeOf(battle.rng), "attackingDuration"))
2998
+ const duration: u3 = (if (options.calc.overridden(player, .duration)) |val|
2999
+ if (val >= 2 and val <= 3) @as(u3, @intCast(val)) else null
3000
+ else
3001
+ null) orelse if (@hasDecl(@TypeOf(battle.rng), "attackingDuration"))
2930
3002
  battle.rng.attackingDuration(player)
2931
3003
  else if (showdown)
2932
3004
  battle.rng.range(u3, 2, 4)
@@ -2934,7 +3006,7 @@ pub const Rolls = struct {
2934
3006
  @intCast((battle.rng.next() & 1) + 2);
2935
3007
 
2936
3008
  assert(duration >= 2 and duration <= 3);
2937
- options.chance.duration(.attacking, player, player, duration);
3009
+ options.chance.duration(player, duration);
2938
3010
  return duration;
2939
3011
  }
2940
3012
 
@@ -2947,9 +3019,10 @@ pub const Rolls = struct {
2947
3019
  options: anytype,
2948
3020
  ) !u3 {
2949
3021
  const roll = if (effect == .Binding) .duration else .multi_hit;
2950
- const n: u3 = if (options.calc.overridden(player, roll)) |val|
2951
- @intCast(val)
2952
- else if (@hasDecl(@TypeOf(battle.rng), "distribution"))
3022
+ const n: u3 = (if (options.calc.overridden(player, roll)) |val|
3023
+ if (val >= 2 and val <= 5) @as(u3, @intCast(val)) else null
3024
+ else
3025
+ null) orelse if (@hasDecl(@TypeOf(battle.rng), "distribution"))
2953
3026
  battle.rng.distribution(player)
2954
3027
  else if (showdown)
2955
3028
  DISTRIBUTION[battle.rng.range(u3, 0, DISTRIBUTION.len)]
@@ -2960,7 +3033,7 @@ pub const Rolls = struct {
2960
3033
 
2961
3034
  assert(n >= 2 and n <= 5);
2962
3035
  if (effect == .Binding) {
2963
- options.chance.duration(.binding, player, player, n);
3036
+ options.chance.duration(player, n);
2964
3037
  } else {
2965
3038
  try options.chance.multiHit(player, n);
2966
3039
  }
@@ -2975,15 +3048,13 @@ pub const Rolls = struct {
2975
3048
  options: anytype,
2976
3049
  ) !u4 {
2977
3050
  // TODO: consider throwing error instead of rerolling?
2978
- const overridden = if (options.calc.overridden(player, .move_slot)) |val|
3051
+ const slot: u4 = (if (options.calc.overridden(player, .move_slot)) |val|
2979
3052
  if (moves[val - 1].id != .None and (check_pp == 0 or moves[val - 1].pp > 0))
2980
3053
  val
2981
3054
  else
2982
3055
  null
2983
3056
  else
2984
- null;
2985
-
2986
- const slot: u4 = overridden orelse if (@hasDecl(@TypeOf(battle.rng), "moveSlot"))
3057
+ null) orelse if (@hasDecl(@TypeOf(battle.rng), "moveSlot"))
2987
3058
  battle.rng.moveSlot(player, moves, check_pp)
2988
3059
  else slot: {
2989
3060
  if (showdown) {