@platonic-dice/core 2.2.2 → 3.0.0

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 (66) hide show
  1. package/README.md +11 -2
  2. package/dist/analyseModTest.js +12 -10
  3. package/dist/analyseTest.js +43 -11
  4. package/dist/entities/DiceTestConditions.js +174 -0
  5. package/dist/entities/ModifiedTestConditions.js +30 -41
  6. package/dist/entities/RollModifier.js +15 -1
  7. package/dist/entities/TestConditions.js +26 -182
  8. package/dist/entities/TestConditionsArray.js +87 -0
  9. package/dist/entities/index.js +250 -39
  10. package/dist/index.js +42 -25
  11. package/dist/rollDiceMod.js +30 -17
  12. package/dist/rollDiceTest.js +68 -0
  13. package/dist/rollMod.js +3 -4
  14. package/dist/rollModTest.js +40 -13
  15. package/dist/rollTest.js +50 -15
  16. package/dist/utils/determineOutcome.js +35 -7
  17. package/dist/utils/getArrayEvaluator.js +48 -0
  18. package/dist/utils/getEvaluator.js +84 -0
  19. package/dist/utils/index.js +244 -16
  20. package/dist/utils/outcomeMapper.js +62 -15
  21. package/dist/utils/testRegistry.js +91 -0
  22. package/dist/utils/testValidators.js +190 -0
  23. package/package.json +12 -15
  24. package/dist/analyseModTest.d.ts +0 -126
  25. package/dist/analyseModTest.d.ts.map +0 -1
  26. package/dist/analyseTest.d.ts +0 -101
  27. package/dist/analyseTest.d.ts.map +0 -1
  28. package/dist/entities/DieType.d.ts +0 -35
  29. package/dist/entities/DieType.d.ts.map +0 -1
  30. package/dist/entities/ModifiedTestConditions.d.ts +0 -89
  31. package/dist/entities/ModifiedTestConditions.d.ts.map +0 -1
  32. package/dist/entities/Outcome.d.ts +0 -26
  33. package/dist/entities/Outcome.d.ts.map +0 -1
  34. package/dist/entities/RollModifier.d.ts +0 -115
  35. package/dist/entities/RollModifier.d.ts.map +0 -1
  36. package/dist/entities/RollType.d.ts +0 -24
  37. package/dist/entities/RollType.d.ts.map +0 -1
  38. package/dist/entities/TestConditions.d.ts +0 -97
  39. package/dist/entities/TestConditions.d.ts.map +0 -1
  40. package/dist/entities/TestType.d.ts +0 -28
  41. package/dist/entities/TestType.d.ts.map +0 -1
  42. package/dist/entities/index.d.ts +0 -19
  43. package/dist/entities/index.d.ts.map +0 -1
  44. package/dist/index.d.ts +0 -5
  45. package/dist/index.d.ts.map +0 -1
  46. package/dist/roll.d.ts +0 -66
  47. package/dist/roll.d.ts.map +0 -1
  48. package/dist/rollDice.d.ts +0 -48
  49. package/dist/rollDice.d.ts.map +0 -1
  50. package/dist/rollDiceMod.d.ts +0 -54
  51. package/dist/rollDiceMod.d.ts.map +0 -1
  52. package/dist/rollMod.d.ts +0 -52
  53. package/dist/rollMod.d.ts.map +0 -1
  54. package/dist/rollModTest.d.ts +0 -72
  55. package/dist/rollModTest.d.ts.map +0 -1
  56. package/dist/rollTest.d.ts +0 -59
  57. package/dist/rollTest.d.ts.map +0 -1
  58. package/dist/utils/determineOutcome.d.ts +0 -45
  59. package/dist/utils/determineOutcome.d.ts.map +0 -1
  60. package/dist/utils/generateResult.d.ts +0 -30
  61. package/dist/utils/generateResult.d.ts.map +0 -1
  62. package/dist/utils/index.d.ts +0 -8
  63. package/dist/utils/index.d.ts.map +0 -1
  64. package/dist/utils/outcomeMapper.d.ts +0 -29
  65. package/dist/utils/outcomeMapper.d.ts.map +0 -1
  66. package/dist-types.d.ts +0 -70
