@dice-o-rolla/dice-engine 0.1.1 → 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 CHANGED
@@ -19,7 +19,9 @@ engine.destroy();
19
19
  ```
20
20
 
21
21
  The browser entry point initializes Rapier WASM, creates the Three.js renderer, uses Web Crypto for
22
- throw generation, and releases partially initialized resources if composition fails.
22
+ throw generation, and releases partially initialized resources if composition fails. Concurrent
23
+ `initialize()` calls coalesce, while every roll promise terminates by settlement, cancellation,
24
+ timeout, or failure.
23
25
 
24
26
  ## Custom adapters
25
27
 
@@ -32,6 +34,17 @@ await engine.initialize();
32
34
 
33
35
  The main entry point depends on domain contracts rather than concrete Rapier or Three.js types.
34
36
 
37
+ ## Visual presets and optional effects
38
+
39
+ `registerVisualPreset()` associates a logical die with a validated physical geometry, scale, face
40
+ labels, and optional value map. `skinId` and `soundPackId` are opaque application-owned references;
41
+ the engine does not load assets. Skins and sound definitions belong in the optional
42
+ `@dice-o-rolla/dice-assets` package, which is not a dependency of the engine.
43
+
44
+ The engine emits `die:spawn` and `die:remove` lifecycle events. Collision events are opt-in through
45
+ `DiceEngineOptions.collisionEvents`, bounded by `maxEventsPerFrame`, and suitable for an external
46
+ sound or effects adapter.
47
+
35
48
  ## Supported notation
36
49
 
37
50
  The initial grammar supports standard polyhedral expressions and integer modifiers, including:
@@ -39,11 +52,19 @@ The initial grammar supports standard polyhedral expressions and integer modifie
39
52
  ```text
40
53
  d20
41
54
  4d6 + 2
55
+ 4d6kh3
56
+ 2d20kl1
57
+ 5d20s{1=-2,17..19=1,20=2}
42
58
  d%
43
59
  d100
44
60
  d66
