@pkmn/engine 0.1.0-dev.7963b917 → 0.1.0-dev.79957401

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 (85) hide show
  1. package/LICENSE +1 -3
  2. package/README.md +70 -28
  3. package/build/pkg/addon/browser.d.ts +3 -0
  4. package/build/pkg/addon/browser.js +47 -0
  5. package/build/pkg/addon/browser.js.map +1 -0
  6. package/build/pkg/addon/node.d.ts +3 -0
  7. package/build/pkg/addon/node.js +110 -0
  8. package/build/pkg/addon/node.js.map +1 -0
  9. package/build/pkg/addon/wasm.d.ts +2 -0
  10. package/build/pkg/addon/wasm.js +53 -0
  11. package/build/pkg/addon/wasm.js.map +1 -0
  12. package/build/pkg/addon.d.ts +26 -4
  13. package/build/pkg/addon.js +39 -71
  14. package/build/pkg/addon.js.map +1 -1
  15. package/build/pkg/data/ids.json +12 -12
  16. package/build/pkg/data/layout.json +17 -4
  17. package/build/pkg/data/protocol.json +84 -13
  18. package/build/pkg/data.d.ts +27 -5
  19. package/build/pkg/data.js.map +1 -1
  20. package/build/pkg/gen1.d.ts +15 -18
  21. package/build/pkg/gen1.js +30 -21
  22. package/build/pkg/gen1.js.map +1 -1
  23. package/build/pkg/index.d.ts +42 -91
  24. package/build/pkg/index.js +23 -70
  25. package/build/pkg/index.js.map +1 -1
  26. package/build/pkg/protocol.d.ts +29 -1
  27. package/build/pkg/protocol.js +123 -102
  28. package/build/pkg/protocol.js.map +1 -1
  29. package/build/tools/debug.js +84 -73
  30. package/build/tools/debug.js.map +1 -1
  31. package/build/tools/display/debug.jsx +66 -0
  32. package/build/tools/display/debug.jsx.map +1 -0
  33. package/build/tools/display/index.js +152 -0
  34. package/build/tools/display/index.js.map +1 -0
  35. package/build/tools/display/ui.jsx +360 -0
  36. package/build/tools/display/ui.jsx.map +1 -0
  37. package/build/tools/display/util.js +157 -0
  38. package/build/tools/display/util.js.map +1 -0
  39. package/build.zig +397 -149
  40. package/package.json +62 -123
  41. package/src/bin/install-pkmn-engine +43 -22
  42. package/src/bin/pkmn-debug +1 -1
  43. package/src/lib/bindings/c.zig +201 -0
  44. package/src/lib/{binding → bindings}/node.zig +15 -32
  45. package/src/lib/bindings/wasm.zig +51 -0
  46. package/src/lib/common/array.zig +70 -0
  47. package/src/lib/common/data.zig +12 -24
  48. package/src/lib/common/debug.zig +46 -30
  49. package/src/lib/common/js.zig +3 -7
  50. package/src/lib/common/optional.zig +30 -24
  51. package/src/lib/common/options.zig +6 -0
  52. package/src/lib/common/protocol.zig +961 -450
  53. package/src/lib/common/rational.zig +341 -254
  54. package/src/lib/common/rng.zig +26 -19
  55. package/src/lib/common/util.zig +53 -1
  56. package/src/lib/gen1/calc.zig +726 -178
  57. package/src/lib/gen1/chance.zig +526 -324
  58. package/src/lib/gen1/data/moves.zig +193 -183
  59. package/src/lib/gen1/data/species.zig +4 -6
  60. package/src/lib/gen1/data/types.zig +9 -10
  61. package/src/lib/gen1/data.zig +78 -90
  62. package/src/lib/gen1/helpers.zig +114 -422
  63. package/src/lib/gen1/mechanics.zig +699 -615
  64. package/src/lib/gen2/calc.zig +150 -0
  65. package/src/lib/gen2/chance.zig +914 -0
  66. package/src/lib/gen2/data/items.zig +13 -21
  67. package/src/lib/gen2/data/moves.zig +664 -572
  68. package/src/lib/gen2/data/species.zig +2 -4
  69. package/src/lib/gen2/data/types.zig +139 -34
  70. package/src/lib/gen2/data.zig +131 -103
  71. package/src/lib/gen2/generated.zig +801 -0
  72. package/src/lib/gen2/helpers.zig +76 -51
  73. package/src/lib/gen2/mechanics.zig +3569 -29
  74. package/src/lib/napi.h +2 -0
  75. package/src/lib/node.zig +14 -0
  76. package/src/lib/pkmn.zig +125 -29
  77. package/src/lib/wasm.zig +13 -0
  78. package/src/tools/display/dom.ts +108 -0
  79. package/src/tools/display/ui.css +354 -0
  80. package/build/test/display.js +0 -400
  81. package/build/test/display.js.map +0 -1
  82. package/src/lib/binding/wasm.zig +0 -12
  83. package/src/lib/gen1/README.md +0 -812
  84. package/src/lib/gen2/README.md +0 -308
  85. package/src/test/display/pkmn.html.tmpl +0 -368
@@ -1,38 +1,28 @@
1
- const std = @import("std");
2
-
3
- const pkmn = @import("../pkmn.zig");
1
+ const DEBUG = @import("../common/debug.zig").print;
4
2
 
3
+ const chance = @import("chance.zig");
5
4
  const common = @import("../common/data.zig");
6
- const DEBUG = @import("../common/debug.zig").print;
5
+ const data = @import("data.zig");
6
+ const pkmn = @import("../pkmn.zig");
7
7
  const protocol = @import("../common/protocol.zig");
8
8
  const rng = @import("../common/rng.zig");
9
-
10
- const Action = @import("chance.zig").Action;
11
-
12
- const data = @import("data.zig");
9
+ const std = @import("std");
13
10
 
14
11
  const assert = std.debug.assert;
15
-
16
- const expectEqual = std.testing.expectEqual;
17
-
18
- const Choice = common.Choice;
19
- const ID = common.ID;
20
- const Player = common.Player;
21
- const Result = common.Result;
22
-
23
- const showdown = pkmn.options.showdown;
24
-
25
12
  const Boost = protocol.Boost;
13
+ const Choice = common.Choice;
26
14
  const Damage = protocol.Damage;
27
- const Heal = protocol.Heal;
28
-
29
- const Gen12 = rng.Gen12;
30
-
31
- const ActivePokemon = data.ActivePokemon;
32
15
  const Effectiveness = data.Effectiveness;
16
+ const expectEqual = std.testing.expectEqual;
17
+ const Fail = protocol.Fail;
18
+ const Gen12 = rng.Gen12;
19
+ const Heal = protocol.Heal;
20
+ const ID = common.ID;
33
21
  const Move = data.Move;
34
22
  const MoveSlot = data.MoveSlot;
35
- const Pokemon = data.Pokemon;
23
+ const Player = common.Player;
24
+ const Result = common.Result;
25
+ const showdown = pkmn.options.showdown;
36
26
  const Side = data.Side;
37
27
  const Species = data.Species;
38
28
  const Stats = data.Stats;
