@yipe/dice 0.12.0 → 0.12.1

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.
package/README.md CHANGED
@@ -189,13 +189,17 @@ when used as a payload that doubles (`onHit`, `doubleDice()`); as a rider it is
189
189
  crit, like the attack or save builder it stands for.
190
190
  An attack string with no crit clause crits too: `"(d20 + 8 AC 16) * (2d6)"` rolls its natural 20 as
191
191
  `4d6`, like `d20.plus(8).ac(16).onHit(roll(2, d6))`; a `crit (…)` clause still wins. A term after
192
- the payload joined by `+`, `*`, `**`, `/` or `//` applies only where the attack deals damage (the
193
- grammar reads left to right, and `+` adds to non-zero totals), so it is part of the payload:
194
- `(d20 + 5 AC 15) * (1d8) + 1d6` crits as `2d8 + 2d6`, like `onHit(roll(1, d8).plus(roll(1, d6)))`,
195
- and keeps its hit, crit and miss labels. After a `crit (…)` clause the term is added to the crit as
196
- written. The crit rate reads the check's natural die, its one d20 wherever it sits in the sum (with
197
- no d20, its largest die), through bonus to-hit dice, advantage (`d20 > d20`, `d20!`, `2kh1d20`),
198
- disadvantage (`2kl1(1d20)`), elven accuracy (`3kh1(1d20)`) and halfling luck (`hd20`,
192
+ the payload joined by `+`, `*`, `**`, `/` or `//` is part of the payload and never applies to a
193
+ miss (the grammar reads left to right). `+` adds to every landed hit and crit, and to every
194
+ other outcome that carries a payload (miss damage, a potent-cantrip half, a save's failure or half),
195
+ one whose payload rolled 0 included, like the builder's `plus`: `(d20 + 5 AC 12) * (1d4 - 1) + 1d6` is
196
+ `onHit(roll(1, d4).minus(1).plus(d6))` (mean 3.8). `*`, `**`, `/` and `//` act on the payload's
197
+ value, so a hit that deals 0 still deals 0. `(d20 + 5 AC 15) * (1d8) + 1d6` crits as `2d8 + 2d6`,
198
+ like `onHit(roll(1, d8).plus(roll(1, d6)))`, and keeps its hit, crit and miss labels. After a
199
+ `crit (…)` clause the term is added to the crit as written, and after a `miss (…)` clause to the
200
+ miss damage too. The crit rate reads the check's natural die, its one d20 wherever it sits in the
201
+ sum (with no d20, its largest die), through bonus to-hit dice, advantage (`d20 > d20`, `d20!`,
202
+ `2kh1d20`), disadvantage (`2kl1(1d20)`), elven accuracy (`3kh1(1d20)`) and halfling luck (`hd20`,
199
203
  `d20 reroll 1`), for `crit` and `xcrit N` alike: `(d20 > d20 + 5 + 1d4 AC 15) * (2d6)` crits at
200
204
  39/400, like `d20.withAdvantage().plus(5).plus(d4)`, `(1d4 + d20 + 5 AC 15)` crits on the d20, not
201
205
  the d4, and `(d20 + d100 AC 60)` on the d20, not the d100 (61/2000). A max or min against another
@@ -438,7 +442,7 @@ associates left to right**: `1d6 + 2 * 3` is `(1d6 + 2) * 3`. Parenthesise to gr
438
442
  | `7`, `d6`, `3d6`, `hd20` | A number, a die, a sum of dice, a die whose 1 is rerolled once (halfling luck, `d20 reroll 1`). |
439
443
  | `N(X)`, `(X)d6` | N independent copies of X, summed. The count may be rolled: `(1d4)d6`. A count of 0 is 0 (`0d6`, `(1d4 - 1)d6` has P(0) = 1/4); a count that can be negative throws. |
440
444
  | `NkhK(X)`, `NklK(X)` | The sum of the K highest (lowest) of N independent copies of X, exact for any X: `4kh3d6`, `2kl1(1d20)`, `4kh1(2d20)`. `2kl1(2d6)` keeps the lower of two 2d6 *sums*. |
441
- | `X + Y` | Adds Y where the total so far is not 0, so a miss (0) stays 0: `(d20 + 5 AC 15) * (1d8) + 1d6`. Inside a check total (left of `AC`/`DC`) `+` always adds, so `d20 - 5 + 1d4 AC 1` adds the d4 on a natural 5 too. |
445
+ | `X + Y` | Adds Y where the total so far is not 0, so a miss (0) stays 0. After an attack's or a save's payload it adds to every outcome that carries a payload (a landed hit or crit, a miss clause's damage, a potent-cantrip half, a save's failure or half), one that deals 0 included: `(d20 + 5 AC 15) * (1d4 - 1) + 1d6`. Inside a check total (left of `AC`/`DC`) `+` always adds, so `d20 - 5 + 1d4 AC 1` adds the d4 on a natural 5 too. |
442
446
  | `X ~+ Y` | Always adds. |
443
447
  | `X - Y`, `-X` | Subtracts. A leading `-` negates the argument after it, repeat included: `-2d6` is `-(2d6)`, `1d6 + -3` is `1d6 - 3`, `-1d8 + 1d6` has mean -1. There is no unary `+`. |
444
448
  | `X * Y` | Y where X is not 0, else 0: the hit gate of `(check) * (damage)`. |
@@ -449,7 +453,7 @@ associates left to right**: `1d6 + 2 * 3` is `(1d6 + 2) * 3`. Parenthesise to gr
449
453
  | `X = Y` | 1 where X equals Y, else 0. |
450
454
  | `X & Y` | A mix weighted by each side's count of outcomes (see the refused shapes above). |
451
455
  | `X reroll R` | Rolls X, and on a result in the face set R rolls X again and keeps the second roll. |
452
- | `X AC T`, `X DC T` | An attack check (X where X ≥ T, else 0) and a saving throw (0 on a save, 1 on a failure). |
456
+ | `X AC T`, `X DC T` | An attack check (X where X ≥ T, else 0) and a saving throw (0 on a save, 1 on a failure). A total of exactly 0 that meets a T of 0 or less lands, though it reads 0 like a miss: `(d20 - 5 AC 0) * (1d6)` hits on a natural 5 and crits on a 20, like `d20.minus(5).ac(0).onHit(roll(1, d6))`. |
453
457
  | `… crit (Y)`, `… xcritN (Y)`, `… miss (Y)`, `… save half`, `… pc` | Outcome clauses after an attack's or a save's payload. `xcrit0 (Y)` never crits: every landing is a hit. |
454
458
 
455
459
  **Rerolls.** `reroll N` rerolls the face N only; `reroll dN` rerolls every face from 1 to N; the
@@ -465,8 +469,8 @@ The reroll decides on the raw face and a minimum applies after: `3>(d6 reroll 1)
465
469
  **Labels.** An attack labels its outcomes `hit`, `crit`, `missNone` and `missDamage`. A save labels a
466
470
  failed save `saveFail` (whatever the payload rolls, 0 included), a halved success `saveHalf`, and a
467
471
  success with no `save half` `missNone`, like the builder's `onSaveFailure()`. A landed hit whose
468
- payload deals 0 is still `hit` (a 0-damage crit is `crit`), like the builder; only a miss is
469
- `missNone`.
472
+ payload deals 0 is still `hit` (a 0-damage crit is `crit`), like the builder, and so is an AC
473
+ check's landed total of exactly 0; only a miss is `missNone`.
470
474
 
471
475
  A `d0` has no faces: it is only a face set (`reroll d0`); rolled on its own it throws, as does a
472
476
  string the grammar cannot read, always as a `DiceParseError`.
@@ -3377,9 +3377,23 @@ var Dice = class _Dice {
3377
3377
  result.privateData.isDCCheck = true;
3378
3378
  return result;
3379
3379
  }
3380
+ /**
3381
+ * An attack check: a total that meets the target lands at its own value, and a miss is 0. A total
3382
+ * of exactly 0 that meets the target (a target of 0 or less) lands at 0 too, where the misses sit:
3383
+ * its count is recorded under `hit` at 0, like a payload's 0-damage hit, so the ops after the
3384
+ * check tell it from a miss.
3385
+ */
3380
3386
  ac(other) {
3381
3387
  const acCheck = (a, b) => a >= b ? a : 0;
3382
- return this.checkTarget(other, acCheck);
3388
+ const result = this.checkTarget(other, acCheck);
3389
+ const zero = this.get(0);
3390
+ if (zero > 0) {
3391
+ let met = 0;
3392
+ if (typeof other === "number") met = other <= 0 ? 1 : 0;
3393
+ else for (const [target, count] of other.getFaceEntries()) if (target <= 0) met += count;
3394
+ if (met > 0) result.setOutcomeDistribution("hit", { 0: zero * met });
3395
+ }
3396
+ return result;
3383
3397
  }
3384
3398
  deleteFace(face) {
3385
3399
  const result = new _Dice();
@@ -3657,6 +3671,8 @@ function parseExpression(arr, n, inCheck = false) {
3657
3671
  const hitText = pending?.slice(0, pending.length - arr.length);
3658
3672
  const termText = opText?.slice(0, opText.length - arr.length);
3659
3673
  const before = finalResult;
3674
+ const attack = isAttack(finalResult);
3675
+ const acCheck = finalResult.privateData.isACCheck === true;
3660
3676
  if (op === Dice.prototype.combine) assertMixable(before, arg, arr);
3661
3677
  let crit;
3662
3678
  let critNorm = 1;
@@ -3678,7 +3694,8 @@ function parseExpression(arr, n, inCheck = false) {
3678
3694
  } else {
3679
3695
  ({ crit, rest: finalResult } = splitCrit(finalResult, count));
3680
3696
  critNorm = crit.total();
3681
- crit = op.call(crit, critClause ? parseBinaryArgument(arg, arr, n) : critPayload(hitText, n));
3697
+ const critArg = critClause ? parseBinaryArgument(arg, arr, n) : critPayload(hitText, n);
3698
+ crit = HIT_ONLY_OPS.has(op) ? applyToLanded(crit, op, critArg, crit.get(0), acCheck) : op.call(crit, critArg);
3682
3699
  critNorm = crit && critNorm ? crit.total() / critNorm : 1;
3683
3700
  }
3684
3701
  }
@@ -3689,12 +3706,10 @@ function parseExpression(arr, n, inCheck = false) {
3689
3706
  assertToken(arr, "a");
3690
3707
  assertToken(arr, "v");
3691
3708
  assertToken(arr, "e");
3692
- save = new Dice();
3693
- const min = finalResult.minFace();
3694
- save.increment(min > 0 ? min : 1, finalResult.get(min));
3695
- saveNorm = save.total();
3696
- finalResult = finalResult.deleteFace(min);
3697
- save = op.call(save, parseBinaryArgument(arg, arr, n));
3709
+ const { miss: missed, rest } = splitMiss(finalResult, attack);
3710
+ finalResult = rest;
3711
+ saveNorm = missed.total();
3712
+ save = op.call(missed, parseBinaryArgument(arg, arr, n));
3698
3713
  saveNorm = save && saveNorm ? save.total() / saveNorm : 1;
3699
3714
  }
3700
3715
  let pc;
@@ -3702,12 +3717,10 @@ function parseExpression(arr, n, inCheck = false) {
3702
3717
  if (arr.length >= 2 && arr[0] === "p" && arr[1] === "c") {
3703
3718
  assertToken(arr, "p");
3704
3719
  assertToken(arr, "c");
3705
- pc = new Dice();
3706
- const min = finalResult.minFace();
3707
- pc.increment(min > 0 ? min : 1, finalResult.get(min));
3708
- const missBefore = pc.total();
3709
- finalResult = finalResult.deleteFace(min);
3710
- pc = op.call(pc, parseBinaryArgument(arg, arr, n)).divideRoundDown(2);
3720
+ const { miss: missed, rest } = splitMiss(finalResult, attack);
3721
+ finalResult = rest;
3722
+ const missBefore = missed.total();
3723
+ pc = op.call(missed, parseBinaryArgument(arg, arr, n)).divideRoundDown(2);
3711
3724
  const missAfter = pc ? pc.total() : 0;
3712
3725
  pcNorm = missBefore ? missAfter / missBefore : 1;
3713
3726
  }
@@ -3718,12 +3731,10 @@ function parseExpression(arr, n, inCheck = false) {
3718
3731
  assertToken(arr, "i");
3719
3732
  assertToken(arr, "s");
3720
3733
  assertToken(arr, "s");
3721
- miss = new Dice();
3722
- const min = finalResult.minFace();
3723
- miss.increment(min > 0 ? min : 1, finalResult.get(min));
3724
- missNorm = miss.total();
3725
- finalResult = finalResult.deleteFace(min);
3726
- miss = op.call(miss, parseBinaryArgument(arg, arr, n));
3734
+ const { miss: missed, rest } = splitMiss(finalResult, attack);
3735
+ finalResult = rest;
3736
+ missNorm = missed.total();
3737
+ miss = op.call(missed, parseBinaryArgument(arg, arr, n));
3727
3738
  missNorm = miss && missNorm ? miss.total() / missNorm : 1;
3728
3739
  }
3729
3740
  let norm = finalResult.total();
@@ -3732,19 +3743,21 @@ function parseExpression(arr, n, inCheck = false) {
3732
3743
  const operand = finalResult;
3733
3744
  if (!clause && labelled && HIT_ONLY_OPS.has(op)) {
3734
3745
  finalResult = applyByOutcome(finalResult, op, arg, termText, n);
3746
+ } else if (HIT_ONLY_OPS.has(op)) {
3747
+ finalResult = applyToLanded(finalResult, op, arg, landedAtZero(finalResult), acCheck);
3735
3748
  } else {
3736
3749
  finalResult = op.call(finalResult, arg);
3737
3750
  }
3738
3751
  if (operand.privateData.isDCCheck && HIT_ONLY_OPS.has(op)) {
3739
3752
  finalResult.setOutcomeDistribution("saveFail", op.call(operand.deleteFace(0), arg).getFaceMap());
3740
3753
  }
3741
- if (!operand.privateData.isDCCheck && HIT_ONLY_OPS.has(op) && (hitText !== void 0 || operand.privateData.attackPayload)) {
3754
+ if (attack && HIT_ONLY_OPS.has(op)) {
3742
3755
  finalResult.privateData.attackPayload = true;
3743
- const landed = landedHitsAtZero(operand, op, arg);
3756
+ const landed = landedHitsAtZero(operand, op, arg, acCheck);
3744
3757
  if (landed > 0) finalResult.setOutcomeDistribution("hit", { 0: landed });
3745
- } else if (op === Dice.prototype.combine && typeof arg !== "number" && arg.privateData.attackPayload) {
3746
- finalResult.privateData.attackPayload = true;
3747
- const landed = operand.getOutcomeCount("hit", 0) + arg.getOutcomeCount("hit", 0);
3758
+ } else if (op === Dice.prototype.combine && typeof arg !== "number") {
3759
+ if (arg.privateData.attackPayload) finalResult.privateData.attackPayload = true;
3760
+ const landed = landedAtZero(operand) + landedAtZero(arg);
3748
3761
  if (landed > 0) finalResult.setOutcomeDistribution("hit", { 0: landed });
3749
3762
  }
3750
3763
  const gated = op === Dice.prototype.combine && typeof arg !== "number" && arg.privateData.isACCheck;
@@ -3834,6 +3847,34 @@ function branchesOf(value) {
3834
3847
  }
3835
3848
  var hasOutcomeLabels = (value) => typeof value !== "number" && Object.keys(value.getFullOutcomeDistribution()).some((label) => label !== "hit");
3836
3849
  var isSave = (value) => typeof value !== "number" && value.privateData.isDCCheck === true;
3850
+ var isAttack = (value) => value.privateData.isDCCheck !== true && (value.privateData.isACCheck === true || value.privateData.attackPayload === true);
3851
+ var landedAtZero = (value) => typeof value === "number" ? 0 : value.getOutcomeCount("hit", 0);
3852
+ function applyToLanded(value, op, arg, landed, gate) {
3853
+ const lands = op === Dice.prototype.addNonZero || gate && op === Dice.prototype.conditionalApply;
3854
+ if (!(landed > 0) || !lands) return op.call(value, arg);
3855
+ const misses = value.deleteFace(0);
3856
+ const missed = value.get(0) - landed;
3857
+ if (missed > 0) misses.setFace(0, missed);
3858
+ const result = op.call(misses, arg);
3859
+ result.combineInPlace(asValue(arg).normalize(landed));
3860
+ return result;
3861
+ }
3862
+ function applyToAttack(value, op, arg, gate) {
3863
+ const result = applyToLanded(value, op, arg, landedAtZero(value), gate);
3864
+ result.privateData.attackPayload = true;
3865
+ const landed = landedHitsAtZero(value, op, arg, gate);
3866
+ if (landed > 0) result.setOutcomeDistribution("hit", { 0: landed });
3867
+ return result;
3868
+ }
3869
+ function splitMiss(check, attack) {
3870
+ const face = attack ? 0 : check.minFace();
3871
+ const landed = face === 0 ? landedAtZero(check) : 0;
3872
+ const miss = new Dice();
3873
+ miss.increment(face > 0 ? face : 1, check.get(face) - landed);
3874
+ const rest = check.deleteFace(face);
3875
+ if (landed > 0) rest.setFace(0, landed);
3876
+ return { miss, rest };
3877
+ }
3837
3878
  function assertMixable(left, right, rest) {
3838
3879
  if (hasOutcomeLabels(left) || hasOutcomeLabels(right)) {
3839
3880
  throw new Error(
@@ -3868,7 +3909,7 @@ function followNaturalRoll(before, op, arg, after, clause) {
3868
3909
  const rights = advantage2 ? lefts : gate ? [asValue(arg)] : branchesOf(arg);
3869
3910
  data.branches = lefts.flatMap(
3870
3911
  (left) => rights.map((right) => {
3871
- const part = pairOp.call(left, right);
3912
+ const part = HIT_ONLY_OPS.has(pairOp) && isAttack(left) ? applyToAttack(left, pairOp, right, left.privateData.isACCheck === true) : pairOp.call(left, right);
3872
3913
  followNaturalRoll(left, pairOp, right, part, false);
3873
3914
  return part;
3874
3915
  })
@@ -3890,7 +3931,10 @@ function followNaturalRoll(before, op, arg, after, clause) {
3890
3931
  } else if (extremum && track?.bare && argTrack?.bare && sides === argSides) {
3891
3932
  data.critTrack = bareTrack(after);
3892
3933
  } else if (followed) {
3893
- const apply = own ? (value) => op.call(value, arg) : (value) => op.call(before, value);
3934
+ const attack = HIT_ONLY_OPS.has(op) && isAttack(before);
3935
+ const gated = before.privateData.isACCheck === true;
3936
+ const call = (value, other) => attack ? applyToAttack(value, op, other, gated) : op.call(value, other);
3937
+ const apply = own ? (value) => call(value, arg) : (value) => call(before, value);
3894
3938
  const step = extremum ? (slice) => keptPart(slice, apply) : apply;
3895
3939
  data.critTrack = { sides: followed.sides, bare: false, slice: (face) => step(followed.slice(face)) };
3896
3940
  }
@@ -3917,7 +3961,13 @@ function mixNaturalRolls(after, branches) {
3917
3961
  bare: tracks.length === branches.length && tracks.every((track) => track.bare),
3918
3962
  slice: (face) => {
3919
3963
  const slice = new Dice();
3920
- for (const track of tracks) slice.combineInPlace(track.slice(face));
3964
+ let landed = 0;
3965
+ for (const track of tracks) {
3966
+ const part = track.slice(face);
3967
+ slice.combineInPlace(part);
3968
+ landed += landedAtZero(part);
3969
+ }
3970
+ if (landed > 0) slice.setOutcomeDistribution("hit", { 0: landed });
3921
3971
  return slice;
3922
3972
  }
3923
3973
  };
@@ -3935,7 +3985,9 @@ function keptPart(slice, apply) {
3935
3985
  function splitCrit(check, count) {
3936
3986
  if (count === 0) {
3937
3987
  const none = new Dice();
3938
- return { crit: none, rest: subtractCounts(check, none) };
3988
+ const rest2 = subtractCounts(check, none);
3989
+ if (landedAtZero(check) > 0) rest2.setOutcomeDistribution("hit", { 0: landedAtZero(check) });
3990
+ return { crit: none, rest: rest2 };
3939
3991
  }
3940
3992
  const track = check.privateData.critTrack;
3941
3993
  if (!track) {
@@ -3948,13 +4000,21 @@ function splitCrit(check, count) {
3948
4000
  throw new Error(`xcrit${count} is wider than the d${sides} it reads its natural roll from`);
3949
4001
  }
3950
4002
  let crit = new Dice();
3951
- for (let face = sides; face > sides - count; face--) crit.combineInPlace(track.slice(face));
4003
+ let landed = 0;
4004
+ for (let face = sides; face > sides - count; face--) {
4005
+ const slice = track.slice(face);
4006
+ crit.combineInPlace(slice);
4007
+ landed += landedAtZero(slice);
4008
+ }
3952
4009
  const rest = subtractCounts(check, crit);
3953
- const missed = crit.get(0);
4010
+ const missed = crit.get(0) - landed;
3954
4011
  if (missed) {
3955
4012
  crit = crit.deleteFace(0);
4013
+ if (landed > 0) crit.setFace(0, landed);
3956
4014
  rest.increment(0, missed);
3957
4015
  }
4016
+ const restLanded = landedAtZero(check) - landed;
4017
+ if (restLanded > 0) rest.setOutcomeDistribution("hit", { 0: restLanded });
3958
4018
  return { crit, rest };
3959
4019
  }
3960
4020
  function critPayload(text, n) {
@@ -3971,6 +4031,13 @@ function critPayload(text, n) {
3971
4031
  }
3972
4032
  return payload;
3973
4033
  }
4034
+ var PAYLOAD_OUTCOMES = {
4035
+ crit: true,
4036
+ missDamage: true,
4037
+ pc: true,
4038
+ saveFail: true,
4039
+ saveHalf: true
4040
+ };
3974
4041
  function applyByOutcome(labelled, op, arg, termText, n) {
3975
4042
  const implicit = labelled.privateData.implicitCrit;
3976
4043
  const argTotal = typeof arg === "number" ? 1 : arg.total();
@@ -3984,27 +4051,29 @@ function applyByOutcome(labelled, op, arg, termText, n) {
3984
4051
  rest = subtractCounts(rest, part);
3985
4052
  let applied;
3986
4053
  if (label === "crit" && implicit && termText !== void 0) {
3987
- payload = implicit.payload + termText;
4054
+ payload = implicit.payload + (op === Dice.prototype.addNonZero ? "~" : "") + termText;
3988
4055
  const doubled = critPayload(payload, n);
3989
4056
  applied = doubled.normalize(part.total() * argTotal / doubled.total());
4057
+ } else if (PAYLOAD_OUTCOMES[label] === true) {
4058
+ applied = applyToLanded(part, op, arg, part.get(0), false);
3990
4059
  } else {
3991
4060
  applied = op.call(part, arg);
3992
4061
  }
3993
4062
  result.combineInPlace(applied);
3994
4063
  result.setOutcomeDistribution(label, applied.getFaceMap());
3995
4064
  }
3996
- result.combineInPlace(op.call(rest, arg));
4065
+ result.combineInPlace(applyToLanded(rest, op, arg, landedAtZero(labelled), false));
3997
4066
  if (labelled.privateData.isDCCheck) result.privateData.isDCCheck = true;
3998
4067
  if (payload !== void 0) result.privateData.implicitCrit = { payload };
3999
4068
  return result;
4000
4069
  }
4001
- function landedHitsAtZero(operand, op, arg) {
4070
+ function landedHitsAtZero(operand, op, arg, gate) {
4002
4071
  let landed = 0;
4003
4072
  for (const [face, count] of Object.entries(operand.calculateHitDistribution())) {
4004
4073
  if (!(count > 0)) continue;
4005
4074
  const hit = new Dice();
4006
4075
  hit.setFace(Number(face), count);
4007
- landed += op.call(hit, arg).get(0);
4076
+ landed += applyToLanded(hit, op, arg, Number(face) === 0 ? count : 0, gate).get(0);
4008
4077
  }
4009
4078
  return landed;
4010
4079
  }