@dice-o-rolla/dice-engine 0.2.0 → 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.
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,227 @@ 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
+ 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
+ }
212
523
  cancel(sessionId) {
213
524
  this.#assertAlive();
525
+ if (this.#replay !== undefined &&
526
+ (sessionId === undefined || this.#replay.trace.result.id === sessionId)) {
527
+ this.#cancelReplay();
528
+ return true;
529
+ }
214
530
  if (this.#active !== undefined &&
215
531
  (sessionId === undefined || this.#active.task.session.id === sessionId)) {
216
532
  const task = this.#active.task;
@@ -226,6 +542,8 @@ export class DiceEngine extends TypedEventEmitter {
226
542
  }
227
543
  clear() {
228
544
  this.#assertAlive();
545
+ if (this.#replay !== undefined)
546
+ this.#cancelReplay();
229
547
  this.#frameToken?.cancel();
230
548
  this.#frameToken = undefined;
231
549
  if (this.#active !== undefined) {
@@ -321,6 +639,8 @@ export class DiceEngine extends TypedEventEmitter {
321
639
  this.#destroyed = true;
322
640
  this.#frameToken?.cancel();
323
641
  this.#frameToken = undefined;
642
+ if (this.#replay !== undefined)
643
+ this.#cancelReplay();
324
644
  if (this.#active !== undefined) {
325
645
  const task = this.#active.task;
326
646
  this.#active = undefined;
@@ -404,7 +724,7 @@ export class DiceEngine extends TypedEventEmitter {
404
724
  #createDice(task) {
405
725
  const dice = [];
406
726
  let index = 0;
407
- const specs = this.#createPhysicalSpecs(task);
727
+ const specs = this.#createPhysicalSpecs(task.parsed, task.session.id);
408
728
  const totalDice = specs.length;
409
729
  try {
410
730
  for (const spec of specs) {
@@ -432,11 +752,15 @@ export class DiceEngine extends TypedEventEmitter {
432
752
  type: spec.type,
433
753
  geometryType: spec.geometryType,
434
754
  preset: spec.preset,
755
+ termId: spec.termId,
435
756
  expressionIndex: spec.expressionIndex,
757
+ dieIndex: spec.dieIndex,
758
+ physicalIndex: spec.physicalIndex,
436
759
  ...(spec.selection === undefined ? {} : { selection: spec.selection }),
437
760
  ...(spec.scoreRules === undefined ? {} : { scoreRules: spec.scoreRules }),
438
761
  ...(spec.component === undefined ? {} : { component: spec.component }),
439
762
  ...(faceLabels === undefined ? {} : { faceLabels }),
763
+ initial: generated,
440
764
  body,
441
765
  detector: new SettlingDetector(this.#settling),
442
766
  previous: state,
@@ -469,12 +793,101 @@ export class DiceEngine extends TypedEventEmitter {
469
793
  });
470
794
  }
471
795
  }
472
- #createPhysicalSpecs(task) {
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 {
858
+ this.#physics.removeDie(die.id);
859
+ }
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);
881
+ }
882
+ events.push(Object.freeze(event));
883
+ }
884
+ #createPhysicalSpecs(parsed, sessionId) {
473
885
  const specs = [];
474
886
  let groupIndex = 0;
475
- for (const [expressionIndex, expression] of task.parsed.expressions.entries()) {
887
+ for (const [expressionIndex, expression] of parsed.expressions.entries()) {
476
888
  if (expression.kind === 'modifier')
477
889
  continue;
890
+ const termId = `term-${expressionIndex}`;
478
891
  if (expression.kind === 'dice') {
479
892
  const type = `d${expression.sides}`;
480
893
  if (!isDieType(type))
@@ -487,7 +900,10 @@ export class DiceEngine extends TypedEventEmitter {
487
900
  type,
488
901
  geometryType: this.#getPresetGeometryType(preset),
489
902
  preset,
903
+ termId,
490
904
  expressionIndex,
905
+ dieIndex: count,
906
+ physicalIndex: specs.length,
491
907
  ...(expression.selection === undefined ? {} : { selection: expression.selection }),
492
908
  ...(expression.score === undefined ? {} : { scoreRules: expression.score }),
493
909
  });
@@ -495,14 +911,17 @@ export class DiceEngine extends TypedEventEmitter {
495
911
  continue;
496
912
  }
497
913
  for (let count = 0; count < expression.count; count += 1) {
498
- const groupId = `${task.session.id}:group-${groupIndex++}`;
914
+ const groupId = `${sessionId}:group-${groupIndex++}`;
499
915
  if (expression.type === 'd100') {
500
916
  const preset = this.getVisualPreset('d10');
501
917
  specs.push({
502
918
  type: 'd100',
503
919
  geometryType: this.#getPresetGeometryType(preset),
504
920
  preset,
921
+ termId,
505
922
  expressionIndex,
923
+ dieIndex: count,
924
+ physicalIndex: specs.length,
506
925
  component: { groupId, groupType: 'd100', role: 'tens' },
507
926
  faceLabels: D100_TENS_LABELS,
508
927
  });
@@ -510,7 +929,10 @@ export class DiceEngine extends TypedEventEmitter {
510
929
  type: 'd10',
511
930
  geometryType: this.#getPresetGeometryType(preset),
512
931
  preset,
932
+ termId,
513
933
  expressionIndex,
934
+ dieIndex: count,
935
+ physicalIndex: specs.length,
514
936
  component: { groupId, groupType: 'd100', role: 'units' },
515
937
  });
516
938
  continue;
@@ -520,7 +942,10 @@ export class DiceEngine extends TypedEventEmitter {
520
942
  type: 'd6',
521
943
  geometryType: this.#getPresetGeometryType(preset),
522
944
  preset,
945
+ termId,
523
946
  expressionIndex,
947
+ dieIndex: count,
948
+ physicalIndex: specs.length,
524
949
  component: { groupId, groupType: 'd66', role: 'tens' },
525
950
  faceLabels: D66_TENS_LABELS,
526
951
  });
@@ -528,7 +953,10 @@ export class DiceEngine extends TypedEventEmitter {
528
953
  type: 'd6',
529
954
  geometryType: this.#getPresetGeometryType(preset),
530
955
  preset,
956
+ termId,
531
957
  expressionIndex,
958
+ dieIndex: count,
959
+ physicalIndex: specs.length,
532
960
  component: { groupId, groupType: 'd66', role: 'units' },
533
961
  });
534
962
  }
@@ -558,6 +986,130 @@ export class DiceEngine extends TypedEventEmitter {
558
986
  this.#runFrame(timestampMs);
559
987
  });
560
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
+ }
561
1113
  #runFrame(timestampMs) {
562
1114
  const active = this.#active;
563
1115
  if (active === undefined)
@@ -631,10 +1183,23 @@ export class DiceEngine extends TypedEventEmitter {
631
1183
  }
632
1184
  #createDieResult(die, faceValue) {
633
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
+ };
634
1194
  if (die.component === undefined) {
635
1195
  if (die.type === 'd100')
636
1196
  throw new Error('A d100 result requires percentile component data');
637
- return Object.freeze({ id: die.id, type: die.type, value: mappedValue });
1197
+ return Object.freeze({
1198
+ id: die.id,
1199
+ type: die.type,
1200
+ value: mappedValue,
1201
+ provenance: Object.freeze({ ...provenance, contribution: mappedValue }),
1202
+ });
638
1203
  }
639
1204
  const { groupId, groupType, role } = die.component;
640
1205
  const digit = groupType === 'd100' ? mappedValue % 10 : mappedValue;
@@ -643,6 +1208,7 @@ export class DiceEngine extends TypedEventEmitter {
643
1208
  type: die.type,
644
1209
  value: role === 'tens' ? digit * 10 : digit,
645
1210
  component: Object.freeze({ groupId, groupType, role, faceValue: mappedValue }),
1211
+ provenance: Object.freeze(provenance),
646
1212
  });
647
1213
  }
648
1214
  #completeActive(active) {
@@ -709,10 +1275,18 @@ export class DiceEngine extends TypedEventEmitter {
709
1275
  if (die.result.component !== undefined) {
710
1276
  throw new Error(`Paired die ${die.id} cannot use keep/drop or score rules`);
711
1277
  }
1278
+ const provenance = die.result.provenance;
1279
+ if (provenance === undefined)
1280
+ throw new Error(`Die ${die.id} has no result provenance`);
712
1281
  return Object.freeze({
713
1282
  ...die.result,
714
1283
  ...(included === undefined ? {} : { included }),
715
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
+ }),
716
1290
  });
717
1291
  });
718
1292
  }
