@dice-o-rolla/dice-core 0.1.0 → 0.2.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.
- package/README.md +4 -1
- package/dist/dice/types.d.ts +4 -0
- package/dist/index.d.ts +1 -1
- package/dist/notation/ast.d.ts +12 -0
- package/dist/notation/errors.d.ts +1 -1
- package/dist/notation/parser.js +137 -2
- package/dist/roll/aggregation.js +11 -5
- package/package.json +10 -1
package/README.md
CHANGED
|
@@ -9,8 +9,11 @@ building a custom engine composition or consuming the domain model independently
|
|
|
9
9
|
```ts
|
|
10
10
|
import { parseNotation, SeededRandomSource } from '@dice-o-rolla/dice-core';
|
|
11
11
|
|
|
12
|
-
const expression = parseNotation('
|
|
12
|
+
const expression = parseNotation('4d20kh3s{1=-2,17..19=1,20=2} + 1');
|
|
13
13
|
const random = new SeededRandomSource(42);
|
|
14
14
|
```
|
|
15
15
|
|
|
16
|
+
Standard dice terms support `kh`, `kl`, `dh`, and `dl` selection followed by an optional score map.
|
|
17
|
+
Unlisted faces score zero. Paired percentile and d66 terms intentionally reject these operations.
|
|
18
|
+
|
|
16
19
|
Licensed under Apache-2.0. See `LICENSE`, `NOTICE`, and `THIRD_PARTY_NOTICES.md` in the package.
|
package/dist/dice/types.d.ts
CHANGED
|
@@ -18,10 +18,14 @@ export interface DiceComponentResult {
|
|
|
18
18
|
export interface StandardDieResult extends BaseDieResult {
|
|
19
19
|
readonly type: Exclude<DieType, 'd100'>;
|
|
20
20
|
readonly component?: never;
|
|
21
|
+
readonly included?: boolean;
|
|
22
|
+
readonly score?: number;
|
|
21
23
|
}
|
|
22
24
|
export interface ComponentDieResult extends BaseDieResult {
|
|
23
25
|
readonly type: DieType;
|
|
24
26
|
readonly component: DiceComponentResult;
|
|
27
|
+
readonly included?: never;
|
|
28
|
+
readonly score?: never;
|
|
25
29
|
}
|
|
26
30
|
export type DieResult = StandardDieResult | ComponentDieResult;
|
|
27
31
|
export declare function isDieType(value: unknown): value is DieType;
|
package/dist/index.d.ts
CHANGED
|
@@ -2,7 +2,7 @@ export type { DiceComponentResult, DiceComponentRole, ComponentDieResult, DieDef
|
|
|
2
2
|
export { isDieType, STANDARD_DIE_TYPES } from './dice/types.js';
|
|
3
3
|
export { TypedEventEmitter } from './events/typed-event-emitter.js';
|
|
4
4
|
export type { QuaternionLike, Vector3Like } from './math/types.js';
|
|
5
|
-
export type { DiceExpression, ModifierExpression, PairedDiceExpression, PairedDiceType, RollExpression, RollNotation, } from './notation/ast.js';
|
|
5
|
+
export type { DiceExpression, DiceScoreRule, DiceSelection, DiceSelectionOperator, ModifierExpression, PairedDiceExpression, PairedDiceType, RollExpression, RollNotation, } from './notation/ast.js';
|
|
6
6
|
export { NotationParseError } from './notation/errors.js';
|
|
7
7
|
export type { NotationParseErrorCode } from './notation/errors.js';
|
|
8
8
|
export { parseNotation } from './notation/parser.js';
|
package/dist/notation/ast.d.ts
CHANGED
|
@@ -1,7 +1,19 @@
|
|
|
1
|
+
export type DiceSelectionOperator = 'kh' | 'kl' | 'dh' | 'dl';
|
|
2
|
+
export interface DiceSelection {
|
|
3
|
+
readonly operator: DiceSelectionOperator;
|
|
4
|
+
readonly count: number;
|
|
5
|
+
}
|
|
6
|
+
export interface DiceScoreRule {
|
|
7
|
+
readonly minimum: number;
|
|
8
|
+
readonly maximum: number;
|
|
9
|
+
readonly score: number;
|
|
10
|
+
}
|
|
1
11
|
export interface DiceExpression {
|
|
2
12
|
readonly kind: 'dice';
|
|
3
13
|
readonly count: number;
|
|
4
14
|
readonly sides: number;
|
|
15
|
+
readonly selection?: DiceSelection;
|
|
16
|
+
readonly score?: readonly DiceScoreRule[];
|
|
5
17
|
}
|
|
6
18
|
export type PairedDiceType = 'd100' | 'd66';
|
|
7
19
|
export interface PairedDiceExpression {
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export type NotationParseErrorCode = 'EMPTY_NOTATION' | 'EXPECTED_OPERATOR' | 'EXPECTED_TERM' | 'INVALID_COUNT' | 'INVALID_SIDES' | 'NEGATIVE_DICE' | 'UNSAFE_INTEGER' | 'UNEXPECTED_CHARACTER';
|
|
1
|
+
export type NotationParseErrorCode = 'EMPTY_NOTATION' | 'EXPECTED_OPERATOR' | 'EXPECTED_TERM' | 'INVALID_COUNT' | 'INVALID_DICE_OPERATION' | 'INVALID_SCORE_RULE' | 'INVALID_SELECTION' | 'INVALID_SIDES' | 'NEGATIVE_DICE' | 'OVERLAPPING_SCORE_RULE' | 'UNSAFE_INTEGER' | 'UNEXPECTED_CHARACTER';
|
|
2
2
|
export declare class NotationParseError extends Error {
|
|
3
3
|
readonly code: NotationParseErrorCode;
|
|
4
4
|
readonly index: number;
|
package/dist/notation/parser.js
CHANGED
|
@@ -62,6 +62,7 @@ class NotationParser {
|
|
|
62
62
|
}
|
|
63
63
|
if (this.#current() === '%') {
|
|
64
64
|
this.#index += 1;
|
|
65
|
+
this.#assertNoPairedDiceOperations();
|
|
65
66
|
return {
|
|
66
67
|
kind: 'paired-dice',
|
|
67
68
|
count,
|
|
@@ -78,17 +79,18 @@ class NotationParser {
|
|
|
78
79
|
this.#failAt('INVALID_SIDES', 'A die must have at least two sides', sidesStart);
|
|
79
80
|
}
|
|
80
81
|
if (sides === 100 || sides === 66) {
|
|
82
|
+
this.#assertNoPairedDiceOperations();
|
|
81
83
|
return {
|
|
82
84
|
kind: 'paired-dice',
|
|
83
85
|
count,
|
|
84
86
|
type: `d${sides}`,
|
|
85
87
|
};
|
|
86
88
|
}
|
|
87
|
-
return {
|
|
89
|
+
return this.#readDiceOperations({
|
|
88
90
|
kind: 'dice',
|
|
89
91
|
count,
|
|
90
92
|
sides,
|
|
91
|
-
};
|
|
93
|
+
});
|
|
92
94
|
}
|
|
93
95
|
if (integerStart === integerEnd) {
|
|
94
96
|
const character = this.#current();
|
|
@@ -103,6 +105,136 @@ class NotationParser {
|
|
|
103
105
|
value,
|
|
104
106
|
};
|
|
105
107
|
}
|
|
108
|
+
#readDiceOperations(expression) {
|
|
109
|
+
this.#skipWhitespace();
|
|
110
|
+
const selection = this.#startsSelection() ? this.#readSelection(expression.count) : undefined;
|
|
111
|
+
this.#skipWhitespace();
|
|
112
|
+
const score = this.#current()?.toLowerCase() === 's' ? this.#readScoreRules(expression.sides) : undefined;
|
|
113
|
+
this.#skipWhitespace();
|
|
114
|
+
if (this.#startsSelection() || this.#current()?.toLowerCase() === 's') {
|
|
115
|
+
this.#fail('INVALID_DICE_OPERATION', 'Dice operations must contain at most one keep/drop operator followed by one score map');
|
|
116
|
+
}
|
|
117
|
+
return {
|
|
118
|
+
...expression,
|
|
119
|
+
...(selection === undefined ? {} : { selection }),
|
|
120
|
+
...(score === undefined ? {} : { score }),
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
#readSelection(diceCount) {
|
|
124
|
+
const start = this.#index;
|
|
125
|
+
const first = this.#current()?.toLowerCase();
|
|
126
|
+
const second = this.#peek(1)?.toLowerCase();
|
|
127
|
+
if ((first !== 'k' && first !== 'd') || (second !== 'h' && second !== 'l')) {
|
|
128
|
+
return this.#failAt('INVALID_SELECTION', 'Expected kh, kl, dh, or dl', start);
|
|
129
|
+
}
|
|
130
|
+
const operator = `${first}${second}`;
|
|
131
|
+
this.#index += 2;
|
|
132
|
+
this.#skipWhitespace();
|
|
133
|
+
const countStart = this.#index;
|
|
134
|
+
this.#consumeDigits();
|
|
135
|
+
if (countStart === this.#index) {
|
|
136
|
+
this.#failAt('INVALID_SELECTION', `Selection ${operator} requires a count`, start);
|
|
137
|
+
}
|
|
138
|
+
const count = this.#readInteger(countStart, this.#index, 'selection count');
|
|
139
|
+
if (count < 1) {
|
|
140
|
+
this.#failAt('INVALID_SELECTION', 'Selection count must be at least one', countStart);
|
|
141
|
+
}
|
|
142
|
+
const keepsDice = operator === 'kh' || operator === 'kl';
|
|
143
|
+
if ((keepsDice && count > diceCount) || (!keepsDice && count >= diceCount)) {
|
|
144
|
+
this.#failAt('INVALID_SELECTION', keepsDice
|
|
145
|
+
? 'Cannot keep more dice than the term rolls'
|
|
146
|
+
: 'Drop count must leave at least one die', countStart);
|
|
147
|
+
}
|
|
148
|
+
return { operator, count };
|
|
149
|
+
}
|
|
150
|
+
#readScoreRules(sides) {
|
|
151
|
+
const operationStart = this.#index;
|
|
152
|
+
this.#index += 1;
|
|
153
|
+
this.#skipWhitespace();
|
|
154
|
+
if (this.#current() !== '{') {
|
|
155
|
+
this.#failAt('INVALID_SCORE_RULE', 'Score notation requires "{" after "s"', operationStart);
|
|
156
|
+
}
|
|
157
|
+
this.#index += 1;
|
|
158
|
+
this.#skipWhitespace();
|
|
159
|
+
if (this.#current() === '}') {
|
|
160
|
+
this.#fail('INVALID_SCORE_RULE', 'Score map must contain at least one rule');
|
|
161
|
+
}
|
|
162
|
+
const rules = [];
|
|
163
|
+
while (true) {
|
|
164
|
+
const ruleStart = this.#index;
|
|
165
|
+
const minimum = this.#readScoreFace();
|
|
166
|
+
this.#skipWhitespace();
|
|
167
|
+
let maximum = minimum;
|
|
168
|
+
if (this.#current() === '.') {
|
|
169
|
+
if (this.#peek(1) !== '.') {
|
|
170
|
+
this.#fail('INVALID_SCORE_RULE', 'Score ranges must use ".."');
|
|
171
|
+
}
|
|
172
|
+
this.#index += 2;
|
|
173
|
+
this.#skipWhitespace();
|
|
174
|
+
maximum = this.#readScoreFace();
|
|
175
|
+
}
|
|
176
|
+
if (minimum > maximum) {
|
|
177
|
+
this.#failAt('INVALID_SCORE_RULE', 'Score range minimum must not exceed its maximum', ruleStart);
|
|
178
|
+
}
|
|
179
|
+
if (minimum < 1 || maximum > sides) {
|
|
180
|
+
this.#failAt('INVALID_SCORE_RULE', `Score rule faces must be within [1, ${sides}]`, ruleStart);
|
|
181
|
+
}
|
|
182
|
+
this.#skipWhitespace();
|
|
183
|
+
if (this.#current() !== '=') {
|
|
184
|
+
this.#fail('INVALID_SCORE_RULE', 'Score rule requires "=" before its score');
|
|
185
|
+
}
|
|
186
|
+
this.#index += 1;
|
|
187
|
+
this.#skipWhitespace();
|
|
188
|
+
const score = this.#readSignedScore();
|
|
189
|
+
if (rules.some((rule) => minimum <= rule.maximum && maximum >= rule.minimum)) {
|
|
190
|
+
this.#failAt('OVERLAPPING_SCORE_RULE', 'Score rule ranges must not overlap', ruleStart);
|
|
191
|
+
}
|
|
192
|
+
rules.push({ minimum, maximum, score });
|
|
193
|
+
this.#skipWhitespace();
|
|
194
|
+
if (this.#current() === '}') {
|
|
195
|
+
this.#index += 1;
|
|
196
|
+
return Object.freeze(rules.map((rule) => Object.freeze(rule)));
|
|
197
|
+
}
|
|
198
|
+
if (this.#current() !== ',') {
|
|
199
|
+
this.#fail('INVALID_SCORE_RULE', 'Score rules must be separated by commas');
|
|
200
|
+
}
|
|
201
|
+
this.#index += 1;
|
|
202
|
+
this.#skipWhitespace();
|
|
203
|
+
if (this.#current() === '}') {
|
|
204
|
+
this.#fail('INVALID_SCORE_RULE', 'Score map must not have a trailing comma');
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
#readScoreFace() {
|
|
209
|
+
const start = this.#index;
|
|
210
|
+
this.#consumeDigits();
|
|
211
|
+
if (start === this.#index) {
|
|
212
|
+
this.#fail('INVALID_SCORE_RULE', 'Score rule requires a face value');
|
|
213
|
+
}
|
|
214
|
+
return this.#readInteger(start, this.#index, 'score rule face');
|
|
215
|
+
}
|
|
216
|
+
#readSignedScore() {
|
|
217
|
+
const sign = this.#current() === '-' ? -1 : 1;
|
|
218
|
+
if (this.#current() === '-' || this.#current() === '+')
|
|
219
|
+
this.#index += 1;
|
|
220
|
+
const start = this.#index;
|
|
221
|
+
this.#consumeDigits();
|
|
222
|
+
if (start === this.#index) {
|
|
223
|
+
this.#fail('INVALID_SCORE_RULE', 'Score rule requires an integer score');
|
|
224
|
+
}
|
|
225
|
+
return this.#readInteger(start, this.#index, 'score') * sign;
|
|
226
|
+
}
|
|
227
|
+
#assertNoPairedDiceOperations() {
|
|
228
|
+
this.#skipWhitespace();
|
|
229
|
+
if (this.#startsSelection() || this.#current()?.toLowerCase() === 's') {
|
|
230
|
+
this.#fail('INVALID_DICE_OPERATION', 'Keep/drop and score operations are not supported for paired dice');
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
#startsSelection() {
|
|
234
|
+
const first = this.#current()?.toLowerCase();
|
|
235
|
+
const second = this.#peek(1)?.toLowerCase();
|
|
236
|
+
return (first === 'k' || first === 'd') && (second === 'h' || second === 'l');
|
|
237
|
+
}
|
|
106
238
|
#readInteger(start, end, description) {
|
|
107
239
|
const value = Number(this.#input.slice(start, end));
|
|
108
240
|
if (!Number.isSafeInteger(value)) {
|
|
@@ -123,6 +255,9 @@ class NotationParser {
|
|
|
123
255
|
#current() {
|
|
124
256
|
return this.#input[this.#index];
|
|
125
257
|
}
|
|
258
|
+
#peek(offset) {
|
|
259
|
+
return this.#input[this.#index + offset];
|
|
260
|
+
}
|
|
126
261
|
#isAtEnd() {
|
|
127
262
|
return this.#index >= this.#input.length;
|
|
128
263
|
}
|
package/dist/roll/aggregation.js
CHANGED
|
@@ -8,10 +8,7 @@ export function createRollResult(options) {
|
|
|
8
8
|
if (options.completedAt < options.startedAt) {
|
|
9
9
|
throw new RangeError('Roll completion time must not precede its start time');
|
|
10
10
|
}
|
|
11
|
-
const dice = Object.freeze(options.dice.map(
|
|
12
|
-
...die,
|
|
13
|
-
...(die.component === undefined ? {} : { component: Object.freeze({ ...die.component }) }),
|
|
14
|
-
})));
|
|
11
|
+
const dice = Object.freeze(options.dice.map(freezeDieResult));
|
|
15
12
|
const diceTotal = getDiceTotal(dice);
|
|
16
13
|
return Object.freeze({
|
|
17
14
|
id: options.id,
|
|
@@ -23,15 +20,24 @@ export function createRollResult(options) {
|
|
|
23
20
|
completedAt: options.completedAt,
|
|
24
21
|
});
|
|
25
22
|
}
|
|
23
|
+
function freezeDieResult(die) {
|
|
24
|
+
if (die.component === undefined)
|
|
25
|
+
return Object.freeze({ ...die });
|
|
26
|
+
return Object.freeze({ ...die, component: Object.freeze({ ...die.component }) });
|
|
27
|
+
}
|
|
26
28
|
function getDiceTotal(dice) {
|
|
27
29
|
let total = 0;
|
|
28
30
|
const groups = new Map();
|
|
29
31
|
for (const die of dice) {
|
|
30
32
|
const component = die.component;
|
|
31
33
|
if (component === undefined) {
|
|
32
|
-
|
|
34
|
+
if (die.included !== false)
|
|
35
|
+
total += die.score ?? die.value;
|
|
33
36
|
continue;
|
|
34
37
|
}
|
|
38
|
+
if (die.included !== undefined || die.score !== undefined) {
|
|
39
|
+
throw new RangeError('Keep/drop and score metadata is not supported on paired dice components');
|
|
40
|
+
}
|
|
35
41
|
const existing = groups.get(component.groupId);
|
|
36
42
|
const group = existing ?? { type: component.groupType };
|
|
37
43
|
if (group.type !== component.groupType) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dice-o-rolla/dice-core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "Framework-neutral dice notation, roll results, events, and random sources.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"dice",
|
|
@@ -8,7 +8,16 @@
|
|
|
8
8
|
"rpg",
|
|
9
9
|
"typescript"
|
|
10
10
|
],
|
|
11
|
+
"homepage": "https://github.com/creepiest-space/dice-o-rolla#readme",
|
|
12
|
+
"bugs": {
|
|
13
|
+
"url": "https://github.com/creepiest-space/dice-o-rolla/issues"
|
|
14
|
+
},
|
|
11
15
|
"license": "Apache-2.0",
|
|
16
|
+
"repository": {
|
|
17
|
+
"type": "git",
|
|
18
|
+
"url": "https://github.com/creepiest-space/dice-o-rolla.git",
|
|
19
|
+
"directory": "packages/dice-core"
|
|
20
|
+
},
|
|
12
21
|
"files": [
|
|
13
22
|
"dist",
|
|
14
23
|
"README.md",
|