@pkmn/engine 0.1.0-dev.62399ed5 → 0.1.0-dev.6be9a775

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.
@@ -4,10 +4,12 @@ const pkmn = @import("../pkmn.zig");
4
4
 
5
5
  const common = @import("../common/data.zig");
6
6
  const DEBUG = @import("../common/debug.zig").print;
7
+ const optional = @import("../common/optional.zig");
7
8
  const protocol = @import("../common/protocol.zig");
8
9
  const rng = @import("../common/rng.zig");
9
10
 
10
11
  const data = @import("data.zig");
12
+ const chance = @import("chance.zig");
11
13
 
12
14
  const assert = std.debug.assert;
13
15
 
@@ -253,11 +255,11 @@ fn switchIn(battle: anytype, player: Player, slot: u8, initial: bool, options: a
253
255
  active.moves = incoming.moves;
254
256
 
255
257
  statusModify(incoming.status, &active.stats);
258
+ options.chance.switched(player, slot);
256
259
 
257
260
  foe.active.volatiles.Binding = false;
258
261
 
259
262
  try options.log.switched(.{ battle.active(player), incoming });
260
- options.chance.switched(player, side.order[0], out);
261
263
 
262
264
  if (showdown and incoming.status == Status.TOX) {
263
265
  incoming.status = Status.init(.PSN);
@@ -353,7 +355,7 @@ fn doTurn(
353
355
  if (try checkFaint(battle, player, false, options)) |r| return r;
354
356
  } else if (foe_choice.type == .Pass) return null;
355
357
 
356
- options.chance.clearPending();
358
+ if (!showdown) options.chance.clearPending();
357
359
 
358
360
  residual = true;
359
361
  replace = battle.side(foe_player).stored().hp == 0;
@@ -484,17 +486,22 @@ fn beforeMove(
484
486
 
485
487
  if (Status.is(stored.status, .SLP)) {
486
488
  const before = stored.status;
489
+ const slf = Status.is(stored.status, .EXT);
487
490
  // 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;
491
+ if (options.calc.overridden(player, .sleep)) |obs| switch (obs) {
492
+ .started, .ended => stored.status = 0,
493
+ .continuing => if (Status.duration(stored.status) > 1) {
494
+ stored.status -= 1;
495
+ },
496
+ else => unreachable,
490
497
  } else {
491
498
  stored.status -= 1;
492
499
  }
493
500
 
494
501
  const duration = Status.duration(stored.status);
495
- if (!Status.is(stored.status, .EXT)) {
496
- try options.chance.sleep(player, duration);
497
- }
502
+ try options.chance.sleep(player, if (slf)
503
+ .None
504
+ else if (duration == 0) .ended else .continuing);
498
505
 
499
506
  if (duration == 0) {
500
507
  try log.curestatus(.{ ident, before, .Message });
@@ -532,12 +539,12 @@ fn beforeMove(
532
539
  }
533
540
 
534
541
  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);
542
+ volatiles.disable_duration =
543
+ decrement(.disable, player, options, volatiles.disable_duration);
544
+ try options.chance.disable(
545
+ player,
546
+ if (volatiles.disable_duration == 0) .ended else .continuing,
547
+ );
541
548
 
542
549
  if (volatiles.disable_duration == 0) {
543
550
  volatiles.disable_move = 0;
@@ -556,13 +563,19 @@ fn beforeMove(
556
563
 
557
564
  if (volatiles.Confusion) {
558
565
  assert(volatiles.confusion > 0);
559
-
560
- if (options.calc.modify(player, .confusion)) |extend| {
561
- if (!extend) volatiles.confusion = 0;
566
+ if (options.calc.overridden(player, .confusion)) |obs| switch (obs) {
567
+ .started => volatiles.confusion -= 1,
568
+ .continuing => if (volatiles.confusion > 1) {
569
+ volatiles.confusion -= 1;
570
+ },
571
+ .ended => volatiles.confusion = 0,
572
+ .overwritten => {},
573
+ else => unreachable,
562
574
  } else {
563
575
  volatiles.confusion -= 1;
564
576
  }
565
- try options.chance.confusion(player, volatiles.confusion);
577
+
578
+ try options.chance.confusion(player, if (volatiles.confusion == 0) .ended else .continuing);
566
579
 
567
580
  if (volatiles.confusion == 0) {
568
581
  volatiles.Confusion = false;
@@ -580,7 +593,9 @@ fn beforeMove(
580
593
  volatiles.Charging = false;
581
594
  volatiles.Binding = false;
582
595
  volatiles.Invulnerable = false;
583
- options.chance.clearDurations(player, null);
596
+
597
+ options.chance.observe(.attacking, player, .None);
598
+ options.chance.observe(.binding, player, .None);
584
599
  {
585
600
  // This feels (and is) disgusting but the cartridge literally just overwrites
586
601
  // the opponent's defense with the user's defense and resets it after. As a
@@ -588,7 +603,9 @@ fn beforeMove(
588
603
  const def = foe.active.stats.def;
589
604
  foe.active.stats.def = active.stats.def;
590
605
  defer foe.active.stats.def = def;
591
- if (!calcDamage(battle, player, player.foe(), null, false)) return .err;
606
+ if (!calcDamage(battle, player, player.foe(), null, false, options)) {
607
+ return .err;
608
+ }
592
609
  }
593
610
  // Pokémon Showdown incorrectly changes the "target" of the confusion self-hit based
594
611
  // on the targeting behavior of the confused Pokémon's selected move which results
@@ -617,7 +634,10 @@ fn beforeMove(
617
634
  volatiles.Thrashing = false;
618
635
  volatiles.Charging = false;
619
636
  volatiles.Binding = false;
620
- options.chance.clearDurations(player, null);
637
+
638
+ options.chance.observe(.attacking, player, .None);
639
+ options.chance.observe(.binding, player, .None);
640
+
621
641
  // GLITCH: Invulnerable is not cleared, resulting in permanent Fly/Dig invulnerability
622
642
  try log.cant(.{ ident, .Paralysis });
623
643
  return .done;
@@ -635,12 +655,8 @@ fn beforeMove(
635
655
  volatiles.state +%= battle.last_damage;
636
656
  }
637
657
 
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);
658
+ volatiles.attacks = decrement(.attacking, player, options, volatiles.attacks);
659
+ try options.chance.attacking(player, if (volatiles.attacks == 0) .ended else .continuing);
644
660
 
645
661
  if (volatiles.attacks != 0) {
646
662
  try log.activate(.{ ident, .Bide });
@@ -674,35 +690,36 @@ fn beforeMove(
674
690
  }
675
691
 
676
692
  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);
693
+ try log.move(.{ ident, side.last_selected_move, battle.active(player.foe()) });
694
+
695
+ volatiles.attacks = decrement(.attacking, player, options, volatiles.attacks);
696
+ try options.chance.attacking(player, if (volatiles.attacks == 0) .ended else .continuing);
697
+
698
+ if (volatiles.attacks == 0) {
699
+ const overwritten = volatiles.Confusion;
683
700
 
684
- if (!showdown and handleThrashing(battle, active, player, options)) {
701
+ volatiles.Thrashing = false;
702
+ volatiles.Confusion = true;
703
+ volatiles.confusion = Rolls.confusionDuration(battle, player, options);
704
+
705
+ // On Pokémon Showdown this could leak information to the opponent that thrashing has
706
+ // ended (the engine's protocol is always from the omniscient perspective and clients
707
+ // can be expected to recognize this pattern and pass the information only to the
708
+ // correct player)
685
709
  try log.start(.{ battle.active(player), .ConfusionSilent });
710
+ options.chance.observe(.confusion, player, if (overwritten) .overwritten else .started);
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) {
@@ -1069,6 +1110,7 @@ fn calcDamage(
1069
1110
  target_player: Player,
1070
1111
  m: ?Move.Data,
1071
1112
  crit: bool,
1113
+ options: anytype,
1072
1114
  ) bool {
1073
1115
  // Confusion (indicated when m == null) just needs a 40 BP physical move
1074
1116
  const cfz = m == null;
@@ -1080,28 +1122,8 @@ fn calcDamage(
1080
1122
 
1081
1123
  const special = move.type.special();
1082
1124
 
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
1125
+ var atk = attack(side, crit, special);
1126
+ var def = defense(target, crit, special, cfz);
1105
1127
 
1106
1128
  // Pokémon Showdown erroneously skips this for confusion's self-hit damage, but thankfully we
1107
1129
  // will not overflow because the hit is only 40 BP and unboosted (the highest legal unboosted
@@ -1126,11 +1148,41 @@ fn calcDamage(
1126
1148
  d = @min(997, d);
1127
1149
  d += 2;
1128
1150
 
1151
+ // Whether or not a critical hit occurred can change whether a division-by-zero freeze is
1152
+ // encountered so we need to make sure that in the event of a miss we commit the status of the
1153
+ // crit roll. The alternative to doing this extra work is to *always* commit the crit roll but
1154
+ // that results in counter intuitive probabilities in the common case
1155
+ if (pkmn.options.chance and !showdown) {
1156
+ atk = attack(side, !crit, special);
1157
+ def = defense(target, !crit, special, false);
1158
+ if ((atk > 255 or def > 255) and (def / 4) & 255 == 0) options.chance.glitch();
1159
+ }
1160
+
1129
1161
  battle.last_damage = @intCast(d);
1130
1162
 
1131
1163
  return true;
1132
1164
  }
1133
1165
 
1166
+ fn attack(side: *Side, crit: bool, special: bool) u32 {
1167
+ return if (crit)
1168
+ if (special) side.stored().stats.spc else side.stored().stats.atk
1169
+ else if (special) side.active.stats.spc else side.active.stats.atk;
1170
+ }
1171
+
1172
+ fn defense(target: *Side, crit: bool, special: bool, cfz: bool) u32 {
1173
+ return if (crit)
1174
+ if (special) target.stored().stats.spc else target.stored().stats.def
1175
+ else
1176
+ // GLITCH: not capped to MAX_STAT_VALUE, can be 999 * 2 = 1998
1177
+ if (special)
1178
+ target.active.stats.spc *
1179
+ @as(u2, if (target.active.volatiles.LightScreen) 2 else 1)
1180
+ else
1181
+ // Pokémon Showdown doesn't apply the opponent's Reflect to confusion's self-hit
1182
+ target.active.stats.def *
1183
+ @as(u2, if ((!showdown or !cfz) and target.active.volatiles.Reflect) 2 else 1);
1184
+ }
1185
+
1134
1186
  fn adjustDamage(battle: anytype, player: Player) u16 {
1135
1187
  const side = battle.side(player);
1136
1188
  const foe = battle.foe(player);
@@ -1299,7 +1351,7 @@ fn mirrorMove(
1299
1351
  try options.log.fail(.{ battle.active(player), .None });
1300
1352
  return null;
1301
1353
  } else if (!showdown or foe.last_used_move != .Struggle) {
1302
- incrementPP(side, mslot);
1354
+ incrementPP(side, player, mslot, options);
1303
1355
  }
1304
1356
 
1305
1357
  if (!try canMove(battle, player, mslot, auto, false, .MirrorMove, residual, options)) {
@@ -1320,7 +1372,7 @@ fn metronome(
1320
1372
  var side = battle.side(player);
1321
1373
 
1322
1374
  side.last_selected_move = try Rolls.metronome(battle, player, options);
1323
- incrementPP(side, mslot);
1375
+ incrementPP(side, player, mslot, options);
1324
1376
 
1325
1377
  if (!try canMove(battle, player, mslot, auto, false, .Metronome, residual, options)) {
1326
1378
  return null;
@@ -1421,7 +1473,10 @@ fn moveHit(
1421
1473
 
1422
1474
  if (!miss) return true;
1423
1475
  if (!showdown or !foe.active.volatiles.Invulnerable) battle.last_damage = 0;
1476
+
1424
1477
  side.active.volatiles.Binding = false;
1478
+ options.chance.observe(.binding, player, .None);
1479
+
1425
1480
  return false;
1426
1481
  }
1427
1482
 
@@ -1659,17 +1714,6 @@ fn buildRage(battle: anytype, who: Player, options: anytype) !void {
1659
1714
  }
1660
1715
  }
1661
1716
 
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
1717
  fn onBegin(
1674
1718
  battle: anytype,
1675
1719
  player: Player,
@@ -1756,8 +1800,10 @@ pub const Effects = struct {
1756
1800
 
1757
1801
  side.active.volatiles.Bide = true;
1758
1802
  assert(!side.active.volatiles.Thrashing and !side.active.volatiles.Rage);
1803
+
1759
1804
  side.active.volatiles.state = 0;
1760
1805
  side.active.volatiles.attacks = Rolls.attackingDuration(battle, player, options);
1806
+ options.chance.observe(.attacking, player, .started);
1761
1807
 
1762
1808
  try options.log.start(.{ battle.active(player), .Bide });
1763
1809
  }
@@ -1823,7 +1869,9 @@ pub const Effects = struct {
1823
1869
 
1824
1870
  if (foe.active.volatiles.Confusion) return;
1825
1871
  foe.active.volatiles.Confusion = true;
1826
- foe.active.volatiles.confusion = Rolls.confusionDuration(battle, player, false, options);
1872
+
1873
+ foe.active.volatiles.confusion = Rolls.confusionDuration(battle, player, options);
1874
+ options.chance.observe(.confusion, player.foe(), .started);
1827
1875
 
1828
1876
  try options.log.start(.{ battle.active(player.foe()), .Confusion });
1829
1877
  }
@@ -1881,6 +1929,7 @@ pub const Effects = struct {
1881
1929
  volatiles.disable_move =
1882
1930
  @intCast(try Rolls.moveSlot(battle, player, &foe.active.moves, n, options));
1883
1931
  volatiles.disable_duration = Rolls.disableDuration(battle, player, options);
1932
+ options.chance.observe(.disable, player.foe(), .started);
1884
1933
 
1885
1934
  const id = foe.active.move(volatiles.disable_move).id;
1886
1935
  try options.log.start(.{ foe_ident, .Disable, id });
@@ -1987,22 +2036,24 @@ pub const Effects = struct {
1987
2036
  if (p != player and Status.any(s.stored().status)) {
1988
2037
  try log.curestatus(.{ foe_ident, foe_stored.status, .Silent });
1989
2038
  s.stored().status = 0;
2039
+ options.chance.observe(.sleep, p, .None);
1990
2040
  } else if (showdown and s.stored().status == Status.TOX) {
1991
2041
  s.stored().status = Status.init(.PSN);
1992
2042
  try log.status(.{ battle.active(p), s.stored().status, .None });
1993
2043
  }
1994
- try clearVolatiles(battle, p, p != player, options);
2044
+ try clearVolatiles(battle, p, options);
1995
2045
  }
1996
2046
  } else {
1997
2047
  if (Status.any(foe_stored.status)) {
1998
2048
  if (Status.is(foe_stored.status, .FRZ) or Status.is(foe_stored.status, .SLP)) {
1999
2049
  foe.last_selected_move = .SKIP_TURN;
2050
+ options.chance.observe(.sleep, player.foe(), .None);
2000
2051
  }
2001
2052
  try log.curestatus(.{ foe_ident, foe_stored.status, .Silent });
2002
2053
  foe_stored.status = 0;
2003
2054
  }
2004
- try clearVolatiles(battle, player, false, options);
2005
- try clearVolatiles(battle, player.foe(), true, options);
2055
+ try clearVolatiles(battle, player, options);
2056
+ try clearVolatiles(battle, player.foe(), options);
2006
2057
  }
2007
2058
  }
2008
2059
 
@@ -2298,6 +2349,8 @@ pub const Effects = struct {
2298
2349
  foe.active.volatiles.Recharging = false;
2299
2350
 
2300
2351
  foe_stored.status = Status.slp(Rolls.sleepDuration(battle, player, options));
2352
+ options.chance.observe(.sleep, player.foe(), .started);
2353
+
2301
2354
  const last = battle.side(player).last_selected_move;
2302
2355
  try options.log.status(.{ foe_ident, foe_stored.status, .From, last });
2303
2356
  }
@@ -2354,6 +2407,8 @@ pub const Effects = struct {
2354
2407
 
2355
2408
  volatiles.Thrashing = true;
2356
2409
  volatiles.attacks = Rolls.attackingDuration(battle, player, options);
2410
+
2411
+ options.chance.observe(.attacking, player, .started);
2357
2412
  }
2358
2413
 
2359
2414
  fn transform(battle: anytype, player: Player, options: anytype) !void {
@@ -2399,6 +2454,7 @@ pub const Effects = struct {
2399
2454
 
2400
2455
  side.active.volatiles.attacks =
2401
2456
  try Rolls.distribution(battle, .Binding, player, options) - 1;
2457
+ options.chance.observe(.binding, player, .started);
2402
2458
  }
2403
2459
 
2404
2460
  fn boost(battle: anytype, player: Player, move: Move.Data, options: anytype) !void {
@@ -2628,22 +2684,22 @@ fn isForced(active: anytype) bool {
2628
2684
  active.volatiles.Thrashing or active.volatiles.Charging;
2629
2685
  }
2630
2686
 
2631
- fn clearVolatiles(battle: anytype, who: Player, clear: bool, options: anytype) !void {
2687
+ fn clearVolatiles(battle: anytype, who: Player, options: anytype) !void {
2632
2688
  var log = options.log;
2633
2689
  var side = battle.side(who);
2634
2690
  var volatiles = &side.active.volatiles;
2635
2691
  const ident = battle.active(who);
2636
2692
 
2637
- options.chance.clearDurations(who, clear);
2638
-
2639
2693
  if (volatiles.disable_move != 0) {
2640
2694
  volatiles.disable_move = 0;
2641
2695
  volatiles.disable_duration = 0;
2696
+ options.chance.observe(.disable, who, .None);
2642
2697
  try log.end(.{ ident, .DisableSilent });
2643
2698
  }
2644
2699
  if (volatiles.Confusion) {
2645
2700
  // volatiles.confusion is left unchanged
2646
2701
  volatiles.Confusion = false;
2702
+ options.chance.observe(.confusion, who, .None);
2647
2703
  try log.end(.{ ident, .ConfusionSilent });
2648
2704
  }
2649
2705
  if (volatiles.Mist) {
@@ -2678,6 +2734,19 @@ fn clearVolatiles(battle: anytype, who: Player, clear: bool, options: anytype) !
2678
2734
  }
2679
2735
  }
2680
2736
 
2737
+ fn decrement(
2738
+ comptime field: chance.Action.Field,
2739
+ player: Player,
2740
+ options: anytype,
2741
+ n: anytype,
2742
+ ) @TypeOf(n) {
2743
+ return if (options.calc.overridden(player, field)) |obs| switch (obs) {
2744
+ .started, .ended => 0,
2745
+ .continuing => if (n > 1) n - 1 else n,
2746
+ else => unreachable,
2747
+ } else n - 1;
2748
+ }
2749
+
2681
2750
  pub const Rolls = struct {
2682
2751
  fn speedTie(battle: anytype, options: anytype) !bool {
2683
2752
  const p1 = if (options.calc.overridden(.P1, .speed_tie)) |player|
@@ -2889,7 +2958,7 @@ 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
 
@@ -2904,22 +2973,22 @@ pub const Rolls = struct {
2904
2973
  @intCast((battle.rng.next() & 7) + 1);
2905
2974
 
2906
2975
  assert(duration >= 1 and duration <= 8);
2907
- options.chance.duration(.disable, player, player.foe(), duration);
2976
+ options.chance.duration(player, duration);
2908
2977
  return duration;
2909
2978
  }
2910
2979
 
2911
- fn confusionDuration(battle: anytype, player: Player, self: bool, options: anytype) u3 {
2980
+ fn confusionDuration(battle: anytype, player: Player, options: anytype) u3 {
2912
2981
  const duration: u3 = if (options.calc.overridden(player, .duration)) |val|
2913
2982
  @intCast(val)
2914
2983
  else if (@hasDecl(@TypeOf(battle.rng), "confusionDuration"))
2915
- battle.rng.confusionDuration(player, self)
2984
+ battle.rng.confusionDuration(player)
2916
2985
  else if (showdown)
2917
2986
  battle.rng.range(u3, 2, 6)
2918
2987
  else
2919
2988
  @intCast((battle.rng.next() & 3) + 2);
2920
2989
 
2921
2990
  assert(duration >= 2 and duration <= 5);
2922
- options.chance.duration(.confusion, player, if (self) player else player.foe(), duration);
2991
+ options.chance.duration(player, duration);
2923
2992
  return duration;
2924
2993
  }
2925
2994
 
@@ -2934,7 +3003,7 @@ pub const Rolls = struct {
2934
3003
  @intCast((battle.rng.next() & 1) + 2);
2935
3004
 
2936
3005
  assert(duration >= 2 and duration <= 3);
2937
- options.chance.duration(.attacking, player, player, duration);
3006
+ options.chance.duration(player, duration);
2938
3007
  return duration;
2939
3008
  }
2940
3009
 
@@ -2960,7 +3029,7 @@ pub const Rolls = struct {
2960
3029
 
2961
3030
  assert(n >= 2 and n <= 5);
2962
3031
  if (effect == .Binding) {
2963
- options.chance.duration(.binding, player, player, n);
3032
+ options.chance.duration(player, n);
2964
3033
  } else {
2965
3034
  try options.chance.multiHit(player, n);
2966
3035
  }