@dice-o-rolla/dice-engine 0.2.0 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -34,6 +34,38 @@ await engine.initialize();
34
34
 
35
35
  The main entry point depends on domain contracts rather than concrete Rapier or Three.js types.
36
36
 
37
+ ## Deterministic simulation and replay
38
+
39
+ `simulate()` runs the configured physics adapter with a per-call seeded random source and returns a
40
+ JSON-serializable `PhysicalRollTrace`. It does not render or emit the ordinary roll lifecycle events.
41
+ Set `captureFrames: true` when the trace will be animated; otherwise the trace contains only its
42
+ terminal frame.
43
+
44
+ ```ts
45
+ const trace = await engine.simulate('2d20kh1', {
46
+ seed: 2026,
47
+ captureFrames: true,
48
+ frameIntervalSteps: 2,
49
+ });
50
+
51
+ await engine.replay(trace, {
52
+ theme: { material: 'matte', roughness: 0.85 },
53
+ signal: abortController.signal,
54
+ });
55
+ ```
56
+
57
+ `replay()` consumes the captured transforms without stepping physics. Its optional theme is applied
58
+ through the normal engine theme state, and the terminal dice remain rendered until the next roll,
59
+ replay, `clear()`, or `destroy()`. Simulation and replay require an idle engine because the facade
60
+ owns one physics world, renderer, and frame scheduler. Replay cancellation rejects with
61
+ `RollCancelledError`.
62
+
63
+ Traces include producer and physics profile metadata, initial throw conditions, definition
64
+ fingerprints, collision/impact events, and the immutable logical result. Replay validates registered
65
+ definitions, final orientation-derived faces, and the aggregate total before rendering. Default
66
+ trace limits are 1,200 frames, 60,000 die samples, and 20,000 events; customize them through
67
+ `DiceEngineOptions.traceLimits`. `TraceLimitExceededError` identifies the rejected dimension.
68
+
37
69
  ## Visual presets and optional effects
38
70
 
39
71
  `registerVisualPreset()` associates a logical die with a validated physical geometry, scale, face
@@ -65,6 +97,10 @@ Keep/drop rolls retain every physical die in `result.dice` and expose the select
65
97
  Selection is applied before scoring, followed by integer modifiers. Paired `d%`, `d100`, and `d66`
66
98
  terms currently reject keep/drop and score operations.
67
99
 
100
+ Engine-produced dice also contain immutable `provenance`: stable term, logical-die, and physical-die
101
+ coordinates plus the settled face, inclusion state, and contribution. Consumers can explain a
102
+ total without reparsing notation or depending on renderer state.
103
+
68
104
  Default resource limits reject oversized notation, more than 50 logical or physical dice, and more
69
105
  than eight pending rolls. Consumers may lower or explicitly raise these limits through
70
106
  `DiceEngineOptions.limits` after testing their target devices.
