@pkmn/engine 0.1.0-dev.7963b917 → 0.1.0-dev.7c1336ac

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