@@ -16,19 +16,247 @@
16
16
  * // Internal usage only — not part of the public API
17
17
  * import { generateDieResult, determineOutcome } from "../utils";
18
18
  */
19
- const { determineOutcome } = require("./determineOutcome.js");
20
- const { generateResult, numSides } = require("./generateResult.js");
21
- const {
22
- createOutcomeMap,
23
- clearOutcomeMapCache,
24
- getOutcomeMapCacheSize,
25
- } = require("./outcomeMapper.js");
26
-
27
- module.exports = {
28
- determineOutcome,
29
- generateResult,
30
- numSides,
31
- createOutcomeMap,
32
- clearOutcomeMapCache,
33
- getOutcomeMapCacheSize,
34
- };
19
+ // Lazy-loaded per-export getters to avoid circular require during
20
+ // module initialization. Each exported name is resolved on first
21
+ // access and cached locally. Properties are `configurable: true`
22
+ // so test frameworks (e.g., Jest) can spy/mock them.
23
+
24
+ /** @type {typeof import("./determineOutcome").determineOutcome | undefined} */
25
+ let _determineOutcome;
26
+ Object.defineProperty(exports, "determineOutcome", {
27
+ enumerable: true,
28
+ configurable: true,
29
+ get() {
30
+ if (_determineOutcome !== undefined) return _determineOutcome;
31
+ _determineOutcome = require("./determineOutcome.js").determineOutcome;
32
+ return _determineOutcome;
33
+ },
34
+ });
35
+
36
+ /** @type {typeof import("./generateResult").generateResult | undefined} */
37
+ let _generateResult;
38
+ Object.defineProperty(exports, "generateResult", {
39
+ enumerable: true,
40
+ configurable: true,
41
+ get() {
42
+ if (_generateResult !== undefined) return _generateResult;
43
+ _generateResult = require("./generateResult.js").generateResult;
44
+ return _generateResult;
45
+ },
46
+ });
47
+
48
+ /** @type {typeof import("./generateResult").numSides | undefined} */
49
+ let _numSides;
50
+ Object.defineProperty(exports, "numSides", {
51
+ enumerable: true,
52
+ configurable: true,
53
+ get() {
54
+ if (_numSides !== undefined) return _numSides;
55
+ _numSides = require("./generateResult.js").numSides;
56
+ return _numSides;
57
+ },
58
+ });
59
+
60
+ /** @type {typeof import("./outcomeMapper").createOutcomeMap | undefined} */
61
+ let _createOutcomeMap;
62
+ Object.defineProperty(exports, "createOutcomeMap", {
63
+ enumerable: true,
64
+ configurable: true,
65
+ get() {
66
+ if (_createOutcomeMap !== undefined) return _createOutcomeMap;
67
+ _createOutcomeMap = require("./outcomeMapper.js").createOutcomeMap;
68
+ return _createOutcomeMap;
69
+ },
70
+ });
71
+
72
+ /** @type {typeof import("./outcomeMapper").clearOutcomeMapCache | undefined} */
73
+ let _clearOutcomeMapCache;
74
+ Object.defineProperty(exports, "clearOutcomeMapCache", {
75
+ enumerable: true,
76
+ configurable: true,
77
+ get() {
78
+ if (_clearOutcomeMapCache !== undefined) return _clearOutcomeMapCache;
79
+ _clearOutcomeMapCache = require("./outcomeMapper.js").clearOutcomeMapCache;
80
+ return _clearOutcomeMapCache;
81
+ },
82
+ });
83
+
84
+ /** @type {typeof import("./outcomeMapper").getOutcomeMapCacheSize | undefined} */
85
+ let _getOutcomeMapCacheSize;
86
+ Object.defineProperty(exports, "getOutcomeMapCacheSize", {
87
+ enumerable: true,
88
+ configurable: true,
89
+ get() {
90
+ if (_getOutcomeMapCacheSize !== undefined) return _getOutcomeMapCacheSize;
91
+ _getOutcomeMapCacheSize =
92
+ require("./outcomeMapper.js").getOutcomeMapCacheSize;
93
+ return _getOutcomeMapCacheSize;
94
+ },
95
+ });
96
+
97
+ /** @type {typeof import("./getEvaluator").getEvaluator | undefined} */
98
+ let _getEvaluator;
99
+ Object.defineProperty(exports, "getEvaluator", {
100
+ enumerable: true,
101
+ configurable: true,
102
+ get() {
103
+ if (_getEvaluator !== undefined) return _getEvaluator;
104
+ _getEvaluator = require("./getEvaluator.js").getEvaluator;
105
+ return _getEvaluator;
106
+ },
107
+ });
108
+
109
+ // testRegistry exports
110
+ /** @type {typeof import("./testRegistry").registerTestType | undefined} */
111
+ let _registerTestType;
112
+ Object.defineProperty(exports, "registerTestType", {
113
+ enumerable: true,
114
+ configurable: true,
115
+ get() {
116
+ if (_registerTestType !== undefined) return _registerTestType;
117
+ _registerTestType = require("./testRegistry.js").registerTestType;
118
+ return _registerTestType;
119
+ },
120
+ });
121
+
122
+ /** @type {typeof import("./testRegistry").getRegistration | undefined} */
123
+ let _getRegistration;
124
+ Object.defineProperty(exports, "getRegistration", {
125
+ enumerable: true,
126
+ configurable: true,
127
+ get() {
128
+ if (_getRegistration !== undefined) return _getRegistration;
129
+ _getRegistration = require("./testRegistry.js").getRegistration;
130
+ return _getRegistration;
131
+ },
132
+ });
133
+
134
+ /** @type {typeof import("./testRegistry").registry | undefined} */
135
+ let _registry;
136
+ Object.defineProperty(exports, "registry", {
137
+ enumerable: true,
138
+ configurable: true,
139
+ get() {
140
+ if (_registry !== undefined) return _registry;
141
+ _registry = require("./testRegistry.js").registry;
142
+ return _registry;
143
+ },
144
+ });
145
+
146
+ // testValidators exports
147
+ /** @type {typeof import("./testValidators").isValidFaceValue | undefined} */
148
+ let _isValidFaceValue;
149
+ Object.defineProperty(exports, "isValidFaceValue", {
150
+ enumerable: true,
151
+ configurable: true,
152
+ get() {
153
+ if (_isValidFaceValue !== undefined) return _isValidFaceValue;
154
+ _isValidFaceValue = require("./testValidators.js").isValidFaceValue;
155
+ return _isValidFaceValue;
156
+ },
157
+ });
158
+
159
+ /** @type {typeof import("./testValidators").areValidFaceValues | undefined} */
160
+ let _areValidFaceValues;
161
+ Object.defineProperty(exports, "areValidFaceValues", {
162
+ enumerable: true,
163
+ configurable: true,
164
+ get() {
165
+ if (_areValidFaceValues !== undefined) return _areValidFaceValues;
166
+ _areValidFaceValues = require("./testValidators.js").areValidFaceValues;
167
+ return _areValidFaceValues;
168
+ },
169
+ });
170
+
171
+ /** @type {typeof import("./testValidators").isValidThresholdOrder | undefined} */
172
+ let _isValidThresholdOrder;
173
+ Object.defineProperty(exports, "isValidThresholdOrder", {
174
+ enumerable: true,
175
+ configurable: true,
176
+ get() {
177
+ if (_isValidThresholdOrder !== undefined) return _isValidThresholdOrder;
178
+ _isValidThresholdOrder =
179
+ require("./testValidators.js").isValidThresholdOrder;
180
+ return _isValidThresholdOrder;
181
+ },
182
+ });
183
+
184
+ /** @type {typeof import("./testValidators").isValidTargetConditions | undefined} */
185
+ let _isValidTargetConditions;
186
+ Object.defineProperty(exports, "isValidTargetConditions", {
187
+ enumerable: true,
188
+ configurable: true,
189
+ get() {
190
+ if (_isValidTargetConditions !== undefined) return _isValidTargetConditions;
191
+ _isValidTargetConditions =
192
+ require("./testValidators.js").isValidTargetConditions;
193
+ return _isValidTargetConditions;
194
+ },
195
+ });
196
+
197
+ /** @type {typeof import("./testValidators").isValidSkillTestCondition | undefined} */
198
+ let _isValidSkillTestCondition;
199
+ Object.defineProperty(exports, "isValidSkillTestCondition", {
200
+ enumerable: true,
201
+ configurable: true,
202
+ get() {
203
+ if (_isValidSkillTestCondition !== undefined)
204
+ return _isValidSkillTestCondition;
205
+ _isValidSkillTestCondition =
206
+ require("./testValidators.js").isValidSkillTestCondition;
207
+ return _isValidSkillTestCondition;
208
+ },
209
+ });
210
+
211
+ /** @type {typeof import("./testValidators").isValidWithinConditions | undefined} */
212
+ let _isValidWithinConditions;
213
+ Object.defineProperty(exports, "isValidWithinConditions", {
214
+ enumerable: true,
215
+ configurable: true,
216
+ get() {
217
+ if (_isValidWithinConditions !== undefined) return _isValidWithinConditions;
218
+ _isValidWithinConditions =
219
+ require("./testValidators.js").isValidWithinConditions;
220
+ return _isValidWithinConditions;
221
+ },
222
+ });
223
+
224
+ /** @type {typeof import("./testValidators").isValidSpecificListConditions | undefined} */
225
+ let _isValidSpecificListConditions;
226
+ Object.defineProperty(exports, "isValidSpecificListConditions", {
227
+ enumerable: true,
228
+ configurable: true,
229
+ get() {
230
+ if (_isValidSpecificListConditions !== undefined)
231
+ return _isValidSpecificListConditions;
232
+ _isValidSpecificListConditions =
233
+ require("./testValidators.js").isValidSpecificListConditions;
234
+ return _isValidSpecificListConditions;
235
+ },
236
+ });
237
+
238
+ /** @type {typeof import("./testValidators").areValidValuesInRange | undefined} */
239
+ let _areValidValuesInRange;
240
+ Object.defineProperty(exports, "areValidValuesInRange", {
241
+ enumerable: true,
242
+ configurable: true,
243
+ get() {
244
+ if (_areValidValuesInRange !== undefined) return _areValidValuesInRange;
245
+ _areValidValuesInRange =
246
+ require("./testValidators.js").areValidValuesInRange;
247
+ return _areValidValuesInRange;
248
+ },
249
+ });
250
+
251
+ /** @type {typeof import("./testValidators").areValidTestConditions | undefined} */
252
+ let _areValidTestConditions;
253
+ Object.defineProperty(exports, "areValidTestConditions", {
254
+ enumerable: true,
255
+ configurable: true,
256
+ get() {
257
+ if (_areValidTestConditions !== undefined) return _areValidTestConditions;
258
+ _areValidTestConditions =
259
+ require("./testValidators.js").areValidTestConditions;
260
+ return _areValidTestConditions;
261
+ },
262
+ });
@@ -2,7 +2,7 @@
2
2
  * @module @platonic-dice/core/src/utils/outcomeMapper