@@ -78,6 +114,8 @@ and final. See the canonical
78
114
  [lifecycle and runtime contract](https://github.com/creepiest-space/dice-o-rolla/blob/main/docs/engine.md).
79
115
  Consumers upgrading from `0.1` should also review the
80
116
  [0.2 migration notes](https://github.com/creepiest-space/dice-o-rolla/blob/main/docs/migration-0.2.md).
117
+ Consumers upgrading from `0.2` should review the
118
+ [0.3 migration notes](https://github.com/creepiest-space/dice-o-rolla/blob/main/docs/migration-0.3.md).
81
119
 
82
120
  ## License
83
121
 
@@ -1,13 +1,15 @@
1
1
  import { TypedEventEmitter } from '@dice-o-rolla/dice-core';
2
2
  import type { RollResult } from '@dice-o-rolla/dice-core';
3
3
  import { type RendererViewport, type VisualPresetDescriptor } from '@dice-o-rolla/dice-renderer';
4
- import type { DiceEngineEvents, DiceEngineFacade, DiceEngineOptions, DiceTheme, RegisterEngineVisualPresetOptions, RollOptions } from './types.js';
4
+ import type { DiceEngineEvents, DiceEngineFacade, DiceEngineOptions, DiceTheme, PhysicalRollTrace, RegisterEngineVisualPresetOptions, ReplayOptions, RollOptions, SimulateOptions } from './types.js';
5
5
  import { type PhysicalDieType } from './visual-presets.js';
6
6
  export declare class DiceEngine extends TypedEventEmitter<DiceEngineEvents> implements DiceEngineFacade {
7
7
  #private;
8
8
  constructor(options: DiceEngineOptions);
9
9
  initialize(): Promise<void>;
10
10
  roll(notation: string, options?: RollOptions): Promise<RollResult>;
11
+ simulate(notation: string, options: SimulateOptions): Promise<PhysicalRollTrace>;
12
+ replay(trace: PhysicalRollTrace, options?: ReplayOptions): Promise<void>;
11
13
  cancel(sessionId?: string): boolean;
12
14
  clear(): void;
13
15
  resize(viewport: RendererViewport): void;
@@ -1,9 +1,10 @@
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
4
  import { createVisualPresetDescriptor, VisualPresetRegistry, } from '@dice-o-rolla/dice-renderer';
5
5
  import { DEFAULT_THEME, defaultFrameScheduler } from './defaults.js';
6
- 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';
7
8
  import { getStandardVisualPresetId, isPhysicalDieType, PHYSICAL_DIE_TYPES, STANDARD_VISUAL_PRESETS, } from './visual-presets.js';
8
9
  const DEFAULT_SETTLING = {
9
10
  linearVelocityThreshold: 0.08,
@@ -48,6 +49,11 @@ const DEFAULT_LIMITS = Object.freeze({
48
49
  maxQueuedRolls: 8,
49
50
  });
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
+ });
51
57
  const D100_TENS_LABELS = Object.freeze({
52
58
  1: 10,
53
59
  2: 20,
@@ -89,6 +95,82 @@ function mergeFaceLabels(preset, roll) {
89
95
  return preset;
90
96
  return Object.freeze({ ...preset, ...roll });
91
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
+ }
92
174
  function assertPositive(value, name) {
93
175
  if (!Number.isFinite(value) || value <= 0) {
94
176
  throw new RangeError(`${name} must be a positive finite number`);
@@ -107,10 +189,12 @@ export class DiceEngine extends TypedEventEmitter {
107
189
  #fixedStepSeconds;
108
190
  #maxFrameDeltaSeconds;
109
191
  #settling;
192
+ #throwOptions;
110
193
  #throwGenerator;
111
194
  #tray;
112
195
  #diceMaterial;
113
196
  #limits;
197
+ #traceLimits;
114
198
  #collisionEvents;
115
199
  #visualPresets = new VisualPresetRegistry(STANDARD_VISUAL_PRESETS);
116
200
  #visualPresetIds = new Map();
@@ -118,10 +202,12 @@ export class DiceEngine extends TypedEventEmitter {
118
202
  #displayedDieIds = new Set();
119
203
  #dieEvents = new Map();
120
204
  #active;
205
+ #replay;
121
206
  #frameToken;
122
207
  #lastFrameMs = 0;
123
208
  #accumulatorSeconds = 0;
124
209
  #nextSessionId = 1;
210
+ #nextSimulationId = 1;
125
211
  #initialization;
126
212
  #initialized = false;
127
213
  #destroyed = false;
@@ -137,10 +223,12 @@ export class DiceEngine extends TypedEventEmitter {
137
223
  assertPositive(this.#fixedStepSeconds, 'fixedStepSeconds');
138
224
  assertPositive(this.#maxFrameDeltaSeconds, 'maxFrameDeltaSeconds');
139
225
  this.#settling = options.settling ?? DEFAULT_SETTLING;
140
- 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);
141
228
  this.#tray = options.tray ?? DEFAULT_TRAY;
142
229
  this.#diceMaterial = options.diceMaterial ?? DEFAULT_DICE_MATERIAL;
143
230
  this.#limits = Object.freeze({ ...DEFAULT_LIMITS, ...options.limits });
231
+ this.#traceLimits = Object.freeze({ ...DEFAULT_TRACE_LIMITS, ...options.traceLimits });
144
232
  this.#collisionEvents = Object.freeze({
145
233
  ...DEFAULT_COLLISION_EVENTS,
146
234
  ...options.collisionEvents,
@@ -154,6 +242,13 @@ export class DiceEngine extends TypedEventEmitter {
154
242
  ]) {
155
243
  assertPositiveSafeInteger(value, `limits.${name}`);
156
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
+ }
157
252
  for (const type of PHYSICAL_DIE_TYPES) {
158
253
  this.#visualPresetIds.set(type, getStandardVisualPresetId(type));
159
254
  }
@@ -185,6 +280,8 @@ export class DiceEngine extends TypedEventEmitter {
185
280
  roll(notation, options = {}) {
186
281
  try {
187
282
  this.#assertReady();
283
+ if (this.#replay !== undefined)
284
+ throw new Error('Cannot roll while a trace replay is active');
188
285
  if ((options.mode ?? 'queue') !== 'queue') {
189
286
  throw new RangeError('Only queue roll mode is currently supported');
190
287
  }
@@ -209,8 +306,234 @@ export class DiceEngine extends TypedEventEmitter {
209
306
  return Promise.reject(error);
210
307
  }
211
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
+ this.#physics.clear();
324
+ const dice = [];
325
+ let trace;
326
+ let failure;
327
+ try {
328
+ this.#physics.setCollisionEventsEnabled(true);
329
+ this.#physics.drainCollisionEvents();
330
+ this.#physics.drainImpactEvents();
331
+ dice.push(...this.#createSimulationDice(parsed, simulationId, throwGenerator));
332
+ const captureFrames = options.captureFrames === true;
333
+ const frames = [];
334
+ const events = [];
335
+ if (captureFrames)
336
+ this.#appendTraceFrame(frames, dice, 0);
337
+ let elapsedSeconds = 0;
338
+ let stepIndex = 0;
339
+ while (dice.some((die) => die.result === undefined)) {
340
+ for (const die of dice)
341
+ die.previous = die.current;
342
+ this.#physics.step(this.#fixedStepSeconds);
343
+ stepIndex += 1;
344
+ elapsedSeconds += this.#fixedStepSeconds;
345
+ for (const collision of this.#physics.drainCollisionEvents()) {
346
+ this.#appendTraceEvent(events, {
347
+ kind: 'collision',
348
+ elapsedSeconds,
349
+ dieId: collision.dieId,
350
+ ...(collision.otherDieId === undefined ? {} : { otherDieId: collision.otherDieId }),
351
+ started: collision.started,
352
+ });
353
+ }
354
+ for (const impact of this.#physics.drainImpactEvents()) {
355
+ this.#appendTraceEvent(events, {
356
+ kind: 'impact',
357
+ elapsedSeconds,
358
+ dieId: impact.dieId,
359
+ ...(impact.otherDieId === undefined ? {} : { otherDieId: impact.otherDieId }),
360
+ force: impact.force,
361
+ });
362
+ }
363
+ for (const die of dice) {
364
+ die.current = die.body.getState();
365
+ if (die.result !== undefined)
366
+ continue;
367
+ const settling = die.detector.update(die.current, this.#fixedStepSeconds * 1_000);
368
+ if (settling === 'timed-out')
369
+ throw new RollTimeoutError(simulationId);
370
+ if (settling === 'settled') {
371
+ const geometry = getDieGeometry(die.geometryType);
372
+ die.result = this.#createDieResult(die, resolveFace(geometry, die.current.quaternion));
373
+ }
374
+ }
375
+ const allSettled = dice.every((die) => die.result !== undefined);
376
+ if (captureFrames && (stepIndex % frameIntervalSteps === 0 || allSettled)) {
377
+ if (frames.at(-1)?.elapsedSeconds !== elapsedSeconds) {
378
+ this.#appendTraceFrame(frames, dice, elapsedSeconds);
379
+ }
380
+ }
381
+ }
382
+ if (!captureFrames)
383
+ this.#appendTraceFrame(frames, dice, elapsedSeconds);
384
+ const result = createRollResult({
385
+ id: simulationId,
386
+ notation,
387
+ dice: this.#applyRollRules(dice),
388
+ modifier: getNotationModifier(parsed),
389
+ startedAt: 0,
390
+ completedAt: elapsedSeconds * 1_000,
391
+ });
392
+ trace = Object.freeze({
393
+ version: 1,
394
+ producer: Object.freeze({
395
+ name: '@dice-o-rolla/dice-engine',
396
+ version: DICE_ENGINE_VERSION,
397
+ }),
398
+ notation,
399
+ seed: options.seed,
400
+ fixedStepSeconds: this.#fixedStepSeconds,
401
+ frameIntervalSteps,
402
+ durationSeconds: elapsedSeconds,
403
+ profile: snapshotTraceProfile({
404
+ fixedStepSeconds: this.#fixedStepSeconds,
405
+ settling: this.#settling,
406
+ throw: this.#throwOptions,
407
+ tray: this.#tray,
408
+ diceMaterial: this.#diceMaterial,
409
+ }),
410
+ dice: Object.freeze(dice.map((die) => Object.freeze({
411
+ id: die.id,
412
+ type: die.type,
413
+ presetId: die.preset.id,
414
+ geometryId: die.geometryType,
415
+ definitionFingerprint: definitionFingerprint(getDieGeometry(die.geometryType), die.preset, die.faceLabels),
416
+ scale: die.preset.scale ?? 1,
417
+ ...(die.faceLabels === undefined
418
+ ? {}
419
+ : { faceLabels: Object.freeze({ ...die.faceLabels }) }),
420
+ initial: freezeThrowParameters(die.initial),
421
+ }))),
422
+ frames: Object.freeze(frames),
423
+ events: Object.freeze(events),
424
+ result,
425
+ });
426
+ }
427
+ catch (error) {
428
+ failure = error;
429
+ }
430
+ const cleanupErrors = this.#removeSimulationDice(dice);
431
+ try {
432
+ this.#physics.setCollisionEventsEnabled(this.#collisionEvents.enabled);
433
+ }
434
+ catch (error) {
435
+ cleanupErrors.push(error);
436
+ }
437
+ if (failure !== undefined) {
438
+ if (cleanupErrors.length === 0)
439
+ throw failure;
440
+ throw new AggregateError([failure, ...cleanupErrors], `Simulation ${simulationId} failed`, {
441
+ cause: failure,
442
+ });
443
+ }
444
+ if (cleanupErrors.length > 0) {
445
+ throw new AggregateError(cleanupErrors, `Failed to clean up ${simulationId}`);
446
+ }
447
+ if (trace === undefined)
448
+ throw new Error(`Simulation ${simulationId} produced no trace`);
449
+ this.#assertValidTrace(trace);
450
+ return trace;
451
+ }
452
+ replay(trace, options = {}) {
453
+ try {
454
+ this.#assertReady();
455
+ this.#assertExclusiveOperationAvailable('replay');
456
+ this.#assertValidTrace(trace);
457
+ if (options.signal?.aborted === true) {
458
+ return Promise.reject(new RollCancelledError(trace.result.id));
459
+ }
460
+ this.#removeDisplayedDice();
461
+ if (options.theme !== undefined)
462
+ this.setTheme(options.theme);
463
+ const firstFrame = trace.frames[0];
464
+ const createdIds = [];
465
+ try {
466
+ for (const die of trace.dice) {
467
+ const frameDie = getFrameDie(firstFrame, die.id);
468
+ this.#renderer.createDie({
469
+ id: die.id,
470
+ presetId: die.presetId,
471
+ geometryId: die.geometryId,
472
+ scale: die.scale,
473
+ ...(die.faceLabels === undefined ? {} : { faceLabels: die.faceLabels }),
474
+ previous: frameDie,
475
+ current: frameDie,
476
+ });
477
+ createdIds.push(die.id);
478
+ const preset = this.#visualPresets.get(die.presetId);
479
+ const event = Object.freeze({
480
+ sessionId: trace.result.id,
481
+ dieId: die.id,
482
+ dieType: die.type,
483
+ presetId: die.presetId,
484
+ ...(preset.skinId === undefined ? {} : { skinId: preset.skinId }),
485
+ ...(preset.soundPackId === undefined ? {} : { soundPackId: preset.soundPackId }),
486
+ });
487
+ this.#dieEvents.set(die.id, event);
488
+ this.emit('die:spawn', event);
489
+ }
490
+ }
491
+ catch (error) {
492
+ for (const id of createdIds) {
493
+ this.#dieEvents.delete(id);
494
+ this.#renderer.removeDie(id);
495
+ }
496
+ throw error;
497
+ }
498
+ let resolve;
499
+ let reject;
500
+ const promise = new Promise((resolvePromise, rejectPromise) => {
501
+ resolve = resolvePromise;
502
+ reject = rejectPromise;
503
+ });
504
+ const abortListener = options.signal === undefined ? undefined : () => this.#cancelReplay();
505
+ if (abortListener !== undefined) {
506
+ options.signal?.addEventListener('abort', abortListener, { once: true });
507
+ }
508
+ const replay = {
509
+ trace,
510
+ resolve,
511
+ reject,
512
+ createdIds,
513
+ nextEventIndex: 0,
514
+ ...(options.signal === undefined ? {} : { signal: options.signal }),
515
+ ...(abortListener === undefined ? {} : { abortListener }),
516
+ };
517
+ this.#replay = replay;
518
+ try {
519
+ this.#scheduleReplayFrame();
520
+ }
521
+ catch (error) {
522
+ this.#finishReplay(replay, error);
523
+ }
524
+ return promise;
525
+ }
526
+ catch (error) {
527
+ return Promise.reject(error);
528
+ }
529
+ }
212
530
  cancel(sessionId) {
213
531
  this.#assertAlive();
532
+ if (this.#replay !== undefined &&
533
+ (sessionId === undefined || this.#replay.trace.result.id === sessionId)) {
534
+ this.#cancelReplay();
535
+ return true;
536
+ }
214
537
  if (this.#active !== undefined &&
215
538
  (sessionId === undefined || this.#active.task.session.id === sessionId)) {
216
539
  const task = this.#active.task;
@@ -226,6 +549,8 @@ export class DiceEngine extends TypedEventEmitter {
226
549
  }
227
550
  clear() {
228
551
  this.#assertAlive();
552
+ if (this.#replay !== undefined)
553
+ this.#cancelReplay();
229
554
  this.#frameToken?.cancel();
230
555
  this.#frameToken = undefined;
231
556
  if (this.#active !== undefined) {
@@ -321,6 +646,8 @@ export class DiceEngine extends TypedEventEmitter {
321
646
  this.#destroyed = true;
322
647
  this.#frameToken?.cancel();
323
648
  this.#frameToken = undefined;
649
+ if (this.#replay !== undefined)
650
+ this.#cancelReplay();
324
651
  if (this.#active !== undefined) {
325
652
  const task = this.#active.task;
326
653
  this.#active = undefined;
@@ -404,7 +731,7 @@ export class DiceEngine extends TypedEventEmitter {
404
731
  #createDice(task) {
405
732
  const dice = [];
406
733
  let index = 0;
407
- const specs = this.#createPhysicalSpecs(task);
734
+ const specs = this.#createPhysicalSpecs(task.parsed, task.session.id);
408
735
  const totalDice = specs.length;
409
736
  try {
410
737
  for (const spec of specs) {
@@ -432,11 +759,15 @@ export class DiceEngine extends TypedEventEmitter {
432
759
  type: spec.type,
433
760
  geometryType: spec.geometryType,
434
761
  preset: spec.preset,
762
+ termId: spec.termId,
435
763
  expressionIndex: spec.expressionIndex,
764
+ dieIndex: spec.dieIndex,
765
+ physicalIndex: spec.physicalIndex,
436
766
  ...(spec.selection === undefined ? {} : { selection: spec.selection }),
437
767
  ...(spec.scoreRules === undefined ? {} : { scoreRules: spec.scoreRules }),
438
768
  ...(spec.component === undefined ? {} : { component: spec.component }),
439
769
  ...(faceLabels === undefined ? {} : { faceLabels }),
770
+ initial: generated,
440
771
  body,
441
772
  detector: new SettlingDetector(this.#settling),
442
773
  previous: state,
@@ -469,12 +800,101 @@ export class DiceEngine extends TypedEventEmitter {
469
800
  });
470
801
  }
471
802
  }
472
- #createPhysicalSpecs(task) {
803
+ #createSimulationDice(parsed, simulationId, throwGenerator) {
804
+ const dice = [];
805
+ const specs = this.#createPhysicalSpecs(parsed, simulationId);
806
+ const totalDice = specs.length;
807
+ try {
808
+ for (const [index, spec] of specs.entries()) {
809
+ const geometry = getDieGeometry(spec.geometryType);
810
+ const faceLabels = mergeFaceLabels(spec.preset.faceLabels, spec.faceLabels);
811
+ const id = `${simulationId}:die-${index}`;
812
+ const generated = throwGenerator.generate();
813
+ const position = this.#placeDie(generated.position, index, totalDice);
814
+ const body = this.#physics.createDie({
815
+ id,
816
+ type: spec.geometryType,
817
+ collider: {
818
+ kind: 'convex-hull',
819
+ vertices: geometry.vertices.map(([x, y, z]) => ({ x, y, z })),
820
+ },
821
+ scale: spec.preset.scale ?? 1,
822
+ mass: 1,
823
+ material: this.#diceMaterial,
824
+ position,
825
+ quaternion: generated.quaternion,
826
+ });
827
+ const state = body.getState();
828
+ const die = {
829
+ id,
830
+ type: spec.type,
831
+ geometryType: spec.geometryType,
832
+ preset: spec.preset,
833
+ termId: spec.termId,
834
+ expressionIndex: spec.expressionIndex,
835
+ dieIndex: spec.dieIndex,
836
+ physicalIndex: spec.physicalIndex,
837
+ ...(spec.selection === undefined ? {} : { selection: spec.selection }),
838
+ ...(spec.scoreRules === undefined ? {} : { scoreRules: spec.scoreRules }),
839
+ ...(spec.component === undefined ? {} : { component: spec.component }),
840
+ ...(faceLabels === undefined ? {} : { faceLabels }),
841
+ initial: generated,
842
+ body,
843
+ detector: new SettlingDetector(this.#settling),
844
+ previous: state,
845
+ current: state,
846
+ };
847
+ dice.push(die);
848
+ body.applyImpulse(generated.impulse, generated.torqueImpulse);
849
+ }
850
+ return dice;
851
+ }
852
+ catch (error) {
853
+ const cleanupErrors = this.#removeSimulationDice(dice);
854
+ if (cleanupErrors.length === 0)
855
+ throw error;
856
+ throw new AggregateError([error, ...cleanupErrors], 'Failed to create simulation dice', {
857
+ cause: error,
858
+ });
859
+ }
860
+ }
861
+ #removeSimulationDice(dice) {
862
+ const errors = [];
863
+ for (const die of dice) {
864
+ try {
865
+ this.#physics.removeDie(die.id);
866
+ }
867
+ catch (error) {
868
+ errors.push(error);
869
+ }
870
+ }
871
+ return errors;
872
+ }
873
+ #appendTraceFrame(frames, dice, elapsedSeconds) {
874
+ const frameCount = frames.length + 1;
875
+ if (frameCount > this.#traceLimits.maxFrames) {
876
+ throw new TraceLimitExceededError('frames', this.#traceLimits.maxFrames, frameCount);
877
+ }
878
+ const sampleCount = frameCount * dice.length;
879
+ if (sampleCount > this.#traceLimits.maxSamples) {
880
+ throw new TraceLimitExceededError('samples', this.#traceLimits.maxSamples, sampleCount);
881
+ }
882
+ frames.push(snapshotFrame(dice, elapsedSeconds));
883
+ }
884
+ #appendTraceEvent(events, event) {
885
+ const eventCount = events.length + 1;
886
+ if (eventCount > this.#traceLimits.maxEvents) {
887
+ throw new TraceLimitExceededError('events', this.#traceLimits.maxEvents, eventCount);
888
+ }
889
+ events.push(Object.freeze(event));
890
+ }
891
+ #createPhysicalSpecs(parsed, sessionId) {
473
892
  const specs = [];
474
893
  let groupIndex = 0;
475
- for (const [expressionIndex, expression] of task.parsed.expressions.entries()) {
894
+ for (const [expressionIndex, expression] of parsed.expressions.entries()) {
476
895
  if (expression.kind === 'modifier')
477
896
  continue;
897
+ const termId = `term-${expressionIndex}`;
478
898
  if (expression.kind === 'dice') {
479
899
  const type = `d${expression.sides}`;
480
900
  if (!isDieType(type))
@@ -487,7 +907,10 @@ export class DiceEngine extends TypedEventEmitter {
487
907
  type,
488
908
  geometryType: this.#getPresetGeometryType(preset),
489
909
  preset,
910
+ termId,
490
911
  expressionIndex,
912
+ dieIndex: count,
913
+ physicalIndex: specs.length,
491
914
  ...(expression.selection === undefined ? {} : { selection: expression.selection }),
492
915
  ...(expression.score === undefined ? {} : { scoreRules: expression.score }),
493
916
  });
@@ -495,14 +918,17 @@ export class DiceEngine extends TypedEventEmitter {
495
918
  continue;
496
919
  }
497
920
  for (let count = 0; count < expression.count; count += 1) {
498
- const groupId = `${task.session.id}:group-${groupIndex++}`;
921
+ const groupId = `${sessionId}:group-${groupIndex++}`;
499
922
  if (expression.type === 'd100') {
500
923
  const preset = this.getVisualPreset('d10');
501
924
  specs.push({
502
925
  type: 'd100',
503
926
  geometryType: this.#getPresetGeometryType(preset),
504
927
  preset,
928
+ termId,
505
929
  expressionIndex,
930
+ dieIndex: count,
931
+ physicalIndex: specs.length,
506
932
  component: { groupId, groupType: 'd100', role: 'tens' },
507
933
  faceLabels: D100_TENS_LABELS,
508
934
  });
@@ -510,7 +936,10 @@ export class DiceEngine extends TypedEventEmitter {
510
936
  type: 'd10',
511
937
  geometryType: this.#getPresetGeometryType(preset),
512
938
  preset,
939
+ termId,
513
940
  expressionIndex,
941
+ dieIndex: count,
942
+ physicalIndex: specs.length,
514
943
  component: { groupId, groupType: 'd100', role: 'units' },
515
944
  });
516
945
  continue;
@@ -520,7 +949,10 @@ export class DiceEngine extends TypedEventEmitter {
520
949
  type: 'd6',
521
950
  geometryType: this.#getPresetGeometryType(preset),
522
951
  preset,
952
+ termId,
523
953
  expressionIndex,
954
+ dieIndex: count,
955
+ physicalIndex: specs.length,
524
956
  component: { groupId, groupType: 'd66', role: 'tens' },
525
957
  faceLabels: D66_TENS_LABELS,
526
958
  });
@@ -528,7 +960,10 @@ export class DiceEngine extends TypedEventEmitter {
528
960
  type: 'd6',
529
961
  geometryType: this.#getPresetGeometryType(preset),
530
962
  preset,
963
+ termId,
531
964
  expressionIndex,
965
+ dieIndex: count,
966
+ physicalIndex: specs.length,
532
967
  component: { groupId, groupType: 'd66', role: 'units' },
533
968
  });
534
969
  }
@@ -558,6 +993,130 @@ export class DiceEngine extends TypedEventEmitter {
558
993
  this.#runFrame(timestampMs);
559
994
  });
560
995
  }
996
+ #scheduleReplayFrame() {
997
+ if (this.#replay === undefined || this.#frameToken !== undefined)
998
+ return;
999
+ this.#frameToken = this.#scheduler.request((timestampMs) => {
1000
+ this.#frameToken = undefined;
1001
+ this.#runReplayFrame(timestampMs);
1002
+ });
1003
+ }
1004
+ #runReplayFrame(timestampMs) {
1005
+ const replay = this.#replay;
1006
+ if (replay === undefined)
1007
+ return;
1008
+ try {
1009
+ replay.startMs ??= timestampMs;
1010
+ const elapsedSeconds = Math.max(0, (timestampMs - replay.startMs) / 1_000);
1011
+ const frames = replay.trace.frames;
1012
+ const finalFrame = frames.at(-1);
1013
+ if (frames.length === 1 || elapsedSeconds >= finalFrame.elapsedSeconds) {
1014
+ this.#dispatchReplayEvents(replay, Number.POSITIVE_INFINITY);
1015
+ this.#renderReplayPair(replay.trace, finalFrame, finalFrame, 1);
1016
+ this.#completeReplay(replay);
1017
+ return;
1018
+ }
1019
+ let nextIndex = frames.findIndex((frame) => frame.elapsedSeconds > elapsedSeconds);
1020
+ if (nextIndex < 0)
1021
+ nextIndex = frames.length - 1;
1022
+ const previous = frames[Math.max(0, nextIndex - 1)];
1023
+ const current = frames[nextIndex];
1024
+ const duration = current.elapsedSeconds - previous.elapsedSeconds;
1025
+ const alpha = duration <= 0 ? 1 : (elapsedSeconds - previous.elapsedSeconds) / duration;
1026
+ this.#dispatchReplayEvents(replay, elapsedSeconds);
1027
+ this.#renderReplayPair(replay.trace, previous, current, alpha);
1028
+ this.#scheduleReplayFrame();
1029
+ }
1030
+ catch (error) {
1031
+ this.#finishReplay(replay, error);
1032
+ }
1033
+ }
1034
+ #renderReplayPair(trace, previous, current, alpha) {
1035
+ for (const die of trace.dice) {
1036
+ this.#renderer.updateDie({
1037
+ id: die.id,
1038
+ presetId: die.presetId,
1039
+ geometryId: die.geometryId,
1040
+ scale: die.scale,
1041
+ ...(die.faceLabels === undefined ? {} : { faceLabels: die.faceLabels }),
1042
+ previous: getFrameDie(previous, die.id),
1043
+ current: getFrameDie(current, die.id),
1044
+ });
1045
+ }
1046
+ this.#renderer.render(alpha);
1047
+ }
1048
+ #dispatchReplayEvents(replay, elapsedSeconds) {
1049
+ while (replay.nextEventIndex < replay.trace.events.length) {
1050
+ const traceEvent = replay.trace.events[replay.nextEventIndex];
1051
+ if (traceEvent.elapsedSeconds > elapsedSeconds)
1052
+ return;
1053
+ replay.nextEventIndex += 1;
1054
+ const visual = this.#dieEvents.get(traceEvent.dieId);
1055
+ if (visual === undefined)
1056
+ continue;
1057
+ if (traceEvent.kind === 'collision') {
1058
+ this.emit('die:collision', Object.freeze({
1059
+ ...visual,
1060
+ ...(traceEvent.otherDieId === undefined ? {} : { otherDieId: traceEvent.otherDieId }),
1061
+ started: traceEvent.started,
1062
+ }));
1063
+ }
1064
+ else {
1065
+ this.emit('die:impact', Object.freeze({
1066
+ ...visual,
1067
+ ...(traceEvent.otherDieId === undefined ? {} : { otherDieId: traceEvent.otherDieId }),
1068
+ force: traceEvent.force,
1069
+ }));
1070
+ }
1071
+ }
1072
+ }
1073
+ #completeReplay(replay) {
1074
+ this.#finishReplay(replay);
1075
+ }
1076
+ #cancelReplay() {
1077
+ const replay = this.#replay;
1078
+ if (replay === undefined)
1079
+ return;
1080
+ this.#frameToken?.cancel();
1081
+ this.#frameToken = undefined;
1082
+ this.#finishReplay(replay, new RollCancelledError(replay.trace.result.id));
1083
+ }
1084
+ #finishReplay(replay, error) {
1085
+ if (this.#replay !== replay)
1086
+ return;
1087
+ this.#replay = undefined;
1088
+ if (replay.signal !== undefined && replay.abortListener !== undefined) {
1089
+ replay.signal.removeEventListener('abort', replay.abortListener);
1090
+ }
1091
+ if (error === undefined) {
1092
+ for (const id of replay.createdIds)
1093
+ this.#displayedDieIds.add(id);
1094
+ replay.resolve();
1095
+ return;
1096
+ }
1097
+ const reason = error instanceof RollCancelledError ? 'cancelled' : 'failed';
1098
+ const cleanupErrors = this.#removeDiceByIdSafely(replay.createdIds, reason);
1099
+ if (cleanupErrors.length === 0)
1100
+ replay.reject(error);
1101
+ else {
1102
+ replay.reject(new AggregateError([error, ...cleanupErrors], 'Trace replay failed', { cause: error }));
1103
+ }
1104
+ }
1105
+ #removeDiceByIdSafely(ids, reason) {
1106
+ const errors = [];
1107
+ for (const id of ids) {
1108
+ try {
1109
+ this.#removeDie(id, reason);
1110
+ }
1111
+ catch (error) {
1112
+ if (error instanceof AggregateError)
1113
+ errors.push(...error.errors);
1114
+ else
1115
+ errors.push(error);
1116
+ }
1117
+ }
1118
+ return errors;
1119
+ }
561
1120
  #runFrame(timestampMs) {
562
1121
  const active = this.#active;
563
1122
  if (active === undefined)
@@ -631,10 +1190,23 @@ export class DiceEngine extends TypedEventEmitter {
631
1190
  }
632
1191
  #createDieResult(die, faceValue) {
633
1192
  const mappedValue = die.preset.valueMap?.[faceValue] ?? faceValue;
1193
+ const provenance = {
1194
+ termId: die.termId,
1195
+ termIndex: die.expressionIndex,
1196
+ dieIndex: die.dieIndex,
1197
+ physicalIndex: die.physicalIndex,
1198
+ state: 'included',
1199
+ faceValue: mappedValue,
1200
+ };
634
1201
  if (die.component === undefined) {
635
1202
  if (die.type === 'd100')
636
1203
  throw new Error('A d100 result requires percentile component data');
637
- return Object.freeze({ id: die.id, type: die.type, value: mappedValue });
1204
+ return Object.freeze({
1205
+ id: die.id,
1206
+ type: die.type,
1207
+ value: mappedValue,
1208
+ provenance: Object.freeze({ ...provenance, contribution: mappedValue }),
1209
+ });
638
1210
  }
639
1211
  const { groupId, groupType, role } = die.component;
640
1212
  const digit = groupType === 'd100' ? mappedValue % 10 : mappedValue;
@@ -643,6 +1215,7 @@ export class DiceEngine extends TypedEventEmitter {
643
1215
  type: die.type,
644
1216
  value: role === 'tens' ? digit * 10 : digit,
645
1217
  component: Object.freeze({ groupId, groupType, role, faceValue: mappedValue }),
1218
+ provenance: Object.freeze(provenance),
646
1219
  });
647
1220
  }
648
1221
  #completeActive(active) {
@@ -709,10 +1282,18 @@ export class DiceEngine extends TypedEventEmitter {
709
1282
  if (die.result.component !== undefined) {
710
1283
  throw new Error(`Paired die ${die.id} cannot use keep/drop or score rules`);
711
1284
  }
1285
+ const provenance = die.result.provenance;
1286
+ if (provenance === undefined)
1287
+ throw new Error(`Die ${die.id} has no result provenance`);
712
1288
  return Object.freeze({
713
1289
  ...die.result,
714
1290
  ...(included === undefined ? {} : { included }),
715
1291
  ...(score === undefined ? {} : { score }),
1292
+ provenance: Object.freeze({
1293
+ ...provenance,
1294
+ state: included === false ? 'discarded' : 'included',
1295
+ contribution: included === false ? 0 : (score ?? die.result.value),
1296
+ }),
716
1297
  });
717
1298
  });
718
1299
  }
@@ -839,6 +1420,197 @@ export class DiceEngine extends TypedEventEmitter {
839
1420
  task.signal.removeEventListener('abort', task.abortListener);
840
1421
  }
841
1422
  }
1423
+ #assertExclusiveOperationAvailable(operation) {
1424
+ if (this.#active !== undefined || this.#queue.length > 0 || this.#replay !== undefined) {
1425
+ throw new Error(`Cannot ${operation} while the engine is busy`);
1426
+ }
1427
+ }
1428
+ #assertValidTrace(trace) {
1429
+ if (trace.version !== 1)
1430
+ throw new TypeError(`Unsupported physical roll trace version`);
1431
+ if (trace.producer.name !== '@dice-o-rolla/dice-engine') {
1432
+ throw new TypeError('Trace producer metadata is invalid');
1433
+ }
1434
+ if (trace.producer.version !== DICE_ENGINE_VERSION) {
1435
+ throw new TypeError(`Trace producer version ${trace.producer.version} is incompatible with ${DICE_ENGINE_VERSION}`);
1436
+ }
1437
+ if (!Number.isSafeInteger(trace.seed))
1438
+ throw new TypeError('Trace seed must be a safe integer');
1439
+ assertPositive(trace.fixedStepSeconds, 'trace.fixedStepSeconds');
1440
+ assertPositiveSafeInteger(trace.frameIntervalSteps, 'trace.frameIntervalSteps');
1441
+ if (trace.profile.fixedStepSeconds !== trace.fixedStepSeconds) {
1442
+ throw new TypeError('Trace profile fixed step does not match its envelope');
1443
+ }
1444
+ const profileValidators = [
1445
+ new SettlingDetector(trace.profile.settling),
1446
+ new ThrowGenerator(new SeededRandomSource(trace.seed), trace.profile.throw),
1447
+ ];
1448
+ void profileValidators;
1449
+ for (const [name, value] of [
1450
+ ['tray.width', trace.profile.tray.width],
1451
+ ['tray.depth', trace.profile.tray.depth],
1452
+ ['tray.wallHeight', trace.profile.tray.wallHeight],
1453
+ ['tray.wallThickness', trace.profile.tray.wallThickness],
1454
+ ]) {
1455
+ assertPositive(value, `trace.profile.${name}`);
1456
+ }
1457
+ for (const [name, value] of [
1458
+ ['tray.material.friction', trace.profile.tray.material.friction],
1459
+ ['tray.material.restitution', trace.profile.tray.material.restitution],
1460
+ ['diceMaterial.friction', trace.profile.diceMaterial.friction],
1461
+ ['diceMaterial.restitution', trace.profile.diceMaterial.restitution],
1462
+ ['diceMaterial.linearDamping', trace.profile.diceMaterial.linearDamping],
1463
+ ['diceMaterial.angularDamping', trace.profile.diceMaterial.angularDamping],
1464
+ ]) {
1465
+ if (!Number.isFinite(value) || value < 0) {
1466
+ throw new RangeError(`trace.profile.${name} must be a non-negative finite number`);
1467
+ }
1468
+ }
1469
+ if (!Number.isFinite(trace.durationSeconds) || trace.durationSeconds < 0) {
1470
+ throw new RangeError('trace.durationSeconds must be a non-negative finite number');
1471
+ }
1472
+ if (trace.frames.length === 0)
1473
+ throw new TypeError('Trace must contain at least one frame');
1474
+ if (trace.dice.length === 0)
1475
+ throw new TypeError('Trace must contain at least one die');
1476
+ if (trace.frames.length > this.#traceLimits.maxFrames) {
1477
+ throw new TraceLimitExceededError('frames', this.#traceLimits.maxFrames, trace.frames.length);
1478
+ }
1479
+ const sampleCount = trace.frames.length * trace.dice.length;
1480
+ if (sampleCount > this.#traceLimits.maxSamples) {
1481
+ throw new TraceLimitExceededError('samples', this.#traceLimits.maxSamples, sampleCount);
1482
+ }
1483
+ if (trace.events.length > this.#traceLimits.maxEvents) {
1484
+ throw new TraceLimitExceededError('events', this.#traceLimits.maxEvents, trace.events.length);
1485
+ }
1486
+ if (trace.result.notation !== trace.notation) {
1487
+ throw new TypeError('Trace result notation does not match its envelope');
1488
+ }
1489
+ const ids = new Set();
1490
+ for (const die of trace.dice) {
1491
+ if (ids.has(die.id))
1492
+ throw new TypeError(`Trace contains duplicate die id "${die.id}"`);
1493
+ ids.add(die.id);
1494
+ if (!isDieType(die.type) || !isDieType(die.geometryId) || !hasDieGeometry(die.geometryId)) {
1495
+ throw new TypeError(`Trace die "${die.id}" has an unsupported type or geometry`);
1496
+ }
1497
+ assertPositive(die.scale, `trace die "${die.id}" scale`);
1498
+ const preset = this.#visualPresets.get(die.presetId);
1499
+ if (preset === undefined) {
1500
+ throw new TypeError(`Trace requires unregistered visual preset "${die.presetId}"`);
1501
+ }
1502
+ if (preset.geometryId !== die.geometryId) {
1503
+ throw new TypeError(`Trace geometry does not match visual preset "${die.presetId}"`);
1504
+ }
1505
+ if ((preset.scale ?? 1) !== die.scale) {
1506
+ throw new TypeError(`Trace scale does not match visual preset "${die.presetId}"`);
1507
+ }
1508
+ const fingerprint = definitionFingerprint(getDieGeometry(die.geometryId), preset, die.faceLabels);
1509
+ if (die.definitionFingerprint !== fingerprint) {
1510
+ throw new TypeError(`Trace definition fingerprint mismatch for "${die.id}"`);
1511
+ }
1512
+ this.#assertFiniteTraceTransform(die.initial, `trace die "${die.id}" initial`);
1513
+ for (const [name, value] of Object.entries({
1514
+ impulseX: die.initial.impulse.x,
1515
+ impulseY: die.initial.impulse.y,
1516
+ impulseZ: die.initial.impulse.z,
1517
+ torqueX: die.initial.torqueImpulse.x,
1518
+ torqueY: die.initial.torqueImpulse.y,
1519
+ torqueZ: die.initial.torqueImpulse.z,
1520
+ })) {
1521
+ if (!Number.isFinite(value))
1522
+ throw new RangeError(`${name} must be finite`);
1523
+ }
1524
+ }
1525
+ let previousElapsed = -1;
1526
+ for (const frame of trace.frames) {
1527
+ if (!Number.isFinite(frame.elapsedSeconds) ||
1528
+ frame.elapsedSeconds < 0 ||
1529
+ frame.elapsedSeconds < previousElapsed) {
1530
+ throw new RangeError('Trace frame times must be finite, non-negative, and ordered');
1531
+ }
1532
+ previousElapsed = frame.elapsedSeconds;
1533
+ if (frame.dice.length !== trace.dice.length) {
1534
+ throw new TypeError('Every trace frame must contain every die exactly once');
1535
+ }
1536
+ const frameIds = new Set();
1537
+ for (const die of frame.dice) {
1538
+ if (!ids.has(die.id) || frameIds.has(die.id)) {
1539
+ throw new TypeError(`Trace frame contains an unknown or duplicate die id "${die.id}"`);
1540
+ }
1541
+ frameIds.add(die.id);
1542
+ this.#assertFiniteTraceTransform(die, `trace frame die "${die.id}"`);
1543
+ }
1544
+ }
1545
+ const finalFrame = trace.frames.at(-1);
1546
+ if (Math.abs(finalFrame.elapsedSeconds - trace.durationSeconds) > 1e-9) {
1547
+ throw new RangeError('Trace final frame must match trace duration');
1548
+ }
1549
+ let previousEventTime = -1;
1550
+ for (const event of trace.events) {
1551
+ if (event.kind !== 'collision' && event.kind !== 'impact') {
1552
+ throw new TypeError('Trace event kind is invalid');
1553
+ }
1554
+ if (!ids.has(event.dieId) || (event.otherDieId !== undefined && !ids.has(event.otherDieId))) {
1555
+ throw new TypeError('Trace event references an unknown die');
1556
+ }
1557
+ if (!Number.isFinite(event.elapsedSeconds) ||
1558
+ event.elapsedSeconds < previousEventTime ||
1559
+ event.elapsedSeconds > trace.durationSeconds) {
1560
+ throw new RangeError('Trace event times must be finite, ordered, and inside the trace');
1561
+ }
1562
+ previousEventTime = event.elapsedSeconds;
1563
+ if (event.kind === 'collision' && typeof event.started !== 'boolean') {
1564
+ throw new TypeError('Trace collision state must be boolean');
1565
+ }
1566
+ if (event.kind === 'impact' && (!Number.isFinite(event.force) || event.force < 0)) {
1567
+ throw new RangeError('Trace impact force must be a non-negative finite number');
1568
+ }
1569
+ }
1570
+ const resultDice = new Map(trace.result.dice.map((die) => [die.id, die]));
1571
+ if (resultDice.size !== trace.dice.length || trace.result.dice.length !== trace.dice.length) {
1572
+ throw new TypeError('Trace result must contain every physical die exactly once');
1573
+ }
1574
+ for (const die of trace.dice) {
1575
+ const result = resultDice.get(die.id);
1576
+ if (result === undefined)
1577
+ throw new TypeError(`Trace result is missing die "${die.id}"`);
1578
+ if (!isDieType(die.geometryId)) {
1579
+ throw new TypeError(`Trace die "${die.id}" has an unsupported geometry`);
1580
+ }
1581
+ const preset = this.#visualPresets.get(die.presetId);
1582
+ const resolved = resolveFace(getDieGeometry(die.geometryId), getFrameDie(finalFrame, die.id).quaternion);
1583
+ const mapped = preset.valueMap?.[resolved] ?? resolved;
1584
+ const reportedFace = result.component?.faceValue ?? result.value;
1585
+ if (mapped !== reportedFace) {
1586
+ throw new TypeError(`Trace final orientation does not match result die "${die.id}"`);
1587
+ }
1588
+ }
1589
+ const verifiedResult = createRollResult({
1590
+ id: trace.result.id,
1591
+ notation: trace.result.notation,
1592
+ dice: trace.result.dice,
1593
+ modifier: trace.result.modifier,
1594
+ startedAt: trace.result.startedAt,
1595
+ completedAt: trace.result.completedAt,
1596
+ });
1597
+ if (verifiedResult.total !== trace.result.total) {
1598
+ throw new TypeError('Trace result total does not match its dice and modifier');
1599
+ }
1600
+ }
1601
+ #assertFiniteTraceTransform(transform, description) {
1602
+ if (![
1603
+ transform.position.x,
1604
+ transform.position.y,
1605
+ transform.position.z,
1606
+ transform.quaternion.x,
1607
+ transform.quaternion.y,
1608
+ transform.quaternion.z,
1609
+ transform.quaternion.w,
1610
+ ].every(Number.isFinite)) {
1611
+ throw new RangeError(`${description} transform must contain finite values`);
1612
+ }
1613
+ }
842
1614
  #assertSupportedAndWithinLimits(parsed) {
843
1615
  let logicalDice = 0;
844
1616
  let physicalDice = 0;
package/dist/errors.d.ts CHANGED
@@ -16,3 +16,10 @@ export declare class RollLimitExceededError extends RangeError {
16
16
  readonly actual: number;
17
17
  constructor(limit: RollLimit, maximum: number, actual: number);
18
18
  }
19
+ export type TraceLimit = 'events' | 'frames' | 'samples';
20
+ export declare class TraceLimitExceededError extends RangeError {
21
+ readonly limit: TraceLimit;
22
+ readonly maximum: number;
23
+ readonly actual: number;
24
+ constructor(limit: TraceLimit, maximum: number, actual: number);
25
+ }
package/dist/errors.js CHANGED
@@ -32,3 +32,15 @@ export class RollLimitExceededError extends RangeError {
32
32
  this.actual = actual;
33
33
  }
34
34
  }
35
+ export class TraceLimitExceededError extends RangeError {
36
+ limit;
37
+ maximum;
38
+ actual;
39
+ constructor(limit, maximum, actual) {
40
+ super(`Physical roll trace exceeds ${limit} limit of ${maximum} (received ${actual})`);
41
+ this.name = 'TraceLimitExceededError';
42
+ this.limit = limit;
43
+ this.maximum = maximum;
44
+ this.actual = actual;
45
+ }
46
+ }
package/dist/index.d.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  export { DiceEngine } from './dice-engine.js';
2
- export { DiceEngineDestroyedError, RollCancelledError, RollLimitExceededError, RollTimeoutError, } from './errors.js';
3
- export type { RollLimit } from './errors.js';
4
- export type { DiceEngineEvents, DiceEngineFacade, DiceEngineLimits, DiceEngineOptions, DiceCollisionEvent, DiceImpactEvent, DiceCollisionEventOptions, DiceMaterialType, DiceRemovalReason, DiceRemoveEvent, DiceTheme, DiceVisualEvent, FrameScheduler, FrameToken, RegisterEngineVisualPresetOptions, RollOptions, } from './types.js';
2
+ export { DiceEngineDestroyedError, RollCancelledError, RollLimitExceededError, RollTimeoutError, TraceLimitExceededError, } from './errors.js';
3
+ export type { RollLimit, TraceLimit } from './errors.js';
4
+ export type { DiceEngineEvents, DiceEngineFacade, DiceEngineLimits, DiceEngineOptions, DiceCollisionEvent, DiceImpactEvent, DiceCollisionEventOptions, DiceMaterialType, DiceRemovalReason, DiceRemoveEvent, DiceTheme, DiceTraceLimits, DiceVisualEvent, FrameScheduler, FrameToken, PhysicalRollFrame, PhysicalRollFrameDie, PhysicalRollTrace, PhysicalRollTraceCollisionEvent, PhysicalRollTraceDie, PhysicalRollTraceEvent, PhysicalRollTraceImpactEvent, PhysicalRollTraceProducer, PhysicalRollTraceProfile, RegisterEngineVisualPresetOptions, ReplayOptions, RollOptions, SimulateOptions, } from './types.js';
5
5
  export { getStandardVisualPresetId, PHYSICAL_DIE_TYPES, STANDARD_VISUAL_PRESETS, type PhysicalDieType, } from './visual-presets.js';
6
+ export { DICE_ENGINE_VERSION } from './version.js';
package/dist/index.js CHANGED
@@ -1,3 +1,4 @@
1
1
  export { DiceEngine } from './dice-engine.js';
2
- export { DiceEngineDestroyedError, RollCancelledError, RollLimitExceededError, RollTimeoutError, } from './errors.js';
2
+ export { DiceEngineDestroyedError, RollCancelledError, RollLimitExceededError, RollTimeoutError, TraceLimitExceededError, } from './errors.js';
3
3
  export { getStandardVisualPresetId, PHYSICAL_DIE_TYPES, STANDARD_VISUAL_PRESETS, } from './visual-presets.js';
4
+ export { DICE_ENGINE_VERSION } from './version.js';
package/dist/types.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { DieResult, RandomSource, RollMode, RollResult, RollSession } from '@dice-o-rolla/dice-core';
1
+ import type { DieResult, DieType, QuaternionLike, RandomSource, RollMode, RollResult, RollSession, Vector3Like } 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
4
  import type { RegisterVisualPresetOptions, VisualPresetDescriptor } from '@dice-o-rolla/dice-renderer';
@@ -15,6 +15,86 @@ export interface RollOptions {
15
15
  readonly mode?: RollMode;
16
16
  readonly signal?: AbortSignal;
17
17
  }
18
+ export interface SimulateOptions {
19
+ readonly seed: number;
20
+ readonly captureFrames?: boolean;
21
+ /** Capture one frame every N fixed simulation steps. */
22
+ readonly frameIntervalSteps?: number;
23
+ }
24
+ export interface ReplayOptions {
25
+ readonly theme?: Partial<DiceTheme>;
26
+ readonly signal?: AbortSignal;
27
+ }
28
+ export interface PhysicalRollFrameDie {
29
+ readonly id: string;
30
+ readonly position: Vector3Like;
31
+ readonly quaternion: QuaternionLike;
32
+ }
33
+ export interface PhysicalRollFrame {
34
+ readonly elapsedSeconds: number;
35
+ readonly dice: readonly PhysicalRollFrameDie[];
36
+ }
37
+ export interface PhysicalRollTraceDie {
38
+ readonly id: string;
39
+ readonly type: DieType;
40
+ readonly presetId: string;
41
+ readonly geometryId: string;
42
+ readonly definitionFingerprint: string;
43
+ readonly scale: number;
44
+ readonly faceLabels?: Readonly<Record<number, string | number>>;
45
+ readonly initial: {
46
+ readonly position: Vector3Like;
47
+ readonly quaternion: QuaternionLike;
48
+ readonly impulse: Vector3Like;
49
+ readonly torqueImpulse: Vector3Like;
50
+ };
51
+ }
52
+ export interface PhysicalRollTraceCollisionEvent {
53
+ readonly kind: 'collision';
54
+ readonly elapsedSeconds: number;
55
+ readonly dieId: string;
56
+ readonly otherDieId?: string;
57
+ readonly started: boolean;
58
+ }
59
+ export interface PhysicalRollTraceImpactEvent {
60
+ readonly kind: 'impact';
61
+ readonly elapsedSeconds: number;
62
+ readonly dieId: string;
63
+ readonly otherDieId?: string;
64
+ readonly force: number;
65
+ }
66
+ export type PhysicalRollTraceEvent = PhysicalRollTraceCollisionEvent | PhysicalRollTraceImpactEvent;
67
+ export interface PhysicalRollTraceProfile {
68
+ readonly fixedStepSeconds: number;
69
+ readonly settling: SettlingOptions;
70
+ readonly throw: ThrowGeneratorOptions;
71
+ readonly tray: TrayOptions;
72
+ readonly diceMaterial: DicePhysicsMaterial;
73
+ }
74
+ export interface PhysicalRollTraceProducer {
75
+ readonly name: '@dice-o-rolla/dice-engine';
76
+ readonly version: string;
77
+ }
78
+ /** JSON-serializable, renderer-neutral output of a deterministic physical simulation. */
79
+ export interface PhysicalRollTrace {
80
+ readonly version: 1;
81
+ readonly producer: PhysicalRollTraceProducer;
82
+ readonly notation: string;
83
+ readonly seed: number;
84
+ readonly fixedStepSeconds: number;
85
+ readonly frameIntervalSteps: number;
86
+ readonly durationSeconds: number;
87
+ readonly profile: PhysicalRollTraceProfile;
88
+ readonly dice: readonly PhysicalRollTraceDie[];
89
+ readonly frames: readonly PhysicalRollFrame[];
90
+ readonly events: readonly PhysicalRollTraceEvent[];
91
+ readonly result: RollResult;
92
+ }
93
+ export interface DiceTraceLimits {
94
+ readonly maxFrames: number;
95
+ readonly maxSamples: number;
96
+ readonly maxEvents: number;
97
+ }
18
98
  export interface RegisterEngineVisualPresetOptions extends RegisterVisualPresetOptions {
19
99
  readonly makeDefault?: boolean;
20
100
  }
@@ -80,6 +160,7 @@ export interface DiceEngineOptions {
80
160
  readonly diceMaterial?: DicePhysicsMaterial;
81
161
  readonly theme?: Partial<DiceTheme>;
82
162
  readonly limits?: Partial<DiceEngineLimits>;
163
+ readonly traceLimits?: Partial<DiceTraceLimits>;
83
164
  readonly visualPresets?: readonly VisualPresetDescriptor[];
84
165
  readonly visualPresetIds?: Partial<Readonly<Record<PhysicalDieType, string>>>;
85
166
  readonly collisionEvents?: Partial<DiceCollisionEventOptions>;
@@ -87,6 +168,8 @@ export interface DiceEngineOptions {
87
168
  export interface DiceEngineFacade {
88
169
  initialize(): Promise<void>;
89
170
  roll(notation: string, options?: RollOptions): Promise<RollResult>;
171
+ simulate(notation: string, options: SimulateOptions): Promise<PhysicalRollTrace>;
172
+ replay(trace: PhysicalRollTrace, options?: ReplayOptions): Promise<void>;
90
173
  cancel(sessionId?: string): boolean;
91
174
  clear(): void;
92
175
  resize(viewport: RendererViewport): void;
@@ -0,0 +1,2 @@
1
+ /** Runtime package version used in portable trace provenance. */
2
+ export declare const DICE_ENGINE_VERSION = "0.3.1";
@@ -0,0 +1,2 @@
1
+ /** Runtime package version used in portable trace provenance. */
2
+ export const DICE_ENGINE_VERSION = '0.3.1';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dice-o-rolla/dice-engine",
3
- "version": "0.2.0",
3
+ "version": "0.3.1",
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.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"
50
+ "@dice-o-rolla/dice-core": "0.3.1",
51
+ "@dice-o-rolla/dice-geometry": "0.3.1",
52
+ "@dice-o-rolla/dice-physics": "0.3.1",
53
+ "@dice-o-rolla/dice-physics-rapier": "0.3.1",
54
+ "@dice-o-rolla/dice-renderer": "0.3.1",
55
+ "@dice-o-rolla/dice-renderer-three": "0.3.1"
56
56
  },
57
57
  "engines": {
58
58
  "node": ">=20.0.0"