45
61
  ```
46
62
 
63
+ Keep/drop rolls retain every physical die in `result.dice` and expose the selection through
64
+ `included`. Score maps expose each contribution through `score`; unlisted faces contribute zero.
65
+ Selection is applied before scoring, followed by integer modifiers. Paired `d%`, `d100`, and `d66`
66
+ terms currently reject keep/drop and score operations.
67
+
47
68
  Default resource limits reject oversized notation, more than 50 logical or physical dice, and more
48
69
  than eight pending rolls. Consumers may lower or explicitly raise these limits through
49
70
  `DiceEngineOptions.limits` after testing their target devices.
@@ -52,7 +73,11 @@ than eight pending rolls. Consumers may lower or explicitly raise these limits t
52
73
 
53
74
  Client-side results are not authoritative for rankings, prizes, or wagering. Call `destroy()` when
54
75
  the engine is no longer needed to release frame scheduling, observers, physics resources, WebGL
55
- resources, and the renderer canvas.
76
+ resources, and the renderer canvas. `clear()` keeps the engine reusable; `destroy()` is idempotent
77
+ and final. See the canonical
78
+ [lifecycle and runtime contract](https://github.com/creepiest-space/dice-o-rolla/blob/main/docs/engine.md).
79
+ Consumers upgrading from `0.1` should also review the
80
+ [0.2 migration notes](https://github.com/creepiest-space/dice-o-rolla/blob/main/docs/migration-0.2.md).
56
81
 
57
82
  ## License
58
83
 
@@ -1,7 +1,8 @@
1
1
  import { TypedEventEmitter } from '@dice-o-rolla/dice-core';
2
2
  import type { RollResult } from '@dice-o-rolla/dice-core';
3
- import type { RendererViewport } from '@dice-o-rolla/dice-renderer';
4
- import type { DiceEngineEvents, DiceEngineFacade, DiceEngineOptions, DiceTheme, RollOptions } from './types.js';
3
+ import { type RendererViewport, type VisualPresetDescriptor } from '@dice-o-rolla/dice-renderer';
4
+ import type { DiceEngineEvents, DiceEngineFacade, DiceEngineOptions, DiceTheme, RegisterEngineVisualPresetOptions, RollOptions } from './types.js';
5
+ import { type PhysicalDieType } from './visual-presets.js';
5
6
  export declare class DiceEngine extends TypedEventEmitter<DiceEngineEvents> implements DiceEngineFacade {
6
7
  #private;
7
8
  constructor(options: DiceEngineOptions);
@@ -12,5 +13,9 @@ export declare class DiceEngine extends TypedEventEmitter<DiceEngineEvents> impl
12
13
  resize(viewport: RendererViewport): void;
13
14
  setTheme(theme: Partial<DiceTheme>): DiceTheme;
14
15
  get theme(): DiceTheme;
16
+ registerVisualPreset(source: VisualPresetDescriptor, options?: RegisterEngineVisualPresetOptions): VisualPresetDescriptor;
17
+ unregisterVisualPreset(id: string): boolean;
18
+ setVisualPreset(dieType: PhysicalDieType, presetId: string): void;
19
+ getVisualPreset(dieType: PhysicalDieType): VisualPresetDescriptor;
15
20
  destroy(): void;
16
21
  }
@@ -1,8 +1,10 @@
1
1
  import { createRollResult, getNotationModifier, isDieType, mathRandomSource, parseNotation, TypedEventEmitter, } from '@dice-o-rolla/dice-core';
2
2
  import { getDieGeometry, hasDieGeometry, resolveFace } from '@dice-o-rolla/dice-geometry';
3
3
  import { SettlingDetector, ThrowGenerator } from '@dice-o-rolla/dice-physics';
4
+ import { createVisualPresetDescriptor, VisualPresetRegistry, } from '@dice-o-rolla/dice-renderer';
4
5
  import { DEFAULT_THEME, defaultFrameScheduler } from './defaults.js';
5
6
  import { DiceEngineDestroyedError, RollCancelledError, RollLimitExceededError, RollTimeoutError, } from './errors.js';
7
+ import { getStandardVisualPresetId, isPhysicalDieType, PHYSICAL_DIE_TYPES, STANDARD_VISUAL_PRESETS, } from './visual-presets.js';
6
8
  const DEFAULT_SETTLING = {
7
9
  linearVelocityThreshold: 0.08,
8
10
  angularVelocityThreshold: 0.08,
@@ -45,6 +47,7 @@ const DEFAULT_LIMITS = Object.freeze({
45
47
  maxPhysicalDice: 50,
46
48
  maxQueuedRolls: 8,
47
49
  });
50
+ const DEFAULT_COLLISION_EVENTS = Object.freeze({ enabled: false, maxEventsPerFrame: 32 });
48
51
  const D100_TENS_LABELS = Object.freeze({
49
52
  1: 10,
50
53
  2: 20,
@@ -71,12 +74,21 @@ function snapshotSession(session) {
71
74
  function toRenderState(die) {
72
75
  return {
73
76
  id: die.id,
77
+ presetId: die.preset.id,
74
78
  geometryId: die.geometryType,
79
+ scale: die.preset.scale ?? 1,
75
80
  ...(die.faceLabels === undefined ? {} : { faceLabels: die.faceLabels }),
76
81
  previous: die.previous,
77
82
  current: die.current,
78
83
  };
79
84
  }
85
+ function mergeFaceLabels(preset, roll) {
86
+ if (preset === undefined)
87
+ return roll;
88
+ if (roll === undefined)
89
+ return preset;
90
+ return Object.freeze({ ...preset, ...roll });
91
+ }
80
92
  function assertPositive(value, name) {
81
93
  if (!Number.isFinite(value) || value <= 0) {
82
94
  throw new RangeError(`${name} must be a positive finite number`);
@@ -99,13 +111,18 @@ export class DiceEngine extends TypedEventEmitter {
99
111
  #tray;
100
112
  #diceMaterial;
101
113
  #limits;
114
+ #collisionEvents;
115
+ #visualPresets = new VisualPresetRegistry(STANDARD_VISUAL_PRESETS);
116
+ #visualPresetIds = new Map();
102
117
  #queue = [];
103
118
  #displayedDieIds = new Set();
119
+ #dieEvents = new Map();
104
120
  #active;
105
121
  #frameToken;
106
122
  #lastFrameMs = 0;
107
123
  #accumulatorSeconds = 0;
108
124
  #nextSessionId = 1;
125
+ #initialization;
109
126
  #initialized = false;
110
127
  #destroyed = false;
111
128
  #theme;
@@ -124,6 +141,11 @@ export class DiceEngine extends TypedEventEmitter {
124
141
  this.#tray = options.tray ?? DEFAULT_TRAY;
125
142
  this.#diceMaterial = options.diceMaterial ?? DEFAULT_DICE_MATERIAL;
126
143
  this.#limits = Object.freeze({ ...DEFAULT_LIMITS, ...options.limits });
144
+ this.#collisionEvents = Object.freeze({
145
+ ...DEFAULT_COLLISION_EVENTS,
146
+ ...options.collisionEvents,
147
+ });
148
+ assertPositiveSafeInteger(this.#collisionEvents.maxEventsPerFrame, 'collisionEvents.maxEventsPerFrame');
127
149
  for (const [name, value] of [
128
150
  ['maxNotationLength', this.#limits.maxNotationLength],
129
151
  ['maxLogicalDice', this.#limits.maxLogicalDice],
@@ -132,16 +154,33 @@ export class DiceEngine extends TypedEventEmitter {
132
154
  ]) {
133
155
  assertPositiveSafeInteger(value, `limits.${name}`);
134
156
  }
157
+ for (const type of PHYSICAL_DIE_TYPES) {
158
+ this.#visualPresetIds.set(type, getStandardVisualPresetId(type));
159
+ }
160
+ for (const preset of options.visualPresets ?? [])
161
+ this.registerVisualPreset(preset);
162
+ for (const type of PHYSICAL_DIE_TYPES) {
163
+ const presetId = options.visualPresetIds?.[type];
164
+ if (presetId !== undefined)
165
+ this.setVisualPreset(type, presetId);
166
+ }
135
167
  this.#theme = this.#mergeTheme(options.theme ?? {});
136
168
  }
137
169
  async initialize() {
138
170
  this.#assertAlive();
139
171
  if (this.#initialized)
140
172
  return;
141
- this.#physics.configureTray(this.#tray);
142
- await this.#renderer.initialize();
143
- this.#renderer.setTheme(this.#theme);
144
- this.#initialized = true;
173
+ if (this.#initialization !== undefined)
174
+ return this.#initialization;
175
+ const initialization = this.#performInitialization();
176
+ this.#initialization = initialization;
177
+ try {
178
+ await initialization;
179
+ }
180
+ finally {
181
+ if (this.#initialization === initialization)
182
+ this.#initialization = undefined;
183
+ }
145
184
  }
146
185
  roll(notation, options = {}) {
147
186
  try {
@@ -196,10 +235,8 @@ export class DiceEngine extends TypedEventEmitter {
196
235
  }
197
236
  for (const task of this.#queue.splice(0))
198
237
  this.#rejectCancelled(task);
199
- this.#physics.clear();
200
- this.#renderer.clear();
201
- this.#displayedDieIds.clear();
202
238
  this.#accumulatorSeconds = 0;
239
+ this.#clearRenderedDice('cleared');
203
240
  }
204
241
  resize(viewport) {
205
242
  this.#assertReady();
@@ -216,15 +253,102 @@ export class DiceEngine extends TypedEventEmitter {
216
253
  get theme() {
217
254
  return this.#theme;
218
255
  }
256
+ registerVisualPreset(source, options = {}) {
257
+ this.#assertAlive();
258
+ const preset = createVisualPresetDescriptor(source);
259
+ this.#assertValidVisualPreset(preset);
260
+ if ([...this.#dieEvents.values()].some((event) => event.presetId === preset.id)) {
261
+ throw new Error(`Visual preset "${preset.id}" is currently in use`);
262
+ }
263
+ const existing = this.#visualPresets.get(preset.id);
264
+ if (existing !== undefined && options.replace !== true) {
265
+ throw new Error(`Visual preset "${preset.id}" is already registered`);
266
+ }
267
+ if (this.#initialized)
268
+ this.#renderer.registerPreset(preset);
269
+ this.#visualPresets.register(preset, options.replace === undefined ? {} : { replace: options.replace });
270
+ if (options.makeDefault === true) {
271
+ if (!isPhysicalDieType(preset.dieType)) {
272
+ throw new Error(`Visual preset has an invalid physical die type: ${preset.dieType}`);
273
+ }
274
+ this.#visualPresetIds.set(preset.dieType, preset.id);
275
+ }
276
+ return preset;
277
+ }
278
+ unregisterVisualPreset(id) {
279
+ this.#assertAlive();
280
+ if (STANDARD_VISUAL_PRESETS.some((preset) => preset.id === id)) {
281
+ throw new Error(`Built-in visual preset "${id}" cannot be unregistered`);
282
+ }
283
+ const preset = this.#visualPresets.unregister(id);
284
+ if (preset === undefined)
285
+ return false;
286
+ if (!isPhysicalDieType(preset.dieType)) {
287
+ throw new Error(`Visual preset has an invalid physical die type: ${preset.dieType}`);
288
+ }
289
+ const type = preset.dieType;
290
+ if (this.#visualPresetIds.get(type) === id) {
291
+ this.#visualPresetIds.set(type, getStandardVisualPresetId(type));
292
+ }
293
+ if (this.#initialized)
294
+ this.#renderer.unregisterPreset(id);
295
+ return true;
296
+ }
297
+ setVisualPreset(dieType, presetId) {
298
+ this.#assertAlive();
299
+ if (!PHYSICAL_DIE_TYPES.includes(dieType)) {
300
+ throw new RangeError(`${dieType} is not a physical die type`);
301
+ }
302
+ const preset = this.#visualPresets.get(presetId);
303
+ if (preset === undefined)
304
+ throw new RangeError(`Unknown visual preset: ${presetId}`);
305
+ if (preset.dieType !== dieType) {
306
+ throw new RangeError(`Visual preset "${presetId}" is for ${preset.dieType}, not ${dieType}`);
307
+ }
308
+ this.#visualPresetIds.set(dieType, presetId);
309
+ }
310
+ getVisualPreset(dieType) {
311
+ this.#assertAlive();
312
+ const id = this.#visualPresetIds.get(dieType);
313
+ const preset = id === undefined ? undefined : this.#visualPresets.get(id);
314
+ if (preset === undefined)
315
+ throw new Error(`No visual preset is selected for ${dieType}`);
316
+ return preset;
317
+ }
219
318
  destroy() {
220
319
  if (this.#destroyed)
221
320
  return;
222
- this.clear();
223
- this.#renderer.destroy();
224
- this.#physics.destroy();
321
+ this.#destroyed = true;
322
+ this.#frameToken?.cancel();
323
+ this.#frameToken = undefined;
324
+ if (this.#active !== undefined) {
325
+ const task = this.#active.task;
326
+ this.#active = undefined;
327
+ this.#rejectCancelled(task);
328
+ }
329
+ for (const task of this.#queue.splice(0))
330
+ this.#rejectCancelled(task);
331
+ this.#forgetRenderedDice('destroyed');
332
+ const cleanupErrors = this.#runCleanup([
333
+ () => this.#renderer.destroy(),
334
+ () => this.#physics.destroy(),
335
+ ]);
225
336
  super.clear();
337
+ this.#initialization = undefined;
226
338
  this.#initialized = false;
227
- this.#destroyed = true;
339
+ if (cleanupErrors.length > 0) {
340
+ throw new AggregateError(cleanupErrors, 'DiceEngine teardown failed');
341
+ }
342
+ }
343
+ async #performInitialization() {
344
+ this.#physics.configureTray(this.#tray);
345
+ this.#physics.setCollisionEventsEnabled(this.#collisionEvents.enabled);
346
+ await this.#renderer.initialize();
347
+ this.#assertAlive();
348
+ for (const preset of this.#visualPresets.list())
349
+ this.#renderer.registerPreset(preset);
350
+ this.#renderer.setTheme(this.#theme);
351
+ this.#initialized = true;
228
352
  }
229
353
  #createTask(notation, parsed, signal) {
230
354
  const session = {
@@ -285,6 +409,7 @@ export class DiceEngine extends TypedEventEmitter {
285
409
  try {
286
410
  for (const spec of specs) {
287
411
  const geometry = getDieGeometry(spec.geometryType);
412
+ const faceLabels = mergeFaceLabels(spec.preset.faceLabels, spec.faceLabels);
288
413
  const id = `${task.session.id}:die-${index++}`;
289
414
  const generated = this.#throwGenerator.generate();
290
415
  const position = this.#placeDie(generated.position, index - 1, totalDice);
@@ -295,7 +420,7 @@ export class DiceEngine extends TypedEventEmitter {
295
420
  kind: 'convex-hull',
296
421
  vertices: geometry.vertices.map(([x, y, z]) => ({ x, y, z })),
297
422
  },
298
- scale: 1,
423
+ scale: spec.preset.scale ?? 1,
299
424
  mass: 1,
300
425
  material: this.#diceMaterial,
301
426
  position,
@@ -306,8 +431,12 @@ export class DiceEngine extends TypedEventEmitter {
306
431
  id,
307
432
  type: spec.type,
308
433
  geometryType: spec.geometryType,
434
+ preset: spec.preset,
435
+ expressionIndex: spec.expressionIndex,
436
+ ...(spec.selection === undefined ? {} : { selection: spec.selection }),
437
+ ...(spec.scoreRules === undefined ? {} : { scoreRules: spec.scoreRules }),
309
438
  ...(spec.component === undefined ? {} : { component: spec.component }),
310
- ...(spec.faceLabels === undefined ? {} : { faceLabels: spec.faceLabels }),
439
+ ...(faceLabels === undefined ? {} : { faceLabels }),
311
440
  body,
312
441
  detector: new SettlingDetector(this.#settling),
313
442
  previous: state,
@@ -316,57 +445,90 @@ export class DiceEngine extends TypedEventEmitter {
316
445
  dice.push(die);
317
446
  this.#renderer.createDie(toRenderState(die));
318
447
  body.applyImpulse(generated.impulse, generated.torqueImpulse);
448
+ const event = Object.freeze({
449
+ sessionId: task.session.id,
450
+ dieId: id,
451
+ dieType: spec.type,
452
+ presetId: spec.preset.id,
453
+ ...(spec.preset.skinId === undefined ? {} : { skinId: spec.preset.skinId }),
454
+ ...(spec.preset.soundPackId === undefined
455
+ ? {}
456
+ : { soundPackId: spec.preset.soundPackId }),
457
+ });
458
+ this.#dieEvents.set(id, event);
459
+ this.emit('die:spawn', event);
319
460
  }
320
461
  return dice;
321
462
  }
322
463
  catch (error) {
323
- for (const die of dice) {
324
- this.#physics.removeDie(die.id);
325
- this.#renderer.removeDie(die.id);
326
- }
327
- throw error;
464
+ const cleanupErrors = this.#removeDiceSafely(dice, 'failed');
465
+ if (cleanupErrors.length === 0)
466
+ throw error;
467
+ throw new AggregateError([error, ...cleanupErrors], 'Failed to create dice', {
468
+ cause: error,
469
+ });
328
470
  }
329
471
  }
330
472
  #createPhysicalSpecs(task) {
331
473
  const specs = [];
332
474
  let groupIndex = 0;
333
- for (const expression of task.parsed.expressions) {
475
+ for (const [expressionIndex, expression] of task.parsed.expressions.entries()) {
334
476
  if (expression.kind === 'modifier')
335
477
  continue;
336
478
  if (expression.kind === 'dice') {
337
479
  const type = `d${expression.sides}`;
338
480
  if (!isDieType(type))
339
481
  throw new RangeError(`${type} is not a standard die type`);
482
+ if (!isPhysicalDieType(type))
483
+ throw new RangeError(`${type} is not a physical die type`);
484
+ const preset = this.getVisualPreset(type);
340
485
  for (let count = 0; count < expression.count; count += 1) {
341
- specs.push({ type, geometryType: type });
486
+ specs.push({
487
+ type,
488
+ geometryType: this.#getPresetGeometryType(preset),
489
+ preset,
490
+ expressionIndex,
491
+ ...(expression.selection === undefined ? {} : { selection: expression.selection }),
492
+ ...(expression.score === undefined ? {} : { scoreRules: expression.score }),
493
+ });
342
494
  }
343
495
  continue;
344
496
  }
345
497
  for (let count = 0; count < expression.count; count += 1) {
346
498
  const groupId = `${task.session.id}:group-${groupIndex++}`;
347
499
  if (expression.type === 'd100') {
500
+ const preset = this.getVisualPreset('d10');
348
501
  specs.push({
349
502
  type: 'd100',
350
- geometryType: 'd10',
503
+ geometryType: this.#getPresetGeometryType(preset),
504
+ preset,
505
+ expressionIndex,
351
506
  component: { groupId, groupType: 'd100', role: 'tens' },
352
507
  faceLabels: D100_TENS_LABELS,
353
508
  });
354
509
  specs.push({
355
510
  type: 'd10',
356
- geometryType: 'd10',
511
+ geometryType: this.#getPresetGeometryType(preset),
512
+ preset,
513
+ expressionIndex,
357
514
  component: { groupId, groupType: 'd100', role: 'units' },
358
515
  });
359
516
  continue;
360
517
  }
518
+ const preset = this.getVisualPreset('d6');
361
519
  specs.push({
362
520
  type: 'd6',
363
- geometryType: 'd6',
521
+ geometryType: this.#getPresetGeometryType(preset),
522
+ preset,
523
+ expressionIndex,
364
524
  component: { groupId, groupType: 'd66', role: 'tens' },
365
525
  faceLabels: D66_TENS_LABELS,
366
526
  });
367
527
  specs.push({
368
528
  type: 'd6',
369
- geometryType: 'd6',
529
+ geometryType: this.#getPresetGeometryType(preset),
530
+ preset,
531
+ expressionIndex,
370
532
  component: { groupId, groupType: 'd66', role: 'units' },
371
533
  });
372
534
  }
@@ -404,10 +566,39 @@ export class DiceEngine extends TypedEventEmitter {
404
566
  const frameDelta = Math.max(0, Math.min((timestampMs - this.#lastFrameMs) / 1_000, this.#maxFrameDeltaSeconds));
405
567
  this.#lastFrameMs = timestampMs;
406
568
  this.#accumulatorSeconds += frameDelta;
569
+ let emittedCollisionEvents = 0;
407
570
  while (this.#accumulatorSeconds >= this.#fixedStepSeconds) {
408
571
  for (const die of active.dice)
409
572
  die.previous = die.current;
410
573
  this.#physics.step(this.#fixedStepSeconds);
574
+ if (this.#collisionEvents.enabled) {
575
+ for (const collision of this.#physics.drainCollisionEvents()) {
576
+ if (emittedCollisionEvents >= this.#collisionEvents.maxEventsPerFrame)
577
+ continue;
578
+ const event = this.#dieEvents.get(collision.dieId);
579
+ if (event === undefined)
580
+ continue;
581
+ this.emit('die:collision', Object.freeze({
582
+ ...event,
583
+ ...(collision.otherDieId === undefined ? {} : { otherDieId: collision.otherDieId }),
584
+ started: collision.started,
585
+ }));
586
+ emittedCollisionEvents += 1;
587
+ }
588
+ for (const impact of this.#physics.drainImpactEvents()) {
589
+ if (emittedCollisionEvents >= this.#collisionEvents.maxEventsPerFrame)
590
+ continue;
591
+ const event = this.#dieEvents.get(impact.dieId);
592
+ if (event === undefined)
593
+ continue;
594
+ this.emit('die:impact', Object.freeze({
595
+ ...event,
596
+ ...(impact.otherDieId === undefined ? {} : { otherDieId: impact.otherDieId }),
597
+ force: impact.force,
598
+ }));
599
+ emittedCollisionEvents += 1;
600
+ }
601
+ }
411
602
  this.#accumulatorSeconds -= this.#fixedStepSeconds;
412
603
  for (const die of active.dice) {
413
604
  die.current = die.body.getState();
@@ -439,18 +630,19 @@ export class DiceEngine extends TypedEventEmitter {
439
630
  }
440
631
  }
441
632
  #createDieResult(die, faceValue) {
633
+ const mappedValue = die.preset.valueMap?.[faceValue] ?? faceValue;
442
634
  if (die.component === undefined) {
443
635
  if (die.type === 'd100')
444
636
  throw new Error('A d100 result requires percentile component data');
445
- return Object.freeze({ id: die.id, type: die.type, value: faceValue });
637
+ return Object.freeze({ id: die.id, type: die.type, value: mappedValue });
446
638
  }
447
639
  const { groupId, groupType, role } = die.component;
448
- const digit = groupType === 'd100' ? faceValue % 10 : faceValue;
640
+ const digit = groupType === 'd100' ? mappedValue % 10 : mappedValue;
449
641
  return Object.freeze({
450
642
  id: die.id,
451
643
  type: die.type,
452
644
  value: role === 'tens' ? digit * 10 : digit,
453
- component: Object.freeze({ groupId, groupType, role, faceValue }),
645
+ component: Object.freeze({ groupId, groupType, role, faceValue: mappedValue }),
454
646
  });
455
647
  }
456
648
  #completeActive(active) {
@@ -459,11 +651,7 @@ export class DiceEngine extends TypedEventEmitter {
459
651
  session.completedAt = this.#now();
460
652
  if (session.startedAt === undefined)
461
653
  throw new Error('Active roll has no start time');
462
- const diceResults = active.dice.map((die) => {
463
- if (die.result === undefined)
464
- throw new Error(`Die ${die.id} has no settled result`);
465
- return die.result;
466
- });
654
+ const diceResults = this.#applyRollRules(active.dice);
467
655
  const result = createRollResult({
468
656
  id: session.id,
469
657
  notation: session.notation,
@@ -480,14 +668,70 @@ export class DiceEngine extends TypedEventEmitter {
480
668
  this.emit('roll:complete', result);
481
669
  this.#startNext();
482
670
  }
671
+ #applyRollRules(dice) {
672
+ const expressionGroups = new Map();
673
+ for (const die of dice) {
674
+ const group = expressionGroups.get(die.expressionIndex) ?? [];
675
+ group.push(die);
676
+ expressionGroups.set(die.expressionIndex, group);
677
+ }
678
+ const inclusion = new Map();
679
+ for (const group of expressionGroups.values()) {
680
+ const selection = group[0]?.selection;
681
+ if (selection === undefined)
682
+ continue;
683
+ const ranked = group
684
+ .map((die, index) => {
685
+ if (die.result === undefined)
686
+ throw new Error(`Die ${die.id} has no settled result`);
687
+ return { die, index, value: die.result.value };
688
+ })
689
+ .toSorted((left, right) => {
690
+ const highest = selection.operator === 'kh' || selection.operator === 'dh';
691
+ const valueOrder = highest ? right.value - left.value : left.value - right.value;
692
+ return valueOrder === 0 ? left.index - right.index : valueOrder;
693
+ });
694
+ const selected = new Set(ranked.slice(0, selection.count).map(({ die }) => die.id));
695
+ const keepsSelected = selection.operator === 'kh' || selection.operator === 'kl';
696
+ for (const die of group) {
697
+ inclusion.set(die.id, keepsSelected ? selected.has(die.id) : !selected.has(die.id));
698
+ }
699
+ }
700
+ return dice.map((die) => {
701
+ if (die.result === undefined)
702
+ throw new Error(`Die ${die.id} has no settled result`);
703
+ const included = inclusion.get(die.id);
704
+ const score = die.scoreRules === undefined
705
+ ? undefined
706
+ : this.#scoreFace(die.result.value, die.scoreRules);
707
+ if (included === undefined && score === undefined)
708
+ return die.result;
709
+ if (die.result.component !== undefined) {
710
+ throw new Error(`Paired die ${die.id} cannot use keep/drop or score rules`);
711
+ }
712
+ return Object.freeze({
713
+ ...die.result,
714
+ ...(included === undefined ? {} : { included }),
715
+ ...(score === undefined ? {} : { score }),
716
+ });
717
+ });
718
+ }
719
+ #scoreFace(value, rules) {
720
+ return rules.find((rule) => value >= rule.minimum && value <= rule.maximum)?.score ?? 0;
721
+ }
483
722
  #cancelActive(task) {
484
723
  this.#frameToken?.cancel();
485
724
  this.#frameToken = undefined;
486
725
  const active = this.#active;
487
726
  this.#active = undefined;
488
- if (active !== undefined)
489
- this.#removeDice(active.dice);
727
+ const cleanupErrors = active === undefined ? [] : this.#removeDiceSafely(active.dice, 'cancelled');
490
728
  this.#rejectCancelled(task);
729
+ if (cleanupErrors.length > 0) {
730
+ this.emit('error', {
731
+ session: snapshotSession(task.session),
732
+ error: new AggregateError(cleanupErrors, `Failed to clean up ${task.session.id}`),
733
+ });
734
+ }
491
735
  this.#startNext();
492
736
  }
493
737
  #rejectCancelled(task) {
@@ -499,8 +743,12 @@ export class DiceEngine extends TypedEventEmitter {
499
743
  }
500
744
  #failActive(active, error) {
501
745
  this.#active = undefined;
502
- this.#removeDice(active.dice);
503
- this.#failTask(active.task, error);
746
+ const cleanupErrors = this.#removeDiceSafely(active.dice, 'failed');
747
+ this.#failTask(active.task, cleanupErrors.length === 0
748
+ ? error
749
+ : new AggregateError([error, ...cleanupErrors], `Roll ${active.task.session.id} failed`, {
750
+ cause: error,
751
+ }));
504
752
  }
505
753
  #failTask(task, error) {
506
754
  task.session.state = 'failed';
@@ -510,18 +758,81 @@ export class DiceEngine extends TypedEventEmitter {
510
758
  this.emit('error', { session: snapshotSession(task.session), error });
511
759
  this.#startNext();
512
760
  }
513
- #removeDice(dice) {
761
+ #removeDiceSafely(dice, reason) {
762
+ const errors = [];
514
763
  for (const die of dice) {
515
- this.#physics.removeDie(die.id);
516
- this.#renderer.removeDie(die.id);
764
+ try {
765
+ this.#removeDie(die.id, reason);
766
+ }
767
+ catch (error) {
768
+ if (error instanceof AggregateError)
769
+ errors.push(...error.errors);
770
+ else
771
+ errors.push(error);
772
+ }
517
773
  }
774
+ return errors;
518
775
  }
519
776
  #removeDisplayedDice() {
777
+ const cleanupErrors = [];
520
778
  for (const id of this.#displayedDieIds) {
521
- this.#physics.removeDie(id);
522
- this.#renderer.removeDie(id);
779
+ try {
780
+ this.#removeDie(id, 'replaced');
781
+ }
782
+ catch (error) {
783
+ if (error instanceof AggregateError)
784
+ cleanupErrors.push(...error.errors);
785
+ else
786
+ cleanupErrors.push(error);
787
+ }
523
788
  }
524
789
  this.#displayedDieIds.clear();
790
+ if (cleanupErrors.length > 0) {
791
+ throw new AggregateError(cleanupErrors, 'Failed to remove displayed dice');
792
+ }
793
+ }
794
+ #removeDie(id, reason) {
795
+ const cleanupErrors = this.#runCleanup([
796
+ () => this.#physics.removeDie(id),
797
+ () => this.#renderer.removeDie(id),
798
+ ]);
799
+ const event = this.#dieEvents.get(id);
800
+ if (event !== undefined) {
801
+ this.#dieEvents.delete(id);
802
+ this.emit('die:remove', Object.freeze({ ...event, reason }));
803
+ }
804
+ if (cleanupErrors.length > 0) {
805
+ throw new AggregateError(cleanupErrors, `Failed to remove die ${id}`);
806
+ }
807
+ }
808
+ #clearRenderedDice(reason) {
809
+ const cleanupErrors = this.#runCleanup([
810
+ () => this.#physics.clear(),
811
+ () => this.#renderer.clear(),
812
+ ]);
813
+ this.#forgetRenderedDice(reason);
814
+ if (cleanupErrors.length > 0) {
815
+ throw new AggregateError(cleanupErrors, 'Failed to clear rendered dice');
816
+ }
817
+ }
818
+ #forgetRenderedDice(reason) {
819
+ for (const event of this.#dieEvents.values()) {
820
+ this.emit('die:remove', Object.freeze({ ...event, reason }));
821
+ }
822
+ this.#dieEvents.clear();
823
+ this.#displayedDieIds.clear();
824
+ }
825
+ #runCleanup(actions) {
826
+ const errors = [];
827
+ for (const action of actions) {
828
+ try {
829
+ action();
830
+ }
831
+ catch (error) {
832
+ errors.push(error);
833
+ }
834
+ }
835
+ return errors;
525
836
  }
526
837
  #detachAbort(task) {
527
838
  if (task.signal !== undefined && task.abortListener !== undefined) {
@@ -571,6 +882,40 @@ export class DiceEngine extends TypedEventEmitter {
571
882
  }
572
883
  return merged;
573
884
  }
885
+ #assertValidVisualPreset(preset) {
886
+ if (!isDieType(preset.dieType) || !isPhysicalDieType(preset.dieType)) {
887
+ throw new RangeError(`Visual preset die type is not supported: ${preset.dieType}`);
888
+ }
889
+ if (!isDieType(preset.geometryId) || !hasDieGeometry(preset.geometryId)) {
890
+ throw new RangeError(`Visual preset geometry is not registered: ${preset.geometryId}`);
891
+ }
892
+ const geometry = getDieGeometry(preset.geometryId);
893
+ const faceValues = new Set(geometry.faces.map((face) => face.value));
894
+ const logicalSides = Number(preset.dieType.slice(1));
895
+ if (preset.valueMap !== undefined) {
896
+ const mappedFaces = Object.keys(preset.valueMap).map(Number);
897
+ if (mappedFaces.length !== faceValues.size ||
898
+ mappedFaces.some((face) => !faceValues.has(face))) {
899
+ throw new RangeError('Visual preset valueMap must map every geometry face exactly once');
900
+ }
901
+ }
902
+ for (const face of faceValues) {
903
+ const value = preset.valueMap?.[face] ?? face;
904
+ if (!Number.isSafeInteger(value) || value < 1 || value > logicalSides) {
905
+ throw new RangeError(`Visual preset maps geometry face ${face} outside ${preset.dieType}`);
906
+ }
907
+ }
908
+ if (preset.faceLabels !== undefined &&
909
+ Object.keys(preset.faceLabels).some((face) => !faceValues.has(Number(face)))) {
910
+ throw new RangeError('Visual preset faceLabels contain a face absent from its geometry');
911
+ }
912
+ }
913
+ #getPresetGeometryType(preset) {
914
+ if (!isDieType(preset.geometryId) || !hasDieGeometry(preset.geometryId)) {
915
+ throw new RangeError(`Visual preset geometry is not registered: ${preset.geometryId}`);
916
+ }
917
+ return preset.geometryId;
918
+ }
574
919
  #assertReady() {
575
920
  this.#assertAlive();
576
921
  if (!this.#initialized)
package/dist/index.d.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  export { DiceEngine } from './dice-engine.js';
2
2
  export { DiceEngineDestroyedError, RollCancelledError, RollLimitExceededError, RollTimeoutError, } from './errors.js';
3
3
  export type { RollLimit } from './errors.js';
4
- export type { DiceEngineEvents, DiceEngineFacade, DiceEngineLimits, DiceEngineOptions, DiceMaterialType, DiceTheme, FrameScheduler, FrameToken, RollOptions, } from './types.js';
4
+ export type { DiceEngineEvents, DiceEngineFacade, DiceEngineLimits, DiceEngineOptions, DiceCollisionEvent, DiceImpactEvent, DiceCollisionEventOptions, DiceMaterialType, DiceRemovalReason, DiceRemoveEvent, DiceTheme, DiceVisualEvent, FrameScheduler, FrameToken, RegisterEngineVisualPresetOptions, RollOptions, } from './types.js';
5
+ export { getStandardVisualPresetId, PHYSICAL_DIE_TYPES, STANDARD_VISUAL_PRESETS, type PhysicalDieType, } from './visual-presets.js';
package/dist/index.js CHANGED
@@ -1,2 +1,3 @@
1
1
  export { DiceEngine } from './dice-engine.js';
2
2
  export { DiceEngineDestroyedError, RollCancelledError, RollLimitExceededError, RollTimeoutError, } from './errors.js';
3
+ export { getStandardVisualPresetId, PHYSICAL_DIE_TYPES, STANDARD_VISUAL_PRESETS, } from './visual-presets.js';
package/dist/types.d.ts CHANGED
@@ -1,6 +1,8 @@
1
1
  import type { DieResult, RandomSource, RollMode, RollResult, RollSession } from '@dice-o-rolla/dice-core';
2
2
  import type { DicePhysicsMaterial, PhysicsWorld, SettlingOptions, ThrowGeneratorOptions, TrayOptions } from '@dice-o-rolla/dice-physics';
3
3
  import type { DiceRenderer, RendererTheme, RendererViewport } from '@dice-o-rolla/dice-renderer';
4
+ import type { RegisterVisualPresetOptions, VisualPresetDescriptor } from '@dice-o-rolla/dice-renderer';
5
+ import type { PhysicalDieType } from './visual-presets.js';
4
6
  export interface FrameToken {
5
7
  cancel(): void;
6
8
  }
@@ -13,18 +15,49 @@ export interface RollOptions {
13
15
  readonly mode?: RollMode;
14
16
  readonly signal?: AbortSignal;
15
17
  }
18
+ export interface RegisterEngineVisualPresetOptions extends RegisterVisualPresetOptions {
19
+ readonly makeDefault?: boolean;
20
+ }
16
21
  export interface DiceEngineLimits {
17
22
  readonly maxNotationLength: number;
18
23
  readonly maxLogicalDice: number;
19
24
  readonly maxPhysicalDice: number;
20
25
  readonly maxQueuedRolls: number;
21
26
  }
27
+ export interface DiceVisualEvent {
28
+ readonly sessionId: string;
29
+ readonly dieId: string;
30
+ readonly dieType: string;
31
+ readonly presetId: string;
32
+ readonly skinId?: string;
33
+ readonly soundPackId?: string;
34
+ }
35
+ export type DiceRemovalReason = 'replaced' | 'cancelled' | 'failed' | 'cleared' | 'destroyed';
36
+ export interface DiceRemoveEvent extends DiceVisualEvent {
37
+ readonly reason: DiceRemovalReason;
38
+ }
39
+ export interface DiceCollisionEvent extends DiceVisualEvent {
40
+ readonly otherDieId?: string;
41
+ readonly started: boolean;
42
+ }
43
+ export interface DiceImpactEvent extends DiceVisualEvent {
44
+ readonly otherDieId?: string;
45
+ readonly force: number;
46
+ }
47
+ export interface DiceCollisionEventOptions {
48
+ readonly enabled: boolean;
49
+ readonly maxEventsPerFrame: number;
50
+ }
22
51
  export interface DiceEngineEvents {
23
52
  readonly 'roll:start': RollSession;
53
+ readonly 'die:spawn': DiceVisualEvent;
24
54
  readonly 'die:settled': {
25
55
  readonly sessionId: string;
26
56
  readonly die: DieResult;
27
57
  };
58
+ readonly 'die:remove': DiceRemoveEvent;
59
+ readonly 'die:collision': DiceCollisionEvent;
60
+ readonly 'die:impact': DiceImpactEvent;
28
61
  readonly 'roll:complete': RollResult;
29
62
  readonly 'roll:cancel': RollSession;
30
63
  readonly 'theme:change': DiceTheme;
@@ -47,6 +80,9 @@ export interface DiceEngineOptions {
47
80
  readonly diceMaterial?: DicePhysicsMaterial;
48
81
  readonly theme?: Partial<DiceTheme>;
49
82
  readonly limits?: Partial<DiceEngineLimits>;
83
+ readonly visualPresets?: readonly VisualPresetDescriptor[];
84
+ readonly visualPresetIds?: Partial<Readonly<Record<PhysicalDieType, string>>>;
85
+ readonly collisionEvents?: Partial<DiceCollisionEventOptions>;
50
86
  }
51
87
  export interface DiceEngineFacade {
52
88
  initialize(): Promise<void>;
@@ -55,5 +91,9 @@ export interface DiceEngineFacade {
55
91
  clear(): void;
56
92
  resize(viewport: RendererViewport): void;
57
93
  setTheme(theme: Partial<DiceTheme>): DiceTheme;
94
+ registerVisualPreset(preset: VisualPresetDescriptor, options?: RegisterEngineVisualPresetOptions): VisualPresetDescriptor;
95
+ unregisterVisualPreset(id: string): boolean;
96
+ setVisualPreset(dieType: PhysicalDieType, presetId: string): void;
97
+ getVisualPreset(dieType: PhysicalDieType): VisualPresetDescriptor;
58
98
  destroy(): void;
59
99
  }
@@ -0,0 +1,7 @@
1
+ import type { DieType } from '@dice-o-rolla/dice-core';
2
+ import type { VisualPresetDescriptor } from '@dice-o-rolla/dice-renderer';
3
+ export type PhysicalDieType = Exclude<DieType, 'd100'>;
4
+ export declare const PHYSICAL_DIE_TYPES: readonly PhysicalDieType[];
5
+ export declare const STANDARD_VISUAL_PRESETS: readonly VisualPresetDescriptor[];
6
+ export declare function getStandardVisualPresetId(dieType: PhysicalDieType): string;
7
+ export declare function isPhysicalDieType(value: unknown): value is PhysicalDieType;
@@ -0,0 +1,20 @@
1
+ export const PHYSICAL_DIE_TYPES = Object.freeze([
2
+ 'd4',
3
+ 'd6',
4
+ 'd8',
5
+ 'd10',
6
+ 'd12',
7
+ 'd20',
8
+ ]);
9
+ export const STANDARD_VISUAL_PRESETS = Object.freeze(PHYSICAL_DIE_TYPES.map((dieType) => Object.freeze({
10
+ id: `standard:${dieType}`,
11
+ dieType,
12
+ geometryId: dieType,
13
+ scale: 1,
14
+ })));
15
+ export function getStandardVisualPresetId(dieType) {
16
+ return `standard:${dieType}`;
17
+ }
18
+ export function isPhysicalDieType(value) {
19
+ return PHYSICAL_DIE_TYPES.some((type) => type === value);
20
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dice-o-rolla/dice-engine",
3
- "version": "0.1.1",
3
+ "version": "0.2.0",
4
4
  "description": "Framework-neutral physical dice engine with Rapier and Three.js browser adapters.",
5
5
  "keywords": [
6
6
  "dice",
@@ -47,12 +47,12 @@
47
47
  "registry": "https://registry.npmjs.org/"
48
48
  },
49
49
  "dependencies": {
50
- "@dice-o-rolla/dice-core": "0.1.1",
51
- "@dice-o-rolla/dice-geometry": "0.1.1",
52
- "@dice-o-rolla/dice-physics": "0.1.1",
53
- "@dice-o-rolla/dice-physics-rapier": "0.1.1",
54
- "@dice-o-rolla/dice-renderer": "0.1.1",
55
- "@dice-o-rolla/dice-renderer-three": "0.1.1"
50
+ "@dice-o-rolla/dice-core": "0.2.0",
51
+ "@dice-o-rolla/dice-geometry": "0.2.0",
52
+ "@dice-o-rolla/dice-physics": "0.2.0",
53
+ "@dice-o-rolla/dice-physics-rapier": "0.2.0",
54
+ "@dice-o-rolla/dice-renderer": "0.2.0",
55
+ "@dice-o-rolla/dice-renderer-three": "0.2.0"
56
56
  },
57
57
  "engines": {
58
58
  "node": ">=20.0.0"