@@ -59,34 +49,26 @@ const BOOSTS = &[_][2]u8{
59
49
 
60
50
  const MAX_STAT_VALUE = 999;
61
51
 
62
- const DISTRIBUTION = [_]u3{ 2, 2, 2, 3, 3, 3, 4, 5 };
63
-
64
52
  pub fn update(battle: anytype, c1: Choice, c2: Choice, options: anytype) !Result {
65
- // FIXME
66
- // std.debug.print("\n", .{});
67
- // DEBUG(battle.turn);
68
- // std.debug.print("// ", .{});
69
53
  assert(c1.type != .Pass or c2.type != .Pass or battle.turn == 0);
70
54
  if (battle.turn == 0) return start(battle, options);
71
55
 
72
56
  var s1 = false;
73
57
  var s2 = false;
74
- var f1: ?Move = null;
75
- var f2: ?Move = null;
76
58
 
77
- if (selectMove(battle, .P1, c1, c2, &s1, &f1)) |r| return r;
78
- if (selectMove(battle, .P2, c2, c1, &s2, &f2)) |r| return r;
59
+ if (selectMove(battle, .P1, c1, c2, &s1)) |r| return r;
60
+ if (selectMove(battle, .P2, c2, c1, &s2)) |r| return r;
79
61
 
80
62
  var p1 = battle.side(.P1);
81
63
  var p2 = battle.side(.P2);
82
64
 
83
- var r1 = showdown and p1.active.volatiles.Binding and c2.type == .Switch;
84
- var r2 = showdown and p2.active.volatiles.Binding and c1.type == .Switch;
65
+ const r1 = showdown and p1.active.volatiles.Binding and c2.type == .Switch;
66
+ const r2 = showdown and p2.active.volatiles.Binding and c1.type == .Switch;
85
67
 
86
68
  if (try turnOrder(battle, c1, c2, options) == .P1) {
87
- if (try doTurn(battle, .P1, c1, r1, s1, f1, .P2, c2, r2, s2, f2, options)) |r| return r;
69
+ if (try doTurn(battle, .P1, c1, r1, s1, .P2, c2, r2, s2, options)) |r| return r;
88
70
  } else {
89
- if (try doTurn(battle, .P2, c2, r2, s2, f2, .P1, c1, r1, s1, f1, options)) |r| return r;
71
+ if (try doTurn(battle, .P2, c2, r2, s2, .P1, c1, r1, s1, options)) |r| return r;
90
72
  }
91
73
 
92
74
  if (p1.active.volatiles.attacks == 0) p1.active.volatiles.Binding = false;
@@ -99,13 +81,13 @@ fn start(battle: anytype, options: anytype) !Result {
99
81
  const p1 = battle.side(.P1);
100
82
  const p2 = battle.side(.P2);
101
83
 
102
- var p1_slot = findFirstAlive(p1);
84
+ const p1_slot = findFirstAlive(p1);
103
85
  assert(!showdown or p1_slot == 1);
104
- if (p1_slot == 0) return if (findFirstAlive(p2) == 0) Result.Tie else Result.Lose;
86
+ if (p1_slot == 0) return if (findFirstAlive(p2) == 0) .Tie else .Lose;
105
87
 
106
- var p2_slot = findFirstAlive(p2);
88
+ const p2_slot = findFirstAlive(p2);
107
89
  assert(!showdown or p2_slot == 1);
108
- if (p2_slot == 0) return Result.Win;
90
+ if (p2_slot == 0) return .Win;
109
91
 
110
92
  try switchIn(battle, .P1, p1_slot, true, options);
111
93
  try switchIn(battle, .P2, p2_slot, true, options);
@@ -124,7 +106,6 @@ fn selectMove(
124
106
  choice: Choice,
125
107
  foe_choice: Choice,
126
108
  skip_turn: *bool,
127
- from: *?Move,
128
109
  ) ?Result {
129
110
  if (choice.type == .Pass) return null;
130
111
 
@@ -141,7 +122,6 @@ fn selectMove(
141
122
  return null;
142
123
  }
143
124
  if (volatiles.Rage) {
144
- from.* = side.last_used_move;
145
125
  if (showdown) {
146
126
  if (battle.foe(player).active.volatiles.Binding) skip_turn.* = true;
147
127
  saveMove(battle, player, null);
@@ -151,7 +131,6 @@ fn selectMove(
151
131
  // Pokémon Showdown removes Flinch at the end-of-turn in its residual handler
152
132
  if (!showdown) volatiles.Flinch = false;
153
133
  if (volatiles.Thrashing or volatiles.Charging) {
154
- from.* = side.last_selected_move;
155
134
  if (showdown) {
156
135
  if (battle.foe(player).active.volatiles.Binding) skip_turn.* = true;
157
136
  saveMove(battle, player, null);
@@ -172,15 +151,13 @@ fn selectMove(
172
151
  return null;
173
152
  }
174
153
  if (volatiles.Binding) {
175
- from.* = side.last_used_move;
176
154
  if (showdown) {
177
- if (foe_choice.type == .Switch) from.* = null;
178
155
  // Pokémon Showdown overwrites Mirror Move with whatever was selected - really this
179
156
  // should set side.last_selected_move = last.id to reuse Mirror Move and fail in order
180
157
  // to satisfy the conditions of the Desync Clause Mod. However, because Binding is still
181
158
  // set the selected move will not actually be used, it will just be reported as having
182
159
  // been used (this differs from how Pokémon Showdown works, but its impossible to
183
- // replicate the incorrect behavior with the correct mechanisms).
160
+ // replicate the incorrect behavior with the correct mechanisms)
184
161
  saveMove(battle, player, choice);
185
162
  } else {
186
163
  assert(choice.data == 0);
@@ -188,7 +165,10 @@ fn selectMove(
188
165
  if (foe_choice.type == .Switch) {
189
166
  const last = side.active.move(battle.lastMove(player).index);
190
167
  if (last.id == .Metronome) side.last_selected_move = last.id;
191
- if (last.id == .MirrorMove) return Result.Error;
168
+ if (last.id == .MirrorMove) {
169
+ if (!pkmn.options.mod) return .Error;
170
+ side.last_selected_move = last.id;
171
+ }
192
172
  }
193
173
  }
194
174
  return null;
@@ -210,16 +190,13 @@ fn selectMove(
210
190
  if (choice.data == 0) {
211
191
  const struggle = ok: {
212
192
  for (side.active.moves, 0..) |move, i| {
213
- if (move.pp > 0 and volatiles.disabled_move != i + 1) break :ok false;
193
+ if (move.pp > 0 and volatiles.disable_move != i + 1) break :ok false;
214
194
  }
215
195
  break :ok true;
216
196
  };
217
-
218
197
  assert(struggle);
219
- saveMove(battle, player, choice);
220
- } else {
221
- saveMove(battle, player, choice);
222
198
  }
199
+ saveMove(battle, player, choice);
223
200
 
224
201
  return null;
225
202
  }
@@ -232,7 +209,7 @@ fn saveMove(battle: anytype, player: Player, choice: ?Choice) void {
232
209
  if (c.data == 0) {
233
210
  side.last_selected_move = .Struggle;
234
211
  } else {
235
- assert(showdown or side.active.volatiles.disabled_move != c.data);
212
+ assert(showdown or side.active.volatiles.disable_move != c.data);
236
213
  const move = side.active.move(c.data);
237
214
  // You cannot *select* a move with 0 PP (except on Pokémon Showdown where that is
238
215
  // sometimes required...), but a 0 PP move can be used automatically
@@ -270,17 +247,17 @@ fn switchIn(battle: anytype, player: Player, slot: u8, initial: bool, options: a
270
247
  active.moves = incoming.moves;
271
248
 
272
249
  statusModify(incoming.status, &active.stats);
250
+ options.chance.switched(player, slot);
273
251
 
274
252
  foe.active.volatiles.Binding = false;
275
253
 
276
- try options.log.switched(battle.active(player), incoming);
277
- options.chance.switched(player, side.order[0], out);
254
+ try options.log.switched(.{ battle.active(player), incoming });
278
255
 
279
256
  if (showdown and incoming.status == Status.TOX) {
280
257
  incoming.status = Status.init(.PSN);
281
258
  // Technically, Pokémon Showdown adds these after *both* Pokémon have switched, but we'd
282
259
  // rather not clutter up turnOrder just for this (incorrect) log message
283
- try options.log.status(battle.active(player), incoming.status, .Silent);
260
+ try options.log.status(.{ battle.active(player), incoming.status, .Silent });
284
261
  }
285
262
  }
286
263
 
@@ -343,12 +320,10 @@ fn doTurn(
343
320
  player_choice: Choice,
344
321
  player_rewrap: bool,
345
322
  player_skip: bool,
346
- player_from: ?Move,
347
323
  foe_player: Player,
348
324
  foe_choice: Choice,
349
325
  foe_rewrap: bool,
350
326
  foe_skip: bool,
351
- foe_from: ?Move,
352
327
  options: anytype,
353
328
  ) !?Result {
354
329
  assert(player_choice.type != .Pass);
@@ -361,19 +336,18 @@ fn doTurn(
361
336
  player_choice,
362
337
  player_rewrap,
363
338
  player_skip,
364
- player_from,
365
339
  &residual,
366
340
  options,
367
341
  )) |r| return r;
368
342
  if (!replace) {
369
343
  if (player_choice.type != .Switch) {
370
- if (try checkFaint(battle, foe_player, options)) |r| return r;
344
+ if (try checkFaint(battle, foe_player, true, options)) |r| return r;
371
345
  }
372
346
  if (residual) try handleResidual(battle, player, options);
373
- if (try checkFaint(battle, player, options)) |r| return r;
347
+ if (try checkFaint(battle, player, false, options)) |r| return r;
374
348
  } else if (foe_choice.type == .Pass) return null;
375
349
 
376
- options.chance.clearPending();
350
+ if (!showdown) options.chance.clearPending();
377
351
 
378
352
  residual = true;
379
353
  replace = battle.side(foe_player).stored().hp == 0;
@@ -384,16 +358,15 @@ fn doTurn(
384
358
  foe_choice,
385
359
  foe_rewrap,
386
360
  foe_skip,
387
- foe_from,
388
361
  &residual,
389
362
  options,
390
363
  )) |r| return r;
391
364
  if (!replace) {
392
365
  if (!calc and foe_choice.type != .Switch) {
393
- if (try checkFaint(battle, player, options)) |r| return r;
366
+ if (try checkFaint(battle, player, true, options)) |r| return r;
394
367
  }
395
368
  if (residual) try handleResidual(battle, foe_player, options);
396
- if (try checkFaint(battle, foe_player, options)) |r| return r;
369
+ if (try checkFaint(battle, foe_player, false, options)) |r| return r;
397
370
  }
398
371
 
399
372
  // Flinch is bugged on Pokémon Showdown because it gets implemented with a duration which causes
@@ -412,7 +385,6 @@ fn executeMove(
412
385
  choice: Choice,
413
386
  rewrap: bool,
414
387
  skip: bool,
415
- from: ?Move,
416
388
  residual: *bool,
417
389
  options: anytype,
418
390
  ) !?Result {
@@ -429,7 +401,7 @@ fn executeMove(
429
401
  if (side.last_selected_move == .SKIP_TURN) {
430
402
  assert(!showdown);
431
403
  if (battle.foe(player).active.volatiles.Binding) {
432
- try options.log.cant(battle.active(player), .Bound);
404
+ try options.log.cant(.{ battle.active(player), .Bound });
433
405
  }
434
406
  return null;
435
407
  }
@@ -447,17 +419,19 @@ fn executeMove(
447
419
  mslot = @intCast(battle.lastMove(player).index);
448
420
  const stored = side.stored();
449
421
  // GLITCH: Struggle bypass PP underflow via Hyper Beam / Trapping-switch auto selection
450
- auto = side.last_selected_move == .HyperBeam or side.active.volatiles.Bide or
451
- from != null or Status.is(stored.status, .FRZ) or Status.is(stored.status, .SLP);
422
+ auto = pkmn.options.mod or isForced(&side.active) or
423
+ side.active.volatiles.Binding or side.active.volatiles.Bide or
424
+ side.last_selected_move == .HyperBeam or
425
+ Status.is(stored.status, .FRZ) or Status.is(stored.status, .SLP);
452
426
  // If it wasn't Hyper Beam or the continuation of a move effect then we must have just
453
427
  // thawed, in which case we will desync unless the last_selected_move happened to be at
454
- // index 1 and the current Pokémon has the same move in its first slot.
428
+ // index 1 and the current Pokémon has the same move in its first slot
455
429
  if (!auto) {
456
430
  // side.active.moves(slot) is safe to check even though the slot in question might not
457
431
  // technically be from this Pokémon because it must be exactly 1 to not desync and
458
432
  // every Pokémon must have at least one move
459
433
  if (mslot != 1 or side.active.move(mslot).id != side.last_selected_move) {
460
- return Result.Error;
434
+ return .Error;
461
435
  } else {
462
436
  auto = true;
463
437
  }
@@ -470,18 +444,37 @@ fn executeMove(
470
444
 
471
445
  var skip_can = false;
472
446
  var skip_pp = false;
473
- switch (try beforeMove(battle, player, skip, from, residual, options)) {
447
+ switch (try beforeMove(battle, player, skip, residual, options)) {
474
448
  .done => return null,
475
449
  .skip_can => skip_can = true,
476
450
  .skip_pp => skip_pp = true,
477
451
  .ok => {},
478
- .err => return @as(?Result, Result.Error),
452
+ .err => return .Error,
479
453
  }
480
454
 
481
- if (!skip_can and !try canMove(battle, player, mslot, auto, skip_pp, from, residual, options)) {
482
- return null;
455
+ // Pokémon Showdown incorrectly implements PP deduction when handling the Hyper Beam automatic
456
+ // selection glitch - if the move was proced due to Metronome / Mirror Move it either subtracts
457
+ // from the Hyper Beam slot if present or skips PP deduction entirely...
458
+ if (showdown and !skip_pp and side.last_selected_move == .HyperBeam and
459
+ side.last_selected_move != side.active.move(mslot).id)
460
+ {
461
+ assert(mslot == 1);
462
+ const has_beam = has_beam: {
463
+ for (side.active.moves, 0..) |m, i| {
464
+ if (m.id == .HyperBeam) {
465
+ mslot = @intCast(i + 1);
466
+ break :has_beam true;
467
+ }
468
+ }
469
+ break :has_beam false;
470
+ };
471
+ if (!has_beam) skip_pp = true;
483
472
  }
484
473
 
474
+ const can = skip_can or
475
+ try canMove(battle, player, mslot, auto, skip_pp, .None, residual, options);
476
+ if (!can) return null;
477
+
485
478
  return doMove(battle, player, mslot, rewrap, auto, residual, options);
486
479
  }
487
480
 
@@ -491,7 +484,6 @@ fn beforeMove(
491
484
  battle: anytype,
492
485
  player: Player,
493
486
  skip: bool,
494
- from: ?Move,
495
487
  residual: *bool,
496
488
  options: anytype,
497
489
  ) !BeforeMove {
@@ -505,36 +497,41 @@ fn beforeMove(
505
497
 
506
498
  if (Status.is(stored.status, .SLP)) {
507
499
  const before = stored.status;
500
+ const slf = Status.is(stored.status, .EXT);
508
501
  // Even if the EXT bit is set this will still correctly modify the sleep duration
509
- if (options.calc.modify(player, .sleep)) |extend| {
510
- if (!extend) stored.status = 0;
502
+ if (options.calc.overridden(player, .sleep)) |obs| switch (obs) {
503
+ .started, .ended => stored.status = 0,
504
+ .continuing => if (Status.duration(stored.status) > 1) {
505
+ stored.status -= 1;
506
+ },
507
+ else => unreachable,
511
508
  } else {
512
509
  stored.status -= 1;
513
510
  }
514
511
 
515
512
  const duration = Status.duration(stored.status);
516
- if (!Status.is(stored.status, .EXT)) {
517
- try options.chance.sleep(player, duration);
518
- }
513
+ try options.chance.sleep(player, if (slf)
514
+ .None
515
+ else if (duration == 0) .ended else .continuing);
519
516
 
520
517
  if (duration == 0) {
521
- try log.curestatus(ident, before, .Message);
518
+ try log.curestatus(.{ ident, before, .Message });
522
519
  stored.status = 0; // clears EXT if present
523
520
  } else {
524
- try log.cant(ident, .Sleep);
521
+ try log.cant(.{ ident, .Sleep });
525
522
  }
526
523
  side.last_used_move = .None;
527
524
  return .done;
528
525
  }
529
526
 
530
527
  if (Status.is(stored.status, .FRZ)) {
531
- try log.cant(ident, .Freeze);
528
+ try log.cant(.{ ident, .Freeze });
532
529
  side.last_used_move = .None;
533
530
  return .done;
534
531
  }
535
532
 
536
533
  if (skip or foe.active.volatiles.Binding) {
537
- try log.cant(ident, .Bound);
534
+ try log.cant(.{ ident, .Bound });
538
535
  return .done;
539
536
  }
540
537
 
@@ -542,27 +539,27 @@ fn beforeMove(
542
539
  // Pokémon Showdown doesn't clear Flinch until its imaginary "residual" phase, meaning
543
540
  // Pokémon can sometimes flinch multiple times from the same original hit
544
541
  if (!showdown) volatiles.Flinch = false;
545
- try log.cant(ident, .Flinch);
542
+ try log.cant(.{ ident, .Flinch });
546
543
  return .done;
547
544
  }
548
545
 
549
546
  if (volatiles.Recharging) {
550
547
  volatiles.Recharging = false;
551
- try log.cant(ident, .Recharge);
548
+ try log.cant(.{ ident, .Recharge });
552
549
  return .done;
553
550
  }
554
551
 
555
- if (volatiles.disabled_duration > 0) {
556
- if (options.calc.modify(player, .disable)) |extend| {
557
- if (!extend) volatiles.disabled_duration = 0;
558
- } else {
559
- volatiles.disabled_duration -= 1;
560
- }
561
- try options.chance.disable(player, volatiles.disabled_duration);
552
+ if (volatiles.disable_duration > 0) {
553
+ volatiles.disable_duration =
554
+ decrement(.disable, player, options, volatiles.disable_duration);
555
+ try options.chance.disable(
556
+ player,
557
+ if (volatiles.disable_duration == 0) .ended else .continuing,
558
+ );
562
559
 
563
- if (volatiles.disabled_duration == 0) {
564
- volatiles.disabled_move = 0;
565
- try log.end(ident, .Disable);
560
+ if (volatiles.disable_duration == 0) {
561
+ volatiles.disable_move = 0;
562
+ try log.end(.{ ident, .Disable });
566
563
  }
567
564
  }
568
565
  // Pokémon Showdown's disable condition has a single onBeforeMove handler
@@ -571,25 +568,30 @@ fn beforeMove(
571
568
  // This can only happen if a Pokémon started the battle frozen/sleeping and was thawed/woken
572
569
  // before the side had a selected a move - we simply need to assume this leads to a desync
573
570
  if (side.last_selected_move == .None) {
574
- assert(!showdown);
571
+ assert(!pkmn.options.mod);
575
572
  return .err;
576
573
  }
577
574
 
578
575
  if (volatiles.Confusion) {
579
576
  assert(volatiles.confusion > 0);
580
-
581
- if (options.calc.modify(player, .confusion)) |extend| {
582
- if (!extend) volatiles.confusion = 0;
577
+ if (options.calc.overridden(player, .confusion)) |obs| switch (obs) {
578
+ .started, .ended => volatiles.confusion = 0,
579
+ .continuing => if (volatiles.confusion > 1) {
580
+ volatiles.confusion -= 1;
581
+ },
582
+ .overwritten => {},
583
+ else => unreachable,
583
584
  } else {
584
585
  volatiles.confusion -= 1;
585
586
  }
586
- try options.chance.confusion(player, volatiles.confusion);
587
+
588
+ try options.chance.confusion(player, if (volatiles.confusion == 0) .ended else .continuing);
587
589
 
588
590
  if (volatiles.confusion == 0) {
589
591
  volatiles.Confusion = false;
590
- try log.end(ident, .Confusion);
592
+ try log.end(.{ ident, .Confusion });
591
593
  } else {
592
- try log.activate(ident, .Confusion);
594
+ try log.activate(.{ ident, .Confusion });
593
595
 
594
596
  if (try Rolls.confused(battle, player, options)) {
595
597
  assert(!volatiles.MultiHit);
@@ -601,7 +603,9 @@ fn beforeMove(
601
603
  volatiles.Charging = false;
602
604
  volatiles.Binding = false;
603
605
  volatiles.Invulnerable = false;
604
- options.chance.clearDurations(player, null);
606
+
607
+ options.chance.observe(.attacking, player, .None);
608
+ options.chance.observe(.binding, player, .None);
605
609
  {
606
610
  // This feels (and is) disgusting but the cartridge literally just overwrites
607
611
  // the opponent's defense with the user's defense and resets it after. As a
@@ -609,19 +613,14 @@ fn beforeMove(
609
613
  const def = foe.active.stats.def;
610
614
  foe.active.stats.def = active.stats.def;
611
615
  defer foe.active.stats.def = def;
612
- if (!calcDamage(battle, player, player.foe(), null, false)) return .err;
616
+ if (!calcDamage(battle, player, player.foe(), null, false, options)) {
617
+ return .err;
618
+ }
613
619
  }
614
- // Pokémon Showdown incorrectly changes the "target" of the confusion self-hit based
615
- // on the targeting behavior of the confused Pokémon's selected move which results
616
- // in the wrong behavior with respect to the Substitute + Confusion glitch
617
- const target = if (showdown and Move.get(side.last_selected_move).target == .Self)
618
- player
619
- else
620
- player.foe();
621
620
 
622
621
  const uncapped = battle.last_damage;
623
622
  // Skipping adjustDamage / randomizeDamage / checkHit
624
- _ = try applyDamage(battle, player, target, .Confusion, options);
623
+ _ = try applyDamage(battle, player, player.foe(), .Confusion, options);
625
624
  // Pokémon Showdown thinks that confusion damage is uncapped ¯\_(ツ)_/¯
626
625
  if (showdown) battle.last_damage = uncapped;
627
626
 
@@ -638,9 +637,12 @@ fn beforeMove(
638
637
  volatiles.Thrashing = false;
639
638
  volatiles.Charging = false;
640
639
  volatiles.Binding = false;
641
- options.chance.clearDurations(player, null);
640
+
641
+ options.chance.observe(.attacking, player, .None);
642
+ options.chance.observe(.binding, player, .None);
643
+
642
644
  // GLITCH: Invulnerable is not cleared, resulting in permanent Fly/Dig invulnerability
643
- try log.cant(ident, .Paralysis);
645
+ try log.cant(.{ ident, .Paralysis });
644
646
  return .done;
645
647
  }
646
648
 
@@ -656,28 +658,22 @@ fn beforeMove(
656
658
  volatiles.state +%= battle.last_damage;
657
659
  }
658
660
 
659
- assert(volatiles.attacks > 0);
660
-
661
- if (options.calc.modify(player, .attacking)) |extend| {
662
- if (!extend) volatiles.attacks = 0;
663
- } else {
664
- volatiles.attacks -= 1;
665
- }
666
- try options.chance.attacking(player, volatiles.attacks);
661
+ volatiles.attacks = decrement(.attacking, player, options, volatiles.attacks);
662
+ try options.chance.attacking(player, if (volatiles.attacks == 0) .ended else .continuing);
667
663
 
668
664
  if (volatiles.attacks != 0) {
669
- try log.activate(ident, .Bide);
665
+ try log.activate(.{ ident, .Bide });
670
666
  return .done;
671
667
  }
672
668
 
673
669
  volatiles.Bide = false;
674
- try log.end(ident, .Bide);
670
+ try log.end(.{ ident, .Bide });
675
671
 
676
672
  battle.last_damage = volatiles.state *% 2;
677
673
  volatiles.state = 0;
678
674
 
679
675
  if (battle.last_damage == 0) {
680
- try log.fail(ident, .None);
676
+ try log.fail(.{ ident, .None });
681
677
  return .done;
682
678
  }
683
679
 
@@ -697,47 +693,42 @@ fn beforeMove(
697
693
  }
698
694
 
699
695
  if (volatiles.Thrashing) {
700
- assert(volatiles.attacks > 0);
701
- assert(from != null);
696
+ try log.move(.{ ident, side.last_selected_move, battle.active(player.foe()) });
702
697
 
703
- if (options.calc.modify(player, .attacking)) |extend| {
704
- if (!extend) volatiles.attacks = 0;
705
- } else {
706
- volatiles.attacks -= 1;
707
- }
708
- try options.chance.attacking(player, volatiles.attacks);
698
+ volatiles.attacks = decrement(.attacking, player, options, volatiles.attacks);
699
+ try options.chance.attacking(player, if (volatiles.attacks == 0) .ended else .continuing);
709
700
 
710
- if (!showdown and handleThrashing(battle, active, player, options)) {
711
- try log.start(battle.active(player), .ConfusionSilent);
712
- }
713
- try log.move(ident, side.last_selected_move, battle.active(player.foe()), from);
714
- if (showdown) {
715
- // This shouldn't actually set last_used_move, but Pokémon Showdown sets last
716
- // used in useMove and doesn't have the notion of skipping canMove semantics
717
- side.last_used_move = side.last_selected_move;
718
- if (handleThrashing(battle, active, player, options)) {
719
- try log.start(battle.active(player), .ConfusionSilent);
720
- }
701
+ if (volatiles.attacks == 0) {
702
+ const overwritten = volatiles.Confusion;
703
+
704
+ volatiles.Thrashing = false;
705
+ volatiles.Confusion = true;
706
+ volatiles.confusion = Rolls.confusionDuration(battle, player, options);
707
+
708
+ // On Pokémon Showdown this could leak information to the opponent that thrashing has
709
+ // ended (the engine's protocol is always from the omniscient perspective and clients
710
+ // can be expected to recognize this pattern and pass the information only to the
711
+ // correct player)
712
+ try log.start(.{ battle.active(player), .ConfusionSilent });
713
+ options.chance.observe(.confusion, player, if (overwritten)
714
+ if (pkmn.options.overwrite) .overwritten else .continuing
715
+ else
716
+ .started);
717
+ if (!overwritten) options.calc.confusion(player);
721
718
  }
719
+
720
+ // This shouldn't actually set last_used_move, but Pokémon Showdown sets last
721
+ // used in useMove and doesn't have the notion of skipping canMove semantics
722
+ if (showdown) side.last_used_move = side.last_selected_move;
723
+
722
724
  return .skip_can;
723
725
  }
724
726
 
725
727
  if (volatiles.Binding) {
726
- assert(volatiles.attacks > 0);
728
+ volatiles.attacks = decrement(.binding, player, options, volatiles.attacks);
729
+ try options.chance.binding(player, if (volatiles.attacks == 0) .ended else .continuing);
727
730
 
728
- if (options.calc.modify(player, .binding)) |extend| {
729
- if (!extend) volatiles.attacks = 0;
730
- } else {
731
- volatiles.attacks -= 1;
732
- }
733
- try options.chance.binding(player, volatiles.attacks);
734
-
735
- try log.move(
736
- battle.active(player),
737
- side.last_selected_move,
738
- battle.active(player.foe()),
739
- side.last_selected_move,
740
- );
731
+ try log.move(.{ ident, side.last_selected_move, battle.active(player.foe()) });
741
732
  if (showdown or battle.last_damage != 0) {
742
733
  const sub = showdown and foe.active.volatiles.Substitute;
743
734
  _ = try applyDamage(battle, player.foe(), player.foe(), .None, options);
@@ -757,7 +748,7 @@ fn canMove(
757
748
  mslot: u4,
758
749
  auto: bool,
759
750
  skip_pp: bool,
760
- from: ?Move,
751
+ from: Move,
761
752
  residual: *bool,
762
753
  options: anytype,
763
754
  ) !bool {
@@ -765,33 +756,21 @@ fn canMove(
765
756
  const player_ident = battle.active(player);
766
757
  const move = Move.get(side.last_selected_move);
767
758
 
768
- const foe_last = battle.foe(player).last_used_move;
769
- const special = from != null and (from.? == .Metronome and
770
- (from.? == .MirrorMove and !(foe_last == .None or foe_last == .MirrorMove)));
771
-
772
- var skip = skip_pp;
773
759
  if (side.active.volatiles.Charging) {
774
760
  side.active.volatiles.Charging = false;
775
761
  side.active.volatiles.Invulnerable = false;
776
762
  } else if (move.effect == .Charge) {
777
- try options.log.move(player_ident, side.last_selected_move, .{}, from);
763
+ try options.log.move(.{ player_ident, side.last_selected_move, ID{}, from });
778
764
  setCounterable(battle, player, side, move);
779
765
  try Effects.charge(battle, player, options);
780
766
  return false;
781
767
  }
782
768
 
783
- if (!showdown or !special) side.last_used_move = side.last_selected_move;
784
- if (!skip) decrementPP(side, mslot, auto);
769
+ side.last_used_move = side.last_selected_move;
770
+ if (!skip_pp) decrementPP(side, mslot, auto);
785
771
 
786
772
  const target = if (move.target == .Self) player else player.foe();
787
- // Pokémon Showdown's protocol for Rage and Binding moves should come with a [from], though we
788
- // don't have space to be able to track it in all circumstances and thus usually try to infer it
789
- // from side.last_used_move. However, this gets reset by the opponent switching or fainting,
790
- // meaning sometimes we lose this information. from is still set because it not being null is
791
- // relevant for control flow, but we don't have anything useful to put in the log. This is
792
- // regrettable, but this information is more "nice to have" than required
793
- const f = if (from != null and from.? == .None) null else from;
794
- try options.log.move(player_ident, side.last_selected_move, battle.active(target), f);
773
+ try options.log.move(.{ player_ident, side.last_selected_move, battle.active(target), from });
795
774
  setCounterable(battle, player, side, move);
796
775
 
797
776
  if (move.effect.onBegin()) {
@@ -801,9 +780,14 @@ fn canMove(
801
780
 
802
781
  if (move.effect == .Thrashing) {
803
782
  Effects.thrashing(battle, player, options);
804
- } else if (!showdown and move.effect == .Binding) {
805
- // Pokémon Showdown handles this after hit/miss checks and damage calculation
806
- try Effects.binding(battle, player, false, options);
783
+ } else if (move.effect == .Binding) {
784
+ // Pokémon Showdown handles this after hit/miss checks and damage calculation, though clears
785
+ // Recharging in onTryMove (required for the Hyper Beam automatic selection glitch)
786
+ if (showdown) {
787
+ battle.foe(player).active.volatiles.Recharging = false;
788
+ } else {
789
+ try Effects.binding(battle, player, false, options);
790
+ }
807
791
  }
808
792
 
809
793
  return true;
@@ -829,16 +813,18 @@ fn decrementPP(side: *Side, mslot: u4, auto: bool) void {
829
813
  assert(!volatiles.Rage and !volatiles.Thrashing and !volatiles.MultiHit);
830
814
  if (volatiles.Bide) return;
831
815
 
832
- assert(active.move(mslot).pp > 0 or auto);
833
- active.move(mslot).pp = @as(u6, @intCast(active.move(mslot).pp)) -% 1;
816
+ var move_slot = active.move(mslot);
817
+ assert(move_slot.pp > 0 or auto);
818
+ move_slot.pp = @as(u6, @intCast(move_slot.pp)) -% 1;
834
819
  if (volatiles.Transform) return;
835
820
 
836
- assert(side.stored().move(mslot).pp > 0 or auto);
837
- side.stored().move(mslot).pp = @as(u6, @intCast(side.stored().move(mslot).pp)) -% 1;
821
+ move_slot = side.stored().move(mslot);
822
+ assert(move_slot.pp > 0 or auto);
823
+ move_slot.pp = @as(u6, @intCast(move_slot.pp)) -% 1;
838
824
  assert(active.move(mslot).pp == side.stored().move(mslot).pp);
839
825
  }
840
826
 
841
- fn incrementPP(side: *Side, mslot: u4) void {
827
+ fn incrementPP(side: *Side, player: Player, mslot: u4, options: anytype) void {
842
828
  var active = &side.active;
843
829
  const volatiles = &active.volatiles;
844
830
 
@@ -846,8 +832,11 @@ fn incrementPP(side: *Side, mslot: u4) void {
846
832
  // GLITCH: No check for Transform means an empty/incorrect stored slot can get incremented
847
833
  if (showdown and volatiles.Transform) return;
848
834
 
835
+ const n = options.calc.overridden(player, .pp) orelse 1;
836
+ if (volatiles.Transform and !options.chance.pp(player, n)) return;
837
+
849
838
  assert(mslot > 0 and mslot <= 4);
850
- side.stored().moves[mslot - 1].pp = @as(u6, @intCast(side.stored().moves[mslot - 1].pp)) +% 1;
839
+ side.stored().moves[mslot - 1].pp = @as(u6, @intCast(side.stored().moves[mslot - 1].pp)) +% n;
851
840
  }
852
841
 
853
842
  // Pokémon Showdown does hit/multi/crit/damage instead of crit/damage/hit/multi
@@ -888,20 +877,20 @@ fn doMove(
888
877
  var miss = showdown and miss: {
889
878
  immune = move.target != .Self and !status and !counter and
890
879
  (@intFromEnum(move.type.effectiveness(foe.active.types.type1)) == 0 or
891
- @intFromEnum(move.type.effectiveness(foe.active.types.type2)) == 0);
880
+ @intFromEnum(move.type.effectiveness(foe.active.types.type2)) == 0);
892
881
  if (immune and move.effect != .Binding) break :miss true;
893
882
  if (move.effect == .OHKO and side.active.stats.spe < foe.active.stats.spe) {
894
883
  battle.last_damage = 0;
895
884
  break :miss true;
896
885
  }
897
886
  break :miss (move.target != .Self and
898
- !moveHit(battle, player, move, &immune, &mist, options));
887
+ !try moveHit(battle, player, move, &immune, &mist, options));
899
888
  };
900
889
  assert(!immune or miss or (showdown and move.effect == .Binding));
901
890
 
902
891
  var late = showdown and move.effect != .Explode;
903
892
  const skip = status or immune;
904
- if ((!showdown or (!skip or counter)) and !miss) blk: {
893
+ if ((!showdown or (!skip or counter)) and !miss) damage: {
905
894
  if (showdown and move.effect.isMulti()) {
906
895
  try Effects.multiHit(battle, player, move, options);
907
896
  hits = side.active.volatiles.attacks;
@@ -923,9 +912,10 @@ fn doMove(
923
912
  ohko = if (!showdown) side.active.stats.spe >= foe.active.stats.spe else true;
924
913
  // This can overflow after adjustDamage, but will still be sufficient to OHKO
925
914
  battle.last_damage = if (ohko) 65535 else 0;
926
- if (showdown) break :blk; // skip adjustDamage / randomizeDamage
927
- } else if (!calcDamage(battle, player, player.foe(), move, crit)) {
928
- return @as(?Result, Result.Error);
915
+ if (showdown) break :damage; // skip adjustDamage / randomizeDamage
916
+ } else if (!calcDamage(battle, player, player.foe(), move, crit, options)) {
917
+ if (!showdown) try options.chance.commit(player, .err);
918
+ return .Error;
929
919
  }
930
920
  if (battle.last_damage == 0) {
931
921
  immune = true;
@@ -938,11 +928,12 @@ fn doMove(
938
928
  }
939
929
  }
940
930
 
941
- var zero = battle.last_damage == 0;
942
- miss = if (showdown or skip)
943
- miss
944
- else
945
- (!moveHit(battle, player, move, &immune, &mist, options) or zero);
931
+ var missed: bool = undefined;
932
+ const zero = battle.last_damage == 0;
933
+ if (!showdown and !skip) {
934
+ missed = !try moveHit(battle, player, move, &immune, &mist, options);
935
+ miss = missed or zero;
936
+ }
946
937
 
947
938
  assert(showdown or miss or battle.last_damage > 0 or skip);
948
939
  assert((!showdown and miss) or !(ohko and immune));
@@ -966,16 +957,37 @@ fn doMove(
966
957
  ohko = (!showdown or (!immune and !invulnerable)) and
967
958
  move.effect == .OHKO and side.active.stats.spe < foe.active.stats.spe;
968
959
  if (ohko) {
969
- try log.immune(foe_ident, .OHKO);
960
+ try log.immune(.{ foe_ident, .OHKO });
970
961
  } else if (immune and !invulnerable and (!showdown or move.effect != .Binding)) {
971
- try log.immune(foe_ident, .None);
962
+ try log.immune(.{ foe_ident, .None });
963
+ if (!showdown) {
964
+ if (move.effect == .Binding) {
965
+ // Rather obnoxiously, the cartridge sets the Binding volatile in canMove and
966
+ // then simply clears it later on if the move happens to miss in moveHit.
967
+ // However, moveHit can return true and we'll still end up in the outer miss
968
+ // handling block because attacking into an immune Pokemon does zero damage
969
+ // (considered missing)
970
+ assert(zero);
971
+ // Thus we still need to actually save the hit result (but not the crit roll).
972
+ // Using .miss for this commit would accomplish what we want, but is somewhat
973
+ // misleading as its entirely possible the move actually did hit (and the target
974
+ // is now trapped) so we use the .binding value instead which is effectively
975
+ // identical, just doesn't claim to know about the actual results of the hit
976
+ // roll)
977
+ try options.chance.commit(player, .binding);
978
+ } else {
979
+ // The .glitch option allows for conditionally committing if we are in a
980
+ // division-by-zero freeze alternative timeline scenario
981
+ try options.chance.commit(player, .glitch);
982
+ }
983
+ }
972
984
  } else if (mist) {
973
- if (!foe.active.volatiles.Substitute) try log.activate(foe_ident, .Mist);
974
- try log.fail(foe_ident, .None);
985
+ if (!foe.active.volatiles.Substitute) try log.activate(.{ foe_ident, .Mist });
986
+ try log.fail(.{ foe_ident, .None });
975
987
  } else {
976
- if (showdown or !zero) try options.chance.commit(player, .miss);
977
- try log.lastmiss();
978
- try log.miss(battle.active(player));
988
+ if (!showdown) try options.chance.commit(player, if (zero and !missed) .hit else .miss);
989
+ try log.lastmiss(.{});
990
+ try log.miss(.{battle.active(player)});
979
991
  }
980
992
  if (move.effect == .JumpKick) {
981
993
  // Recoil is supposed to be damage/8 but damage will always be 0 here
@@ -988,17 +1000,15 @@ fn doMove(
988
1000
  try buildRage(battle, player.foe(), options);
989
1001
  } else if (showdown and move.effect == .Disable) {
990
1002
  try buildRage(battle, player.foe(), options);
991
- } else if (!showdown and immune and move.effect == .Binding) {
992
- try options.chance.commit(player, .binding);
993
1003
  }
994
1004
  return null;
995
- } else {
1005
+ } else if (!showdown) {
996
1006
  try options.chance.commit(player, .hit);
997
1007
  }
998
1008
 
999
1009
  // On the cartridge MultiHit doesn't get set up until after damage has been applied for the
1000
1010
  // first time but its more convenient and efficient to set it up here (Pokémon Showdown sets
1001
- // it up above before damage calculation).
1011
+ // it up above before damage calculation)
1002
1012
  if (!showdown and move.effect.isMulti()) {
1003
1013
  try Effects.multiHit(battle, player, move, options);
1004
1014
  hits = side.active.volatiles.attacks;
@@ -1008,18 +1018,18 @@ fn doMove(
1008
1018
  const sub = showdown and foe.active.volatiles.Substitute;
1009
1019
 
1010
1020
  var nullified = false;
1011
- var hit: u4 = 0;
1021
+ var hit: usize = 0;
1012
1022
  while (hit < hits) {
1013
1023
  if (hit == 0) {
1014
- if (crit) try log.crit(battle.active(player.foe()));
1024
+ if (crit) try log.crit(.{battle.active(player.foe())});
1015
1025
  if (effectiveness > Effectiveness.neutral) {
1016
- try log.supereffective(battle.active(player.foe()));
1026
+ try log.supereffective(.{battle.active(player.foe())});
1017
1027
  } else if (effectiveness < Effectiveness.neutral) {
1018
- try log.resisted(battle.active(player.foe()));
1028
+ try log.resisted(.{battle.active(player.foe())});
1019
1029
  }
1020
1030
  }
1021
1031
  if (!skip) nullified = try applyDamage(battle, player.foe(), player.foe(), .None, options);
1022
- if (hit == 0 and ohko) try log.ohko();
1032
+ if (hit == 0 and ohko) try log.ohko(.{});
1023
1033
  hit += 1;
1024
1034
  if (foe.stored().hp == 0) break;
1025
1035
  if (!late and (!sub or move.effect == .Explode)) {
@@ -1036,33 +1046,31 @@ fn doMove(
1036
1046
  if (showdown and move.effect == .Twineedle and !nullified and foe.stored().hp > 0) {
1037
1047
  try Effects.poison(battle, player, Move.get(.PoisonSting), options);
1038
1048
  }
1039
- try log.hitcount(battle.active(player.foe()), hit);
1040
- } else if (showdown) {
1049
+ try log.hitcount(.{ battle.active(player.foe()), @as(u4, @intCast(hit)) });
1050
+ } else if (showdown and move.effect == .Binding) {
1041
1051
  // This should be handled much earlier but Pokémon Showdown does it here... ¯\_(ツ)_/¯
1042
- if (move.effect == .Binding) {
1043
- try Effects.binding(battle, player, rewrap, options);
1044
- if (immune) {
1045
- battle.last_damage = 0;
1046
- assert(foe.stored().hp > 0);
1047
- // Pokémon Showdown logs |-damage| here instead of |-immune| because logic...
1048
- if (sub) {
1049
- try log.activate(battle.active(player.foe()), .Substitute);
1050
- } else {
1051
- try log.damage(battle.active(player.foe()), foe.stored(), .None);
1052
- try buildRage(battle, player.foe(), options);
1053
- }
1054
- return null;
1052
+ try Effects.binding(battle, player, rewrap, options);
1053
+ if (immune) {
1054
+ battle.last_damage = 0;
1055
+ assert(foe.stored().hp > 0);
1056
+ // Pokémon Showdown logs |-damage| here instead of |-immune| because logic...
1057
+ if (sub) {
1058
+ try log.activate(.{ battle.active(player.foe()), .Substitute });
1059
+ } else {
1060
+ try log.damage(.{ battle.active(player.foe()), foe.stored(), .None });
1061
+ try buildRage(battle, player.foe(), options);
1055
1062
  }
1063
+ return null;
1056
1064
  }
1057
1065
  }
1058
1066
 
1059
1067
  // Substitute being broken nullifies the move's effect completely so even
1060
- // if an effect was intended to "always happen" it will still get skipped.
1068
+ // if an effect was intended to "always happen" it will still get skipped
1061
1069
  if (nullified) return null;
1062
1070
 
1063
1071
  // On the cartridge, "always happen" effect handlers are called in the applyDamage loop above,
1064
1072
  // but this is only done to setup the MultiHit looping in the first place. Moving the MultiHit
1065
- // setup before the loop means we can avoid having to waste time doing no-op handler searches.
1073
+ // setup before the loop means we can avoid having to waste time doing no-op handler searches
1066
1074
  if (move.effect.alwaysHappens()) try alwaysHappens(battle, player, move, residual, options);
1067
1075
 
1068
1076
  if (foe.stored().hp == 0) return null;
@@ -1077,7 +1085,7 @@ fn doMove(
1077
1085
  // below). For Twineedle we change the data to that of one with PoisonChance1 given its
1078
1086
  // MultiHit behavior is complete after the loop above, though Pokémon Showdown handles the
1079
1087
  // Twineedle secondary effect in the MultiHit cleanup block above because it incorrectly
1080
- // puts the |-status| message before |-hitcount| instead of after.
1088
+ // puts the |-status| message before |-hitcount| instead of after
1081
1089
  if (move.effect == .Twineedle) {
1082
1090
  if (!showdown) try Effects.poison(battle, player, Move.get(.PoisonSting), options);
1083
1091
  } else if (move.effect == .Disable) {
@@ -1096,12 +1104,14 @@ fn checkCriticalHit(battle: anytype, player: Player, move: Move.Data, options: a
1096
1104
  const side = battle.side(player);
1097
1105
 
1098
1106
  // Base speed is used for the critical hit calculation, even when Transform-ed
1099
- var rate = @as(u16, Species.chance(side.stored().species));
1107
+ var rate: u16 = Species.chance(side.stored().species);
1100
1108
  // GLITCH: Focus Energy reduces critical hit rate instead of increasing it
1101
1109
  rate = if (side.active.volatiles.FocusEnergy) rate / 2 else @min(rate * 2, 255);
1102
1110
  rate = if (move.effect == .HighCritical) @min(rate * 4, 255) else rate / 2;
1103
1111
 
1104
- return Rolls.criticalHit(battle, player, @intCast(rate), options);
1112
+ const spurious =
1113
+ !showdown and ((move.bp == 0 and move.effect != .OHKO) or move.target == .Depends);
1114
+ return Rolls.criticalHit(battle, player, @intCast(rate), spurious, options);
1105
1115
  }
1106
1116
 
1107
1117
  fn calcDamage(
@@ -1110,6 +1120,7 @@ fn calcDamage(
1110
1120
  target_player: Player,
1111
1121
  m: ?Move.Data,
1112
1122
  crit: bool,
1123
+ options: anytype,
1113
1124
  ) bool {
1114
1125
  // Confusion (indicated when m == null) just needs a 40 BP physical move
1115
1126
  const cfz = m == null;
@@ -1121,28 +1132,8 @@ fn calcDamage(
1121
1132
 
1122
1133
  const special = move.type.special();
1123
1134
 
1124
- // zig fmt: off
1125
- var atk: u32 =
1126
- if (crit)
1127
- if (special) side.stored().stats.spc
1128
- else side.stored().stats.atk
1129
- else
1130
- if (special) side.active.stats.spc
1131
- else side.active.stats.atk;
1132
- var def: u32 =
1133
- if (crit)
1134
- if (special) target.stored().stats.spc
1135
- else target.stored().stats.def
1136
- else
1137
- // GLITCH: not capped to MAX_STAT_VALUE, can be 999 * 2 = 1998
1138
- if (special)
1139
- target.active.stats.spc *
1140
- @as(u2, if (target.active.volatiles.LightScreen) 2 else 1)
1141
- // Pokémon Showdown doesn't apply the opponent's Reflect to confusion's self-hit
1142
- else
1143
- target.active.stats.def *
1144
- @as(u2, if ((!showdown or !cfz) and target.active.volatiles.Reflect) 2 else 1);
1145
- // zig fmt: on
1135
+ var atk = attack(side, crit, special);
1136
+ var def = defense(target, crit, special, cfz);
1146
1137
 
1147
1138
  // Pokémon Showdown erroneously skips this for confusion's self-hit damage, but thankfully we
1148
1139
  // will not overflow because the hit is only 40 BP and unboosted (the highest legal unboosted
@@ -1150,12 +1141,12 @@ fn calcDamage(
1150
1141
  if ((!showdown or !cfz) and (atk > 255 or def > 255)) {
1151
1142
  atk = @max((atk / 4) & 255, 1);
1152
1143
  // GLITCH: not adjusted to be a min of 1 on cartridge (can lead to division-by-zero freeze)
1153
- def = @max((def / 4) & 255, if (showdown) 1 else 0);
1144
+ def = @max((def / 4) & 255, if (pkmn.options.mod) 1 else 0);
1154
1145
  }
1155
1146
 
1156
- const lvl = @as(u32, side.stored().level * @as(u2, if (crit) 2 else 1));
1147
+ const lvl: u32 = side.stored().level * @as(u2, if (crit) 2 else 1);
1157
1148
 
1158
- def = @as(u32, if (move.effect == .Explode) @max(def / 2, 1) else def);
1149
+ def = if (move.effect == .Explode) @max(def / 2, 1) else def;
1159
1150
 
1160
1151
  if (def == 0) return false;
1161
1152
 
@@ -1167,11 +1158,41 @@ fn calcDamage(
1167
1158
  d = @min(997, d);
1168
1159
  d += 2;
1169
1160
 
1161
+ // Whether or not a critical hit occurred can change whether a division-by-zero freeze is
1162
+ // encountered so we need to make sure that in the event of a miss we commit the status of the
1163
+ // crit roll. The alternative to doing this extra work is to *always* commit the crit roll but
1164
+ // that results in counter intuitive probabilities in the common case
1165
+ if (pkmn.options.chance and !pkmn.options.mod and move.effect != .Explode) {
1166
+ atk = attack(side, !crit, special);
1167
+ def = defense(target, !crit, special, false);
1168
+ if ((atk > 255 or def > 255) and (def / 4) & 255 == 0) options.chance.glitch();
1169
+ }
1170
+
1170
1171
  battle.last_damage = @intCast(d);
1171
1172
 
1172
1173
  return true;
1173
1174
  }
1174
1175
 
1176
+ fn attack(side: *Side, crit: bool, special: bool) u32 {
1177
+ return if (crit)
1178
+ if (special) side.stored().stats.spc else side.stored().stats.atk
1179
+ else if (special) side.active.stats.spc else side.active.stats.atk;
1180
+ }
1181
+
1182
+ fn defense(target: *Side, crit: bool, special: bool, cfz: bool) u32 {
1183
+ return if (crit)
1184
+ if (special) target.stored().stats.spc else target.stored().stats.def
1185
+ else
1186
+ // GLITCH: not capped to MAX_STAT_VALUE, can be 999 * 2 = 1998
1187
+ if (special)
1188
+ target.active.stats.spc *
1189
+ @as(u2, if (target.active.volatiles.LightScreen) 2 else 1)
1190
+ else
1191
+ // Pokémon Showdown doesn't apply the opponent's Reflect to confusion's self-hit
1192
+ target.active.stats.def *
1193
+ @as(u2, if ((!showdown or !cfz) and target.active.volatiles.Reflect) 2 else 1);
1194
+ }
1195
+
1175
1196
  fn adjustDamage(battle: anytype, player: Player) u16 {
1176
1197
  const side = battle.side(player);
1177
1198
  const foe = battle.foe(player);
@@ -1216,7 +1237,6 @@ fn specialDamage(battle: anytype, player: Player, move: Move.Data, options: anyt
1216
1237
  const foe = battle.foe(player);
1217
1238
 
1218
1239
  if (!try checkHit(battle, player, move, options)) return null;
1219
- try options.chance.commit(player, .hit);
1220
1240
 
1221
1241
  battle.last_damage = switch (side.last_selected_move) {
1222
1242
  .SuperFang => @max(foe.stored().hp / 2, 1),
@@ -1227,13 +1247,13 @@ fn specialDamage(battle: anytype, player: Player, move: Move.Data, options: anyt
1227
1247
  .Psywave => power: {
1228
1248
  const max: u8 = @intCast(@as(u16, side.stored().level) * 3 / 2);
1229
1249
  // GLITCH: Psywave infinite glitch loop
1230
- if (!showdown and max <= 1) return Result.Error;
1250
+ if (!pkmn.options.mod and max <= 1) return .Error;
1231
1251
  break :power try Rolls.psywave(battle, player, max, options);
1232
1252
  },
1233
1253
  else => unreachable,
1234
1254
  };
1235
1255
 
1236
- if (battle.last_damage == 0) return if (showdown) null else Result.Error;
1256
+ if (battle.last_damage == 0) return if (pkmn.options.mod) null else .Error;
1237
1257
 
1238
1258
  const sub = showdown and foe.active.volatiles.Substitute;
1239
1259
  _ = try applyDamage(battle, player.foe(), player.foe(), .None, options);
@@ -1246,40 +1266,39 @@ fn counterDamage(battle: anytype, player: Player, move: Move.Data, options: anyt
1246
1266
  const foe = battle.foe(player);
1247
1267
 
1248
1268
  if (battle.last_damage == 0) {
1249
- try options.log.fail(battle.active(player), .None);
1269
+ try options.log.fail(.{ battle.active(player), .None });
1250
1270
  return null;
1251
1271
  }
1252
1272
 
1253
1273
  // Pretend Splash was used as a stand-in when no move has been used to fail below with 0 BP
1254
1274
  const foe_last_selected_move =
1255
1275
  Move.get(if (foe.last_selected_move == .None or foe.last_selected_move == .SKIP_TURN)
1256
- .Splash
1257
- else
1258
- foe.last_selected_move);
1276
+ .Splash
1277
+ else
1278
+ foe.last_selected_move);
1259
1279
 
1260
1280
  const used = battle.lastMove(player.foe()).counterable != 0;
1261
1281
  const selected = foe_last_selected_move.bp > 0 and
1262
1282
  foe.last_selected_move != .Counter and
1263
1283
  (foe_last_selected_move.type == .Normal or
1264
- foe_last_selected_move.type == .Fighting);
1284
+ foe_last_selected_move.type == .Fighting);
1265
1285
 
1266
1286
  if (!used and !selected) {
1267
- try options.log.fail(battle.active(player), .None);
1287
+ try options.log.fail(.{ battle.active(player), .None });
1268
1288
  return null;
1269
1289
  }
1270
1290
 
1271
1291
  if (!used or !selected) {
1272
1292
  // GLITCH: Counter desync (covered by Desync Clause Mod on Pokémon Showdown)
1273
- if (!showdown) return Result.Error;
1274
- try options.log.fail(battle.active(player), .None);
1293
+ if (!pkmn.options.mod) return .Error;
1294
+ try options.log.fail(.{ battle.active(player), .None });
1275
1295
  return null;
1276
1296
  }
1277
1297
 
1278
1298
  battle.last_damage *|= 2;
1279
1299
 
1280
- // Pokémon Showdown calls checkHit before Counter
1300
+ // Pokémon Showdown calls moveHit before Counter
1281
1301
  if (!showdown and !try checkHit(battle, player, move, options)) return null;
1282
- try options.chance.commit(player, .hit);
1283
1302
 
1284
1303
  const sub = showdown and foe.active.volatiles.Substitute;
1285
1304
  _ = try applyDamage(battle, player.foe(), player.foe(), .None, options);
@@ -1307,20 +1326,20 @@ fn applyDamage(
1307
1326
  subbed.active.volatiles.substitute = 0;
1308
1327
  subbed.active.volatiles.Substitute = false;
1309
1328
  // battle.last_damage is not updated with the amount of HP the Substitute had
1310
- try options.log.end(battle.active(sub_player), .Substitute);
1311
- options.calc.capped(target_player);
1329
+ try options.log.end(.{ battle.active(sub_player), .Substitute });
1330
+ if (reason != .Confusion) options.calc.capped(sub_player);
1312
1331
  return true;
1313
1332
  } else {
1314
1333
  // Safe to truncate since less than subbed.volatiles.substitute which is a u8
1315
1334
  subbed.active.volatiles.substitute -= @intCast(battle.last_damage);
1316
- try options.log.activate(battle.active(sub_player), .Substitute);
1335
+ try options.log.activate(.{ battle.active(sub_player), .Substitute });
1317
1336
  return false;
1318
1337
  }
1319
1338
  }
1320
1339
 
1321
1340
  if (battle.last_damage > target.stored().hp) battle.last_damage = target.stored().hp;
1322
1341
  target.stored().hp -= battle.last_damage;
1323
- try options.log.damage(battle.active(target_player), target.stored(), reason);
1342
+ try options.log.damage(.{ battle.active(target_player), target.stored(), reason });
1324
1343
  return false;
1325
1344
  }
1326
1345
 
@@ -1332,17 +1351,17 @@ fn mirrorMove(
1332
1351
  auto: bool,
1333
1352
  residual: *bool,
1334
1353
  options: anytype,
1335
- ) !?Result {
1354
+ ) anyerror!?Result {
1336
1355
  var side = battle.side(player);
1337
1356
  const foe = battle.foe(player);
1338
1357
 
1339
1358
  side.last_selected_move = foe.last_used_move;
1340
1359
 
1341
1360
  if (foe.last_used_move == .None or foe.last_used_move == .MirrorMove) {
1342
- try options.log.fail(battle.active(player), .None);
1361
+ try options.log.fail(.{ battle.active(player), .None });
1343
1362
  return null;
1344
1363
  } else if (!showdown or foe.last_used_move != .Struggle) {
1345
- incrementPP(side, mslot);
1364
+ incrementPP(side, player, mslot, options);
1346
1365
  }
1347
1366
 
1348
1367
  if (!try canMove(battle, player, mslot, auto, false, .MirrorMove, residual, options)) {
@@ -1359,11 +1378,11 @@ fn metronome(
1359
1378
  auto: bool,
1360
1379
  residual: *bool,
1361
1380
  options: anytype,
1362
- ) !?Result {
1381
+ ) anyerror!?Result {
1363
1382
  var side = battle.side(player);
1364
1383
 
1365
1384
  side.last_selected_move = try Rolls.metronome(battle, player, options);
1366
- incrementPP(side, mslot);
1385
+ incrementPP(side, player, mslot, options);
1367
1386
 
1368
1387
  if (!try canMove(battle, player, mslot, auto, false, .Metronome, residual, options)) {
1369
1388
  return null;
@@ -1375,20 +1394,23 @@ fn checkHit(battle: anytype, player: Player, move: Move.Data, options: anytype)
1375
1394
  var immune = false;
1376
1395
  var mist = false;
1377
1396
 
1378
- if (moveHit(battle, player, move, &immune, &mist, options)) return true;
1397
+ if (try moveHit(battle, player, move, &immune, &mist, options)) {
1398
+ if (!showdown) try options.chance.commit(player, .hit);
1399
+ return true;
1400
+ }
1379
1401
 
1380
1402
  assert(!immune);
1381
1403
  if (mist) {
1382
1404
  assert(!showdown);
1383
1405
  const foe_ident = battle.active(player.foe());
1384
- try options.log.activate(foe_ident, .Mist);
1385
- try options.log.fail(foe_ident, .None);
1406
+ try options.log.activate(.{ foe_ident, .Mist });
1407
+ try options.log.fail(.{ foe_ident, .None });
1386
1408
  } else {
1387
- try options.chance.commit(player, .miss);
1388
- try options.log.lastmiss();
1389
- try options.log.miss(battle.active(player));
1409
+ try options.log.lastmiss(.{});
1410
+ try options.log.miss(.{battle.active(player)});
1390
1411
  }
1391
1412
 
1413
+ if (!showdown) try options.chance.commit(player, .miss);
1392
1414
  return false;
1393
1415
  }
1394
1416
 
@@ -1399,7 +1421,7 @@ fn moveHit(
1399
1421
  immune: *bool,
1400
1422
  mist: *bool,
1401
1423
  options: anytype,
1402
- ) bool {
1424
+ ) !bool {
1403
1425
  var side = battle.side(player);
1404
1426
  const foe = battle.foe(player);
1405
1427
 
@@ -1419,7 +1441,16 @@ fn moveHit(
1419
1441
  break :miss true;
1420
1442
  }
1421
1443
  if (!showdown) {
1422
- if (move.effect == .Swift) return true;
1444
+ if (move.effect == .Swift) {
1445
+ // If Swift could cause a division-by-zero freeze (which doesn't exist on Pokémon
1446
+ // Showdown, hence why the block above doesn't include this code), we need the
1447
+ // damage roll to be committed. However, the commit code will not save this roll if
1448
+ // the glitch occurs and the move was not marked as having hit. To work around this
1449
+ // we flag that the move hit (as Swift trivially does), but pass the probability of
1450
+ // the hit as zero to ensure it doesn't actually get saved.
1451
+ try options.chance.hit(player, true, 0);
1452
+ return true;
1453
+ }
1423
1454
  if (foe.active.volatiles.Invulnerable) break :miss true;
1424
1455
  }
1425
1456
 
@@ -1438,7 +1469,7 @@ fn moveHit(
1438
1469
  const overwritten = overwrite and state > 0;
1439
1470
  assert(!overwritten or (0 < state and state <= 255 and !side.active.volatiles.Bide));
1440
1471
 
1441
- var accuracy = if (overwritten) state else @as(u16, Gen12.percent(move.accuracy));
1472
+ var accuracy: u16 = if (overwritten) state else move.accuracy;
1442
1473
  var boost = BOOSTS[@as(u4, @intCast(@as(i8, side.active.boosts.accuracy) + 6))];
1443
1474
  accuracy = accuracy * boost[0] / boost[1];
1444
1475
  boost = BOOSTS[@as(u4, @intCast(@as(i8, -foe.active.boosts.evasion) + 6))];
@@ -1450,7 +1481,7 @@ fn moveHit(
1450
1481
  if (overwrite) side.active.volatiles.state = accuracy;
1451
1482
 
1452
1483
  // GLITCH: max accuracy is 255 so 1/256 chance of miss
1453
- break :miss !Rolls.hit(battle, player, @intCast(accuracy), options);
1484
+ break :miss !try Rolls.hit(battle, player, @intCast(accuracy), options);
1454
1485
  };
1455
1486
 
1456
1487
  // Pokémon Showdown reports miss instead of fail for moves blocked by Mist that 1/256 miss
@@ -1461,13 +1492,17 @@ fn moveHit(
1461
1492
 
1462
1493
  if (!miss) return true;
1463
1494
  if (!showdown or !foe.active.volatiles.Invulnerable) battle.last_damage = 0;
1495
+
1464
1496
  side.active.volatiles.Binding = false;
1497
+ options.chance.observe(.binding, player, .None);
1498
+
1465
1499
  return false;
1466
1500
  }
1467
1501
 
1468
1502
  fn checkFaint(
1469
1503
  battle: anytype,
1470
1504
  player: Player,
1505
+ cap: bool,
1471
1506
  options: anytype,
1472
1507
  ) @TypeOf(options.log).Error!?Result {
1473
1508
  const side = battle.side(player);
@@ -1477,33 +1512,32 @@ fn checkFaint(
1477
1512
  const foe_fainted = foe.stored().hp == 0;
1478
1513
 
1479
1514
  const player_out = findFirstAlive(side) == 0;
1480
- const foe_out = findFirstAlive(foe) == 0;
1481
- const tie = player_out and foe_out;
1482
- const more = tie or player_out or foe_out;
1515
+ const foe_out = foe_fainted and findFirstAlive(foe) == 0;
1516
+ const more = player_out or foe_out;
1483
1517
 
1484
- if (try faint(battle, player, !(more or foe_fainted), options)) |r| return r;
1485
- if (foe_fainted) if (try faint(battle, player.foe(), !more, options)) |r| return r;
1518
+ if (try faint(battle, player, !(more or foe_fainted), cap, options)) |r| return r;
1519
+ if (foe_fainted) if (try faint(battle, player.foe(), !more, false, options)) |r| return r;
1486
1520
 
1487
1521
  assert(!side.active.volatiles.MultiHit);
1488
1522
  assert(!foe.active.volatiles.MultiHit);
1489
1523
 
1490
- if (tie) {
1491
- try options.log.tie();
1492
- return Result.Tie;
1524
+ if (player_out and foe_out) {
1525
+ try options.log.tie(.{});
1526
+ return .Tie;
1493
1527
  } else if (player_out) {
1494
- try options.log.win(player.foe());
1495
- return if (player == .P1) Result.Lose else Result.Win;
1528
+ try options.log.win(.{player.foe()});
1529
+ return if (player == .P1) .Lose else .Win;
1496
1530
  } else if (foe_out) {
1497
- try options.log.win(player);
1498
- return if (player == .P1) Result.Win else Result.Lose;
1531
+ try options.log.win(.{player});
1532
+ return if (player == .P1) .Win else .Lose;
1499
1533
  }
1500
1534
 
1501
1535
  const foe_choice: Choice.Type = if (foe_fainted) .Switch else .Pass;
1502
- if (player == .P1) return Result{ .p1 = .Switch, .p2 = foe_choice };
1503
- return Result{ .p1 = foe_choice, .p2 = .Switch };
1536
+ if (player == .P1) return .{ .p1 = .Switch, .p2 = foe_choice };
1537
+ return .{ .p1 = foe_choice, .p2 = .Switch };
1504
1538
  }
1505
1539
 
1506
- fn faint(battle: anytype, player: Player, done: bool, options: anytype) !?Result {
1540
+ fn faint(battle: anytype, player: Player, done: bool, cap: bool, options: anytype) !?Result {
1507
1541
  var side = battle.side(player);
1508
1542
  var foe = battle.foe(player);
1509
1543
  assert(side.stored().hp == 0);
@@ -1512,8 +1546,8 @@ fn faint(battle: anytype, player: Player, done: bool, options: anytype) !?Result
1512
1546
  assert(!foe_volatiles.MultiHit);
1513
1547
  if (foe_volatiles.Bide) {
1514
1548
  assert(!foe_volatiles.Thrashing and !foe_volatiles.Rage);
1515
- foe_volatiles.state = if (showdown) 0 else foe_volatiles.state & 255;
1516
- if (foe_volatiles.state != 0) return Result.Error;
1549
+ foe_volatiles.state = if (pkmn.options.mod) 0 else foe_volatiles.state & 255;
1550
+ if (foe_volatiles.state != 0) return .Error;
1517
1551
  }
1518
1552
 
1519
1553
  // Clearing these is not strictly necessary as provided the battle hasn't ended the side that
@@ -1536,8 +1570,10 @@ fn faint(battle: anytype, player: Player, done: bool, options: anytype) !?Result
1536
1570
  side.stored().stats.spe;
1537
1571
  }
1538
1572
 
1539
- try options.log.faint(battle.active(player), done);
1540
- options.calc.capped(player);
1573
+ try options.log.faint(.{ battle.active(player), done });
1574
+ // We can only safely set capped if the faint occured as the direct result of an opponent's
1575
+ // move (as opposed to due to residual damage, recoil, Explosion, etc)
1576
+ if (cap) options.calc.capped(player);
1541
1577
  return null;
1542
1578
  }
1543
1579
 
@@ -1548,21 +1584,19 @@ fn handleResidual(battle: anytype, player: Player, options: anytype) !void {
1548
1584
  var volatiles = &side.active.volatiles;
1549
1585
 
1550
1586
  const brn = Status.is(stored.status, .BRN);
1551
- if (brn or Status.is(stored.status, .PSN)) blk: {
1587
+ if (brn or Status.is(stored.status, .PSN)) {
1552
1588
  var damage = @max(stored.stats.hp / 16, 1);
1553
-
1554
1589
  if (volatiles.Toxic) {
1555
1590
  volatiles.toxic += 1;
1556
1591
  damage *= volatiles.toxic;
1557
1592
  }
1593
+ damage = @min(damage, stored.hp);
1558
1594
 
1559
- const amount = @min(damage, stored.hp);
1560
- if (showdown and amount == 0) break :blk;
1561
-
1562
- stored.hp -= amount;
1563
-
1564
- // Pokémon Showdown uses damageOf here but its not relevant in Generation I
1565
- try options.log.damage(ident, stored, if (brn) Damage.Burn else Damage.Poison);
1595
+ if (!showdown or damage > 0) {
1596
+ stored.hp -= damage;
1597
+ // Pokémon Showdown uses damage [of] here but its not relevant in Generation I
1598
+ try options.log.damage(.{ ident, stored, if (brn) Damage.Burn else Damage.Poison });
1599
+ }
1566
1600
  }
1567
1601
 
1568
1602
  if (volatiles.LeechSeed) {
@@ -1586,14 +1620,14 @@ fn handleResidual(battle: anytype, player: Player, options: anytype) !void {
1586
1620
  const amount = @min(damage, stored.hp);
1587
1621
  stored.hp -= amount;
1588
1622
 
1589
- // As above, Pokémon Showdown uses damageOf but its not relevant
1590
- if (amount > 0) try options.log.damage(ident, stored, .LeechSeed);
1623
+ // As above, Pokémon Showdown uses damage [of] but its not relevant
1624
+ if (amount > 0) try options.log.damage(.{ ident, stored, .LeechSeed });
1591
1625
 
1592
1626
  const before = foe_stored.hp;
1593
1627
  // Uncapped damage is added back to the foe
1594
1628
  foe_stored.hp = @min(foe_stored.hp + damage, foe_stored.stats.hp);
1595
1629
  // Pokémon Showdown uses the less specific heal here instead of drain... because reasons?
1596
- if (foe_stored.hp > before) try options.log.heal(foe_ident, foe_stored, .Silent);
1630
+ if (foe_stored.hp > before) try options.log.heal(.{ foe_ident, foe_stored, .Silent });
1597
1631
  }
1598
1632
  }
1599
1633
 
@@ -1603,21 +1637,21 @@ fn endTurn(battle: anytype, options: anytype) @TypeOf(options.log).Error!Result
1603
1637
 
1604
1638
  battle.turn += 1;
1605
1639
 
1606
- if (showdown and pkmn.options.ebc and checkEBC(battle)) {
1607
- try options.log.tie();
1608
- return Result.Tie;
1640
+ if (pkmn.options.mod and pkmn.options.ebc and checkEBC(battle)) {
1641
+ try options.log.tie(.{});
1642
+ return .Tie;
1609
1643
  }
1610
1644
 
1611
- if (showdown and battle.turn >= 1000) {
1612
- try options.log.tie();
1613
- return Result.Tie;
1645
+ if (pkmn.options.mod and battle.turn >= 1000) {
1646
+ try options.log.tie(.{});
1647
+ return .Tie;
1614
1648
  } else if (battle.turn >= 65535) {
1615
- return Result.Error;
1649
+ return .Error;
1616
1650
  }
1617
1651
 
1618
- try options.log.turn(battle.turn);
1652
+ try options.log.turn(.{battle.turn});
1619
1653
 
1620
- return Result.Default;
1654
+ return .Default;
1621
1655
  }
1622
1656
 
1623
1657
  fn checkEBC(battle: anytype) bool {
@@ -1675,9 +1709,9 @@ fn checkEBC(battle: anytype) bool {
1675
1709
  }
1676
1710
 
1677
1711
  fn disabled(side: *Side, ident: ID, options: anytype) !bool {
1678
- if (side.active.volatiles.disabled_move != 0) {
1712
+ if (side.active.volatiles.disable_move != 0) {
1679
1713
  // A Pokémon that transforms after being disabled may end up with less move slots
1680
- const m = side.active.moves[side.active.volatiles.disabled_move - 1].id;
1714
+ const m = side.active.moves[side.active.volatiles.disable_move - 1].id;
1681
1715
  // side.last_selected_move can be Struggle here on Pokemon Showdown we need an extra check
1682
1716
  const last = if (showdown and m == .Bide and side.active.volatiles.Bide)
1683
1717
  m
@@ -1685,32 +1719,21 @@ fn disabled(side: *Side, ident: ID, options: anytype) !bool {
1685
1719
  side.last_selected_move;
1686
1720
  if (m != .None and m == last) {
1687
1721
  side.active.volatiles.Charging = false;
1688
- try options.log.disabled(ident, last);
1722
+ try options.log.cant(.{ ident, .Disable, last });
1689
1723
  return true;
1690
1724
  }
1691
1725
  }
1692
1726
  return false;
1693
1727
  }
1694
1728
 
1695
- inline fn buildRage(battle: anytype, who: Player, options: anytype) !void {
1729
+ fn buildRage(battle: anytype, who: Player, options: anytype) !void {
1696
1730
  const side = battle.side(who);
1697
1731
  if (side.active.volatiles.Rage and side.active.boosts.atk < 6) {
1698
1732
  try Effects.boost(battle, who, Move.get(.Rage), options);
1699
1733
  }
1700
1734
  }
1701
1735
 
1702
- fn handleThrashing(battle: anytype, active: *ActivePokemon, player: Player, options: anytype) bool {
1703
- var volatiles = &active.volatiles;
1704
- assert(volatiles.Thrashing);
1705
- if (volatiles.attacks > 0) return false;
1706
-
1707
- volatiles.Thrashing = false;
1708
- volatiles.Confusion = true;
1709
- volatiles.confusion = Rolls.confusionDuration(battle, player, true, options);
1710
- return true;
1711
- }
1712
-
1713
- inline fn onBegin(
1736
+ fn onBegin(
1714
1737
  battle: anytype,
1715
1738
  player: Player,
1716
1739
  move: Move.Data,
@@ -1718,7 +1741,6 @@ inline fn onBegin(
1718
1741
  residual: *bool,
1719
1742
  options: anytype,
1720
1743
  ) !void {
1721
- assert(move.effect.onBegin());
1722
1744
  return switch (move.effect) {
1723
1745
  .Confusion => Effects.confusion(battle, player, move, options),
1724
1746
  .Conversion => Effects.conversion(battle, player, options),
@@ -1740,8 +1762,7 @@ inline fn onBegin(
1740
1762
  };
1741
1763
  }
1742
1764
 
1743
- inline fn onEnd(battle: anytype, player: Player, move: Move.Data, options: anytype) !void {
1744
- assert(move.effect.onEnd());
1765
+ fn onEnd(battle: anytype, player: Player, move: Move.Data, options: anytype) !void {
1745
1766
  return switch (move.effect) {
1746
1767
  .Bide => Effects.bide(battle, player, options),
1747
1768
  // zig fmt: off
@@ -1756,14 +1777,13 @@ inline fn onEnd(battle: anytype, player: Player, move: Move.Data, options: anyty
1756
1777
  };
1757
1778
  }
1758
1779
 
1759
- inline fn alwaysHappens(
1780
+ fn alwaysHappens(
1760
1781
  battle: anytype,
1761
1782
  player: Player,
1762
1783
  move: Move.Data,
1763
1784
  residual: *bool,
1764
1785
  options: anytype,
1765
1786
  ) !void {
1766
- assert(move.effect.alwaysHappens());
1767
1787
  return switch (move.effect) {
1768
1788
  .DrainHP, .DreamEater => Effects.drainHP(battle, player, options),
1769
1789
  .Explode => Effects.explode(battle, player),
@@ -1775,7 +1795,7 @@ inline fn alwaysHappens(
1775
1795
  };
1776
1796
  }
1777
1797
 
1778
- inline fn moveEffect(battle: anytype, player: Player, move: Move.Data, options: anytype) !void {
1798
+ fn moveEffect(battle: anytype, player: Player, move: Move.Data, options: anytype) !void {
1779
1799
  return switch (move.effect) {
1780
1800
  .BurnChance1, .BurnChance2 => Effects.burnChance(battle, player, move, options),
1781
1801
  .ConfusionChance => Effects.confusion(battle, player, move, options),
@@ -1799,10 +1819,12 @@ pub const Effects = struct {
1799
1819
 
1800
1820
  side.active.volatiles.Bide = true;
1801
1821
  assert(!side.active.volatiles.Thrashing and !side.active.volatiles.Rage);
1822
+
1802
1823
  side.active.volatiles.state = 0;
1803
1824
  side.active.volatiles.attacks = Rolls.attackingDuration(battle, player, options);
1825
+ options.chance.observe(.attacking, player, .started);
1804
1826
 
1805
- try options.log.start(battle.active(player), .Bide);
1827
+ try options.log.start(.{ battle.active(player), .Bide });
1806
1828
  }
1807
1829
 
1808
1830
  fn burnChance(battle: anytype, player: Player, move: Move.Data, options: anytype) !void {
@@ -1816,11 +1838,11 @@ pub const Effects = struct {
1816
1838
  // GLITCH: Freeze top move selection desync can occur if thawed Pokémon is slower
1817
1839
  if (Status.is(foe_stored.status, .FRZ)) {
1818
1840
  assert(move.type == .Fire);
1819
- try options.log.curestatus(
1841
+ try options.log.curestatus(.{
1820
1842
  battle.active(player.foe()),
1821
1843
  foe_stored.status,
1822
1844
  .Message,
1823
- );
1845
+ });
1824
1846
  foe_stored.status = 0;
1825
1847
  }
1826
1848
  return;
@@ -1834,7 +1856,7 @@ pub const Effects = struct {
1834
1856
  foe_stored.status = Status.init(.BRN);
1835
1857
  foe.active.stats.atk = @max(foe.active.stats.atk / 2, 1);
1836
1858
 
1837
- try options.log.status(battle.active(player.foe()), foe_stored.status, .None);
1859
+ try options.log.status(.{ battle.active(player.foe()), foe_stored.status, .None });
1838
1860
  }
1839
1861
 
1840
1862
  fn charge(battle: anytype, player: Player, options: anytype) !void {
@@ -1844,8 +1866,8 @@ pub const Effects = struct {
1844
1866
  volatiles.Charging = true;
1845
1867
  const move = side.last_selected_move;
1846
1868
  if (move == .Fly or move == .Dig) volatiles.Invulnerable = true;
1847
- try options.log.laststill();
1848
- try options.log.prepare(battle.active(player), move);
1869
+ try options.log.laststill(.{});
1870
+ try options.log.prepare(.{ battle.active(player), move });
1849
1871
  }
1850
1872
 
1851
1873
  fn confusion(battle: anytype, player: Player, move: Move.Data, options: anytype) !void {
@@ -1856,42 +1878,39 @@ pub const Effects = struct {
1856
1878
  if (!try Rolls.confusionChance(battle, player, options)) return;
1857
1879
  } else {
1858
1880
  if (showdown) {
1859
- if (!try checkHit(battle, player, move, options)) {
1860
- return;
1861
- } else if (sub) {
1862
- return options.log.fail(battle.active(player.foe()), .None);
1863
- }
1881
+ if (!try checkHit(battle, player, move, options)) return;
1882
+ if (sub) return options.log.fail(.{ battle.active(player.foe()), .None });
1864
1883
  } else {
1865
- if (sub) {
1866
- return options.log.fail(battle.active(player.foe()), .None);
1867
- } else if (!try checkHit(battle, player, move, options)) {
1868
- return;
1869
- }
1884
+ if (sub) return options.log.fail(.{ battle.active(player.foe()), .None });
1885
+ if (!try checkHit(battle, player, move, options)) return;
1870
1886
  }
1871
- try options.chance.commit(player, .hit);
1872
1887
  }
1873
1888
 
1874
1889
  if (foe.active.volatiles.Confusion) return;
1875
1890
  foe.active.volatiles.Confusion = true;
1876
- foe.active.volatiles.confusion = Rolls.confusionDuration(battle, player, false, options);
1877
1891
 
1878
- try options.log.start(battle.active(player.foe()), .Confusion);
1892
+ foe.active.volatiles.confusion = Rolls.confusionDuration(battle, player, options);
1893
+ options.chance.observe(.confusion, player.foe(), .started);
1894
+ options.calc.confusion(player.foe());
1895
+
1896
+ try options.log.start(.{ battle.active(player.foe()), .Confusion });
1879
1897
  }
1880
1898
 
1881
1899
  fn conversion(battle: anytype, player: Player, options: anytype) !void {
1882
1900
  const foe = battle.foe(player);
1883
1901
 
1884
1902
  if (foe.active.volatiles.Invulnerable) {
1885
- try options.log.lastmiss();
1886
- return options.log.miss(battle.active(player));
1903
+ try options.log.lastmiss(.{});
1904
+ return options.log.miss(.{battle.active(player)});
1887
1905
  }
1888
1906
 
1889
1907
  battle.side(player).active.types = foe.active.types;
1890
- return options.log.typechange(
1908
+ return options.log.start(.{
1891
1909
  battle.active(player),
1910
+ .TypeChange,
1892
1911
  foe.active.types,
1893
1912
  battle.active(player.foe()),
1894
- );
1913
+ });
1895
1914
  }
1896
1915
 
1897
1916
  fn disable(battle: anytype, player: Player, move: Move.Data, options: anytype) !?Result {
@@ -1900,13 +1919,10 @@ pub const Effects = struct {
1900
1919
  const foe_ident = battle.active(player.foe());
1901
1920
 
1902
1921
  // Pokémon Showdown handles hit/miss earlier in doMove
1903
- if (!showdown) {
1904
- if (!try checkHit(battle, player, move, options)) return null;
1905
- try options.chance.commit(player, .hit);
1906
- }
1922
+ if (!showdown and !try checkHit(battle, player, move, options)) return null;
1907
1923
 
1908
- if (volatiles.disabled_move != 0) {
1909
- try options.log.fail(foe_ident, .None);
1924
+ if (volatiles.disable_move != 0) {
1925
+ try options.log.fail(.{ foe_ident, .None });
1910
1926
  return null;
1911
1927
  }
1912
1928
 
@@ -1921,27 +1937,27 @@ pub const Effects = struct {
1921
1937
 
1922
1938
  // Technically this is still considered simply a "miss" on the cartridge,
1923
1939
  // but diverging from Pokémon Showdown here would mostly just be pedantic
1924
- if (n == 0) {
1925
- try options.log.fail(foe_ident, .None);
1940
+ if (n == 0 or (pkmn.options.mod and err)) {
1941
+ try options.log.fail(.{ foe_ident, .None });
1926
1942
  return null;
1927
1943
  } else if (err) {
1928
1944
  // GLITCH: Transform + Mirror Move / Metronome PP softlock
1929
- assert(!showdown);
1930
- return Result.Error;
1945
+ return .Error;
1931
1946
  }
1932
1947
 
1933
- volatiles.disabled_move =
1948
+ volatiles.disable_move =
1934
1949
  @intCast(try Rolls.moveSlot(battle, player, &foe.active.moves, n, options));
1935
- volatiles.disabled_duration = Rolls.disableDuration(battle, player, options);
1950
+ volatiles.disable_duration = Rolls.disableDuration(battle, player, options);
1951
+ options.chance.observe(.disable, player.foe(), .started);
1936
1952
 
1937
- const id = foe.active.move(volatiles.disabled_move).id;
1938
- try options.log.startEffect(foe_ident, .Disable, id);
1953
+ const id = foe.active.move(volatiles.disable_move).id;
1954
+ try options.log.start(.{ foe_ident, .Disable, id });
1939
1955
  return null;
1940
1956
  }
1941
1957
 
1942
1958
  fn drainHP(battle: anytype, player: Player, options: anytype) !void {
1943
- var side = battle.side(player);
1944
- var stored = side.stored();
1959
+ var stored = battle.side(player).stored();
1960
+ const ident = battle.active(player);
1945
1961
 
1946
1962
  if (battle.last_damage == 0) {
1947
1963
  assert(showdown);
@@ -1954,7 +1970,7 @@ pub const Effects = struct {
1954
1970
  if (stored.hp == stored.stats.hp) return;
1955
1971
  stored.hp = @min(stored.stats.hp, stored.hp + drain);
1956
1972
 
1957
- try options.log.drain(battle.active(player), stored, battle.active(player.foe()));
1973
+ try options.log.heal(.{ ident, stored, .Drain, battle.active(player.foe()) });
1958
1974
  }
1959
1975
 
1960
1976
  fn explode(battle: anytype, player: Player) !void {
@@ -1986,7 +2002,7 @@ pub const Effects = struct {
1986
2002
  if (side.active.volatiles.FocusEnergy) return;
1987
2003
  side.active.volatiles.FocusEnergy = true;
1988
2004
 
1989
- try options.log.start(battle.active(player), .FocusEnergy);
2005
+ try options.log.start(.{ battle.active(player), .FocusEnergy });
1990
2006
  }
1991
2007
 
1992
2008
  fn freezeChance(battle: anytype, player: Player, move: Move.Data, options: anytype) !void {
@@ -2003,12 +2019,12 @@ pub const Effects = struct {
2003
2019
  if (foe.active.types.includes(move.type)) return;
2004
2020
  if (!try Rolls.secondaryChance(battle, player, true, options)) return;
2005
2021
  // Freeze Clause Mod
2006
- if (showdown) for (foe.pokemon) |p| if (Status.is(p.status, .FRZ)) return;
2022
+ if (pkmn.options.mod) for (foe.pokemon) |p| if (Status.is(p.status, .FRZ)) return;
2007
2023
 
2008
2024
  foe_stored.status = Status.init(.FRZ);
2009
2025
  // GLITCH: Hyper Beam recharging status is not cleared
2010
2026
 
2011
- try options.log.status(foe_ident, foe_stored.status, .None);
2027
+ try options.log.status(.{ foe_ident, foe_stored.status, .None });
2012
2028
  }
2013
2029
 
2014
2030
  fn haze(battle: anytype, player: Player, options: anytype) !void {
@@ -2016,7 +2032,7 @@ pub const Effects = struct {
2016
2032
  var side = battle.side(player);
2017
2033
  var foe = battle.foe(player);
2018
2034
 
2019
- var side_stored = side.stored();
2035
+ const side_stored = side.stored();
2020
2036
  var foe_stored = foe.stored();
2021
2037
 
2022
2038
  const player_ident = battle.active(player);
@@ -2028,8 +2044,8 @@ pub const Effects = struct {
2028
2044
  side.active.stats = side_stored.stats;
2029
2045
  foe.active.stats = foe_stored.stats;
2030
2046
 
2031
- try log.activate(player_ident, .Haze);
2032
- try log.clearallboost();
2047
+ try log.activate(.{ player_ident, .Haze });
2048
+ try log.clearallboost(.{});
2033
2049
 
2034
2050
  // Pokémon Showdown clears P1 then P2 instead of status -> side -> foe
2035
2051
  if (showdown) {
@@ -2037,24 +2053,26 @@ pub const Effects = struct {
2037
2053
  const p: Player = @enumFromInt(i);
2038
2054
  // Pokémon Showdown incorrectly does not prevent sleep/freeze from moving
2039
2055
  if (p != player and Status.any(s.stored().status)) {
2040
- try log.curestatus(foe_ident, foe_stored.status, .Silent);
2056
+ try log.curestatus(.{ foe_ident, foe_stored.status, .Silent });
2041
2057
  s.stored().status = 0;
2058
+ options.chance.observe(.sleep, p, .None);
2042
2059
  } else if (showdown and s.stored().status == Status.TOX) {
2043
2060
  s.stored().status = Status.init(.PSN);
2044
- try log.status(battle.active(p), s.stored().status, .None);
2061
+ try log.status(.{ battle.active(p), s.stored().status, .None });
2045
2062
  }
2046
- try clearVolatiles(battle, p, p != player, options);
2063
+ try clearVolatiles(battle, p, options);
2047
2064
  }
2048
2065
  } else {
2049
2066
  if (Status.any(foe_stored.status)) {
2050
2067
  if (Status.is(foe_stored.status, .FRZ) or Status.is(foe_stored.status, .SLP)) {
2051
2068
  foe.last_selected_move = .SKIP_TURN;
2069
+ options.chance.observe(.sleep, player.foe(), .None);
2052
2070
  }
2053
- try log.curestatus(foe_ident, foe_stored.status, .Silent);
2071
+ try log.curestatus(.{ foe_ident, foe_stored.status, .Silent });
2054
2072
  foe_stored.status = 0;
2055
2073
  }
2056
- try clearVolatiles(battle, player, false, options);
2057
- try clearVolatiles(battle, player.foe(), true, options);
2074
+ try clearVolatiles(battle, player, options);
2075
+ try clearVolatiles(battle, player.foe(), options);
2058
2076
  }
2059
2077
  }
2060
2078
 
@@ -2066,7 +2084,7 @@ pub const Effects = struct {
2066
2084
  // GLITCH: HP recovery move failure glitches
2067
2085
  const delta = stored.stats.hp - stored.hp;
2068
2086
  if (delta == 0 or (delta & 255 == 255 and stored.hp % 256 != 0)) {
2069
- return try options.log.fail(ident, .None);
2087
+ return options.log.fail(.{ ident, .None });
2070
2088
  }
2071
2089
 
2072
2090
  const rest = side.last_selected_move == .Rest;
@@ -2074,17 +2092,17 @@ pub const Effects = struct {
2074
2092
  // Adding the sleep status runs the sleep condition handler to roll duration
2075
2093
  if (showdown) battle.rng.advance(1);
2076
2094
  stored.status = Status.slf(2);
2077
- try options.log.statusFrom(ident, stored.status, Move.Rest);
2095
+ try options.log.status(.{ ident, stored.status, .From, Move.Rest });
2078
2096
  stored.hp = stored.stats.hp;
2079
2097
  } else {
2080
2098
  stored.hp = @min(stored.stats.hp, stored.hp + (stored.stats.hp / 2));
2081
2099
  }
2082
- try options.log.heal(ident, stored, if (rest) Heal.Silent else Heal.None);
2100
+ try options.log.heal(.{ ident, stored, if (rest) Heal.Silent else Heal.None });
2083
2101
  }
2084
2102
 
2085
2103
  fn hyperBeam(battle: anytype, player: Player, options: anytype) !void {
2086
2104
  battle.side(player).active.volatiles.Recharging = true;
2087
- try options.log.mustrecharge(battle.active(player));
2105
+ try options.log.mustrecharge(.{battle.active(player)});
2088
2106
  }
2089
2107
 
2090
2108
  fn leechSeed(battle: anytype, player: Player, move: Move.Data, options: anytype) !void {
@@ -2093,33 +2111,31 @@ pub const Effects = struct {
2093
2111
  if (showdown) {
2094
2112
  // Invulnerability trumps type immunity on Pokémon Showdown
2095
2113
  if (!foe.active.volatiles.Invulnerable and foe.active.types.includes(.Grass)) {
2096
- return options.log.immune(battle.active(player.foe()), .None);
2114
+ return options.log.immune(.{ battle.active(player.foe()), .None });
2097
2115
  }
2098
2116
  if (!try checkHit(battle, player, move, options)) return;
2099
2117
  if (foe.active.volatiles.LeechSeed) return;
2100
2118
  } else {
2101
2119
  if (!try checkHit(battle, player, move, options)) return;
2102
2120
  if (foe.active.types.includes(.Grass) or foe.active.volatiles.LeechSeed) {
2103
- try options.log.lastmiss();
2104
- return options.log.miss(battle.active(player));
2121
+ try options.log.lastmiss(.{});
2122
+ return options.log.miss(.{battle.active(player)});
2105
2123
  }
2106
2124
  }
2107
2125
 
2108
- try options.chance.commit(player, .hit);
2109
2126
  foe.active.volatiles.LeechSeed = true;
2110
2127
 
2111
- try options.log.start(battle.active(player.foe()), .LeechSeed);
2128
+ try options.log.start(.{ battle.active(player.foe()), .LeechSeed });
2112
2129
  }
2113
2130
 
2114
2131
  fn lightScreen(battle: anytype, player: Player, options: anytype) !void {
2115
2132
  var side = battle.side(player);
2133
+ const ident = battle.active(player);
2116
2134
 
2117
- if (side.active.volatiles.LightScreen) {
2118
- return options.log.fail(battle.active(player), .None);
2119
- }
2135
+ if (side.active.volatiles.LightScreen) return options.log.fail(.{ ident, .None });
2120
2136
  side.active.volatiles.LightScreen = true;
2121
2137
 
2122
- try options.log.start(battle.active(player), .LightScreen);
2138
+ try options.log.start(.{ ident, .LightScreen });
2123
2139
  }
2124
2140
 
2125
2141
  fn mimic(battle: anytype, player: Player, move: Move.Data, mslot: u8, options: anytype) !void {
@@ -2147,17 +2163,15 @@ pub const Effects = struct {
2147
2163
  if (!has_mimic) {
2148
2164
  // Invulnerable foes or 1/256 miss can trigger |-miss| instead of |-fail|
2149
2165
  if (!try checkHit(battle, player, move, options)) return;
2150
- try options.chance.commit(player, .hit);
2151
- return try options.log.fail(battle.active(player.foe()), .None);
2166
+ return options.log.fail(.{ battle.active(player.foe()), .None });
2152
2167
  }
2153
2168
  }
2154
2169
  if (!try checkHit(battle, player, move, options)) return;
2155
- try options.chance.commit(player, .hit);
2156
2170
 
2157
2171
  const rslot = try Rolls.moveSlot(battle, player, &foe.active.moves, 0, options);
2158
2172
  side.active.move(oslot).id = foe.active.move(rslot).id;
2159
2173
 
2160
- try options.log.startEffect(battle.active(player), .Mimic, side.active.move(oslot).id);
2174
+ try options.log.start(.{ battle.active(player), .Mimic, side.active.move(oslot).id });
2161
2175
  }
2162
2176
 
2163
2177
  fn mist(battle: anytype, player: Player, options: anytype) !void {
@@ -2166,7 +2180,7 @@ pub const Effects = struct {
2166
2180
  if (side.active.volatiles.Mist) return;
2167
2181
  side.active.volatiles.Mist = true;
2168
2182
 
2169
- try options.log.start(battle.active(player), .Mist);
2183
+ try options.log.start(.{ battle.active(player), .Mist });
2170
2184
  }
2171
2185
 
2172
2186
  fn multiHit(battle: anytype, player: Player, move: Move.Data, options: anytype) !void {
@@ -2192,25 +2206,25 @@ pub const Effects = struct {
2192
2206
 
2193
2207
  if (showdown) {
2194
2208
  // Invulnerability trumps type immunity on Pokémon Showdown
2195
- if (immune and !foe.active.volatiles.Invulnerable) return log.immune(foe_ident, .None);
2196
- if (!try checkHit(battle, player, move, options)) return;
2209
+ if (immune and !foe.active.volatiles.Invulnerable) {
2210
+ return log.immune(.{ foe_ident, .None });
2211
+ } else if (!try checkHit(battle, player, move, options)) return;
2197
2212
  }
2198
2213
  if (Status.any(foe_stored.status)) {
2199
- return log.fail(
2214
+ return log.fail(.{
2200
2215
  foe_ident,
2201
- if (Status.is(foe_stored.status, .PAR)) .Paralysis else .None,
2202
- );
2216
+ if (Status.is(foe_stored.status, .PAR)) Fail.Paralysis else Fail.None,
2217
+ });
2203
2218
  }
2204
2219
  if (!showdown) {
2205
- if (immune) return log.immune(foe_ident, .None);
2220
+ if (immune) return log.immune(.{ foe_ident, .None });
2206
2221
  if (!try checkHit(battle, player, move, options)) return;
2207
2222
  }
2208
- try options.chance.commit(player, .hit);
2209
2223
 
2210
2224
  foe_stored.status = Status.init(.PAR);
2211
2225
  foe.active.stats.spe = @max(foe.active.stats.spe / 4, 1);
2212
2226
 
2213
- try log.status(foe_ident, foe_stored.status, .None);
2227
+ try log.status(.{ foe_ident, foe_stored.status, .None });
2214
2228
  }
2215
2229
 
2216
2230
  fn paralyzeChance(battle: anytype, player: Player, move: Move.Data, options: anytype) !void {
@@ -2232,12 +2246,12 @@ pub const Effects = struct {
2232
2246
  foe_stored.status = Status.init(.PAR);
2233
2247
  foe.active.stats.spe = @max(foe.active.stats.spe / 4, 1);
2234
2248
 
2235
- try options.log.status(battle.active(player.foe()), foe_stored.status, .None);
2249
+ try options.log.status(.{ battle.active(player.foe()), foe_stored.status, .None });
2236
2250
  }
2237
2251
 
2238
2252
  fn payDay(battle: anytype, player: Player, options: anytype) !void {
2239
2253
  if (!showdown or !battle.foe(player).active.volatiles.Substitute) {
2240
- try options.log.fieldactivate();
2254
+ try options.log.fieldactivate(.{});
2241
2255
  }
2242
2256
  }
2243
2257
 
@@ -2252,28 +2266,27 @@ pub const Effects = struct {
2252
2266
  return;
2253
2267
  } else if (foe.active.volatiles.Substitute) {
2254
2268
  if (move.effect != .Poison) return;
2255
- return log.fail(foe_ident, .None);
2269
+ return log.fail(.{ foe_ident, .None });
2256
2270
  } else if (Status.any(foe_stored.status)) {
2257
2271
  if (move.effect != .Poison) return if (showdown) battle.rng.advance(1);
2258
2272
  // Pokémon Showdown considers Toxic to be a status even in Generation I and so
2259
2273
  // will not include a fail reason for Toxic vs. Poison or vice-versa...
2260
- return log.fail(foe_ident, if (Status.is(foe_stored.status, .PSN))
2274
+ return log.fail(.{ foe_ident, if (Status.is(foe_stored.status, .PSN))
2261
2275
  if (!showdown)
2262
- .Poison
2276
+ Fail.Poison
2263
2277
  else if (toxic == (foe_stored.status == Status.TOX))
2264
- if (toxic) .Toxic else .Poison
2278
+ if (toxic) Fail.Toxic else Fail.Poison
2265
2279
  else
2266
- .None
2280
+ Fail.None
2267
2281
  else
2268
- .None);
2282
+ Fail.None });
2269
2283
  } else if (foe.active.types.includes(.Poison)) {
2270
2284
  if (move.effect != .Poison) return if (showdown) battle.rng.advance(1);
2271
- return log.immune(foe_ident, .None);
2285
+ return log.immune(.{ foe_ident, .None });
2272
2286
  }
2273
2287
 
2274
2288
  if (move.effect == .Poison) {
2275
2289
  if (!showdown and !try checkHit(battle, player, move, options)) return;
2276
- try options.chance.commit(player, .hit);
2277
2290
  } else {
2278
2291
  if (!try Rolls.poisonChance(battle, player, move.effect == .PoisonChance1, options)) {
2279
2292
  return;
@@ -2287,7 +2300,7 @@ pub const Effects = struct {
2287
2300
  foe.active.volatiles.toxic = 0;
2288
2301
  }
2289
2302
 
2290
- try log.status(foe_ident, foe_stored.status, .None);
2303
+ try log.status(.{ foe_ident, foe_stored.status, .None });
2291
2304
  }
2292
2305
 
2293
2306
  fn rage(battle: anytype, player: Player) !void {
@@ -2307,22 +2320,23 @@ pub const Effects = struct {
2307
2320
  @as(u8, if (side.last_selected_move == .Struggle) 2 else 4), 1));
2308
2321
  stored.hp = @intCast(@max(@as(i16, @intCast(stored.hp)) - damage, 0));
2309
2322
 
2310
- try options.log.damageOf(
2323
+ try options.log.damage(.{
2311
2324
  battle.active(player),
2312
2325
  stored,
2313
2326
  .RecoilOf,
2314
2327
  battle.active(player.foe()),
2315
- );
2328
+ });
2316
2329
  if (showdown and stored.hp == 0) residual.* = false;
2317
2330
  }
2318
2331
 
2319
2332
  fn reflect(battle: anytype, player: Player, options: anytype) !void {
2320
2333
  var side = battle.side(player);
2334
+ const ident = battle.active(player);
2321
2335
 
2322
- if (side.active.volatiles.Reflect) return options.log.fail(battle.active(player), .None);
2336
+ if (side.active.volatiles.Reflect) return options.log.fail(.{ ident, .None });
2323
2337
  side.active.volatiles.Reflect = true;
2324
2338
 
2325
- try options.log.start(battle.active(player), .Reflect);
2339
+ try options.log.start(.{ ident, .Reflect });
2326
2340
  }
2327
2341
 
2328
2342
  fn sleep(battle: anytype, player: Player, move: Move.Data, options: anytype) !void {
@@ -2336,18 +2350,17 @@ pub const Effects = struct {
2336
2350
  // the Sleep Clause Mod might activate, causing us to not actually bypass
2337
2351
  } else {
2338
2352
  if (Status.any(foe_stored.status)) {
2339
- return options.log.fail(
2353
+ return options.log.fail(.{
2340
2354
  foe_ident,
2341
- if (Status.is(foe_stored.status, .SLP)) .Sleep else .None,
2342
- );
2355
+ if (Status.is(foe_stored.status, .SLP)) Fail.Sleep else Fail.None,
2356
+ });
2343
2357
  }
2344
- // If checkHit in doMove didn't return true Pokémon Showdown wouldn't be in here
2358
+ // If moveHit in doMove didn't return true Pokémon Showdown wouldn't be in here
2345
2359
  if (!showdown and !try checkHit(battle, player, move, options)) return;
2346
- try options.chance.commit(player, .hit);
2347
2360
  }
2348
2361
 
2349
2362
  // Sleep Clause Mod
2350
- if (showdown) {
2363
+ if (pkmn.options.mod) {
2351
2364
  for (foe.pokemon) |p| {
2352
2365
  if (Status.is(p.status, .SLP) and !Status.is(p.status, .EXT)) return;
2353
2366
  }
@@ -2355,19 +2368,20 @@ pub const Effects = struct {
2355
2368
  foe.active.volatiles.Recharging = false;
2356
2369
 
2357
2370
  foe_stored.status = Status.slp(Rolls.sleepDuration(battle, player, options));
2371
+ options.chance.observe(.sleep, player.foe(), .started);
2372
+
2358
2373
  const last = battle.side(player).last_selected_move;
2359
- try options.log.statusFrom(foe_ident, foe_stored.status, last);
2374
+ try options.log.status(.{ foe_ident, foe_stored.status, .From, last });
2360
2375
  }
2361
2376
 
2362
2377
  fn splash(battle: anytype, player: Player, options: anytype) !void {
2363
- try options.log.activate(battle.active(player), .Splash);
2378
+ try options.log.activate(.{ battle.active(player), .Splash });
2364
2379
  }
2365
2380
 
2366
2381
  fn substitute(battle: anytype, player: Player, residual: *bool, options: anytype) !void {
2367
2382
  var side = battle.side(player);
2368
2383
  if (side.active.volatiles.Substitute) {
2369
- try options.log.fail(battle.active(player), .Substitute);
2370
- return;
2384
+ return options.log.fail(.{ battle.active(player), .Substitute });
2371
2385
  }
2372
2386
 
2373
2387
  assert(side.stored().stats.hp <= 1023);
@@ -2379,28 +2393,30 @@ pub const Effects = struct {
2379
2393
  // avoid having to convert to floating point)
2380
2394
  const required_hp = if (showdown) @divFloor(side.stored().stats.hp - 1, 4) + 1 else hp;
2381
2395
  if (side.stored().hp < required_hp) {
2382
- try options.log.fail(battle.active(player), .Weak);
2383
- return;
2396
+ return options.log.fail(.{ battle.active(player), .Weak });
2384
2397
  }
2385
2398
 
2386
2399
  // GLITCH: can leave the user with 0 HP (faints later) because didn't check '<=' above
2387
2400
  side.stored().hp -= hp;
2388
2401
  side.active.volatiles.substitute = hp + 1;
2389
2402
  side.active.volatiles.Substitute = true;
2390
- try options.log.start(battle.active(player), .Substitute);
2403
+ try options.log.start(.{ battle.active(player), .Substitute });
2391
2404
  if (hp > 0) {
2392
- try options.log.damage(battle.active(player), side.stored(), .None);
2405
+ try options.log.damage(.{ battle.active(player), side.stored(), .None });
2393
2406
  if (showdown and side.stored().hp == 0) residual.* = false;
2394
2407
  }
2395
2408
  }
2396
2409
 
2397
2410
  fn switchAndTeleport(battle: anytype, player: Player, move: Move.Data, options: anytype) !void {
2398
- if (!showdown or battle.side(player).last_selected_move == .Teleport) return;
2399
-
2400
2411
  // Whirlwind/Roar should not roll to hit/reset damage but Pokémon Showdown does anyway
2401
- if (try checkHit(battle, player, move, options)) try options.chance.commit(player, .hit);
2402
-
2403
- battle.last_damage = 0;
2412
+ if (showdown) {
2413
+ if (battle.side(player).last_selected_move == .Teleport) return;
2414
+ _ = try checkHit(battle, player, move, options);
2415
+ battle.last_damage = 0;
2416
+ } else {
2417
+ try options.log.fail(.{ battle.active(player), .None });
2418
+ try options.log.laststill(.{});
2419
+ }
2404
2420
  }
2405
2421
 
2406
2422
  fn thrashing(battle: anytype, player: Player, options: anytype) void {
@@ -2410,6 +2426,8 @@ pub const Effects = struct {
2410
2426
 
2411
2427
  volatiles.Thrashing = true;
2412
2428
  volatiles.attacks = Rolls.attackingDuration(battle, player, options);
2429
+
2430
+ options.chance.observe(.attacking, player, .started);
2413
2431
  }
2414
2432
 
2415
2433
  fn transform(battle: anytype, player: Player, options: anytype) !void {
@@ -2438,7 +2456,7 @@ pub const Effects = struct {
2438
2456
  side.active.moves[i].pp = if (m.id != .None) 5 else 0;
2439
2457
  }
2440
2458
 
2441
- try options.log.transform(battle.active(player), foe_ident);
2459
+ try options.log.transform(.{ battle.active(player), foe_ident });
2442
2460
  }
2443
2461
 
2444
2462
  fn binding(battle: anytype, player: Player, rewrap: bool, options: anytype) !void {
@@ -2448,13 +2466,14 @@ pub const Effects = struct {
2448
2466
  if (side.active.volatiles.Binding) return;
2449
2467
  side.active.volatiles.Binding = true;
2450
2468
  // GLITCH: Hyper Beam automatic selection glitch if Recharging gets cleared on miss
2451
- // (Pokémon Showdown unitentionally patches this glitch, preventing automatic selection)
2469
+ // (Pokémon Showdown clears Recharging in onTryMove instead)
2452
2470
  if (!showdown) {
2453
2471
  foe.active.volatiles.Recharging = false;
2454
2472
  } else if (foe.stored().hp == 0) return if (!rewrap) battle.rng.advance(1);
2455
2473
 
2456
2474
  side.active.volatiles.attacks =
2457
2475
  try Rolls.distribution(battle, .Binding, player, options) - 1;
2476
+ options.chance.observe(.binding, player, .started);
2458
2477
  }
2459
2478
 
2460
2479
  fn boost(battle: anytype, player: Player, move: Move.Data, options: anytype) !void {
@@ -2468,87 +2487,87 @@ pub const Effects = struct {
2468
2487
  switch (move.effect) {
2469
2488
  .AttackUp1, .AttackUp2, .Rage => {
2470
2489
  assert(boosts.atk >= -6 and boosts.atk <= 6);
2471
- if (boosts.atk == 6) return try log.fail(ident, .None);
2490
+ if (boosts.atk == 6) return log.fail(.{ ident, .None });
2472
2491
  const n: u2 = if (move.effect == .AttackUp2) 2 else 1;
2473
2492
  boosts.atk = @as(i4, @intCast(@min(6, @as(i8, boosts.atk) + n)));
2474
- const reason = if (move.effect == .Rage) Boost.Rage else Boost.Attack;
2493
+ const reason: Boost = if (move.effect == .Rage) .Rage else .Attack;
2475
2494
  if (stats.atk == MAX_STAT_VALUE) {
2476
2495
  boosts.atk -= 1;
2477
2496
  if (showdown) {
2478
- try log.boost(ident, reason, n);
2479
- try log.boost(ident, Boost.Attack, -1);
2497
+ try log.boost(.{ ident, reason, n });
2498
+ try log.boost(.{ ident, .Attack, -1 });
2480
2499
  if (move.effect == .Rage) return;
2481
2500
  }
2482
- return try log.fail(ident, .None);
2501
+ return log.fail(.{ ident, .None });
2483
2502
  }
2484
- var mod = BOOSTS[@as(u4, @intCast(@as(i8, boosts.atk) + 6))];
2503
+ const mod = BOOSTS[@as(u4, @intCast(@as(i8, boosts.atk) + 6))];
2485
2504
  const stat = unmodifiedStats(battle, side).atk;
2486
2505
  stats.atk = @min(MAX_STAT_VALUE, stat * mod[0] / mod[1]);
2487
- try log.boost(ident, reason, n);
2506
+ try log.boost(.{ ident, reason, n });
2488
2507
  // Pokémon Showdown doesn't re-apply status modifiers after Rage boosts
2489
2508
  if (showdown and move.effect == .Rage) return;
2490
2509
  },
2491
2510
  .DefenseUp1, .DefenseUp2 => {
2492
2511
  assert(boosts.def >= -6 and boosts.def <= 6);
2493
- if (boosts.def == 6) return try log.fail(ident, .None);
2512
+ if (boosts.def == 6) return log.fail(.{ ident, .None });
2494
2513
  const n: u2 = if (move.effect == .DefenseUp2) 2 else 1;
2495
2514
  boosts.def = @intCast(@min(6, @as(i8, boosts.def) + n));
2496
2515
  if (stats.def == MAX_STAT_VALUE) {
2497
2516
  boosts.def -= 1;
2498
2517
  if (showdown) {
2499
- try log.boost(ident, .Defense, n);
2500
- try log.boost(ident, .Defense, -1);
2518
+ try log.boost(.{ ident, .Defense, n });
2519
+ try log.boost(.{ ident, .Defense, -1 });
2501
2520
  }
2502
- return try log.fail(ident, .None);
2521
+ return log.fail(.{ ident, .None });
2503
2522
  }
2504
- var mod = BOOSTS[@as(u4, @intCast(@as(i8, boosts.def) + 6))];
2523
+ const mod = BOOSTS[@as(u4, @intCast(@as(i8, boosts.def) + 6))];
2505
2524
  const stat = unmodifiedStats(battle, side).def;
2506
2525
  stats.def = @min(MAX_STAT_VALUE, stat * mod[0] / mod[1]);
2507
- try log.boost(ident, .Defense, n);
2526
+ try log.boost(.{ ident, .Defense, n });
2508
2527
  },
2509
2528
  .SpeedUp2 => {
2510
2529
  assert(boosts.spe >= -6 and boosts.spe <= 6);
2511
- if (boosts.spe == 6) return try log.fail(ident, .None);
2530
+ if (boosts.spe == 6) return log.fail(.{ ident, .None });
2512
2531
  boosts.spe = @intCast(@min(6, @as(i8, boosts.spe) + 2));
2513
2532
  if (stats.spe == MAX_STAT_VALUE) {
2514
2533
  boosts.spe -= 1;
2515
2534
  if (showdown) {
2516
- try log.boost(ident, .Speed, 2);
2517
- try log.boost(ident, .Speed, -1);
2535
+ try log.boost(.{ ident, .Speed, 2 });
2536
+ try log.boost(.{ ident, .Speed, -1 });
2518
2537
  }
2519
- return try log.fail(ident, .None);
2538
+ return log.fail(.{ ident, .None });
2520
2539
  }
2521
- var mod = BOOSTS[@as(u4, @intCast(@as(i8, boosts.spe) + 6))];
2540
+ const mod = BOOSTS[@as(u4, @intCast(@as(i8, boosts.spe) + 6))];
2522
2541
  const stat = unmodifiedStats(battle, side).spe;
2523
2542
  stats.spe = @min(MAX_STAT_VALUE, stat * mod[0] / mod[1]);
2524
- try log.boost(ident, .Speed, 2);
2543
+ try log.boost(.{ ident, .Speed, 2 });
2525
2544
  },
2526
2545
  .SpecialUp1, .SpecialUp2 => {
2527
2546
  assert(boosts.spc >= -6 and boosts.spc <= 6);
2528
- if (boosts.spc == 6) return try log.fail(ident, .None);
2547
+ if (boosts.spc == 6) return log.fail(.{ ident, .None });
2529
2548
  const n: u2 = if (move.effect == .SpecialUp2) 2 else 1;
2530
2549
  boosts.spc = @intCast(@min(6, @as(i8, boosts.spc) + n));
2531
2550
  if (stats.spc == MAX_STAT_VALUE) {
2532
2551
  boosts.spc -= 1;
2533
2552
  if (showdown) {
2534
- try log.boost(ident, .SpecialAttack, n);
2535
- try log.boost(ident, .SpecialAttack, -1);
2536
- try log.boost(ident, .SpecialDefense, n);
2537
- try log.boost(ident, .SpecialDefense, -1);
2553
+ try log.boost(.{ ident, .SpecialAttack, n });
2554
+ try log.boost(.{ ident, .SpecialAttack, -1 });
2555
+ try log.boost(.{ ident, .SpecialDefense, n });
2556
+ try log.boost(.{ ident, .SpecialDefense, -1 });
2538
2557
  }
2539
- return try log.fail(ident, .None);
2558
+ return log.fail(.{ ident, .None });
2540
2559
  }
2541
- var mod = BOOSTS[@as(u4, @intCast(@as(i8, boosts.spc) + 6))];
2560
+ const mod = BOOSTS[@as(u4, @intCast(@as(i8, boosts.spc) + 6))];
2542
2561
  const stat = unmodifiedStats(battle, side).spc;
2543
2562
  stats.spc = @min(MAX_STAT_VALUE, stat * mod[0] / mod[1]);
2544
- try log.boost(ident, .SpecialAttack, n);
2545
- try log.boost(ident, .SpecialDefense, n);
2563
+ try log.boost(.{ ident, .SpecialAttack, n });
2564
+ try log.boost(.{ ident, .SpecialDefense, n });
2546
2565
  },
2547
2566
  .EvasionUp1 => {
2548
2567
  assert(boosts.evasion >= -6 and boosts.evasion <= 6);
2549
- if (boosts.evasion == 6) return try log.fail(ident, .None);
2568
+ if (boosts.evasion == 6) return log.fail(.{ ident, .None });
2550
2569
  boosts.evasion = @intCast(@min(6, @as(i8, boosts.evasion) + 1));
2551
- try log.boost(ident, .Evasion, 1);
2570
+ try log.boost(.{ ident, .Evasion, 1 });
2552
2571
  },
2553
2572
  else => unreachable,
2554
2573
  }
@@ -2563,7 +2582,7 @@ pub const Effects = struct {
2563
2582
  const foe_ident = battle.active(player.foe());
2564
2583
  const secondary = move.effect.isStatDownChance();
2565
2584
 
2566
- if (foe.active.volatiles.Substitute) return if (!secondary) log.fail(foe_ident, .None);
2585
+ if (foe.active.volatiles.Substitute) return if (!secondary) log.fail(.{ foe_ident, .None });
2567
2586
 
2568
2587
  if (secondary) {
2569
2588
  const proc = try Rolls.unboost(battle, player, options);
@@ -2571,7 +2590,6 @@ pub const Effects = struct {
2571
2590
  } else {
2572
2591
  // checkHit already checks for Invulnerable
2573
2592
  if (!showdown and !try checkHit(battle, player, move, options)) return;
2574
- try options.chance.commit(player, .hit);
2575
2593
  }
2576
2594
 
2577
2595
  var stats = &foe.active.stats;
@@ -2581,82 +2599,82 @@ pub const Effects = struct {
2581
2599
  switch (move.effect) {
2582
2600
  .AttackDown1, .AttackDownChance => {
2583
2601
  assert(boosts.atk >= -6 and boosts.atk <= 6);
2584
- if (boosts.atk == -6) return if (fail) try log.fail(foe_ident, .None);
2602
+ if (boosts.atk == -6) return if (fail) try log.fail(.{ foe_ident, .None });
2585
2603
  boosts.atk = @intCast(@max(-6, @as(i8, boosts.atk) - 1));
2586
2604
  if (stats.atk == 1) {
2587
2605
  boosts.atk += 1;
2588
2606
  if (showdown) {
2589
- try log.boost(foe_ident, .Attack, -1);
2590
- try log.boost(foe_ident, .Attack, 1);
2607
+ try log.boost(.{ foe_ident, .Attack, -1 });
2608
+ try log.boost(.{ foe_ident, .Attack, 1 });
2591
2609
  }
2592
- return try log.fail(foe_ident, .None);
2610
+ return log.fail(.{ foe_ident, .None });
2593
2611
  }
2594
- var mod = BOOSTS[@as(u4, @intCast(@as(i8, boosts.atk) + 6))];
2612
+ const mod = BOOSTS[@as(u4, @intCast(@as(i8, boosts.atk) + 6))];
2595
2613
  const stat = unmodifiedStats(battle, foe).atk;
2596
2614
  stats.atk = @max(1, stat * mod[0] / mod[1]);
2597
- try log.boost(foe_ident, .Attack, -1);
2615
+ try log.boost(.{ foe_ident, .Attack, -1 });
2598
2616
  },
2599
2617
  .DefenseDown1, .DefenseDown2, .DefenseDownChance => {
2600
2618
  assert(boosts.def >= -6 and boosts.def <= 6);
2601
- if (boosts.def == -6) return if (fail) try log.fail(foe_ident, .None);
2619
+ if (boosts.def == -6) return if (fail) try log.fail(.{ foe_ident, .None });
2602
2620
  const n: u2 = if (move.effect == .DefenseDown2) 2 else 1;
2603
2621
  boosts.def = @intCast(@max(-6, @as(i8, boosts.def) - n));
2604
2622
  if (stats.def == 1) {
2605
2623
  boosts.def += 1;
2606
2624
  if (showdown) {
2607
- try log.boost(foe_ident, .Defense, -@as(i8, n));
2608
- try log.boost(foe_ident, .Defense, 1);
2625
+ try log.boost(.{ foe_ident, .Defense, -@as(i8, n) });
2626
+ try log.boost(.{ foe_ident, .Defense, 1 });
2609
2627
  }
2610
- return try log.fail(foe_ident, .None);
2628
+ return log.fail(.{ foe_ident, .None });
2611
2629
  }
2612
- var mod = BOOSTS[@as(u4, @intCast(@as(i8, boosts.def) + 6))];
2630
+ const mod = BOOSTS[@as(u4, @intCast(@as(i8, boosts.def) + 6))];
2613
2631
  const stat = unmodifiedStats(battle, foe).def;
2614
2632
  stats.def = @max(1, stat * mod[0] / mod[1]);
2615
- try log.boost(foe_ident, .Defense, -@as(i8, n));
2633
+ try log.boost(.{ foe_ident, .Defense, -@as(i8, n) });
2616
2634
  },
2617
2635
  .SpeedDown1, .SpeedDownChance => {
2618
2636
  assert(boosts.spe >= -6 and boosts.spe <= 6);
2619
- if (boosts.spe == -6) return if (fail) try log.fail(foe_ident, .None);
2637
+ if (boosts.spe == -6) return if (fail) try log.fail(.{ foe_ident, .None });
2620
2638
  boosts.spe = @intCast(@max(-6, @as(i8, boosts.spe) - 1));
2621
2639
  if (stats.spe == 1) {
2622
2640
  boosts.spe += 1;
2623
2641
  if (showdown) {
2624
- try log.boost(foe_ident, .Speed, -1);
2625
- try log.boost(foe_ident, .Speed, 1);
2642
+ try log.boost(.{ foe_ident, .Speed, -1 });
2643
+ try log.boost(.{ foe_ident, .Speed, 1 });
2626
2644
  }
2627
- return try log.fail(foe_ident, .None);
2645
+ return log.fail(.{ foe_ident, .None });
2628
2646
  }
2629
- var mod = BOOSTS[@as(u4, @intCast(@as(i8, boosts.spe) + 6))];
2647
+ const mod = BOOSTS[@as(u4, @intCast(@as(i8, boosts.spe) + 6))];
2630
2648
  const stat = unmodifiedStats(battle, foe).spe;
2631
2649
  stats.spe = @max(1, stat * mod[0] / mod[1]);
2632
- try log.boost(foe_ident, .Speed, -1);
2650
+ try log.boost(.{ foe_ident, .Speed, -1 });
2633
2651
  assert(boosts.spe >= -6);
2634
2652
  },
2635
2653
  .SpecialDownChance => {
2636
2654
  assert(boosts.spc >= -6 and boosts.spc <= 6);
2637
- if (boosts.spc == -6) return if (fail) try log.fail(foe_ident, .None);
2655
+ if (boosts.spc == -6) return if (fail) try log.fail(.{ foe_ident, .None });
2638
2656
  boosts.spc = @intCast(@max(-6, @as(i8, boosts.spc) - 1));
2639
2657
  if (stats.spc == 1) {
2640
2658
  boosts.spc += 1;
2641
2659
  if (showdown) {
2642
- try log.boost(foe_ident, .SpecialAttack, -1);
2643
- try log.boost(foe_ident, .SpecialAttack, 1);
2644
- try log.boost(foe_ident, .SpecialDefense, -1);
2645
- try log.boost(foe_ident, .SpecialDefense, 1);
2660
+ try log.boost(.{ foe_ident, .SpecialAttack, -1 });
2661
+ try log.boost(.{ foe_ident, .SpecialAttack, 1 });
2662
+ try log.boost(.{ foe_ident, .SpecialDefense, -1 });
2663
+ try log.boost(.{ foe_ident, .SpecialDefense, 1 });
2646
2664
  }
2647
- return try log.fail(foe_ident, .None);
2665
+ return log.fail(.{ foe_ident, .None });
2648
2666
  }
2649
- var mod = BOOSTS[@as(u4, @intCast(@as(i8, boosts.spc) + 6))];
2667
+ const mod = BOOSTS[@as(u4, @intCast(@as(i8, boosts.spc) + 6))];
2650
2668
  const stat = unmodifiedStats(battle, foe).spc;
2651
2669
  stats.spc = @max(1, stat * mod[0] / mod[1]);
2652
- try log.boost(foe_ident, .SpecialAttack, -1);
2653
- try log.boost(foe_ident, .SpecialDefense, -1);
2670
+ try log.boost(.{ foe_ident, .SpecialAttack, -1 });
2671
+ try log.boost(.{ foe_ident, .SpecialDefense, -1 });
2654
2672
  },
2655
2673
  .AccuracyDown1 => {
2656
2674
  assert(boosts.accuracy >= -6 and boosts.accuracy <= 6);
2657
- if (boosts.accuracy == -6) return if (fail) try log.fail(foe_ident, .None);
2675
+ if (boosts.accuracy == -6) return if (fail) try log.fail(.{ foe_ident, .None });
2658
2676
  boosts.accuracy = @intCast(@max(-6, @as(i8, boosts.accuracy) - 1));
2659
- try log.boost(foe_ident, .Accuracy, -1);
2677
+ try log.boost(.{ foe_ident, .Accuracy, -1 });
2660
2678
  },
2661
2679
  else => unreachable,
2662
2680
  }
@@ -2680,40 +2698,40 @@ fn statusModify(status: u8, stats: *Stats(u16)) void {
2680
2698
  }
2681
2699
  }
2682
2700
 
2683
- inline fn isForced(active: anytype) bool {
2701
+ fn isForced(active: anytype) bool {
2684
2702
  return active.volatiles.Recharging or active.volatiles.Rage or
2685
2703
  active.volatiles.Thrashing or active.volatiles.Charging;
2686
2704
  }
2687
2705
 
2688
- fn clearVolatiles(battle: anytype, who: Player, clear: bool, options: anytype) !void {
2706
+ fn clearVolatiles(battle: anytype, who: Player, options: anytype) !void {
2689
2707
  var log = options.log;
2690
2708
  var side = battle.side(who);
2691
2709
  var volatiles = &side.active.volatiles;
2692
2710
  const ident = battle.active(who);
2693
2711
 
2694
- options.chance.clearDurations(who, clear);
2695
-
2696
- if (volatiles.disabled_move != 0) {
2697
- volatiles.disabled_move = 0;
2698
- volatiles.disabled_duration = 0;
2699
- try log.end(ident, .DisableSilent);
2712
+ if (volatiles.disable_move != 0) {
2713
+ volatiles.disable_move = 0;
2714
+ volatiles.disable_duration = 0;
2715
+ options.chance.observe(.disable, who, .None);
2716
+ try log.end(.{ ident, .DisableSilent });
2700
2717
  }
2701
2718
  if (volatiles.Confusion) {
2702
2719
  // volatiles.confusion is left unchanged
2703
2720
  volatiles.Confusion = false;
2704
- try log.end(ident, .ConfusionSilent);
2721
+ options.chance.observe(.confusion, who, .None);
2722
+ try log.end(.{ ident, .ConfusionSilent });
2705
2723
  }
2706
2724
  if (volatiles.Mist) {
2707
2725
  volatiles.Mist = false;
2708
- try log.end(ident, .Mist);
2726
+ try log.end(.{ ident, .MistSilent });
2709
2727
  }
2710
2728
  if (volatiles.FocusEnergy) {
2711
2729
  volatiles.FocusEnergy = false;
2712
- try log.end(ident, .FocusEnergy);
2730
+ try log.end(.{ ident, .FocusEnergySilent });
2713
2731
  }
2714
2732
  if (volatiles.LeechSeed) {
2715
2733
  volatiles.LeechSeed = false;
2716
- try log.end(ident, .LeechSeed);
2734
+ try log.end(.{ ident, .LeechSeedSilent });
2717
2735
  }
2718
2736
  if (!showdown and volatiles.Toxic) {
2719
2737
  volatiles.Toxic = false;
@@ -2721,24 +2739,39 @@ fn clearVolatiles(battle: anytype, who: Player, clear: bool, options: anytype) !
2721
2739
  }
2722
2740
  if (volatiles.LightScreen) {
2723
2741
  volatiles.LightScreen = false;
2724
- try log.end(ident, .LightScreen);
2742
+ try log.end(.{ ident, .LightScreenSilent });
2725
2743
  }
2726
2744
  if (volatiles.Reflect) {
2727
2745
  volatiles.Reflect = false;
2728
- try log.end(ident, .Reflect);
2746
+ try log.end(.{ ident, .ReflectSilent });
2729
2747
  }
2730
2748
  if (showdown and volatiles.Toxic) {
2731
2749
  volatiles.Toxic = false;
2732
2750
  // Pokémon Showdown erroneously clears the toxic counter
2733
2751
  volatiles.toxic = 0;
2734
- try log.end(ident, .Toxic);
2752
+ try log.end(.{ ident, .ToxicSilent });
2735
2753
  }
2736
2754
  }
2737
2755
 
2756
+ fn decrement(
2757
+ comptime field: chance.Action.Field,
2758
+ player: Player,
2759
+ options: anytype,
2760
+ n: anytype,
2761
+ ) @TypeOf(n) {
2762
+ return if (options.calc.overridden(player, field)) |obs| switch (obs) {
2763
+ .started, .ended => 0,
2764
+ .continuing => if (n > 1) n - 1 else n,
2765
+ else => unreachable,
2766
+ } else n - 1;
2767
+ }
2768
+
2738
2769
  pub const Rolls = struct {
2739
- inline fn speedTie(battle: anytype, options: anytype) !bool {
2770
+ fn speedTie(battle: anytype, options: anytype) !bool {
2740
2771
  const p1 = if (options.calc.overridden(.P1, .speed_tie)) |player|
2741
2772
  player == .P1
2773
+ else if (@hasDecl(@TypeOf(battle.rng), "speedTie"))
2774
+ battle.rng.speedTie()
2742
2775
  else if (showdown)
2743
2776
  battle.rng.range(u8, 0, 2) == 0
2744
2777
  else
@@ -2748,21 +2781,31 @@ pub const Rolls = struct {
2748
2781
  return p1;
2749
2782
  }
2750
2783
 
2751
- inline fn criticalHit(battle: anytype, player: Player, rate: u8, options: anytype) !bool {
2784
+ fn criticalHit(
2785
+ battle: anytype,
2786
+ player: Player,
2787
+ rate: u8,
2788
+ spurious: bool,
2789
+ options: anytype,
2790
+ ) !bool {
2752
2791
  const crit = if (options.calc.overridden(player, .critical_hit)) |val|
2753
2792
  val == .true
2793
+ else if (@hasDecl(@TypeOf(battle.rng), "criticalHit"))
2794
+ battle.rng.criticalHit(player, rate)
2754
2795
  else if (showdown)
2755
2796
  battle.rng.chance(u8, rate, 256)
2756
2797
  else
2757
2798
  std.math.rotl(u8, battle.rng.next(), 3) < rate;
2758
2799
 
2759
- try options.chance.criticalHit(player, crit, rate);
2800
+ if (!spurious) try options.chance.criticalHit(player, crit, rate);
2760
2801
  return crit;
2761
2802
  }
2762
2803
 
2763
- inline fn damage(battle: anytype, player: anytype, options: anytype) !u8 {
2804
+ fn damage(battle: anytype, player: Player, options: anytype) !u8 {
2764
2805
  const roll = if (options.calc.overridden(player, .damage)) |val|
2765
2806
  val
2807
+ else if (@hasDecl(@TypeOf(battle.rng), "damage"))
2808
+ battle.rng.damage(player)
2766
2809
  else roll: {
2767
2810
  if (showdown) break :roll battle.rng.range(u8, 217, 256);
2768
2811
  while (true) {
@@ -2771,25 +2814,32 @@ pub const Rolls = struct {
2771
2814
  }
2772
2815
  };
2773
2816
 
2817
+ assert(roll >= 217 and roll <= 255);
2774
2818
  try options.chance.damage(player, roll);
2775
2819
  return roll;
2776
2820
  }
2777
2821
 
2778
- inline fn hit(battle: anytype, player: Player, accuracy: u8, options: anytype) bool {
2822
+ const uN = if (pkmn.options.miss) u8 else u9;
2823
+ fn hit(battle: anytype, player: Player, accuracy: u8, options: anytype) !bool {
2824
+ const acc: uN = if (pkmn.options.miss) accuracy else @as(uN, accuracy) + 1;
2779
2825
  const ok = if (options.calc.overridden(player, .hit)) |val|
2780
2826
  val == .true
2827
+ else if (@hasDecl(@TypeOf(battle.rng), "hit"))
2828
+ battle.rng.hit(player, acc)
2781
2829
  else if (showdown)
2782
- battle.rng.chance(u8, accuracy, 256)
2830
+ battle.rng.chance(uN, acc, 256)
2783
2831
  else
2784
- battle.rng.next() < accuracy;
2832
+ battle.rng.next() < acc;
2785
2833
 
2786
- options.chance.hit(ok, accuracy);
2834
+ try options.chance.hit(player, ok, acc);
2787
2835
  return ok;
2788
2836
  }
2789
2837
 
2790
- inline fn confused(battle: anytype, player: Player, options: anytype) !bool {
2838
+ fn confused(battle: anytype, player: Player, options: anytype) !bool {
2791
2839
  const cfz = if (options.calc.overridden(player, .confused)) |val|
2792
2840
  val == .true
2841
+ else if (@hasDecl(@TypeOf(battle.rng), "confused"))
2842
+ battle.rng.confused(player)
2793
2843
  else if (showdown)
2794
2844
  !battle.rng.chance(u8, 128, 256)
2795
2845
  else
@@ -2799,9 +2849,11 @@ pub const Rolls = struct {
2799
2849
  return cfz;
2800
2850
  }
2801
2851
 
2802
- inline fn paralyzed(battle: anytype, player: Player, options: anytype) !bool {
2852
+ fn paralyzed(battle: anytype, player: Player, options: anytype) !bool {
2803
2853
  const par = if (options.calc.overridden(player, .paralyzed)) |val|
2804
2854
  val == .true
2855
+ else if (@hasDecl(@TypeOf(battle.rng), "paralyzed"))
2856
+ battle.rng.paralyzed(player)
2805
2857
  else if (showdown)
2806
2858
  battle.rng.chance(u8, 63, 256)
2807
2859
  else
@@ -2811,9 +2863,11 @@ pub const Rolls = struct {
2811
2863
  return par;
2812
2864
  }
2813
2865
 
2814
- inline fn confusionChance(battle: anytype, player: Player, options: anytype) !bool {
2866
+ fn confusionChance(battle: anytype, player: Player, options: anytype) !bool {
2815
2867
  const proc = if (options.calc.overridden(player, .secondary_chance)) |val|
2816
2868
  val == .true
2869
+ else if (@hasDecl(@TypeOf(battle.rng), "secondaryChance"))
2870
+ battle.rng.secondaryChance(player, 25)
2817
2871
  else if (showdown)
2818
2872
  battle.rng.chance(u8, 25, 256)
2819
2873
  else
@@ -2823,11 +2877,13 @@ pub const Rolls = struct {
2823
2877
  return proc;
2824
2878
  }
2825
2879
 
2826
- inline fn secondaryChance(battle: anytype, player: Player, low: bool, options: anytype) !bool {
2880
+ fn secondaryChance(battle: anytype, player: Player, low: bool, options: anytype) !bool {
2827
2881
  const rate: u8 = if (low) 26 else 77;
2828
2882
 
2829
2883
  const proc = if (options.calc.overridden(player, .secondary_chance)) |val|
2830
2884
  val == .true
2885
+ else if (@hasDecl(@TypeOf(battle.rng), "secondaryChance"))
2886
+ battle.rng.secondaryChance(player, rate)
2831
2887
  else if (showdown)
2832
2888
  battle.rng.chance(u8, rate, 256)
2833
2889
  else
@@ -2837,11 +2893,13 @@ pub const Rolls = struct {
2837
2893
  return proc;
2838
2894
  }
2839
2895
 
2840
- inline fn poisonChance(battle: anytype, player: Player, low: bool, options: anytype) !bool {
2896
+ fn poisonChance(battle: anytype, player: Player, low: bool, options: anytype) !bool {
2841
2897
  const rate: u8 = if (low) 52 else 103;
2842
2898
 
2843
2899
  const proc = if (options.calc.overridden(player, .secondary_chance)) |val|
2844
2900
  val == .true
2901
+ else if (@hasDecl(@TypeOf(battle.rng), "secondaryChance"))
2902
+ battle.rng.secondaryChance(player, rate)
2845
2903
  else if (showdown)
2846
2904
  battle.rng.chance(u8, rate, 256)
2847
2905
  else
@@ -2851,9 +2909,11 @@ pub const Rolls = struct {
2851
2909
  return proc;
2852
2910
  }
2853
2911
 
2854
- inline fn unboost(battle: anytype, player: Player, options: anytype) !bool {
2912
+ fn unboost(battle: anytype, player: Player, options: anytype) !bool {
2855
2913
  const proc = if (options.calc.overridden(player, .secondary_chance)) |val|
2856
2914
  val == .true
2915
+ else if (@hasDecl(@TypeOf(battle.rng), "secondaryChance"))
2916
+ battle.rng.secondaryChance(player, 85)
2857
2917
  else if (showdown)
2858
2918
  battle.rng.chance(u8, 85, 256)
2859
2919
  else
@@ -2863,14 +2923,14 @@ pub const Rolls = struct {
2863
2923
  return proc;
2864
2924
  }
2865
2925
 
2866
- inline fn metronome(battle: anytype, player: Player, options: anytype) !Move {
2926
+ fn metronome(battle: anytype, player: Player, options: anytype) !Move {
2867
2927
  const move: Move = if (options.calc.overridden(player, .metronome)) |val|
2868
2928
  val
2869
- else if (showdown) move: {
2870
- const r = battle.rng.range(u8, 0, @intFromEnum(Move.Struggle) - 2);
2871
- const mod = @as(u2, (if (r < @intFromEnum(Move.Metronome) - 1) 1 else 2));
2872
- break :move @enumFromInt(r + mod);
2873
- } else move: {
2929
+ else if (@hasDecl(@TypeOf(battle.rng), "metronome"))
2930
+ battle.rng.metronome(player)
2931
+ else if (showdown)
2932
+ Move.METRONOME[battle.rng.range(u8, 0, Move.METRONOME.len)]
2933
+ else move: {
2874
2934
  while (true) {
2875
2935
  const r = battle.rng.next();
2876
2936
  if (r == 0 or r == @intFromEnum(Move.Metronome)) continue;
@@ -2883,9 +2943,11 @@ pub const Rolls = struct {
2883
2943
  return move;
2884
2944
  }
2885
2945
 
2886
- inline fn psywave(battle: anytype, player: Player, max: u8, options: anytype) !u8 {
2946
+ fn psywave(battle: anytype, player: Player, max: u8, options: anytype) !u8 {
2887
2947
  const power = if (options.calc.overridden(player, .psywave)) |val|
2888
2948
  val - 1
2949
+ else if (@hasDecl(@TypeOf(battle.rng), "psywave"))
2950
+ battle.rng.psywave(player)
2889
2951
  else if (showdown)
2890
2952
  battle.rng.range(u8, 0, max)
2891
2953
  else power: {
@@ -2895,15 +2957,19 @@ pub const Rolls = struct {
2895
2957
  }
2896
2958
  };
2897
2959
 
2960
+ assert(power < max);
2898
2961
  try options.chance.psywave(player, power, max);
2899
2962
  return power;
2900
2963
  }
2901
2964
 
2902
- inline fn sleepDuration(battle: anytype, player: Player, options: anytype) u3 {
2903
- const duration: u3 = if (options.calc.overridden(player, .duration)) |val|
2904
- @intCast(val)
2965
+ fn sleepDuration(battle: anytype, player: Player, options: anytype) u3 {
2966
+ const duration: u3 = (if (options.calc.overridden(player, .duration)) |val|
2967
+ if (val >= 1 and val <= 7) @as(u3, @intCast(val)) else null
2968
+ else
2969
+ null) orelse if (@hasDecl(@TypeOf(battle.rng), "sleepDuration"))
2970
+ battle.rng.sleepDuration(player)
2905
2971
  else if (showdown)
2906
- @intCast(battle.rng.range(u8, 1, 8))
2972
+ battle.rng.range(u3, 1, 8)
2907
2973
  else duration: {
2908
2974
  while (true) {
2909
2975
  const r = battle.rng.next() & 7;
@@ -2911,64 +2977,83 @@ pub const Rolls = struct {
2911
2977
  }
2912
2978
  };
2913
2979
 
2914
- options.chance.duration(.sleep, player, player.foe(), duration);
2980
+ assert(duration >= 1 and duration <= 7);
2981
+ options.chance.duration(player, duration);
2915
2982
  return duration;
2916
2983
  }
2917
2984
 
2918
- inline fn disableDuration(battle: anytype, player: Player, options: anytype) u4 {
2919
- const duration: u4 = if (options.calc.overridden(player, .duration)) |val|
2920
- val
2985
+ fn disableDuration(battle: anytype, player: Player, options: anytype) u4 {
2986
+ const duration: u4 = (if (options.calc.overridden(player, .duration)) |val|
2987
+ if (val >= 1 and val <= 8) val else null
2988
+ else
2989
+ null) orelse if (@hasDecl(@TypeOf(battle.rng), "disableDuration"))
2990
+ battle.rng.disableDuration(player)
2921
2991
  else if (showdown)
2922
- @intCast(battle.rng.range(u8, 1, 9))
2992
+ battle.rng.range(u4, 1, 9)
2923
2993
  else
2924
2994
  @intCast((battle.rng.next() & 7) + 1);
2925
2995
 
2926
- options.chance.duration(.disable, player, player.foe(), duration);
2996
+ assert(duration >= 1 and duration <= 8);
2997
+ options.chance.duration(player, duration);
2927
2998
  return duration;
2928
2999
  }
2929
3000
 
2930
- inline fn confusionDuration(battle: anytype, player: Player, self: bool, options: anytype) u3 {
2931
- const duration: u3 = if (options.calc.overridden(player, .duration)) |val|
2932
- @intCast(val)
3001
+ fn confusionDuration(battle: anytype, player: Player, options: anytype) u3 {
3002
+ const duration: u3 = (if (options.calc.overridden(player, .duration)) |val|
3003
+ if (val >= 2 and val <= 5) @as(u3, @intCast(val)) else null
3004
+ else
3005
+ null) orelse if (@hasDecl(@TypeOf(battle.rng), "confusionDuration"))
3006
+ battle.rng.confusionDuration(player)
2933
3007
  else if (showdown)
2934
- @intCast(battle.rng.range(u8, 2, 6))
3008
+ battle.rng.range(u3, 2, 6)
2935
3009
  else
2936
3010
  @intCast((battle.rng.next() & 3) + 2);
2937
3011
 
2938
- options.chance.duration(.confusion, player, if (self) player else player.foe(), duration);
3012
+ assert(duration >= 2 and duration <= 5);
3013
+ options.chance.duration(player, duration);
2939
3014
  return duration;
2940
3015
  }
2941
3016
 
2942
- inline fn attackingDuration(battle: anytype, player: Player, options: anytype) u3 {
2943
- const duration: u3 = if (options.calc.overridden(player, .duration)) |val|
2944
- @intCast(val)
3017
+ fn attackingDuration(battle: anytype, player: Player, options: anytype) u3 {
3018
+ const duration: u3 = (if (options.calc.overridden(player, .duration)) |val|
3019
+ if (val >= 2 and val <= 3) @as(u3, @intCast(val)) else null
3020
+ else
3021
+ null) orelse if (@hasDecl(@TypeOf(battle.rng), "attackingDuration"))
3022
+ battle.rng.attackingDuration(player)
2945
3023
  else if (showdown)
2946
- @intCast(battle.rng.range(u4, 2, 4))
3024
+ battle.rng.range(u3, 2, 4)
2947
3025
  else
2948
3026
  @intCast((battle.rng.next() & 1) + 2);
2949
3027
 
2950
- options.chance.duration(.attacking, player, player, duration);
3028
+ assert(duration >= 2 and duration <= 3);
3029
+ options.chance.duration(player, duration);
2951
3030
  return duration;
2952
3031
  }
2953
3032
 
2954
- inline fn distribution(
3033
+ const DISTRIBUTION = [_]u3{ 2, 2, 2, 3, 3, 3, 4, 5 };
3034
+
3035
+ fn distribution(
2955
3036
  battle: anytype,
2956
3037
  comptime effect: Move.Effect,
2957
3038
  player: Player,
2958
3039
  options: anytype,
2959
3040
  ) !u3 {
2960
3041
  const roll = if (effect == .Binding) .duration else .multi_hit;
2961
- const n: u3 = if (options.calc.overridden(player, roll)) |val|
2962
- @intCast(val)
3042
+ const n: u3 = (if (options.calc.overridden(player, roll)) |val|
3043
+ if (val >= 2 and val <= 5) @as(u3, @intCast(val)) else null
3044
+ else
3045
+ null) orelse if (@hasDecl(@TypeOf(battle.rng), "distribution"))
3046
+ battle.rng.distribution(player)
2963
3047
  else if (showdown)
2964
- DISTRIBUTION[battle.rng.range(u8, 0, DISTRIBUTION.len)]
3048
+ DISTRIBUTION[battle.rng.range(u3, 0, DISTRIBUTION.len)]
2965
3049
  else n: {
2966
3050
  const r = (battle.rng.next() & 3);
2967
3051
  break :n @intCast((if (r < 2) r else battle.rng.next() & 3) + 2);
2968
3052
  };
2969
3053
 
3054
+ assert(n >= 2 and n <= 5);
2970
3055
  if (effect == .Binding) {
2971
- options.chance.duration(.binding, player, player, n);
3056
+ options.chance.duration(player, n);
2972
3057
  } else {
2973
3058
  try options.chance.multiHit(player, n);
2974
3059
  }
@@ -2983,15 +3068,15 @@ pub const Rolls = struct {
2983
3068
  options: anytype,
2984
3069
  ) !u4 {
2985
3070
  // TODO: consider throwing error instead of rerolling?
2986
- const overridden = if (options.calc.overridden(player, .move_slot)) |val|
3071
+ const slot: u4 = (if (options.calc.overridden(player, .move_slot)) |val|
2987
3072
  if (moves[val - 1].id != .None and (check_pp == 0 or moves[val - 1].pp > 0))
2988
3073
  val
2989
3074
  else
2990
3075
  null
2991
3076
  else
2992
- null;
2993
-
2994
- const slot: u4 = overridden orelse slot: {
3077
+ null) orelse if (@hasDecl(@TypeOf(battle.rng), "moveSlot"))
3078
+ battle.rng.moveSlot(player, moves, check_pp)
3079
+ else slot: {
2995
3080
  if (showdown) {
2996
3081
  if (check_pp == 0) {
2997
3082
  var i: usize = moves.len;
@@ -3007,10 +3092,9 @@ pub const Rolls = struct {
3007
3092
  while (i < moves.len and r > 0) : (i += 1) {
3008
3093
  if (moves[i].pp > 0) {
3009
3094
  r -= 1;
3010
- if (r == 0) break;
3095
+ if (r == 0) break :slot @intCast(i + 1);
3011
3096
  }
3012
3097
  }
3013
- break :slot @intCast(i + 1);
3014
3098
  }
3015
3099
  }
3016
3100
 
@@ -3032,11 +3116,11 @@ test "RNG agreement" {
3032
3116
  expected[i] = @intCast(i * 0x1000000);
3033
3117
  }
3034
3118
 
3035
- var spe = rng.FixedRNG(1, expected.len){ .rolls = expected };
3036
- var cfz = rng.FixedRNG(1, expected.len){ .rolls = expected };
3037
- var par = rng.FixedRNG(1, expected.len){ .rolls = expected };
3038
- var brn = rng.FixedRNG(1, expected.len){ .rolls = expected };
3039
- var eff = rng.FixedRNG(1, expected.len){ .rolls = expected };
3119
+ var spe: rng.FixedRNG(1, expected.len) = .{ .rolls = expected };
3120
+ var cfz: rng.FixedRNG(1, expected.len) = .{ .rolls = expected };
3121
+ var par: rng.FixedRNG(1, expected.len) = .{ .rolls = expected };
3122
+ var brn: rng.FixedRNG(1, expected.len) = .{ .rolls = expected };
3123
+ var eff: rng.FixedRNG(1, expected.len) = .{ .rolls = expected };
3040
3124
 
3041
3125
  for (0..expected.len) |i| {
3042
3126
  try expectEqual(spe.range(u8, 0, 2) == 0, i < Gen12.percent(50) + 1);
@@ -3056,11 +3140,11 @@ pub fn choices(battle: anytype, player: Player, request: Choice.Type, out: []Cho
3056
3140
  },
3057
3141
  .Switch => {
3058
3142
  const side = battle.side(player);
3059
- var slot: u4 = 2;
3143
+ var slot: usize = 2;
3060
3144
  while (slot <= 6) : (slot += 1) {
3061
3145
  const id = side.order[slot - 1];
3062
3146
  if (id == 0 or side.pokemon[id - 1].hp == 0) continue;
3063
- out[n] = .{ .type = .Switch, .data = slot };
3147
+ out[n] = .{ .type = .Switch, .data = @intCast(slot) };
3064
3148
  n += 1;
3065
3149
  }
3066
3150
  if (n == 0) {
@@ -3072,11 +3156,11 @@ pub fn choices(battle: anytype, player: Player, request: Choice.Type, out: []Cho
3072
3156
  const side = battle.side(player);
3073
3157
  const foe = battle.foe(player);
3074
3158
 
3075
- var active = &side.active;
3159
+ const active = &side.active;
3076
3160
  const stored = side.stored();
3077
3161
 
3078
3162
  // While players are not given any input options on the cartridge in these cases,
3079
- // Pokémon Showdown instead produces a list with a single move that must be chosen.
3163
+ // Pokémon Showdown instead produces a list with a single move that must be chosen
3080
3164
  //
3081
3165
  // Given that no input is allowed on the cartridge 'Pass' seems like it would be logical
3082
3166
  // here when not in compatibility mode, but the engine needs to be able to differentiate
@@ -3084,18 +3168,18 @@ pub fn choices(battle: anytype, player: Player, request: Choice.Type, out: []Cho
3084
3168
  // passing due to being forced into using a move. Instead of introducing another option
3085
3169
  // we simply repurpose Move with no move slot, even though pedantically this is not
3086
3170
  // strictly correct as the player would not have been presented the option to move or
3087
- // switch at all.
3171
+ // switch at all
3088
3172
  if (isForced(active)) {
3089
3173
  out[n] = .{ .type = .Move, .data = @intFromBool(showdown) };
3090
3174
  n += 1;
3091
3175
  return n;
3092
3176
  }
3093
3177
 
3094
- var slot: u4 = 2;
3178
+ var slot: usize = 2;
3095
3179
  while (slot <= 6) : (slot += 1) {
3096
3180
  const id = side.order[slot - 1];
3097
3181
  if (id == 0 or side.pokemon[id - 1].hp == 0) continue;
3098
- out[n] = .{ .type = .Switch, .data = slot };
3182
+ out[n] = .{ .type = .Switch, .data = @intCast(slot) };
3099
3183
  n += 1;
3100
3184
  }
3101
3185
 
@@ -3103,7 +3187,7 @@ pub fn choices(battle: anytype, player: Player, request: Choice.Type, out: []Cho
3103
3187
  // On the cartridge, all of these happen after "FIGHT" (indicating you are not
3104
3188
  // switching) but before you are allowed to select a move. Pokémon Showdown instead
3105
3189
  // either disables all other moves in the case of limited or requires you to select a
3106
- // move normally if sleeping/frozen/bound.
3190
+ // move normally if sleeping/frozen/bound
3107
3191
  if (!showdown and (limited or foe.active.volatiles.Binding or
3108
3192
  Status.is(stored.status, .FRZ) or Status.is(stored.status, .SLP)))
3109
3193
  {
@@ -3116,7 +3200,7 @@ pub fn choices(battle: anytype, player: Player, request: Choice.Type, out: []Cho
3116
3200
  // Pokémon Showdown handles Bide and Binding moves by checking if the move in question
3117
3201
  // is present in the Pokémon's moveset (which means moves called via Metronome / Mirror
3118
3202
  // Move will not result in forcing the subsequent use unless the user also had the
3119
- // proc-ed move in their moveset) and disabling all other moves.
3203
+ // proc-ed move in their moveset) and disabling all other moves
3120
3204
  if (limited) {
3121
3205
  assert(showdown);
3122
3206
  assert(side.last_selected_move != .None);
@@ -3126,9 +3210,9 @@ pub fn choices(battle: anytype, player: Player, request: Choice.Type, out: []Cho
3126
3210
  if (m.id == if (active.volatiles.Bide) .Bide else side.last_selected_move) {
3127
3211
  // Pokémon Showdown displays Struggle if limited to Bide but unable to pick
3128
3212
  const struggle =
3129
- m.id == .Bide and (m.pp == 0 or active.volatiles.disabled_move == slot);
3213
+ m.id == .Bide and (m.pp == 0 or active.volatiles.disable_move == slot);
3130
3214
  const s = if (struggle) 0 else slot;
3131
- out[n] = .{ .type = .Move, .data = s };
3215
+ out[n] = .{ .type = .Move, .data = @intCast(s) };
3132
3216
  n += 1;
3133
3217
  return n;
3134
3218
  }
@@ -3141,14 +3225,14 @@ pub fn choices(battle: anytype, player: Player, request: Choice.Type, out: []Cho
3141
3225
  const m = active.moves[slot - 1];
3142
3226
  if (m.id == .None) break;
3143
3227
  if (m.pp == 0) continue;
3144
- if (active.volatiles.disabled_move == slot) continue;
3145
- out[n] = .{ .type = .Move, .data = slot };
3228
+ if (active.volatiles.disable_move == slot) continue;
3229
+ out[n] = .{ .type = .Move, .data = @intCast(slot) };
3146
3230
  n += 1;
3147
3231
  }
3148
3232
  // Struggle (Pokémon Showdown would use 'move 1' here)
3149
3233
  if (n == before) {
3150
3234
  // GLITCH: Transform + Mirror Move / Metronome PP softlock
3151
- if (!showdown) {
3235
+ if (!pkmn.options.mod) {
3152
3236
  while (slot <= 4) : (slot += 1) {
3153
3237
  if (active.moves[slot - 1].pp != 0) return n;
3154
3238
  }