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

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 (58) 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 +34 -67
  22. package/build/tools/debug.js.map +1 -1
  23. package/build/tools/display/debug.jsx +128 -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 +35 -22
  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/js.zig +4 -2
  38. package/src/lib/common/optional.zig +13 -9
  39. package/src/lib/common/protocol.zig +41 -36
  40. package/src/lib/common/rational.zig +113 -22
  41. package/src/lib/common/rng.zig +4 -4
  42. package/src/lib/common/util.zig +50 -1
  43. package/src/lib/gen1/calc.zig +428 -153
  44. package/src/lib/gen1/chance.zig +332 -224
  45. package/src/lib/gen1/data.zig +28 -27
  46. package/src/lib/gen1/helpers.zig +12 -542
  47. package/src/lib/gen1/mechanics.zig +189 -120
  48. package/src/lib/gen2/calc.zig +34 -16
  49. package/src/lib/gen2/chance.zig +90 -503
  50. package/src/lib/gen2/data.zig +15 -14
  51. package/src/lib/gen2/helpers.zig +4 -6
  52. package/src/lib/gen2/mechanics.zig +47 -77
  53. package/src/lib/pkmn.zig +7 -5
  54. package/src/tools/display/dom.ts +96 -0
  55. package/src/tools/display/ui.css +351 -0
  56. package/build/test/display.js +0 -400
  57. package/build/test/display.js.map +0 -1
  58. 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);
@@ -347,13 +346,13 @@ fn doTurn(
347
346
  )) |r| return r;
348
347
  if (!replace) {
349
348
  if (player_choice.type != .Switch) {
350
- if (try checkFaint(battle, foe_player, options)) |r| return r;
349
+ if (try checkFaint(battle, foe_player, true, options)) |r| return r;
351
350
  }
352
351
  if (residual) try handleResidual(battle, player, options);
353
- if (try checkFaint(battle, player, options)) |r| return r;
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;
@@ -369,10 +368,10 @@ fn doTurn(
369
368
  )) |r| return r;
370
369
  if (!replace) {
371
370
  if (!calc and foe_choice.type != .Switch) {
372
- if (try checkFaint(battle, player, options)) |r| return r;
371
+ if (try checkFaint(battle, player, true, options)) |r| return r;
373
372
  }
374
373
  if (residual) try handleResidual(battle, foe_player, options);
375
- if (try checkFaint(battle, foe_player, options)) |r| return r;
374
+ if (try checkFaint(battle, foe_player, false, options)) |r| return r;
376
375
  }
377
376
 
378
377
  // Flinch is bugged on Pokémon Showdown because it gets implemented with a duration which causes
@@ -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,19 @@ 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 => volatiles.confusion -= 1,
565
+ .continuing => if (volatiles.confusion > 1) {
566
+ volatiles.confusion -= 1;
567
+ },
568
+ .ended => volatiles.confusion = 0,
569
+ .overwritten => {},
570
+ else => unreachable,
562
571
  } else {
563
572
  volatiles.confusion -= 1;
564
573
  }
565
- try options.chance.confusion(player, volatiles.confusion);
574
+
575
+ try options.chance.confusion(player, if (volatiles.confusion == 0) .ended else .continuing);
566
576
 
