@dice-o-rolla/dice-engine 0.1.1 → 0.3.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.
@@ -1,8 +1,11 @@
1
- import { createRollResult, getNotationModifier, isDieType, mathRandomSource, parseNotation, TypedEventEmitter, } from '@dice-o-rolla/dice-core';
1
+ import { createRollResult, getNotationModifier, isDieType, mathRandomSource, parseNotation, SeededRandomSource, 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
- import { DiceEngineDestroyedError, RollCancelledError, RollLimitExceededError, RollTimeoutError, } from './errors.js';
6
+ import { DiceEngineDestroyedError, RollCancelledError, RollLimitExceededError, RollTimeoutError, TraceLimitExceededError, } from './errors.js';
7
+ import { DICE_ENGINE_VERSION } from './version.js';
8
+ import { getStandardVisualPresetId, isPhysicalDieType, PHYSICAL_DIE_TYPES, STANDARD_VISUAL_PRESETS, } from './visual-presets.js';
6
9
  const DEFAULT_SETTLING = {
7
10
  linearVelocityThreshold: 0.08,
8
11
  angularVelocityThreshold: 0.08,
@@ -45,6 +48,12 @@ const DEFAULT_LIMITS = Object.freeze({
45
48
  maxPhysicalDice: 50,
46
49
  maxQueuedRolls: 8,
47
50
  });
51
+ const DEFAULT_COLLISION_EVENTS = Object.freeze({ enabled: false, maxEventsPerFrame: 32 });
52
+ const DEFAULT_TRACE_LIMITS = Object.freeze({
53
+ maxFrames: 1_200,
54
+ maxSamples: 60_000,
55
+ maxEvents: 20_000,
56
+ });
48
57
  const D100_TENS_LABELS = Object.freeze({
49
58
  1: 10,
50
59
  2: 20,
@@ -71,12 +80,97 @@ function snapshotSession(session) {
71
80
  function toRenderState(die) {
72
81
  return {
73
82
  id: die.id,
83
+ presetId: die.preset.id,
74
84
  geometryId: die.geometryType,
85
+ scale: die.preset.scale ?? 1,
75
86
  ...(die.faceLabels === undefined ? {} : { faceLabels: die.faceLabels }),
76
87
  previous: die.previous,
77
88
  current: die.current,
78
89
  };
79
90
  }
91
+ function mergeFaceLabels(preset, roll) {
92
+ if (preset === undefined)
93
+ return roll;
94
+ if (roll === undefined)
95
+ return preset;
96
+ return Object.freeze({ ...preset, ...roll });
97
+ }
98
+ function snapshotFrame(dice, elapsedSeconds) {
99
+ return Object.freeze({
100
+ elapsedSeconds,
101
+ dice: Object.freeze(dice.map((die) => Object.freeze({
102
+ id: die.id,
103
+ position: Object.freeze({ ...die.current.position }),
104
+ quaternion: Object.freeze({ ...die.current.quaternion }),
105
+ }))),
106
+ });
107
+ }
108
+ function getFrameDie(frame, id) {
109
+ const die = frame.dice.find((candidate) => candidate.id === id);
110
+ if (die === undefined)
111
+ throw new TypeError(`Trace frame is missing die "${id}"`);
112
+ return die;
113
+ }
114
+ function freezeVector(value) {
115
+ return Object.freeze({ ...value });
116
+ }
117
+ function freezeThrowParameters(parameters) {
118
+ return Object.freeze({
119
+ position: freezeVector(parameters.position),
120
+ quaternion: freezeVector(parameters.quaternion),
121
+ impulse: freezeVector(parameters.impulse),
122
+ torqueImpulse: freezeVector(parameters.torqueImpulse),
123
+ });
124
+ }
125
+ function cloneRange(range) {
126
+ return Object.freeze({ ...range });
127
+ }
128
+ function cloneVectorRange(range) {
129
+ return Object.freeze({ x: cloneRange(range.x), y: cloneRange(range.y), z: cloneRange(range.z) });
130
+ }
131
+ function definitionFingerprint(geometry, preset, effectiveFaceLabels) {
132
+ const serialized = JSON.stringify({
133
+ geometry: {
134
+ id: geometry.id,
135
+ vertices: geometry.vertices,
136
+ faces: geometry.faces,
137
+ faceDefinitions: geometry.faceDefinitions,
138
+ },
139
+ preset: {
140
+ id: preset.id,
141
+ dieType: preset.dieType,
142
+ geometryId: preset.geometryId,
143
+ scale: preset.scale ?? 1,
144
+ faceLabels: preset.faceLabels ?? null,
145
+ effectiveFaceLabels: effectiveFaceLabels ?? null,
146
+ valueMap: preset.valueMap ?? null,
147
+ skinId: preset.skinId ?? null,
148
+ soundPackId: preset.soundPackId ?? null,
149
+ },
150
+ });
151
+ let hash = 0x811c9dc5;
152
+ for (let index = 0; index < serialized.length; index += 1) {
153
+ hash ^= serialized.charCodeAt(index);
154
+ hash = Math.imul(hash, 0x01000193);
155
+ }
156
+ return `fnv1a32:${(hash >>> 0).toString(16).padStart(8, '0')}`;
157
+ }
158
+ function snapshotTraceProfile(options) {
159
+ return Object.freeze({
160
+ fixedStepSeconds: options.fixedStepSeconds,
161
+ settling: Object.freeze({ ...options.settling }),
162
+ throw: Object.freeze({
163
+ position: cloneVectorRange(options.throw.position),
164
+ impulse: cloneVectorRange(options.throw.impulse),
165
+ torqueImpulse: cloneVectorRange(options.throw.torqueImpulse),
166
+ }),
167
+ tray: Object.freeze({
168
+ ...options.tray,
169
+ material: Object.freeze({ ...options.tray.material }),
170
+ }),
171
+ diceMaterial: Object.freeze({ ...options.diceMaterial }),
172
+ });
173
+ }
80
174
  function assertPositive(value, name) {
81
175
  if (!Number.isFinite(value) || value <= 0) {
82
176
  throw new RangeError(`${name} must be a positive finite number`);
@@ -95,17 +189,26 @@ export class DiceEngine extends TypedEventEmitter {
95
189
  #fixedStepSeconds;
96
190
  #maxFrameDeltaSeconds;
97
191
  #settling;
192
+ #throwOptions;
98
193
  #throwGenerator;
99
194
  #tray;
100
195
  #diceMaterial;
101
196
  #limits;
197
+ #traceLimits;
198
+ #collisionEvents;
199
+ #visualPresets = new VisualPresetRegistry(STANDARD_VISUAL_PRESETS);
200
+ #visualPresetIds = new Map();
102
201
  #queue = [];
103
202
  #displayedDieIds = new Set();
203
+ #dieEvents = new Map();
104
204
  #active;
205
+ #replay;
105
206
  #frameToken;
106
207
  #lastFrameMs = 0;
107
208
  #accumulatorSeconds = 0;
108
209
  #nextSessionId = 1;
210
+ #nextSimulationId = 1;
211
+ #initialization;
109
212
  #initialized = false;
110
213
  #destroyed = false;
111
214
  #theme;
@@ -120,10 +223,17 @@ export class DiceEngine extends TypedEventEmitter {
120
223
  assertPositive(this.#fixedStepSeconds, 'fixedStepSeconds');
121
224
  assertPositive(this.#maxFrameDeltaSeconds, 'maxFrameDeltaSeconds');
122
225
  this.#settling = options.settling ?? DEFAULT_SETTLING;
123
- this.#throwGenerator = new ThrowGenerator(options.random ?? mathRandomSource, options.throw ?? DEFAULT_THROW);
226
+ this.#throwOptions = options.throw ?? DEFAULT_THROW;
227
+ this.#throwGenerator = new ThrowGenerator(options.random ?? mathRandomSource, this.#throwOptions);
124
228
  this.#tray = options.tray ?? DEFAULT_TRAY;
125
229
  this.#diceMaterial = options.diceMaterial ?? DEFAULT_DICE_MATERIAL;
126
230
  this.#limits = Object.freeze({ ...DEFAULT_LIMITS, ...options.limits });
231
+ this.#traceLimits = Object.freeze({ ...DEFAULT_TRACE_LIMITS, ...options.traceLimits });
232
+ this.#collisionEvents = Object.freeze({
233
+ ...DEFAULT_COLLISION_EVENTS,
234
+ ...options.collisionEvents,
235
+ });
236
+ assertPositiveSafeInteger(this.#collisionEvents.maxEventsPerFrame, 'collisionEvents.maxEventsPerFrame');
127
237
  for (const [name, value] of [
128
238
  ['maxNotationLength', this.#limits.maxNotationLength],
129
239
  ['maxLogicalDice', this.#limits.maxLogicalDice],
@@ -132,20 +242,46 @@ export class DiceEngine extends TypedEventEmitter {
132
242
  ]) {
133
243
  assertPositiveSafeInteger(value, `limits.${name}`);
134
244
  }
245
+ for (const [name, value] of [
246
+ ['maxFrames', this.#traceLimits.maxFrames],
247
+ ['maxSamples', this.#traceLimits.maxSamples],
248
+ ['maxEvents', this.#traceLimits.maxEvents],
249
+ ]) {
250
+ assertPositiveSafeInteger(value, `traceLimits.${name}`);
251
+ }
252
+ for (const type of PHYSICAL_DIE_TYPES) {
253
+ this.#visualPresetIds.set(type, getStandardVisualPresetId(type));
254
+ }
255
+ for (const preset of options.visualPresets ?? [])
256
+ this.registerVisualPreset(preset);
257
+ for (const type of PHYSICAL_DIE_TYPES) {
258
+ const presetId = options.visualPresetIds?.[type];
259
+ if (presetId !== undefined)
260
+ this.setVisualPreset(type, presetId);
261
+ }
135
262
  this.#theme = this.#mergeTheme(options.theme ?? {});
136
263
  }
137
264
  async initialize() {
138
265
  this.#assertAlive();
139
266
  if (this.#initialized)
140
267
  return;
141
- this.#physics.configureTray(this.#tray);
142
- await this.#renderer.initialize();
143
- this.#renderer.setTheme(this.#theme);
144
- this.#initialized = true;
268
+ if (this.#initialization !== undefined)
269
+ return this.#initialization;
270
+ const initialization = this.#performInitialization();
271
+ this.#initialization = initialization;
272
+ try {
273
+ await initialization;
274
+ }
275
+ finally {
276
+ if (this.#initialization === initialization)
277
+ this.#initialization = undefined;
278
+ }
145
279
  }
146
280
  roll(notation, options = {}) {
147
281
  try {
148
282
  this.#assertReady();
283
+ if (this.#replay !== undefined)
284
+ throw new Error('Cannot roll while a trace replay is active');
149
285
  if ((options.mode ?? 'queue') !== 'queue') {
150
286
  throw new RangeError('Only queue roll mode is currently supported');
151
287
  }
@@ -170,8 +306,227 @@ export class DiceEngine extends TypedEventEmitter {
170
306
  return Promise.reject(error);
171
307
  }
172
308
  }
309
+ async simulate(notation, options) {
310
+ this.#assertReady();
311
+ this.#assertExclusiveOperationAvailable('simulate');
312
+ if (notation.length > this.#limits.maxNotationLength) {
313
+ throw new RollLimitExceededError('notation-length', this.#limits.maxNotationLength, notation.length);
314
+ }
315
+ const parsed = parseNotation(notation);
316
+ this.#assertSupportedAndWithinLimits(parsed);
317
+ const frameIntervalSteps = options.frameIntervalSteps ?? 1;
318
+ assertPositiveSafeInteger(frameIntervalSteps, 'frameIntervalSteps');
319
+ const random = new SeededRandomSource(options.seed);
320
+ const throwGenerator = new ThrowGenerator(random, this.#throwOptions);
321
+ const simulationId = `simulation-${this.#nextSimulationId++}`;
322
+ this.#removeDisplayedDice();
323
+ const dice = [];
324
+ let trace;
325
+ let failure;
326
+ try {
327
+ this.#physics.setCollisionEventsEnabled(true);
328
+ this.#physics.drainCollisionEvents();
329
+ this.#physics.drainImpactEvents();
330
+ dice.push(...this.#createSimulationDice(parsed, simulationId, throwGenerator));
331
+ const captureFrames = options.captureFrames === true;
332
+ const frames = [];
333
+ const events = [];
334
+ if (captureFrames)
335
+ this.#appendTraceFrame(frames, dice, 0);
336
+ let elapsedSeconds = 0;
337
+ let stepIndex = 0;
338
+ while (dice.some((die) => die.result === undefined)) {
339
+ for (const die of dice)
340
+ die.previous = die.current;
341
+ this.#physics.step(this.#fixedStepSeconds);
342
+ stepIndex += 1;
343
+ elapsedSeconds += this.#fixedStepSeconds;
344
+ for (const collision of this.#physics.drainCollisionEvents()) {
345
+ this.#appendTraceEvent(events, {
346
+ kind: 'collision',
347
+ elapsedSeconds,
348
+ dieId: collision.dieId,
349
+ ...(collision.otherDieId === undefined ? {} : { otherDieId: collision.otherDieId }),
350
+ started: collision.started,
351
+ });
352
+ }
353
+ for (const impact of this.#physics.drainImpactEvents()) {
354
+ this.#appendTraceEvent(events, {
355
+ kind: 'impact',
356
+ elapsedSeconds,
357
+ dieId: impact.dieId,
358
+ ...(impact.otherDieId === undefined ? {} : { otherDieId: impact.otherDieId }),
359
+ force: impact.force,
360
+ });
361
+ }
362
+ for (const die of dice) {
363
+ die.current = die.body.getState();
364
+ if (die.result !== undefined)
365
+ continue;
366
+ const settling = die.detector.update(die.current, this.#fixedStepSeconds * 1_000);
367
+ if (settling === 'timed-out')
368
+ throw new RollTimeoutError(simulationId);
369
+ if (settling === 'settled') {
370
+ const geometry = getDieGeometry(die.geometryType);
371
+ die.result = this.#createDieResult(die, resolveFace(geometry, die.current.quaternion));
372
+ }
373
+ }
374
+ const allSettled = dice.every((die) => die.result !== undefined);
375
+ if (captureFrames && (stepIndex % frameIntervalSteps === 0 || allSettled)) {
376
+ if (frames.at(-1)?.elapsedSeconds !== elapsedSeconds) {
377
+ this.#appendTraceFrame(frames, dice, elapsedSeconds);
378
+ }
379
+ }
380
+ }
381
+ if (!captureFrames)
382
+ this.#appendTraceFrame(frames, dice, elapsedSeconds);
383
+ const result = createRollResult({
384
+ id: simulationId,
385
+ notation,
386
+ dice: this.#applyRollRules(dice),
387
+ modifier: getNotationModifier(parsed),
388
+ startedAt: 0,
389
+ completedAt: elapsedSeconds * 1_000,
390
+ });
391
+ trace = Object.freeze({
392
+ version: 1,
393
+ producer: Object.freeze({
394
+ name: '@dice-o-rolla/dice-engine',
395
+ version: DICE_ENGINE_VERSION,
396
+ }),
397
+ notation,
398
+ seed: options.seed,
399
+ fixedStepSeconds: this.#fixedStepSeconds,
400
+ frameIntervalSteps,
401
+ durationSeconds: elapsedSeconds,
402
+ profile: snapshotTraceProfile({
403
+ fixedStepSeconds: this.#fixedStepSeconds,
404
+ settling: this.#settling,
405
+ throw: this.#throwOptions,
406
+ tray: this.#tray,
407
+ diceMaterial: this.#diceMaterial,
408
+ }),
409
+ dice: Object.freeze(dice.map((die) => Object.freeze({
410
+ id: die.id,
411
+ type: die.type,
412
+ presetId: die.preset.id,
413
+ geometryId: die.geometryType,
414
+ definitionFingerprint: definitionFingerprint(getDieGeometry(die.geometryType), die.preset, die.faceLabels),
415
+ scale: die.preset.scale ?? 1,
416
+ ...(die.faceLabels === undefined
417
+ ? {}
418
+ : { faceLabels: Object.freeze({ ...die.faceLabels }) }),
419
+ initial: freezeThrowParameters(die.initial),
420
+ }))),
421
+ frames: Object.freeze(frames),
422
+ events: Object.freeze(events),
423
+ result,
424
+ });
425
+ }
426
+ catch (error) {
427
+ failure = error;
428
+ }
429
+ const cleanupErrors = this.#removeSimulationDice(dice);
430
+ try {
431
+ this.#physics.setCollisionEventsEnabled(this.#collisionEvents.enabled);
432
+ }
433
+ catch (error) {
434
+ cleanupErrors.push(error);
435
+ }
436
+ if (failure !== undefined) {
437
+ if (cleanupErrors.length === 0)
438
+ throw failure;
439
+ throw new AggregateError([failure, ...cleanupErrors], `Simulation ${simulationId} failed`, {
440
+ cause: failure,
441
+ });
442
+ }
443
+ if (cleanupErrors.length > 0) {
444
+ throw new AggregateError(cleanupErrors, `Failed to clean up ${simulationId}`);
445
+ }
446
+ if (trace === undefined)
447
+ throw new Error(`Simulation ${simulationId} produced no trace`);
448
+ this.#assertValidTrace(trace);
449
+ return trace;
450
+ }
451
+ replay(trace, options = {}) {
452
+ try {
453
+ this.#assertReady();
454
+ this.#assertExclusiveOperationAvailable('replay');
455
+ this.#assertValidTrace(trace);
456
+ if (options.signal?.aborted === true) {
457
+ return Promise.reject(new RollCancelledError(trace.result.id));
458
+ }
459
+ this.#removeDisplayedDice();
460
+ if (options.theme !== undefined)
461
+ this.setTheme(options.theme);
462
+ const firstFrame = trace.frames[0];
463
+ const createdIds = [];
464
+ try {
465
+ for (const die of trace.dice) {
466
+ const frameDie = getFrameDie(firstFrame, die.id);
467
+ this.#renderer.createDie({
468
+ id: die.id,
469
+ presetId: die.presetId,
470
+ geometryId: die.geometryId,
471
+ scale: die.scale,
472
+ ...(die.faceLabels === undefined ? {} : { faceLabels: die.faceLabels }),
473
+ previous: frameDie,
474
+ current: frameDie,
475
+ });
476
+ createdIds.push(die.id);
477
+ const preset = this.#visualPresets.get(die.presetId);
478
+ const event = Object.freeze({
479
+ sessionId: trace.result.id,
480
+ dieId: die.id,
481
+ dieType: die.type,
482
+ presetId: die.presetId,
483
+ ...(preset.skinId === undefined ? {} : { skinId: preset.skinId }),
484
+ ...(preset.soundPackId === undefined ? {} : { soundPackId: preset.soundPackId }),
485
+ });
486
+ this.#dieEvents.set(die.id, event);
487
+ this.emit('die:spawn', event);
488
+ }
489
+ }
490
+ catch (error) {
491
+ for (const id of createdIds) {
492
+ this.#dieEvents.delete(id);
493
+ this.#renderer.removeDie(id);
494
+ }
495
+ throw error;
496
+ }
497
+ let resolve;
498
+ let reject;
499
+ const promise = new Promise((resolvePromise, rejectPromise) => {
500
+ resolve = resolvePromise;
501
+ reject = rejectPromise;
502
+ });
503
+ const abortListener = options.signal === undefined ? undefined : () => this.#cancelReplay();
504
+ if (abortListener !== undefined) {
505
+ options.signal?.addEventListener('abort', abortListener, { once: true });
506
+ }
507
+ this.#replay = {
508
+ trace,
509
+ resolve,
510
+ reject,
511
+ createdIds,
512
+ nextEventIndex: 0,
513
+ ...(options.signal === undefined ? {} : { signal: options.signal }),
514
+ ...(abortListener === undefined ? {} : { abortListener }),
515
+ };
516
+ this.#scheduleReplayFrame();
517
+ return promise;
518
+ }
519
+ catch (error) {
520
+ return Promise.reject(error);
521
+ }
522
+ }
173
523
  cancel(sessionId) {
174
524
  this.#assertAlive();
525
+ if (this.#replay !== undefined &&
526
+ (sessionId === undefined || this.#replay.trace.result.id === sessionId)) {
527
+ this.#cancelReplay();
528
+ return true;
529
+ }
175
530
  if (this.#active !== undefined &&
176
531
  (sessionId === undefined || this.#active.task.session.id === sessionId)) {
177
532
  const task = this.#active.task;
@@ -187,6 +542,8 @@ export class DiceEngine extends TypedEventEmitter {
187
542
  }
188
543
  clear() {
189
544
  this.#assertAlive();
545
+ if (this.#replay !== undefined)
546
+ this.#cancelReplay();
190
547
  this.#frameToken?.cancel();
191
548
  this.#frameToken = undefined;
192
549
  if (this.#active !== undefined) {
@@ -196,10 +553,8 @@ export class DiceEngine extends TypedEventEmitter {
196
553
  }
197
554
  for (const task of this.#queue.splice(0))
198
555
  this.#rejectCancelled(task);
199
- this.#physics.clear();
200
- this.#renderer.clear();
201
- this.#displayedDieIds.clear();
202
556
  this.#accumulatorSeconds = 0;
557
+ this.#clearRenderedDice('cleared');
203
558
  }
204
559
  resize(viewport) {
205
560
  this.#assertReady();
@@ -216,15 +571,104 @@ export class DiceEngine extends TypedEventEmitter {
216
571
  get theme() {
217
572
  return this.#theme;
218
573
  }
574
+ registerVisualPreset(source, options = {}) {
575
+ this.#assertAlive();
576
+ const preset = createVisualPresetDescriptor(source);
577
+ this.#assertValidVisualPreset(preset);
578
+ if ([...this.#dieEvents.values()].some((event) => event.presetId === preset.id)) {
579
+ throw new Error(`Visual preset "${preset.id}" is currently in use`);
580
+ }
581
+ const existing = this.#visualPresets.get(preset.id);
582
+ if (existing !== undefined && options.replace !== true) {
583
+ throw new Error(`Visual preset "${preset.id}" is already registered`);
584
+ }
585
+ if (this.#initialized)
586
+ this.#renderer.registerPreset(preset);
587
+ this.#visualPresets.register(preset, options.replace === undefined ? {} : { replace: options.replace });
588
+ if (options.makeDefault === true) {
589
+ if (!isPhysicalDieType(preset.dieType)) {
590
+ throw new Error(`Visual preset has an invalid physical die type: ${preset.dieType}`);
591
+ }
592
+ this.#visualPresetIds.set(preset.dieType, preset.id);
593
+ }
594
+ return preset;
595
+ }
596
+ unregisterVisualPreset(id) {
597
+ this.#assertAlive();
598
+ if (STANDARD_VISUAL_PRESETS.some((preset) => preset.id === id)) {
599
+ throw new Error(`Built-in visual preset "${id}" cannot be unregistered`);
600
+ }
601
+ const preset = this.#visualPresets.unregister(id);
602
+ if (preset === undefined)
603
+ return false;
604
+ if (!isPhysicalDieType(preset.dieType)) {
605
+ throw new Error(`Visual preset has an invalid physical die type: ${preset.dieType}`);
606
+ }
607
+ const type = preset.dieType;
608
+ if (this.#visualPresetIds.get(type) === id) {
609
+ this.#visualPresetIds.set(type, getStandardVisualPresetId(type));
610
+ }
611
+ if (this.#initialized)
612
+ this.#renderer.unregisterPreset(id);
613
+ return true;
614
+ }
615
+ setVisualPreset(dieType, presetId) {
616
+ this.#assertAlive();
617
+ if (!PHYSICAL_DIE_TYPES.includes(dieType)) {
618
+ throw new RangeError(`${dieType} is not a physical die type`);
619
+ }
620
+ const preset = this.#visualPresets.get(presetId);
621
+ if (preset === undefined)
622
+ throw new RangeError(`Unknown visual preset: ${presetId}`);
623
+ if (preset.dieType !== dieType) {
624
+ throw new RangeError(`Visual preset "${presetId}" is for ${preset.dieType}, not ${dieType}`);
625
+ }
626
+ this.#visualPresetIds.set(dieType, presetId);
627
+ }
628
+ getVisualPreset(dieType) {
629
+ this.#assertAlive();
630
+ const id = this.#visualPresetIds.get(dieType);
631
+ const preset = id === undefined ? undefined : this.#visualPresets.get(id);
632
+ if (preset === undefined)
633
+ throw new Error(`No visual preset is selected for ${dieType}`);
634
+ return preset;
635
+ }
219
636
  destroy() {
220
637
  if (this.#destroyed)
221
638
  return;
222
- this.clear();
223
- this.#renderer.destroy();
224
- this.#physics.destroy();
639
+ this.#destroyed = true;
640
+ this.#frameToken?.cancel();
641
+ this.#frameToken = undefined;
642
+ if (this.#replay !== undefined)
643
+ this.#cancelReplay();
644
+ if (this.#active !== undefined) {
645
+ const task = this.#active.task;
646
+ this.#active = undefined;
647
+ this.#rejectCancelled(task);
648
+ }
649
+ for (const task of this.#queue.splice(0))
650
+ this.#rejectCancelled(task);
651
+ this.#forgetRenderedDice('destroyed');
652
+ const cleanupErrors = this.#runCleanup([
653
+ () => this.#renderer.destroy(),
654
+ () => this.#physics.destroy(),
655
+ ]);
225
656
  super.clear();
657
+ this.#initialization = undefined;
226
658
  this.#initialized = false;
227
- this.#destroyed = true;
659
+ if (cleanupErrors.length > 0) {
660
+ throw new AggregateError(cleanupErrors, 'DiceEngine teardown failed');
661
+ }
662
+ }
663
+ async #performInitialization() {
664
+ this.#physics.configureTray(this.#tray);
665
+ this.#physics.setCollisionEventsEnabled(this.#collisionEvents.enabled);
666
+ await this.#renderer.initialize();
667
+ this.#assertAlive();
668
+ for (const preset of this.#visualPresets.list())
669
+ this.#renderer.registerPreset(preset);
670
+ this.#renderer.setTheme(this.#theme);
671
+ this.#initialized = true;
228
672
  }
229
673
  #createTask(notation, parsed, signal) {
230
674
  const session = {
@@ -280,11 +724,12 @@ export class DiceEngine extends TypedEventEmitter {
280
724
  #createDice(task) {
281
725
  const dice = [];
282
726
  let index = 0;
283
- const specs = this.#createPhysicalSpecs(task);
727
+ const specs = this.#createPhysicalSpecs(task.parsed, task.session.id);
284
728
  const totalDice = specs.length;
285
729
  try {
286
730
  for (const spec of specs) {
287
731
  const geometry = getDieGeometry(spec.geometryType);
732
+ const faceLabels = mergeFaceLabels(spec.preset.faceLabels, spec.faceLabels);
288
733
  const id = `${task.session.id}:die-${index++}`;
289
734
  const generated = this.#throwGenerator.generate();
290
735
  const position = this.#placeDie(generated.position, index - 1, totalDice);
@@ -295,7 +740,7 @@ export class DiceEngine extends TypedEventEmitter {
295
740
  kind: 'convex-hull',
296
741
  vertices: geometry.vertices.map(([x, y, z]) => ({ x, y, z })),
297
742
  },
298
- scale: 1,
743
+ scale: spec.preset.scale ?? 1,
299
744
  mass: 1,
300
745
  material: this.#diceMaterial,
301
746
  position,
@@ -306,8 +751,16 @@ export class DiceEngine extends TypedEventEmitter {
306
751
  id,
307
752
  type: spec.type,
308
753
  geometryType: spec.geometryType,
754
+ preset: spec.preset,
755
+ termId: spec.termId,
756
+ expressionIndex: spec.expressionIndex,
757
+ dieIndex: spec.dieIndex,
758
+ physicalIndex: spec.physicalIndex,
759
+ ...(spec.selection === undefined ? {} : { selection: spec.selection }),
760
+ ...(spec.scoreRules === undefined ? {} : { scoreRules: spec.scoreRules }),
309
761
  ...(spec.component === undefined ? {} : { component: spec.component }),
310
- ...(spec.faceLabels === undefined ? {} : { faceLabels: spec.faceLabels }),
762
+ ...(faceLabels === undefined ? {} : { faceLabels }),
763
+ initial: generated,
311
764
  body,
312
765
  detector: new SettlingDetector(this.#settling),
313
766
  previous: state,
@@ -316,57 +769,194 @@ export class DiceEngine extends TypedEventEmitter {
316
769
  dice.push(die);
317
770
  this.#renderer.createDie(toRenderState(die));
318
771
  body.applyImpulse(generated.impulse, generated.torqueImpulse);
772
+ const event = Object.freeze({
773
+ sessionId: task.session.id,
774
+ dieId: id,
775
+ dieType: spec.type,
776
+ presetId: spec.preset.id,
777
+ ...(spec.preset.skinId === undefined ? {} : { skinId: spec.preset.skinId }),
778
+ ...(spec.preset.soundPackId === undefined
779
+ ? {}
780
+ : { soundPackId: spec.preset.soundPackId }),
781
+ });
782
+ this.#dieEvents.set(id, event);
783
+ this.emit('die:spawn', event);
319
784
  }
320
785
  return dice;
321
786
  }
322
787
  catch (error) {
323
- for (const die of dice) {
788
+ const cleanupErrors = this.#removeDiceSafely(dice, 'failed');
789
+ if (cleanupErrors.length === 0)
790
+ throw error;
791
+ throw new AggregateError([error, ...cleanupErrors], 'Failed to create dice', {
792
+ cause: error,
793
+ });
794
+ }
795
+ }
796
+ #createSimulationDice(parsed, simulationId, throwGenerator) {
797
+ const dice = [];
798
+ const specs = this.#createPhysicalSpecs(parsed, simulationId);
799
+ const totalDice = specs.length;
800
+ try {
801
+ for (const [index, spec] of specs.entries()) {
802
+ const geometry = getDieGeometry(spec.geometryType);
803
+ const faceLabels = mergeFaceLabels(spec.preset.faceLabels, spec.faceLabels);
804
+ const id = `${simulationId}:die-${index}`;
805
+ const generated = throwGenerator.generate();
806
+ const position = this.#placeDie(generated.position, index, totalDice);
807
+ const body = this.#physics.createDie({
808
+ id,
809
+ type: spec.geometryType,
810
+ collider: {
811
+ kind: 'convex-hull',
812
+ vertices: geometry.vertices.map(([x, y, z]) => ({ x, y, z })),
813
+ },
814
+ scale: spec.preset.scale ?? 1,
815
+ mass: 1,
816
+ material: this.#diceMaterial,
817
+ position,
818
+ quaternion: generated.quaternion,
819
+ });
820
+ const state = body.getState();
821
+ const die = {
822
+ id,
823
+ type: spec.type,
824
+ geometryType: spec.geometryType,
825
+ preset: spec.preset,
826
+ termId: spec.termId,
827
+ expressionIndex: spec.expressionIndex,
828
+ dieIndex: spec.dieIndex,
829
+ physicalIndex: spec.physicalIndex,
830
+ ...(spec.selection === undefined ? {} : { selection: spec.selection }),
831
+ ...(spec.scoreRules === undefined ? {} : { scoreRules: spec.scoreRules }),
832
+ ...(spec.component === undefined ? {} : { component: spec.component }),
833
+ ...(faceLabels === undefined ? {} : { faceLabels }),
834
+ initial: generated,
835
+ body,
836
+ detector: new SettlingDetector(this.#settling),
837
+ previous: state,
838
+ current: state,
839
+ };
840
+ dice.push(die);
841
+ body.applyImpulse(generated.impulse, generated.torqueImpulse);
842
+ }
843
+ return dice;
844
+ }
845
+ catch (error) {
846
+ const cleanupErrors = this.#removeSimulationDice(dice);
847
+ if (cleanupErrors.length === 0)
848
+ throw error;
849
+ throw new AggregateError([error, ...cleanupErrors], 'Failed to create simulation dice', {
850
+ cause: error,
851
+ });
852
+ }
853
+ }
854
+ #removeSimulationDice(dice) {
855
+ const errors = [];
856
+ for (const die of dice) {
857
+ try {
324
858
  this.#physics.removeDie(die.id);
325
- this.#renderer.removeDie(die.id);
326
859
  }
327
- throw error;
860
+ catch (error) {
861
+ errors.push(error);
862
+ }
863
+ }
864
+ return errors;
865
+ }
866
+ #appendTraceFrame(frames, dice, elapsedSeconds) {
867
+ const frameCount = frames.length + 1;
868
+ if (frameCount > this.#traceLimits.maxFrames) {
869
+ throw new TraceLimitExceededError('frames', this.#traceLimits.maxFrames, frameCount);
870
+ }
871
+ const sampleCount = frameCount * dice.length;
872
+ if (sampleCount > this.#traceLimits.maxSamples) {
873
+ throw new TraceLimitExceededError('samples', this.#traceLimits.maxSamples, sampleCount);
874
+ }
875
+ frames.push(snapshotFrame(dice, elapsedSeconds));
876
+ }
877
+ #appendTraceEvent(events, event) {
878
+ const eventCount = events.length + 1;
879
+ if (eventCount > this.#traceLimits.maxEvents) {
880
+ throw new TraceLimitExceededError('events', this.#traceLimits.maxEvents, eventCount);
328
881
  }
882
+ events.push(Object.freeze(event));
329
883
  }
330
- #createPhysicalSpecs(task) {
884
+ #createPhysicalSpecs(parsed, sessionId) {
331
885
  const specs = [];
332
886
  let groupIndex = 0;
333
- for (const expression of task.parsed.expressions) {
887
+ for (const [expressionIndex, expression] of parsed.expressions.entries()) {
334
888
  if (expression.kind === 'modifier')
335
889
  continue;
890
+ const termId = `term-${expressionIndex}`;
336
891
  if (expression.kind === 'dice') {
337
892
  const type = `d${expression.sides}`;
338
893
  if (!isDieType(type))
339
894
  throw new RangeError(`${type} is not a standard die type`);
895
+ if (!isPhysicalDieType(type))
896
+ throw new RangeError(`${type} is not a physical die type`);
897
+ const preset = this.getVisualPreset(type);
340
898
  for (let count = 0; count < expression.count; count += 1) {
341
- specs.push({ type, geometryType: type });
899
+ specs.push({
900
+ type,
901
+ geometryType: this.#getPresetGeometryType(preset),
902
+ preset,
903
+ termId,
904
+ expressionIndex,
905
+ dieIndex: count,
906
+ physicalIndex: specs.length,
907
+ ...(expression.selection === undefined ? {} : { selection: expression.selection }),
908
+ ...(expression.score === undefined ? {} : { scoreRules: expression.score }),
909
+ });
342
910
  }
343
911
  continue;
344
912
  }
345
913
  for (let count = 0; count < expression.count; count += 1) {
346
- const groupId = `${task.session.id}:group-${groupIndex++}`;
914
+ const groupId = `${sessionId}:group-${groupIndex++}`;
347
915
  if (expression.type === 'd100') {
916
+ const preset = this.getVisualPreset('d10');
348
917
  specs.push({
349
918
  type: 'd100',
350
- geometryType: 'd10',
919
+ geometryType: this.#getPresetGeometryType(preset),
920
+ preset,
921
+ termId,
922
+ expressionIndex,
923
+ dieIndex: count,
924
+ physicalIndex: specs.length,
351
925
  component: { groupId, groupType: 'd100', role: 'tens' },
352
926
  faceLabels: D100_TENS_LABELS,
353
927
  });
354
928
  specs.push({
355
929
  type: 'd10',
356
- geometryType: 'd10',
930
+ geometryType: this.#getPresetGeometryType(preset),
931
+ preset,
932
+ termId,
933
+ expressionIndex,
934
+ dieIndex: count,
935
+ physicalIndex: specs.length,
357
936
  component: { groupId, groupType: 'd100', role: 'units' },
358
937
  });
359
938
  continue;
360
939
  }
940
+ const preset = this.getVisualPreset('d6');
361
941
  specs.push({
362
942
  type: 'd6',
363
- geometryType: 'd6',
943
+ geometryType: this.#getPresetGeometryType(preset),
944
+ preset,
945
+ termId,
946
+ expressionIndex,
947
+ dieIndex: count,
948
+ physicalIndex: specs.length,
364
949
  component: { groupId, groupType: 'd66', role: 'tens' },
365
950
  faceLabels: D66_TENS_LABELS,
366
951
  });
367
952
  specs.push({
368
953
  type: 'd6',
369
- geometryType: 'd6',
954
+ geometryType: this.#getPresetGeometryType(preset),
955
+ preset,
956
+ termId,
957
+ expressionIndex,
958
+ dieIndex: count,
959
+ physicalIndex: specs.length,
370
960
  component: { groupId, groupType: 'd66', role: 'units' },
371
961
  });
372
962
  }
@@ -396,6 +986,130 @@ export class DiceEngine extends TypedEventEmitter {
396
986
  this.#runFrame(timestampMs);
397
987
  });
398
988
  }
989
+ #scheduleReplayFrame() {
990
+ if (this.#replay === undefined || this.#frameToken !== undefined)
991
+ return;
992
+ this.#frameToken = this.#scheduler.request((timestampMs) => {
993
+ this.#frameToken = undefined;
994
+ this.#runReplayFrame(timestampMs);
995
+ });
996
+ }
997
+ #runReplayFrame(timestampMs) {
998
+ const replay = this.#replay;
999
+ if (replay === undefined)
1000
+ return;
1001
+ try {
1002
+ replay.startMs ??= timestampMs;
1003
+ const elapsedSeconds = Math.max(0, (timestampMs - replay.startMs) / 1_000);
1004
+ const frames = replay.trace.frames;
1005
+ const finalFrame = frames.at(-1);
1006
+ if (frames.length === 1 || elapsedSeconds >= finalFrame.elapsedSeconds) {
1007
+ this.#dispatchReplayEvents(replay, Number.POSITIVE_INFINITY);
1008
+ this.#renderReplayPair(replay.trace, finalFrame, finalFrame, 1);
1009
+ this.#completeReplay(replay);
1010
+ return;
1011
+ }
1012
+ let nextIndex = frames.findIndex((frame) => frame.elapsedSeconds > elapsedSeconds);
1013
+ if (nextIndex < 0)
1014
+ nextIndex = frames.length - 1;
1015
+ const previous = frames[Math.max(0, nextIndex - 1)];
1016
+ const current = frames[nextIndex];
1017
+ const duration = current.elapsedSeconds - previous.elapsedSeconds;
1018
+ const alpha = duration <= 0 ? 1 : (elapsedSeconds - previous.elapsedSeconds) / duration;
1019
+ this.#dispatchReplayEvents(replay, elapsedSeconds);
1020
+ this.#renderReplayPair(replay.trace, previous, current, alpha);
1021
+ this.#scheduleReplayFrame();
1022
+ }
1023
+ catch (error) {
1024
+ this.#finishReplay(replay, error);
1025
+ }
1026
+ }
1027
+ #renderReplayPair(trace, previous, current, alpha) {
1028
+ for (const die of trace.dice) {
1029
+ this.#renderer.updateDie({
1030
+ id: die.id,
1031
+ presetId: die.presetId,
1032
+ geometryId: die.geometryId,
1033
+ scale: die.scale,
1034
+ ...(die.faceLabels === undefined ? {} : { faceLabels: die.faceLabels }),
1035
+ previous: getFrameDie(previous, die.id),
1036
+ current: getFrameDie(current, die.id),
1037
+ });
1038
+ }
1039
+ this.#renderer.render(alpha);
1040
+ }
1041
+ #dispatchReplayEvents(replay, elapsedSeconds) {
1042
+ while (replay.nextEventIndex < replay.trace.events.length) {
1043
+ const traceEvent = replay.trace.events[replay.nextEventIndex];
1044
+ if (traceEvent.elapsedSeconds > elapsedSeconds)
1045
+ return;
1046
+ replay.nextEventIndex += 1;
1047
+ const visual = this.#dieEvents.get(traceEvent.dieId);
1048
+ if (visual === undefined)
1049
+ continue;
1050
+ if (traceEvent.kind === 'collision') {
1051
+ this.emit('die:collision', Object.freeze({
1052
+ ...visual,
1053
+ ...(traceEvent.otherDieId === undefined ? {} : { otherDieId: traceEvent.otherDieId }),
1054
+ started: traceEvent.started,
1055
+ }));
1056
+ }
1057
+ else {
1058
+ this.emit('die:impact', Object.freeze({
1059
+ ...visual,
1060
+ ...(traceEvent.otherDieId === undefined ? {} : { otherDieId: traceEvent.otherDieId }),
1061
+ force: traceEvent.force,
1062
+ }));
1063
+ }
1064
+ }
1065
+ }
1066
+ #completeReplay(replay) {
1067
+ this.#finishReplay(replay);
1068
+ }
1069
+ #cancelReplay() {
1070
+ const replay = this.#replay;
1071
+ if (replay === undefined)
1072
+ return;
1073
+ this.#frameToken?.cancel();
1074
+ this.#frameToken = undefined;
1075
+ this.#finishReplay(replay, new RollCancelledError(replay.trace.result.id));
1076
+ }
1077
+ #finishReplay(replay, error) {
1078
+ if (this.#replay !== replay)
1079
+ return;
1080
+ this.#replay = undefined;
1081
+ if (replay.signal !== undefined && replay.abortListener !== undefined) {
1082
+ replay.signal.removeEventListener('abort', replay.abortListener);
1083
+ }
1084
+ if (error === undefined) {
1085
+ for (const id of replay.createdIds)
1086
+ this.#displayedDieIds.add(id);
1087
+ replay.resolve();
1088
+ return;
1089
+ }
1090
+ const reason = error instanceof RollCancelledError ? 'cancelled' : 'failed';
1091
+ const cleanupErrors = this.#removeDiceByIdSafely(replay.createdIds, reason);
1092
+ if (cleanupErrors.length === 0)
1093
+ replay.reject(error);
1094
+ else {
1095
+ replay.reject(new AggregateError([error, ...cleanupErrors], 'Trace replay failed', { cause: error }));
1096
+ }
1097
+ }
1098
+ #removeDiceByIdSafely(ids, reason) {
1099
+ const errors = [];
1100
+ for (const id of ids) {
1101
+ try {
1102
+ this.#removeDie(id, reason);
1103
+ }
1104
+ catch (error) {
1105
+ if (error instanceof AggregateError)
1106
+ errors.push(...error.errors);
1107
+ else
1108
+ errors.push(error);
1109
+ }
1110
+ }
1111
+ return errors;
1112
+ }
399
1113
  #runFrame(timestampMs) {
400
1114
  const active = this.#active;
401
1115
  if (active === undefined)
@@ -404,10 +1118,39 @@ export class DiceEngine extends TypedEventEmitter {
404
1118
  const frameDelta = Math.max(0, Math.min((timestampMs - this.#lastFrameMs) / 1_000, this.#maxFrameDeltaSeconds));
405
1119
  this.#lastFrameMs = timestampMs;
406
1120
  this.#accumulatorSeconds += frameDelta;
1121
+ let emittedCollisionEvents = 0;
407
1122
  while (this.#accumulatorSeconds >= this.#fixedStepSeconds) {
408
1123
  for (const die of active.dice)
409
1124
  die.previous = die.current;
410
1125
  this.#physics.step(this.#fixedStepSeconds);
1126
+ if (this.#collisionEvents.enabled) {
1127
+ for (const collision of this.#physics.drainCollisionEvents()) {
1128
+ if (emittedCollisionEvents >= this.#collisionEvents.maxEventsPerFrame)
1129
+ continue;
1130
+ const event = this.#dieEvents.get(collision.dieId);
1131
+ if (event === undefined)
1132
+ continue;
1133
+ this.emit('die:collision', Object.freeze({
1134
+ ...event,
1135
+ ...(collision.otherDieId === undefined ? {} : { otherDieId: collision.otherDieId }),
1136
+ started: collision.started,
1137
+ }));
1138
+ emittedCollisionEvents += 1;
1139
+ }
1140
+ for (const impact of this.#physics.drainImpactEvents()) {
1141
+ if (emittedCollisionEvents >= this.#collisionEvents.maxEventsPerFrame)
1142
+ continue;
1143
+ const event = this.#dieEvents.get(impact.dieId);
1144
+ if (event === undefined)
1145
+ continue;
1146
+ this.emit('die:impact', Object.freeze({
1147
+ ...event,
1148
+ ...(impact.otherDieId === undefined ? {} : { otherDieId: impact.otherDieId }),
1149
+ force: impact.force,
1150
+ }));
1151
+ emittedCollisionEvents += 1;
1152
+ }
1153
+ }
411
1154
  this.#accumulatorSeconds -= this.#fixedStepSeconds;
412
1155
  for (const die of active.dice) {
413
1156
  die.current = die.body.getState();
@@ -439,18 +1182,33 @@ export class DiceEngine extends TypedEventEmitter {
439
1182
  }
440
1183
  }
441
1184
  #createDieResult(die, faceValue) {
1185
+ const mappedValue = die.preset.valueMap?.[faceValue] ?? faceValue;
1186
+ const provenance = {
1187
+ termId: die.termId,
1188
+ termIndex: die.expressionIndex,
1189
+ dieIndex: die.dieIndex,
1190
+ physicalIndex: die.physicalIndex,
1191
+ state: 'included',
1192
+ faceValue: mappedValue,
1193
+ };
442
1194
  if (die.component === undefined) {
443
1195
  if (die.type === 'd100')
444
1196
  throw new Error('A d100 result requires percentile component data');
445
- return Object.freeze({ id: die.id, type: die.type, value: faceValue });
1197
+ return Object.freeze({
1198
+ id: die.id,
1199
+ type: die.type,
1200
+ value: mappedValue,
1201
+ provenance: Object.freeze({ ...provenance, contribution: mappedValue }),
1202
+ });
446
1203
  }
447
1204
  const { groupId, groupType, role } = die.component;
448
- const digit = groupType === 'd100' ? faceValue % 10 : faceValue;
1205
+ const digit = groupType === 'd100' ? mappedValue % 10 : mappedValue;
449
1206
  return Object.freeze({
450
1207
  id: die.id,
451
1208
  type: die.type,
452
1209
  value: role === 'tens' ? digit * 10 : digit,
453
- component: Object.freeze({ groupId, groupType, role, faceValue }),
1210
+ component: Object.freeze({ groupId, groupType, role, faceValue: mappedValue }),
1211
+ provenance: Object.freeze(provenance),
454
1212
  });
455
1213
  }
456
1214
  #completeActive(active) {
@@ -459,11 +1217,7 @@ export class DiceEngine extends TypedEventEmitter {
459
1217
  session.completedAt = this.#now();
460
1218
  if (session.startedAt === undefined)
461
1219
  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
- });
1220
+ const diceResults = this.#applyRollRules(active.dice);
467
1221
  const result = createRollResult({
468
1222
  id: session.id,
469
1223
  notation: session.notation,
@@ -480,14 +1234,78 @@ export class DiceEngine extends TypedEventEmitter {
480
1234
  this.emit('roll:complete', result);
481
1235
  this.#startNext();
482
1236
  }
1237
+ #applyRollRules(dice) {
1238
+ const expressionGroups = new Map();
1239
+ for (const die of dice) {
1240
+ const group = expressionGroups.get(die.expressionIndex) ?? [];
1241
+ group.push(die);
1242
+ expressionGroups.set(die.expressionIndex, group);
1243
+ }
1244
+ const inclusion = new Map();
1245
+ for (const group of expressionGroups.values()) {
1246
+ const selection = group[0]?.selection;
1247
+ if (selection === undefined)
1248
+ continue;
1249
+ const ranked = group
1250
+ .map((die, index) => {
1251
+ if (die.result === undefined)
1252
+ throw new Error(`Die ${die.id} has no settled result`);
1253
+ return { die, index, value: die.result.value };
1254
+ })
1255
+ .toSorted((left, right) => {
1256
+ const highest = selection.operator === 'kh' || selection.operator === 'dh';
1257
+ const valueOrder = highest ? right.value - left.value : left.value - right.value;
1258
+ return valueOrder === 0 ? left.index - right.index : valueOrder;
1259
+ });
1260
+ const selected = new Set(ranked.slice(0, selection.count).map(({ die }) => die.id));
1261
+ const keepsSelected = selection.operator === 'kh' || selection.operator === 'kl';
1262
+ for (const die of group) {
1263
+ inclusion.set(die.id, keepsSelected ? selected.has(die.id) : !selected.has(die.id));
1264
+ }
1265
+ }
1266
+ return dice.map((die) => {
1267
+ if (die.result === undefined)
1268
+ throw new Error(`Die ${die.id} has no settled result`);
1269
+ const included = inclusion.get(die.id);
1270
+ const score = die.scoreRules === undefined
1271
+ ? undefined
1272
+ : this.#scoreFace(die.result.value, die.scoreRules);
1273
+ if (included === undefined && score === undefined)
1274
+ return die.result;
1275
+ if (die.result.component !== undefined) {
1276
+ throw new Error(`Paired die ${die.id} cannot use keep/drop or score rules`);
1277
+ }
1278
+ const provenance = die.result.provenance;
1279
+ if (provenance === undefined)
1280
+ throw new Error(`Die ${die.id} has no result provenance`);
1281
+ return Object.freeze({
1282
+ ...die.result,
1283
+ ...(included === undefined ? {} : { included }),
1284
+ ...(score === undefined ? {} : { score }),
1285
+ provenance: Object.freeze({
1286
+ ...provenance,
1287
+ state: included === false ? 'discarded' : 'included',
1288
+ contribution: included === false ? 0 : (score ?? die.result.value),
1289
+ }),
1290
+ });
1291
+ });
1292
+ }
1293
+ #scoreFace(value, rules) {
1294
+ return rules.find((rule) => value >= rule.minimum && value <= rule.maximum)?.score ?? 0;
1295
+ }
483
1296
  #cancelActive(task) {
484
1297
  this.#frameToken?.cancel();
485
1298
  this.#frameToken = undefined;
486
1299
  const active = this.#active;
487
1300
  this.#active = undefined;
488
- if (active !== undefined)
489
- this.#removeDice(active.dice);
1301
+ const cleanupErrors = active === undefined ? [] : this.#removeDiceSafely(active.dice, 'cancelled');
490
1302
  this.#rejectCancelled(task);
1303
+ if (cleanupErrors.length > 0) {
1304
+ this.emit('error', {
1305
+ session: snapshotSession(task.session),
1306
+ error: new AggregateError(cleanupErrors, `Failed to clean up ${task.session.id}`),
1307
+ });
1308
+ }
491
1309
  this.#startNext();
492
1310
  }
493
1311
  #rejectCancelled(task) {
@@ -499,8 +1317,12 @@ export class DiceEngine extends TypedEventEmitter {
499
1317
  }
500
1318
  #failActive(active, error) {
501
1319
  this.#active = undefined;
502
- this.#removeDice(active.dice);
503
- this.#failTask(active.task, error);
1320
+ const cleanupErrors = this.#removeDiceSafely(active.dice, 'failed');
1321
+ this.#failTask(active.task, cleanupErrors.length === 0
1322
+ ? error
1323
+ : new AggregateError([error, ...cleanupErrors], `Roll ${active.task.session.id} failed`, {
1324
+ cause: error,
1325
+ }));
504
1326
  }
505
1327
  #failTask(task, error) {
506
1328
  task.session.state = 'failed';
@@ -510,24 +1332,278 @@ export class DiceEngine extends TypedEventEmitter {
510
1332
  this.emit('error', { session: snapshotSession(task.session), error });
511
1333
  this.#startNext();
512
1334
  }
513
- #removeDice(dice) {
1335
+ #removeDiceSafely(dice, reason) {
1336
+ const errors = [];
514
1337
  for (const die of dice) {
515
- this.#physics.removeDie(die.id);
516
- this.#renderer.removeDie(die.id);
1338
+ try {
1339
+ this.#removeDie(die.id, reason);
1340
+ }
1341
+ catch (error) {
1342
+ if (error instanceof AggregateError)
1343
+ errors.push(...error.errors);
1344
+ else
1345
+ errors.push(error);
1346
+ }
517
1347
  }
1348
+ return errors;
518
1349
  }
519
1350
  #removeDisplayedDice() {
1351
+ const cleanupErrors = [];
520
1352
  for (const id of this.#displayedDieIds) {
521
- this.#physics.removeDie(id);
522
- this.#renderer.removeDie(id);
1353
+ try {
1354
+ this.#removeDie(id, 'replaced');
1355
+ }
1356
+ catch (error) {
1357
+ if (error instanceof AggregateError)
1358
+ cleanupErrors.push(...error.errors);
1359
+ else
1360
+ cleanupErrors.push(error);
1361
+ }
523
1362
  }
524
1363
  this.#displayedDieIds.clear();
1364
+ if (cleanupErrors.length > 0) {
1365
+ throw new AggregateError(cleanupErrors, 'Failed to remove displayed dice');
1366
+ }
1367
+ }
1368
+ #removeDie(id, reason) {
1369
+ const cleanupErrors = this.#runCleanup([
1370
+ () => this.#physics.removeDie(id),
1371
+ () => this.#renderer.removeDie(id),
1372
+ ]);
1373
+ const event = this.#dieEvents.get(id);
1374
+ if (event !== undefined) {
1375
+ this.#dieEvents.delete(id);
1376
+ this.emit('die:remove', Object.freeze({ ...event, reason }));
1377
+ }
1378
+ if (cleanupErrors.length > 0) {
1379
+ throw new AggregateError(cleanupErrors, `Failed to remove die ${id}`);
1380
+ }
1381
+ }
1382
+ #clearRenderedDice(reason) {
1383
+ const cleanupErrors = this.#runCleanup([
1384
+ () => this.#physics.clear(),
1385
+ () => this.#renderer.clear(),
1386
+ ]);
1387
+ this.#forgetRenderedDice(reason);
1388
+ if (cleanupErrors.length > 0) {
1389
+ throw new AggregateError(cleanupErrors, 'Failed to clear rendered dice');
1390
+ }
1391
+ }
1392
+ #forgetRenderedDice(reason) {
1393
+ for (const event of this.#dieEvents.values()) {
1394
+ this.emit('die:remove', Object.freeze({ ...event, reason }));
1395
+ }
1396
+ this.#dieEvents.clear();
1397
+ this.#displayedDieIds.clear();
1398
+ }
1399
+ #runCleanup(actions) {
1400
+ const errors = [];
1401
+ for (const action of actions) {
1402
+ try {
1403
+ action();
1404
+ }
1405
+ catch (error) {
1406
+ errors.push(error);
1407
+ }
1408
+ }
1409
+ return errors;
525
1410
  }
526
1411
  #detachAbort(task) {
527
1412
  if (task.signal !== undefined && task.abortListener !== undefined) {
528
1413
  task.signal.removeEventListener('abort', task.abortListener);
529
1414
  }
530
1415
  }
1416
+ #assertExclusiveOperationAvailable(operation) {
1417
+ if (this.#active !== undefined || this.#queue.length > 0 || this.#replay !== undefined) {
1418
+ throw new Error(`Cannot ${operation} while the engine is busy`);
1419
+ }
1420
+ }
1421
+ #assertValidTrace(trace) {
1422
+ if (trace.version !== 1)
1423
+ throw new TypeError(`Unsupported physical roll trace version`);
1424
+ if (trace.producer.name !== '@dice-o-rolla/dice-engine') {
1425
+ throw new TypeError('Trace producer metadata is invalid');
1426
+ }
1427
+ if (trace.producer.version !== DICE_ENGINE_VERSION) {
1428
+ throw new TypeError(`Trace producer version ${trace.producer.version} is incompatible with ${DICE_ENGINE_VERSION}`);
1429
+ }
1430
+ if (!Number.isSafeInteger(trace.seed))
1431
+ throw new TypeError('Trace seed must be a safe integer');
1432
+ assertPositive(trace.fixedStepSeconds, 'trace.fixedStepSeconds');
1433
+ assertPositiveSafeInteger(trace.frameIntervalSteps, 'trace.frameIntervalSteps');
1434
+ if (trace.profile.fixedStepSeconds !== trace.fixedStepSeconds) {
1435
+ throw new TypeError('Trace profile fixed step does not match its envelope');
1436
+ }
1437
+ const profileValidators = [
1438
+ new SettlingDetector(trace.profile.settling),
1439
+ new ThrowGenerator(new SeededRandomSource(trace.seed), trace.profile.throw),
1440
+ ];
1441
+ void profileValidators;
1442
+ for (const [name, value] of [
1443
+ ['tray.width', trace.profile.tray.width],
1444
+ ['tray.depth', trace.profile.tray.depth],
1445
+ ['tray.wallHeight', trace.profile.tray.wallHeight],
1446
+ ['tray.wallThickness', trace.profile.tray.wallThickness],
1447
+ ]) {
1448
+ assertPositive(value, `trace.profile.${name}`);
1449
+ }
1450
+ for (const [name, value] of [
1451
+ ['tray.material.friction', trace.profile.tray.material.friction],
1452
+ ['tray.material.restitution', trace.profile.tray.material.restitution],
1453
+ ['diceMaterial.friction', trace.profile.diceMaterial.friction],
1454
+ ['diceMaterial.restitution', trace.profile.diceMaterial.restitution],
1455
+ ['diceMaterial.linearDamping', trace.profile.diceMaterial.linearDamping],
1456
+ ['diceMaterial.angularDamping', trace.profile.diceMaterial.angularDamping],
1457
+ ]) {
1458
+ if (!Number.isFinite(value) || value < 0) {
1459
+ throw new RangeError(`trace.profile.${name} must be a non-negative finite number`);
1460
+ }
1461
+ }
1462
+ if (!Number.isFinite(trace.durationSeconds) || trace.durationSeconds < 0) {
1463
+ throw new RangeError('trace.durationSeconds must be a non-negative finite number');
1464
+ }
1465
+ if (trace.frames.length === 0)
1466
+ throw new TypeError('Trace must contain at least one frame');
1467
+ if (trace.dice.length === 0)
1468
+ throw new TypeError('Trace must contain at least one die');
1469
+ if (trace.frames.length > this.#traceLimits.maxFrames) {
1470
+ throw new TraceLimitExceededError('frames', this.#traceLimits.maxFrames, trace.frames.length);
1471
+ }
1472
+ const sampleCount = trace.frames.length * trace.dice.length;
1473
+ if (sampleCount > this.#traceLimits.maxSamples) {
1474
+ throw new TraceLimitExceededError('samples', this.#traceLimits.maxSamples, sampleCount);
1475
+ }
1476
+ if (trace.events.length > this.#traceLimits.maxEvents) {
1477
+ throw new TraceLimitExceededError('events', this.#traceLimits.maxEvents, trace.events.length);
1478
+ }
1479
+ if (trace.result.notation !== trace.notation) {
1480
+ throw new TypeError('Trace result notation does not match its envelope');
1481
+ }
1482
+ const ids = new Set();
1483
+ for (const die of trace.dice) {
1484
+ if (ids.has(die.id))
1485
+ throw new TypeError(`Trace contains duplicate die id "${die.id}"`);
1486
+ ids.add(die.id);
1487
+ if (!isDieType(die.type) || !isDieType(die.geometryId) || !hasDieGeometry(die.geometryId)) {
1488
+ throw new TypeError(`Trace die "${die.id}" has an unsupported type or geometry`);
1489
+ }
1490
+ assertPositive(die.scale, `trace die "${die.id}" scale`);
1491
+ const preset = this.#visualPresets.get(die.presetId);
1492
+ if (preset === undefined) {
1493
+ throw new TypeError(`Trace requires unregistered visual preset "${die.presetId}"`);
1494
+ }
1495
+ if (preset.geometryId !== die.geometryId) {
1496
+ throw new TypeError(`Trace geometry does not match visual preset "${die.presetId}"`);
1497
+ }
1498
+ if ((preset.scale ?? 1) !== die.scale) {
1499
+ throw new TypeError(`Trace scale does not match visual preset "${die.presetId}"`);
1500
+ }
1501
+ const fingerprint = definitionFingerprint(getDieGeometry(die.geometryId), preset, die.faceLabels);
1502
+ if (die.definitionFingerprint !== fingerprint) {
1503
+ throw new TypeError(`Trace definition fingerprint mismatch for "${die.id}"`);
1504
+ }
1505
+ this.#assertFiniteTraceTransform(die.initial, `trace die "${die.id}" initial`);
1506
+ for (const [name, value] of Object.entries({
1507
+ impulseX: die.initial.impulse.x,
1508
+ impulseY: die.initial.impulse.y,
1509
+ impulseZ: die.initial.impulse.z,
1510
+ torqueX: die.initial.torqueImpulse.x,
1511
+ torqueY: die.initial.torqueImpulse.y,
1512
+ torqueZ: die.initial.torqueImpulse.z,
1513
+ })) {
1514
+ if (!Number.isFinite(value))
1515
+ throw new RangeError(`${name} must be finite`);
1516
+ }
1517
+ }
1518
+ let previousElapsed = -1;
1519
+ for (const frame of trace.frames) {
1520
+ if (!Number.isFinite(frame.elapsedSeconds) ||
1521
+ frame.elapsedSeconds < 0 ||
1522
+ frame.elapsedSeconds < previousElapsed) {
1523
+ throw new RangeError('Trace frame times must be finite, non-negative, and ordered');
1524
+ }
1525
+ previousElapsed = frame.elapsedSeconds;
1526
+ if (frame.dice.length !== trace.dice.length) {
1527
+ throw new TypeError('Every trace frame must contain every die exactly once');
1528
+ }
1529
+ const frameIds = new Set();
1530
+ for (const die of frame.dice) {
1531
+ if (!ids.has(die.id) || frameIds.has(die.id)) {
1532
+ throw new TypeError(`Trace frame contains an unknown or duplicate die id "${die.id}"`);
1533
+ }
1534
+ frameIds.add(die.id);
1535
+ this.#assertFiniteTraceTransform(die, `trace frame die "${die.id}"`);
1536
+ }
1537
+ }
1538
+ const finalFrame = trace.frames.at(-1);
1539
+ if (Math.abs(finalFrame.elapsedSeconds - trace.durationSeconds) > 1e-9) {
1540
+ throw new RangeError('Trace final frame must match trace duration');
1541
+ }
1542
+ let previousEventTime = -1;
1543
+ for (const event of trace.events) {
1544
+ if (event.kind !== 'collision' && event.kind !== 'impact') {
1545
+ throw new TypeError('Trace event kind is invalid');
1546
+ }
1547
+ if (!ids.has(event.dieId) || (event.otherDieId !== undefined && !ids.has(event.otherDieId))) {
1548
+ throw new TypeError('Trace event references an unknown die');
1549
+ }
1550
+ if (!Number.isFinite(event.elapsedSeconds) ||
1551
+ event.elapsedSeconds < previousEventTime ||
1552
+ event.elapsedSeconds > trace.durationSeconds) {
1553
+ throw new RangeError('Trace event times must be finite, ordered, and inside the trace');
1554
+ }
1555
+ previousEventTime = event.elapsedSeconds;
1556
+ if (event.kind === 'collision' && typeof event.started !== 'boolean') {
1557
+ throw new TypeError('Trace collision state must be boolean');
1558
+ }
1559
+ if (event.kind === 'impact' && (!Number.isFinite(event.force) || event.force < 0)) {
1560
+ throw new RangeError('Trace impact force must be a non-negative finite number');
1561
+ }
1562
+ }
1563
+ const resultDice = new Map(trace.result.dice.map((die) => [die.id, die]));
1564
+ if (resultDice.size !== trace.dice.length || trace.result.dice.length !== trace.dice.length) {
1565
+ throw new TypeError('Trace result must contain every physical die exactly once');
1566
+ }
1567
+ for (const die of trace.dice) {
1568
+ const result = resultDice.get(die.id);
1569
+ if (result === undefined)
1570
+ throw new TypeError(`Trace result is missing die "${die.id}"`);
1571
+ if (!isDieType(die.geometryId)) {
1572
+ throw new TypeError(`Trace die "${die.id}" has an unsupported geometry`);
1573
+ }
1574
+ const preset = this.#visualPresets.get(die.presetId);
1575
+ const resolved = resolveFace(getDieGeometry(die.geometryId), getFrameDie(finalFrame, die.id).quaternion);
1576
+ const mapped = preset.valueMap?.[resolved] ?? resolved;
1577
+ const reportedFace = result.component?.faceValue ?? result.value;
1578
+ if (mapped !== reportedFace) {
1579
+ throw new TypeError(`Trace final orientation does not match result die "${die.id}"`);
1580
+ }
1581
+ }
1582
+ const verifiedResult = createRollResult({
1583
+ id: trace.result.id,
1584
+ notation: trace.result.notation,
1585
+ dice: trace.result.dice,
1586
+ modifier: trace.result.modifier,
1587
+ startedAt: trace.result.startedAt,
1588
+ completedAt: trace.result.completedAt,
1589
+ });
1590
+ if (verifiedResult.total !== trace.result.total) {
1591
+ throw new TypeError('Trace result total does not match its dice and modifier');
1592
+ }
1593
+ }
1594
+ #assertFiniteTraceTransform(transform, description) {
1595
+ if (![
1596
+ transform.position.x,
1597
+ transform.position.y,
1598
+ transform.position.z,
1599
+ transform.quaternion.x,
1600
+ transform.quaternion.y,
1601
+ transform.quaternion.z,
1602
+ transform.quaternion.w,
1603
+ ].every(Number.isFinite)) {
1604
+ throw new RangeError(`${description} transform must contain finite values`);
1605
+ }
1606
+ }
531
1607
  #assertSupportedAndWithinLimits(parsed) {
532
1608
  let logicalDice = 0;
533
1609
  let physicalDice = 0;
@@ -571,6 +1647,40 @@ export class DiceEngine extends TypedEventEmitter {
571
1647
  }
572
1648
  return merged;
573
1649
  }
1650
+ #assertValidVisualPreset(preset) {
1651
+ if (!isDieType(preset.dieType) || !isPhysicalDieType(preset.dieType)) {
1652
+ throw new RangeError(`Visual preset die type is not supported: ${preset.dieType}`);
1653
+ }
1654
+ if (!isDieType(preset.geometryId) || !hasDieGeometry(preset.geometryId)) {
1655
+ throw new RangeError(`Visual preset geometry is not registered: ${preset.geometryId}`);
1656
+ }
1657
+ const geometry = getDieGeometry(preset.geometryId);
1658
+ const faceValues = new Set(geometry.faces.map((face) => face.value));
1659
+ const logicalSides = Number(preset.dieType.slice(1));
1660
+ if (preset.valueMap !== undefined) {
1661
+ const mappedFaces = Object.keys(preset.valueMap).map(Number);
1662
+ if (mappedFaces.length !== faceValues.size ||
1663
+ mappedFaces.some((face) => !faceValues.has(face))) {
1664
+ throw new RangeError('Visual preset valueMap must map every geometry face exactly once');
1665
+ }
1666
+ }
1667
+ for (const face of faceValues) {
1668
+ const value = preset.valueMap?.[face] ?? face;
1669
+ if (!Number.isSafeInteger(value) || value < 1 || value > logicalSides) {
1670
+ throw new RangeError(`Visual preset maps geometry face ${face} outside ${preset.dieType}`);
1671
+ }
1672
+ }
1673
+ if (preset.faceLabels !== undefined &&
1674
+ Object.keys(preset.faceLabels).some((face) => !faceValues.has(Number(face)))) {
1675
+ throw new RangeError('Visual preset faceLabels contain a face absent from its geometry');
1676
+ }
1677
+ }
1678
+ #getPresetGeometryType(preset) {
1679
+ if (!isDieType(preset.geometryId) || !hasDieGeometry(preset.geometryId)) {
1680
+ throw new RangeError(`Visual preset geometry is not registered: ${preset.geometryId}`);
1681
+ }
1682
+ return preset.geometryId;
1683
+ }
574
1684
  #assertReady() {
575
1685
  this.#assertAlive();
576
1686
  if (!this.#initialized)