@pkmn/engine 0.1.0-dev.a324c50f → 0.1.0-dev.a77ab8e2

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