567
577
  if (volatiles.confusion == 0) {
568
578
  volatiles.Confusion = false;
@@ -580,7 +590,9 @@ fn beforeMove(
580
590
  volatiles.Charging = false;
581
591
  volatiles.Binding = false;
582
592
  volatiles.Invulnerable = false;
583
- options.chance.clearDurations(player, null);
593
+
594
+ options.chance.observe(.attacking, player, .None);
595
+ options.chance.observe(.binding, player, .None);
584
596
  {
585
597
  // This feels (and is) disgusting but the cartridge literally just overwrites
586
598
  // the opponent's defense with the user's defense and resets it after. As a
@@ -588,7 +600,9 @@ fn beforeMove(
588
600
  const def = foe.active.stats.def;
589
601
  foe.active.stats.def = active.stats.def;
590
602
  defer foe.active.stats.def = def;
591
- if (!calcDamage(battle, player, player.foe(), null, false)) return .err;
603
+ if (!calcDamage(battle, player, player.foe(), null, false, options)) {
604
+ return .err;
605
+ }
592
606
  }
593
607
  // Pokémon Showdown incorrectly changes the "target" of the confusion self-hit based
594
608
  // on the targeting behavior of the confused Pokémon's selected move which results
@@ -617,7 +631,10 @@ fn beforeMove(
617
631
  volatiles.Thrashing = false;
618
632
  volatiles.Charging = false;
619
633
  volatiles.Binding = false;
620
- options.chance.clearDurations(player, null);
634
+
635
+ options.chance.observe(.attacking, player, .None);
636
+ options.chance.observe(.binding, player, .None);
637
+
621
638
  // GLITCH: Invulnerable is not cleared, resulting in permanent Fly/Dig invulnerability
622
639
  try log.cant(.{ ident, .Paralysis });
623
640
  return .done;
@@ -635,12 +652,8 @@ fn beforeMove(
635
652
  volatiles.state +%= battle.last_damage;
636
653
  }
637
654
 
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);
655
+ volatiles.attacks = decrement(.attacking, player, options, volatiles.attacks);
656
+ try options.chance.attacking(player, if (volatiles.attacks == 0) .ended else .continuing);
644
657
 
645
658
  if (volatiles.attacks != 0) {
646
659
  try log.activate(.{ ident, .Bide });
@@ -674,35 +687,36 @@ fn beforeMove(
674
687
  }
675
688
 
676
689
  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);
690
+ try log.move(.{ ident, side.last_selected_move, battle.active(player.foe()) });
691
+
692
+ volatiles.attacks = decrement(.attacking, player, options, volatiles.attacks);
693
+ try options.chance.attacking(player, if (volatiles.attacks == 0) .ended else .continuing);
694
+
695
+ if (volatiles.attacks == 0) {
696
+ const overwritten = volatiles.Confusion;
683
697
 
684
- if (!showdown and handleThrashing(battle, active, player, options)) {
698
+ volatiles.Thrashing = false;
699
+ volatiles.Confusion = true;
700
+ volatiles.confusion = Rolls.confusionDuration(battle, player, options);
701
+
702
+ // On Pokémon Showdown this could leak information to the opponent that thrashing has
703
+ // ended (the engine's protocol is always from the omniscient perspective and clients
704
+ // can be expected to recognize this pattern and pass the information only to the
705
+ // correct player)
685
706
  try log.start(.{ battle.active(player), .ConfusionSilent });
707
+ options.chance.observe(.confusion, player, if (overwritten) .overwritten else .started);
686
708
  }
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
- }
709
+
710
+ // This shouldn't actually set last_used_move, but Pokémon Showdown sets last
711
+ // used in useMove and doesn't have the notion of skipping canMove semantics
712
+ if (showdown) side.last_used_move = side.last_selected_move;
713
+
696
714
  return .skip_can;
697
715
  }
698
716
 
699
717
  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);
718
+ volatiles.attacks = decrement(.binding, player, options, volatiles.attacks);
719
+ try options.chance.binding(player, if (volatiles.attacks == 0) .ended else .continuing);
706
720
 
707
721
  try log.move(.{ ident, side.last_selected_move, battle.active(player.foe()) });
708
722
  if (showdown or battle.last_damage != 0) {
@@ -795,7 +809,7 @@ fn decrementPP(side: *Side, mslot: u4, auto: bool) void {
795
809
  assert(active.move(mslot).pp == side.stored().move(mslot).pp);
796
810
  }
797
811
 
798
- fn incrementPP(side: *Side, mslot: u4) void {
812
+ fn incrementPP(side: *Side, player: Player, mslot: u4, options: anytype) void {
799
813
  var active = &side.active;
800
814
  const volatiles = &active.volatiles;
801
815
 
@@ -803,8 +817,11 @@ fn incrementPP(side: *Side, mslot: u4) void {
803
817
  // GLITCH: No check for Transform means an empty/incorrect stored slot can get incremented
804
818
  if (showdown and volatiles.Transform) return;
805
819
 
820
+ const n = options.calc.overridden(player, .pp) orelse 1;
821
+ if (volatiles.Transform and !options.chance.pp(player, n)) return;
822
+
806
823
  assert(mslot > 0 and mslot <= 4);
807
- side.stored().moves[mslot - 1].pp = @as(u6, @intCast(side.stored().moves[mslot - 1].pp)) +% 1;
824
+ side.stored().moves[mslot - 1].pp = @as(u6, @intCast(side.stored().moves[mslot - 1].pp)) +% n;
808
825
  }
809
826
 
810
827
  // Pokémon Showdown does hit/multi/crit/damage instead of crit/damage/hit/multi
@@ -858,7 +875,7 @@ fn doMove(
858
875
 
859
876
  var late = showdown and move.effect != .Explode;
860
877
  const skip = status or immune;
861
- if ((!showdown or (!skip or counter)) and !miss) blk: {
878
+ if ((!showdown or (!skip or counter)) and !miss) damage: {
862
879
  if (showdown and move.effect.isMulti()) {
863
880
  try Effects.multiHit(battle, player, move, options);
864
881
  hits = side.active.volatiles.attacks;
@@ -880,8 +897,9 @@ fn doMove(
880
897
  ohko = if (!showdown) side.active.stats.spe >= foe.active.stats.spe else true;
881
898
  // This can overflow after adjustDamage, but will still be sufficient to OHKO
882
899
  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)) {
900
+ if (showdown) break :damage; // skip adjustDamage / randomizeDamage
901
+ } else if (!calcDamage(battle, player, player.foe(), move, crit, options)) {
902
+ if (!showdown) try options.chance.commit(player, .err);
885
903
  return @as(?Result, Result.Error);
886
904
  }
887
905
  if (battle.last_damage == 0) {
@@ -895,11 +913,12 @@ fn doMove(
895
913
  }
896
914
  }
897
915
 
916
+ var missed: bool = undefined;
898
917
  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);
918
+ if (!showdown and !skip) {
919
+ missed = !try moveHit(battle, player, move, &immune, &mist, options);
920
+ miss = missed or zero;
921
+ }
903
922
 
904
923
  assert(showdown or miss or battle.last_damage > 0 or skip);
905
924
  assert((!showdown and miss) or !(ohko and immune));
@@ -926,11 +945,32 @@ fn doMove(
926
945
  try log.immune(.{ foe_ident, .OHKO });
927
946
  } else if (immune and !invulnerable and (!showdown or move.effect != .Binding)) {
928
947
  try log.immune(.{ foe_ident, .None });
948
+ if (!showdown) {
949
+ if (move.effect == .Binding) {
950
+ // Rather obnoxiously, the cartridge sets the Binding volatile in canMove and
951
+ // then simply clears it later on if the move happens to miss in moveHit.
952
+ // However, moveHit can return true and we'll still end up in the outer miss
953
+ // handling block because attacking into an immune Pokemon does zero damage
954
+ // (considered missing)
955
+ assert(zero);
956
+ // Thus we still need to actually save the hit result (but not the crit roll).
957
+ // Using .miss for this commit would accomplish what we want, but is somewhat
958
+ // misleading as its entirely possible the move actually did hit (and the target
959
+ // is now trapped) so we use the .binding value instead which is effectively
960
+ // identical, just doesn't claim to know about the actual results of the hit
961
+ // roll)
962
+ try options.chance.commit(player, .binding);
963
+ } else {
964
+ // The .glitch option allows for conditionally committing if we are in a
965
+ // division-by-zero freeze alternative timeline scenario
966
+ try options.chance.commit(player, .glitch);
967
+ }
968
+ }
929
969
  } else if (mist) {
930
970
  if (!foe.active.volatiles.Substitute) try log.activate(.{ foe_ident, .Mist });
931
971
  try log.fail(.{ foe_ident, .None });
932
972
  } else {
933
- if (!showdown and !zero) try options.chance.commit(player, .miss);
973
+ if (!showdown) try options.chance.commit(player, if (zero and !missed) .hit else .miss);
934
974
  try log.lastmiss(.{});
935
975
  try log.miss(.{battle.active(player)});
936
976
  }
@@ -945,8 +985,6 @@ fn doMove(
945
985
  try buildRage(battle, player.foe(), options);
946
986
  } else if (showdown and move.effect == .Disable) {
947
987
  try buildRage(battle, player.foe(), options);
948
- } else if (!showdown and immune and move.effect == .Binding) {
949
- try options.chance.commit(player, .binding);
950
988
  }
951
989
  return null;
952
990
  } else if (!showdown) {
@@ -1069,6 +1107,7 @@ fn calcDamage(
1069
1107
  target_player: Player,
1070
1108
  m: ?Move.Data,
1071
1109
  crit: bool,
1110
+ options: anytype,
1072
1111
  ) bool {
1073
1112
  // Confusion (indicated when m == null) just needs a 40 BP physical move
1074
1113
  const cfz = m == null;
@@ -1080,28 +1119,8 @@ fn calcDamage(
1080
1119
 
1081
1120
  const special = move.type.special();
1082
1121
 
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
1122
+ var atk = attack(side, crit, special);
1123
+ var def = defense(target, crit, special, cfz);
1105
1124
 
1106
1125
  // Pokémon Showdown erroneously skips this for confusion's self-hit damage, but thankfully we
1107
1126
  // will not overflow because the hit is only 40 BP and unboosted (the highest legal unboosted
@@ -1126,11 +1145,41 @@ fn calcDamage(
1126
1145
  d = @min(997, d);
1127
1146
  d += 2;
1128
1147
 
1148
+ // Whether or not a critical hit occurred can change whether a division-by-zero freeze is
1149
+ // encountered so we need to make sure that in the event of a miss we commit the status of the
1150
+ // crit roll. The alternative to doing this extra work is to *always* commit the crit roll but
1151
+ // that results in counter intuitive probabilities in the common case
1152
+ if (pkmn.options.chance and !showdown) {
1153
+ atk = attack(side, !crit, special);
1154
+ def = defense(target, !crit, special, false);
1155
+ if ((atk > 255 or def > 255) and (def / 4) & 255 == 0) options.chance.glitch();
1156
+ }
1157
+
1129
1158
  battle.last_damage = @intCast(d);
1130
1159
 
1131
1160
  return true;
1132
1161
  }
1133
1162
 
1163
+ fn attack(side: *Side, crit: bool, special: bool) u32 {
1164
+ return if (crit)
1165
+ if (special) side.stored().stats.spc else side.stored().stats.atk
1166
+ else if (special) side.active.stats.spc else side.active.stats.atk;
1167
+ }
1168
+
1169
+ fn defense(target: *Side, crit: bool, special: bool, cfz: bool) u32 {
1170
+ return if (crit)
1171
+ if (special) target.stored().stats.spc else target.stored().stats.def
1172
+ else
1173
+ // GLITCH: not capped to MAX_STAT_VALUE, can be 999 * 2 = 1998
1174
+ if (special)
1175
+ target.active.stats.spc *
1176
+ @as(u2, if (target.active.volatiles.LightScreen) 2 else 1)
1177
+ else
1178
+ // Pokémon Showdown doesn't apply the opponent's Reflect to confusion's self-hit
1179
+ target.active.stats.def *
1180
+ @as(u2, if ((!showdown or !cfz) and target.active.volatiles.Reflect) 2 else 1);
1181
+ }
1182
+
1134
1183
  fn adjustDamage(battle: anytype, player: Player) u16 {
1135
1184
  const side = battle.side(player);
1136
1185
  const foe = battle.foe(player);
@@ -1265,7 +1314,7 @@ fn applyDamage(
1265
1314
  subbed.active.volatiles.Substitute = false;
1266
1315
  // battle.last_damage is not updated with the amount of HP the Substitute had
1267
1316
  try options.log.end(.{ battle.active(sub_player), .Substitute });
1268
- options.calc.capped(target_player);
1317
+ options.calc.capped(sub_player);
1269
1318
  return true;
1270
1319
  } else {
1271
1320
  // Safe to truncate since less than subbed.volatiles.substitute which is a u8
@@ -1299,7 +1348,7 @@ fn mirrorMove(
1299
1348
  try options.log.fail(.{ battle.active(player), .None });
1300
1349
  return null;
1301
1350
  } else if (!showdown or foe.last_used_move != .Struggle) {
1302
- incrementPP(side, mslot);
1351
+ incrementPP(side, player, mslot, options);
1303
1352
  }
1304
1353
 
1305
1354
  if (!try canMove(battle, player, mslot, auto, false, .MirrorMove, residual, options)) {
@@ -1320,7 +1369,7 @@ fn metronome(
1320
1369
  var side = battle.side(player);
1321
1370
 
1322
1371
  side.last_selected_move = try Rolls.metronome(battle, player, options);
1323
- incrementPP(side, mslot);
1372
+ incrementPP(side, player, mslot, options);
1324
1373
 
1325
1374
  if (!try canMove(battle, player, mslot, auto, false, .Metronome, residual, options)) {
1326
1375
  return null;
@@ -1421,13 +1470,17 @@ fn moveHit(
1421
1470
 
1422
1471
  if (!miss) return true;
1423
1472
  if (!showdown or !foe.active.volatiles.Invulnerable) battle.last_damage = 0;
1473
+
1424
1474
  side.active.volatiles.Binding = false;
1475
+ options.chance.observe(.binding, player, .None);
1476
+
1425
1477
  return false;
1426
1478
  }
1427
1479
 
1428
1480
  fn checkFaint(
1429
1481
  battle: anytype,
1430
1482
  player: Player,
1483
+ cap: bool,
1431
1484
  options: anytype,
1432
1485
  ) @TypeOf(options.log).Error!?Result {
1433
1486
  const side = battle.side(player);
@@ -1440,8 +1493,8 @@ fn checkFaint(
1440
1493
  const foe_out = foe_fainted and findFirstAlive(foe) == 0;
1441
1494
  const more = player_out or foe_out;
1442
1495
 
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;
1496
+ if (try faint(battle, player, !(more or foe_fainted), cap, options)) |r| return r;
1497
+ if (foe_fainted) if (try faint(battle, player.foe(), !more, false, options)) |r| return r;
1445
1498
 
1446
1499
  assert(!side.active.volatiles.MultiHit);
1447
1500
  assert(!foe.active.volatiles.MultiHit);
@@ -1462,7 +1515,7 @@ fn checkFaint(
1462
1515
  return .{ .p1 = foe_choice, .p2 = .Switch };
1463
1516
  }
1464
1517
 
1465
- fn faint(battle: anytype, player: Player, done: bool, options: anytype) !?Result {
1518
+ fn faint(battle: anytype, player: Player, done: bool, cap: bool, options: anytype) !?Result {
1466
1519
  var side = battle.side(player);
1467
1520
  var foe = battle.foe(player);
1468
1521
  assert(side.stored().hp == 0);
@@ -1496,7 +1549,9 @@ fn faint(battle: anytype, player: Player, done: bool, options: anytype) !?Result
1496
1549
  }
1497
1550
 
1498
1551
  try options.log.faint(.{ battle.active(player), done });
1499
- options.calc.capped(player);
1552
+ // We can only safely set capped if the faint occured as the direct result of an opponent's
1553
+ // move (as opposed to due to residual damage, recoil, Explosion, etc)
1554
+ if (cap) options.calc.capped(player);
1500
1555
  return null;
1501
1556
  }
1502
1557
 
@@ -1656,17 +1711,6 @@ fn buildRage(battle: anytype, who: Player, options: anytype) !void {
1656
1711
  }
1657
1712
  }
1658
1713
 
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
1714
  fn onBegin(
1671
1715
  battle: anytype,
1672
1716
  player: Player,
@@ -1753,8 +1797,10 @@ pub const Effects = struct {
1753
1797
 
1754
1798
  side.active.volatiles.Bide = true;
1755
1799
  assert(!side.active.volatiles.Thrashing and !side.active.volatiles.Rage);
1800
+
1756
1801
  side.active.volatiles.state = 0;
1757
1802
  side.active.volatiles.attacks = Rolls.attackingDuration(battle, player, options);
1803
+ options.chance.observe(.attacking, player, .started);
1758
1804
 
1759
1805
  try options.log.start(.{ battle.active(player), .Bide });
1760
1806
  }
@@ -1820,7 +1866,9 @@ pub const Effects = struct {
1820
1866
 
1821
1867
  if (foe.active.volatiles.Confusion) return;
1822
1868
  foe.active.volatiles.Confusion = true;
1823
- foe.active.volatiles.confusion = Rolls.confusionDuration(battle, player, false, options);
1869
+
1870
+ foe.active.volatiles.confusion = Rolls.confusionDuration(battle, player, options);
1871
+ options.chance.observe(.confusion, player.foe(), .started);
1824
1872
 
1825
1873
  try options.log.start(.{ battle.active(player.foe()), .Confusion });
1826
1874
  }
@@ -1878,6 +1926,7 @@ pub const Effects = struct {
1878
1926
  volatiles.disable_move =
1879
1927
  @intCast(try Rolls.moveSlot(battle, player, &foe.active.moves, n, options));
1880
1928
  volatiles.disable_duration = Rolls.disableDuration(battle, player, options);
1929
+ options.chance.observe(.disable, player.foe(), .started);
1881
1930
 
1882
1931
  const id = foe.active.move(volatiles.disable_move).id;
1883
1932
  try options.log.start(.{ foe_ident, .Disable, id });
@@ -1984,22 +2033,24 @@ pub const Effects = struct {
1984
2033
  if (p != player and Status.any(s.stored().status)) {
1985
2034
  try log.curestatus(.{ foe_ident, foe_stored.status, .Silent });
1986
2035
  s.stored().status = 0;
2036
+ options.chance.observe(.sleep, p, .None);
1987
2037
  } else if (showdown and s.stored().status == Status.TOX) {
1988
2038
  s.stored().status = Status.init(.PSN);
1989
2039
  try log.status(.{ battle.active(p), s.stored().status, .None });
1990
2040
  }
1991
- try clearVolatiles(battle, p, p != player, options);
2041
+ try clearVolatiles(battle, p, options);
1992
2042
  }
1993
2043
  } else {
1994
2044
  if (Status.any(foe_stored.status)) {
1995
2045
  if (Status.is(foe_stored.status, .FRZ) or Status.is(foe_stored.status, .SLP)) {
1996
2046
  foe.last_selected_move = .SKIP_TURN;
2047
+ options.chance.observe(.sleep, player.foe(), .None);
1997
2048
  }
1998
2049
  try log.curestatus(.{ foe_ident, foe_stored.status, .Silent });
1999
2050
  foe_stored.status = 0;
2000
2051
  }
2001
- try clearVolatiles(battle, player, false, options);
2002
- try clearVolatiles(battle, player.foe(), true, options);
2052
+ try clearVolatiles(battle, player, options);
2053
+ try clearVolatiles(battle, player.foe(), options);
2003
2054
  }
2004
2055
  }
2005
2056
 
@@ -2295,6 +2346,8 @@ pub const Effects = struct {
2295
2346
  foe.active.volatiles.Recharging = false;
2296
2347
 
2297
2348
  foe_stored.status = Status.slp(Rolls.sleepDuration(battle, player, options));
2349
+ options.chance.observe(.sleep, player.foe(), .started);
2350
+
2298
2351
  const last = battle.side(player).last_selected_move;
2299
2352
  try options.log.status(.{ foe_ident, foe_stored.status, .From, last });
2300
2353
  }
@@ -2351,6 +2404,8 @@ pub const Effects = struct {
2351
2404
 
2352
2405
  volatiles.Thrashing = true;
2353
2406
  volatiles.attacks = Rolls.attackingDuration(battle, player, options);
2407
+
2408
+ options.chance.observe(.attacking, player, .started);
2354
2409
  }
2355
2410
 
2356
2411
  fn transform(battle: anytype, player: Player, options: anytype) !void {
@@ -2396,6 +2451,7 @@ pub const Effects = struct {
2396
2451
 
2397
2452
  side.active.volatiles.attacks =
2398
2453
  try Rolls.distribution(battle, .Binding, player, options) - 1;
2454
+ options.chance.observe(.binding, player, .started);
2399
2455
  }
2400
2456
 
2401
2457
  fn boost(battle: anytype, player: Player, move: Move.Data, options: anytype) !void {
@@ -2625,22 +2681,22 @@ fn isForced(active: anytype) bool {
2625
2681
  active.volatiles.Thrashing or active.volatiles.Charging;
2626
2682
  }
2627
2683
 
2628
- fn clearVolatiles(battle: anytype, who: Player, clear: bool, options: anytype) !void {
2684
+ fn clearVolatiles(battle: anytype, who: Player, options: anytype) !void {
2629
2685
  var log = options.log;
2630
2686
  var side = battle.side(who);
2631
2687
  var volatiles = &side.active.volatiles;
2632
2688
  const ident = battle.active(who);
2633
2689
 
2634
- options.chance.clearDurations(who, clear);
2635
-
2636
2690
  if (volatiles.disable_move != 0) {
2637
2691
  volatiles.disable_move = 0;
2638
2692
  volatiles.disable_duration = 0;
2693
+ options.chance.observe(.disable, who, .None);
2639
2694
  try log.end(.{ ident, .DisableSilent });
2640
2695
  }
2641
2696
  if (volatiles.Confusion) {
2642
2697
  // volatiles.confusion is left unchanged
2643
2698
  volatiles.Confusion = false;
2699
+ options.chance.observe(.confusion, who, .None);
2644
2700
  try log.end(.{ ident, .ConfusionSilent });
2645
2701
  }
2646
2702
  if (volatiles.Mist) {
@@ -2675,6 +2731,19 @@ fn clearVolatiles(battle: anytype, who: Player, clear: bool, options: anytype) !
2675
2731
  }
2676
2732
  }
2677
2733
 
2734
+ fn decrement(
2735
+ comptime field: chance.Action.Field,
2736
+ player: Player,
2737
+ options: anytype,
2738
+ n: anytype,
2739
+ ) @TypeOf(n) {
2740
+ return if (options.calc.overridden(player, field)) |obs| switch (obs) {
2741
+ .started, .ended => 0,
2742
+ .continuing => if (n > 1) n - 1 else n,
2743
+ else => unreachable,
2744
+ } else n - 1;
2745
+ }
2746
+
2678
2747
  pub const Rolls = struct {
2679
2748
  fn speedTie(battle: anytype, options: anytype) !bool {
2680
2749
  const p1 = if (options.calc.overridden(.P1, .speed_tie)) |player|
@@ -2886,7 +2955,7 @@ pub const Rolls = struct {
2886
2955
  };
2887
2956
 
2888
2957
  assert(duration >= 1 and duration <= 7);
2889
- options.chance.duration(.sleep, player, player.foe(), duration);
2958
+ options.chance.duration(player, duration);
2890
2959
  return duration;
2891
2960
  }
2892
2961
 
@@ -2901,22 +2970,22 @@ pub const Rolls = struct {
2901
2970
  @intCast((battle.rng.next() & 7) + 1);
2902
2971
 
2903
2972
  assert(duration >= 1 and duration <= 8);
2904
- options.chance.duration(.disable, player, player.foe(), duration);
2973
+ options.chance.duration(player, duration);
2905
2974
  return duration;
2906
2975
  }
2907
2976
 
2908
- fn confusionDuration(battle: anytype, player: Player, self: bool, options: anytype) u3 {
2977
+ fn confusionDuration(battle: anytype, player: Player, options: anytype) u3 {
2909
2978
  const duration: u3 = if (options.calc.overridden(player, .duration)) |val|
2910
2979
  @intCast(val)
2911
2980
  else if (@hasDecl(@TypeOf(battle.rng), "confusionDuration"))
2912
- battle.rng.confusionDuration(player, self)
2981
+ battle.rng.confusionDuration(player)
2913
2982
  else if (showdown)
2914
2983
  battle.rng.range(u3, 2, 6)
2915
2984
  else
2916
2985
  @intCast((battle.rng.next() & 3) + 2);
2917
2986
 
2918
2987
  assert(duration >= 2 and duration <= 5);
2919
- options.chance.duration(.confusion, player, if (self) player else player.foe(), duration);
2988
+ options.chance.duration(player, duration);
2920
2989
  return duration;
2921
2990
  }
2922
2991
 
@@ -2931,7 +3000,7 @@ pub const Rolls = struct {
2931
3000
  @intCast((battle.rng.next() & 1) + 2);
2932
3001
 
2933
3002
  assert(duration >= 2 and duration <= 3);
2934
- options.chance.duration(.attacking, player, player, duration);
3003
+ options.chance.duration(player, duration);
2935
3004
  return duration;
2936
3005
  }
2937
3006
 
@@ -2957,7 +3026,7 @@ pub const Rolls = struct {
2957
3026
 
2958
3027
  assert(n >= 2 and n <= 5);
2959
3028
  if (effect == .Binding) {
2960
- options.chance.duration(.binding, player, player, n);
3029
+ options.chance.duration(player, n);
2961
3030
  } else {
2962
3031
  try options.chance.multiHit(player, n);
2963
3032
  }