3
3
  * @description
4
4
  * Creates outcome maps for die rolls, handling natural crits and modifiers.
5
- * This provides a centralized way to determine all possible outcomes for a
5
+ * This provides a centralised way to determine all possible outcomes for a
6
6
  * given die configuration.
7
7
  *
8
8
  * Includes memoization cache for performance optimization.
@@ -41,7 +41,7 @@ function applyNaturalCritOverride(
41
41
  currentOutcome,
42
42
  isNaturalMax,
43
43
  isNaturalMin,
44
- testType
44
+ testType,
45
45
  ) {
46
46
  const { TestType, Outcome } = getEntities();
47
47
 
@@ -77,8 +77,8 @@ function applyNaturalCritOverride(
77
77
  * @param {import("../entities/DieType").DieTypeValue} dieType
78
78
  * @param {import("../entities/TestType").TestTypeValue} testType
79
79
  * @param {import("../entities/TestConditions").TestConditionsInstance} testConditions
80
- * @param {import("../entities/RollModifier").RollModifierInstance|null} modifier
81
- * @param {boolean} useNaturalCrits
80
+ * @param {import("../entities/RollModifier").RollModifierInstance|undefined} modifier
81
+ * @param {boolean|undefined} useNaturalCrits
82
82
  * @returns {string}
83
83
  */
84
84
  function createCacheKey(
@@ -86,9 +86,9 @@ function createCacheKey(
86
86
  testType,
87
87
  testConditions,
88
88
  modifier,
89
- useNaturalCrits
89
+ useNaturalCrits,
90
90
  ) {
91
- // Serialize the conditions object for hashing
91
+ // serialise the conditions object for hashing
92
92
  const conditionsKey = JSON.stringify({
93
93
  testType: testConditions.testType,
94
94
  conditions: testConditions.conditions,
@@ -107,22 +107,45 @@ function createCacheKey(
107
107
  * @param {import("../entities/DieType").DieTypeValue} dieType - The type of die
108
108
  * @param {import("../entities/TestType").TestTypeValue} testType - The type of test being performed
109
109
  * @param {import("../entities/TestConditions").TestConditionsInstance} testConditions - The test conditions
110
- * @param {import("../entities/RollModifier").RollModifierInstance|null} modifier - Optional modifier to apply
111
- * @param {boolean|null} useNaturalCrits - Whether to use natural crits (null = auto-determine)
110
+ * @param {import("../entities/RollModifier").RollModifierInstance|undefined} modifier - Optional modifier to apply
111
+ * @param {boolean|undefined} useNaturalCrits - Whether to use natural crits (undefined = auto-determine)
112
112
  * @returns {Object.<number, import("../entities/Outcome").OutcomeValue>} Map of baseRoll -> outcome
113
113
  */
114
114
  function createOutcomeMap(
115
115
  dieType,
116
116
  testType,
117
117
  testConditions,
118
- modifier = null,
119
- useNaturalCrits = null
118
+ modifier = undefined,
119
+ useNaturalCrits = undefined,
120
120
  ) {
121
121
  const { TestType } = getEntities();
122
122
 
123
- // Auto-determine useNaturalCrits if not specified
124
- // Default: true for Skill tests, false for all others
125
- const shouldUseNaturalCrits = useNaturalCrits ?? testType === TestType.Skill;
123
+ // Resolve `useNaturalCrits` with the following precedence:
124
+ // 1. Caller-provided `useNaturalCrits` (boolean)
125
+ // 2. Registry-provided `defaultUseNaturalCrits` (optional boolean)
126
+ // 3. Fallback default: true for Skill tests, false otherwise
127
+ let shouldUseNaturalCrits = useNaturalCrits;
128
+ // Try to get registry metadata (if registry is available) to consult any
129
+ // declared `defaultUseNaturalCrits` for this testType. We swallow errors
130
+ // to remain resilient if the registry cannot be loaded.
131
+ let reg;
132
+ try {
133
+ const tr = require("./testRegistry");
134
+ reg = tr.getRegistration(testType);
135
+ if (
136
+ shouldUseNaturalCrits == null &&
137
+ reg &&
138
+ typeof reg.defaultUseNaturalCrits === "boolean"
139
+ ) {
140
+ shouldUseNaturalCrits = reg.defaultUseNaturalCrits;
141
+ }
142
+ } catch (e) {
143
+ // ignore registry errors and fall back to heuristic below
144
+ }
145
+
146
+ if (shouldUseNaturalCrits == null) {
147
+ shouldUseNaturalCrits = testType === TestType.Skill;
148
+ }
126
149
 
127
150
  // Check cache first
128
151
  const cacheKey = createCacheKey(
@@ -130,13 +153,37 @@ function createOutcomeMap(
130
153
  testType,
131
154
  testConditions,
132
155
  modifier,
133
- shouldUseNaturalCrits
156
+ shouldUseNaturalCrits,
134
157
  );
135
158
  const cached = outcomeMapCache.get(cacheKey);
136
159
  if (cached) {
137
160
  return cached;
138
161
  }
139
162
 
163
+ // Prefer registry builder when available to keep behavior consistent
164
+ // with registered evaluators. Fall back to per-base determineOutcome loop.
165
+ try {
166
+ if (reg && typeof reg.buildEvaluator === "function") {
167
+ const evaluator = reg.buildEvaluator(
168
+ dieType,
169
+ testConditions,
170
+ modifier,
171
+ shouldUseNaturalCrits,
172
+ );
173
+ const sides = numSides(dieType);
174
+ /** @type {Object.<number, import("../entities/Outcome").OutcomeValue>} */
175
+ const outcomeMap = {};
176
+ for (let baseRoll = 1; baseRoll <= sides; baseRoll++) {
177
+ outcomeMap[baseRoll] = evaluator(baseRoll);
178
+ }
179
+ outcomeMapCache.set(cacheKey, outcomeMap);
180
+ return outcomeMap;
181
+ }
182
+ } catch (e) {
183
+ // If registry cannot be loaded for any reason, gracefully fall back
184
+ // to the existing per-base logic below.
185
+ }
186
+
140
187
  const sides = numSides(dieType);
141
188
  /** @type {Object.<number, import("../entities/Outcome").OutcomeValue>} */
142
189
  const outcomeMap = {};
@@ -157,7 +204,7 @@ function createOutcomeMap(
157
204
  outcome,
158
205
  isNaturalMax,
159
206
  isNaturalMin,
160
- testType
207
+ testType,
161
208
  );
162
209
  }
163
210
 
@@ -0,0 +1,91 @@
1
+ /**
2
+ * Lightweight registry for test types.
3
+ *
4
+ * Allows future registration of new test types with associated
5
+ * `validateShape` and `buildEvaluator` functions. For now, only
6
+ * `validateShape` is populated using `testValidators`.
7
+ */
8
+
9
+ const validators = require("./testValidators");
10
+
11
+ /**
12
+ * @typedef {import("./testValidators").Conditions} Conditions
13
+ * @typedef {Record<string, unknown>} PlainObject
14
+ * @typedef {Conditions|PlainObject} ConditionsLike
15
+ *
16
+ * Evaluator: function that maps a rolled base value (1..sides) to an OutcomeValue.
17
+ * @typedef {(base: number) => import("../entities/Outcome").OutcomeValue} Evaluator
18
+ *
19
+ * BuildEvaluator: factory that builds an Evaluator for a specific die/conditions.
20
+ * @typedef {(dieType: import("../entities/DieType").DieTypeValue, testConditions: ConditionsLike, modifier?: import("../entities/RollModifier").RollModifierInstance, useNaturalCrits?: boolean) => Evaluator} BuildEvaluator
21
+ *
22
+ * RegistryEntry: describes the shape validator, optional evaluator builder, and
23
+ * optional default for `useNaturalCrits` for that test type.
24
+ * @typedef {{ validateShape: (c: ConditionsLike) => boolean, buildEvaluator?: BuildEvaluator, defaultUseNaturalCrits?: boolean }} RegistryEntry
25
+ */
26
+
27
+ /** Internal registry map: testType -> { validateShape, buildEvaluator? } */
28
+ const registry = new Map();
29
+
30
+ // Populate with built-in test types using validators
31
+ const builtIns = ["at_least", "at_most", "exact", "within", "in_list", "skill"];
32
+
33
+ for (const t of builtIns) {
34
+ registry.set(t, {
35
+ /** @param {ConditionsLike} c */
36
+ validateShape: (c) => validators.areValidTestConditions(c, t),
37
+ // buildEvaluator: returns an evaluator function (base:number)=>outcome
38
+ /**
39
+ * @param {import("../entities/DieType").DieTypeValue} dieType
40
+ * @param {ConditionsLike} testConditions
41
+ * @param {import("../entities/RollModifier").RollModifierInstance} [modifier]
42
+ * @param {boolean} [useNaturalCrits]
43
+ * @returns {(base: number) => import("../entities/Outcome").OutcomeValue}
44
+ */
45
+ /** @type {import("./testRegistry").BuildEvaluator} */
46
+ buildEvaluator: (
47
+ dieType,
48
+ testConditions,
49
+ modifier = undefined,
50
+ useNaturalCrits = undefined,
51
+ ) => {
52
+ // require lazily to avoid circular requires at module init
53
+ const { createOutcomeMap } = require("./outcomeMapper");
54
+ // t is a string matching TestType values; TS/JSDoc will accept via runtime checks
55
+ const outcomeMap = createOutcomeMap(
56
+ dieType,
57
+ /** @type {import("../entities/TestType").TestTypeValue} */ (t),
58
+ /** @type {any} */ (testConditions),
59
+ modifier,
60
+ useNaturalCrits,
61
+ );
62
+ return /** @param {number} base */ (base) => outcomeMap[base];
63
+ },
64
+ });
65
+ }
66
+
67
+ /**
68
+ * Register a new test type.
69
+ * @param {string} name
70
+ * @param {{ validateShape: (c: ConditionsLike) => boolean, buildEvaluator?: Function }} opts
71
+ */
72
+ function registerTestType(name, { validateShape, buildEvaluator = undefined }) {
73
+ if (!name || typeof name !== "string") {
74
+ throw new TypeError("test type name must be a non-empty string");
75
+ }
76
+ registry.set(name, { validateShape, buildEvaluator });
77
+ }
78
+
79
+ /**
80
+ * @param {string} name
81
+ * @returns {RegistryEntry|undefined}
82
+ */
83
+ function getRegistration(name) {
84
+ return registry.get(name) || undefined;
85
+ }
86
+
87
+ module.exports = {
88
+ registerTestType,
89
+ getRegistration,
90
+ registry, // exported for introspection in tests/tools
91
+ };
@@ -0,0 +1,190 @@
1
+ const { isValidDieType } = require("../entities/DieType");
2
+ const { numSides } = require("./generateResult.js");
3
+
4
+ /* Typedef ownership:
5
+ * - `PlainObject`, `Conditions`, `ConditionsLike`
6
+ */
7
+
8
+ /**
9
+ * @typedef {import("../entities/TestType").TestTypeValue} TestTypeValue
10
+ * @typedef {import("../entities/DieType").DieTypeValue} DieTypeValue
11
+ *
12
+ * @typedef {Object} BaseTestCondition
13
+ * @property {DieTypeValue} dieType
14
+ *
15
+ * @typedef {BaseTestCondition & { target: number }} TargetConditions
16
+ * @typedef {BaseTestCondition & { min: number, max: number }} WithinConditions
17
+ * @typedef {BaseTestCondition & { values: number[] }} SpecificListConditions
18
+ * @typedef {BaseTestCondition & { target: number, critical_success?: number, critical_failure?: number }} SkillConditions
19
+ *
20
+ * @typedef {TargetConditions | SkillConditions | WithinConditions | SpecificListConditions} Conditions
21
+ *
22
+ * @typedef {Record<string, any>} PlainObject
23
+ * @typedef {Conditions|PlainObject} ConditionsLike
24
+ */
25
+
26
+ /**
27
+ * Checks if a number is a valid face value for a die with the given sides.
28
+ */
29
+ /**
30
+ * @param {number} n
31
+ * @param {number} sides
32
+ * @returns {boolean}
33
+ */
34
+ function isValidFaceValue(n, sides) {
35
+ return Number.isInteger(n) && n >= 1 && n <= sides;
36
+ }
37
+
38
+ /**
39
+ * Checks multiple keys in an object for valid face values.
40
+ */
41
+ /**
42
+ * @template T
43
+ * @param {T} obj
44
+ * @param {number} sides
45
+ * @param {(keyof T)[]} keys
46
+ * @returns {boolean}
47
+ */
48
+ function areValidFaceValues(obj, sides, keys) {
49
+ return keys.every((key) => {
50
+ const value = obj[key];
51
+ return (
52
+ value == null || isValidFaceValue(/** @type {number} */ (value), sides)
53
+ );
54
+ });
55
+ }
56
+
57
+ /**
58
+ * Validates the ordering of target and critical thresholds.
59
+ */
60
+ /**
61
+ * @param {SkillConditions|PlainObject} thresholds
62
+ * @returns {boolean}
63
+ */
64
+ function isValidThresholdOrder({ target, critical_success, critical_failure }) {
65
+ if (critical_failure != null && critical_failure >= target) return false;
66
+ if (critical_success != null && critical_success < target) return false;
67
+ return true;
68
+ }
69
+
70
+ /**
71
+ * @param {TargetConditions|PlainObject} c
72
+ * @returns {boolean}
73
+ */
74
+ function isValidTargetConditions(c) {
75
+ if (!c || !isValidDieType(c.dieType)) return false;
76
+ return isValidFaceValue(c.target, numSides(c.dieType));
77
+ }
78
+
79
+ /**
80
+ * @param {SkillConditions|PlainObject} c
81
+ * @returns {boolean}
82
+ */
83
+ function isValidSkillTestCondition(c) {
84
+ if (!c || !isValidDieType(c.dieType)) return false;
85
+ const sides = numSides(c.dieType);
86
+
87
+ if (
88
+ !areValidFaceValues(c, sides, [
89
+ "target",
90
+ "critical_success",
91
+ "critical_failure",
92
+ ])
93
+ )
94
+ return false;
95
+ if (!isValidThresholdOrder(c)) return false;
96
+
97
+ return true;
98
+ }
99
+
100
+ /**
101
+ * @param {WithinConditions|PlainObject} c
102
+ * @returns {boolean}
103
+ */
104
+ function isValidWithinConditions(c) {
105
+ if (!c || !isValidDieType(c.dieType)) return false;
106
+ const sides = numSides(c.dieType);
107
+
108
+ if (!areValidFaceValues(c, sides, ["min", "max"])) return false;
109
+ if (c.min > c.max) return false;
110
+
111
+ return true;
112
+ }
113
+
114
+ /**
115
+ * @param {SpecificListConditions|PlainObject} c
116
+ * @returns {boolean}
117
+ */
118
+ function isValidSpecificListConditions(c) {
119
+ if (!c || !isValidDieType(c.dieType)) return false;
120
+ const sides = numSides(c.dieType);
121
+ if (!Array.isArray(c.values) || c.values.length === 0) return false;
122
+ return c.values.every((v) => isValidFaceValue(v, sides));
123
+ }
124
+
125
+ /**
126
+ * Validates that the given keys on an object are integer values within
127
+ * an explicit inclusive range `[min, max]`.
128
+ *
129
+ * This is useful for modified-range validation where the permissible
130
+ * faces aren't 1..sides but an arbitrary min..max after applying modifiers.
131
+ *
132
+ * @param {PlainObject} obj
133
+ * @param {number} min
134
+ * @param {number} max
135
+ * @param {(string)[]} keys
136
+ * @returns {boolean}
137
+ */
138
+ function areValidValuesInRange(obj, min, max, keys) {
139
+ if (!obj) return false;
140
+ if (!Number.isInteger(min) || !Number.isInteger(max) || min > max)
141
+ return false;
142
+ return keys.every((key) => {
143
+ const v = obj[key];
144
+ if (v == null) return true;
145
+ if (Array.isArray(v)) {
146
+ if (v.length === 0) return false;
147
+ return v.every(
148
+ (item) => Number.isInteger(item) && item >= min && item <= max
149
+ );
150
+ }
151
+ return Number.isInteger(v) && v >= min && v <= max;
152
+ });
153
+ }
154
+
155
+ /**
156
+ * Master validation function for all test conditions.
157
+ */
158
+ /**
159
+ * @param {Conditions|PlainObject} c
160
+ * @param {TestTypeValue|string} testType
161
+ * @returns {boolean}
162
+ */
163
+ function areValidTestConditions(c, testType) {
164
+ switch (testType) {
165
+ case "at_least":
166
+ case "at_most":
167
+ case "exact":
168
+ return isValidTargetConditions(c);
169
+ case "within":
170
+ return isValidWithinConditions(c);
171
+ case "in_list":
172
+ return isValidSpecificListConditions(c);
173
+ case "skill":
174
+ return isValidSkillTestCondition(c);
175
+ default:
176
+ return false;
177
+ }
178
+ }
179
+
180
+ module.exports = {
181
+ isValidFaceValue,
182
+ areValidFaceValues,
183
+ isValidThresholdOrder,
184
+ isValidTargetConditions,
185
+ isValidSkillTestCondition,
186
+ isValidWithinConditions,
187
+ isValidSpecificListConditions,
188
+ areValidTestConditions,
189
+ areValidValuesInRange,
190
+ };