@@ -839,6 +1413,197 @@ export class DiceEngine extends TypedEventEmitter {
839
1413
  task.signal.removeEventListener('abort', task.abortListener);
840
1414
  }
841
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
+ }
842
1607
  #assertSupportedAndWithinLimits(parsed) {
843
1608
  let logicalDice = 0;
844
1609
  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.0";
@@ -0,0 +1,2 @@
1
+ /** Runtime package version used in portable trace provenance. */
2
+ export const DICE_ENGINE_VERSION = '0.3.0';
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.0",
4
4
  "description": "Framework-neutral physical dice engine with Rapier and Three.js browser adapters.",
5
5
  "keywords": [
6
6
  "dice",
@@ -47,12 +47,12 @@
47
47
  "registry": "https://registry.npmjs.org/"
48
48
  },
49
49
  "dependencies": {
50
- "@dice-o-rolla/dice-core": "0.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.0",
51
+ "@dice-o-rolla/dice-geometry": "0.3.0",
52
+ "@dice-o-rolla/dice-physics": "0.3.0",
53
+ "@dice-o-rolla/dice-physics-rapier": "0.3.0",
54
+ "@dice-o-rolla/dice-renderer": "0.3.0",
55
+ "@dice-o-rolla/dice-renderer-three": "0.3.0"
56
56
  },
57
57
  "engines": {
58
58
  "node": ">=20.0.0"