@dice-o-rolla/dice-engine 0.3.0 → 0.4.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
@@ -73,6 +73,11 @@ labels, and optional value map. `skinId` and `soundPackId` are opaque applicatio
73
73
  the engine does not load assets. Skins and sound definitions belong in the optional
74
74
  `@dice-o-rolla/dice-assets` package, which is not a dependency of the engine.
75
75
 
76
+ Use `visualPresetSelector` in `roll()` or `simulate()` options to select a registered preset for
77
+ each physical die. The callback receives stable term/die/physical indices, physical and logical die
78
+ types, the current default, and paired-component metadata. Returning `undefined` keeps the default;
79
+ the engine rejects unknown IDs and presets for another physical die type before creating bodies.
80
+
76
81
  The engine emits `die:spawn` and `die:remove` lifecycle events. Collision events are opt-in through
77
82
  `DiceEngineOptions.collisionEvents`, bounded by `maxEventsPerFrame`, and suitable for an external
78
83
  sound or effects adapter.
@@ -293,7 +293,7 @@ export class DiceEngine extends TypedEventEmitter {
293
293
  if (this.#active !== undefined && this.#queue.length >= this.#limits.maxQueuedRolls) {
294
294
  throw new RollLimitExceededError('queue-size', this.#limits.maxQueuedRolls, this.#queue.length + 1);
295
295
  }
296
- const task = this.#createTask(notation, parsed, options.signal);
296
+ const task = this.#createTask(notation, parsed, options);
297
297
  if (options.signal?.aborted === true) {
298
298
  this.#rejectCancelled(task);
299
299
  return task.promise;
@@ -320,6 +320,7 @@ export class DiceEngine extends TypedEventEmitter {
320
320
  const throwGenerator = new ThrowGenerator(random, this.#throwOptions);
321
321
  const simulationId = `simulation-${this.#nextSimulationId++}`;
322
322
  this.#removeDisplayedDice();
323
+ this.#physics.clear();
323
324
  const dice = [];
324
325
  let trace;
325
326
  let failure;
@@ -327,7 +328,7 @@ export class DiceEngine extends TypedEventEmitter {
327
328
  this.#physics.setCollisionEventsEnabled(true);
328
329
  this.#physics.drainCollisionEvents();
329
330
  this.#physics.drainImpactEvents();
330
- dice.push(...this.#createSimulationDice(parsed, simulationId, throwGenerator));
331
+ dice.push(...this.#createSimulationDice(parsed, simulationId, throwGenerator, options.visualPresetSelector));
331
332
  const captureFrames = options.captureFrames === true;
332
333
  const frames = [];
333
334
  const events = [];
@@ -504,7 +505,7 @@ export class DiceEngine extends TypedEventEmitter {
504
505
  if (abortListener !== undefined) {
505
506
  options.signal?.addEventListener('abort', abortListener, { once: true });
506
507
  }
507
- this.#replay = {
508
+ const replay = {
508
509
  trace,
509
510
  resolve,
510
511
  reject,
@@ -513,7 +514,13 @@ export class DiceEngine extends TypedEventEmitter {
513
514
  ...(options.signal === undefined ? {} : { signal: options.signal }),
514
515
  ...(abortListener === undefined ? {} : { abortListener }),
515
516
  };
516
- this.#scheduleReplayFrame();
517
+ this.#replay = replay;
518
+ try {
519
+ this.#scheduleReplayFrame();
520
+ }
521
+ catch (error) {
522
+ this.#finishReplay(replay, error);
523
+ }
517
524
  return promise;
518
525
  }
519
526
  catch (error) {
@@ -670,7 +677,8 @@ export class DiceEngine extends TypedEventEmitter {
670
677
  this.#renderer.setTheme(this.#theme);
671
678
  this.#initialized = true;
672
679
  }
673
- #createTask(notation, parsed, signal) {
680
+ #createTask(notation, parsed, options) {
681
+ const { signal, visualPresetSelector } = options;
674
682
  const session = {
675
683
  id: `roll-${this.#nextSessionId++}`,
676
684
  notation,
@@ -694,6 +702,7 @@ export class DiceEngine extends TypedEventEmitter {
694
702
  reject,
695
703
  ...(signal === undefined ? {} : { signal }),
696
704
  ...(abortListener === undefined ? {} : { abortListener }),
705
+ ...(visualPresetSelector === undefined ? {} : { visualPresetSelector }),
697
706
  };
698
707
  }
699
708
  #startNext() {
@@ -724,7 +733,7 @@ export class DiceEngine extends TypedEventEmitter {
724
733
  #createDice(task) {
725
734
  const dice = [];
726
735
  let index = 0;
727
- const specs = this.#createPhysicalSpecs(task.parsed, task.session.id);
736
+ const specs = this.#createPhysicalSpecs(task.parsed, task.session.id, task.visualPresetSelector);
728
737
  const totalDice = specs.length;
729
738
  try {
730
739
  for (const spec of specs) {
@@ -793,9 +802,9 @@ export class DiceEngine extends TypedEventEmitter {
793
802
  });
794
803
  }
795
804
  }
796
- #createSimulationDice(parsed, simulationId, throwGenerator) {
805
+ #createSimulationDice(parsed, simulationId, throwGenerator, visualPresetSelector) {
797
806
  const dice = [];
798
- const specs = this.#createPhysicalSpecs(parsed, simulationId);
807
+ const specs = this.#createPhysicalSpecs(parsed, simulationId, visualPresetSelector);
799
808
  const totalDice = specs.length;
800
809
  try {
801
810
  for (const [index, spec] of specs.entries()) {
@@ -881,7 +890,7 @@ export class DiceEngine extends TypedEventEmitter {
881
890
  }
882
891
  events.push(Object.freeze(event));
883
892
  }
884
- #createPhysicalSpecs(parsed, sessionId) {
893
+ #createPhysicalSpecs(parsed, sessionId, visualPresetSelector) {
885
894
  const specs = [];
886
895
  let groupIndex = 0;
887
896
  for (const [expressionIndex, expression] of parsed.expressions.entries()) {
@@ -894,8 +903,16 @@ export class DiceEngine extends TypedEventEmitter {
894
903
  throw new RangeError(`${type} is not a standard die type`);
895
904
  if (!isPhysicalDieType(type))
896
905
  throw new RangeError(`${type} is not a physical die type`);
897
- const preset = this.getVisualPreset(type);
898
906
  for (let count = 0; count < expression.count; count += 1) {
907
+ const physicalIndex = specs.length;
908
+ const preset = this.#resolveVisualPreset({
909
+ physicalDieType: type,
910
+ logicalDieType: type,
911
+ termId,
912
+ expressionIndex,
913
+ dieIndex: count,
914
+ physicalIndex,
915
+ }, visualPresetSelector);
899
916
  specs.push({
900
917
  type,
901
918
  geometryType: this.#getPresetGeometryType(preset),
@@ -903,7 +920,7 @@ export class DiceEngine extends TypedEventEmitter {
903
920
  termId,
904
921
  expressionIndex,
905
922
  dieIndex: count,
906
- physicalIndex: specs.length,
923
+ physicalIndex,
907
924
  ...(expression.selection === undefined ? {} : { selection: expression.selection }),
908
925
  ...(expression.score === undefined ? {} : { scoreRules: expression.score }),
909
926
  });
@@ -913,56 +930,119 @@ export class DiceEngine extends TypedEventEmitter {
913
930
  for (let count = 0; count < expression.count; count += 1) {
914
931
  const groupId = `${sessionId}:group-${groupIndex++}`;
915
932
  if (expression.type === 'd100') {
916
- const preset = this.getVisualPreset('d10');
933
+ const tensPhysicalIndex = specs.length;
934
+ const tensPreset = this.#resolveVisualPreset({
935
+ physicalDieType: 'd10',
936
+ logicalDieType: 'd100',
937
+ termId,
938
+ expressionIndex,
939
+ dieIndex: count,
940
+ physicalIndex: tensPhysicalIndex,
941
+ component: { groupType: 'd100', role: 'tens' },
942
+ }, visualPresetSelector);
917
943
  specs.push({
918
944
  type: 'd100',
919
- geometryType: this.#getPresetGeometryType(preset),
920
- preset,
945
+ geometryType: this.#getPresetGeometryType(tensPreset),
946
+ preset: tensPreset,
921
947
  termId,
922
948
  expressionIndex,
923
949
  dieIndex: count,
924
- physicalIndex: specs.length,
950
+ physicalIndex: tensPhysicalIndex,
925
951
  component: { groupId, groupType: 'd100', role: 'tens' },
926
952
  faceLabels: D100_TENS_LABELS,
927
953
  });
954
+ const unitsPhysicalIndex = specs.length;
955
+ const unitsPreset = this.#resolveVisualPreset({
956
+ physicalDieType: 'd10',
957
+ logicalDieType: 'd100',
958
+ termId,
959
+ expressionIndex,
960
+ dieIndex: count,
961
+ physicalIndex: unitsPhysicalIndex,
962
+ component: { groupType: 'd100', role: 'units' },
963
+ }, visualPresetSelector);
928
964
  specs.push({
929
965
  type: 'd10',
930
- geometryType: this.#getPresetGeometryType(preset),
931
- preset,
966
+ geometryType: this.#getPresetGeometryType(unitsPreset),
967
+ preset: unitsPreset,
932
968
  termId,
933
969
  expressionIndex,
934
970
  dieIndex: count,
935
- physicalIndex: specs.length,
971
+ physicalIndex: unitsPhysicalIndex,
936
972
  component: { groupId, groupType: 'd100', role: 'units' },
937
973
  });
938
974
  continue;
939
975
  }
940
- const preset = this.getVisualPreset('d6');
976
+ const tensPhysicalIndex = specs.length;
977
+ const tensPreset = this.#resolveVisualPreset({
978
+ physicalDieType: 'd6',
979
+ logicalDieType: 'd66',
980
+ termId,
981
+ expressionIndex,
982
+ dieIndex: count,
983
+ physicalIndex: tensPhysicalIndex,
984
+ component: { groupType: 'd66', role: 'tens' },
985
+ }, visualPresetSelector);
941
986
  specs.push({
942
987
  type: 'd6',
943
- geometryType: this.#getPresetGeometryType(preset),
944
- preset,
988
+ geometryType: this.#getPresetGeometryType(tensPreset),
989
+ preset: tensPreset,
945
990
  termId,
946
991
  expressionIndex,
947
992
  dieIndex: count,
948
- physicalIndex: specs.length,
993
+ physicalIndex: tensPhysicalIndex,
949
994
  component: { groupId, groupType: 'd66', role: 'tens' },
950
995
  faceLabels: D66_TENS_LABELS,
951
996
  });
997
+ const unitsPhysicalIndex = specs.length;
998
+ const unitsPreset = this.#resolveVisualPreset({
999
+ physicalDieType: 'd6',
1000
+ logicalDieType: 'd66',
1001
+ termId,
1002
+ expressionIndex,
1003
+ dieIndex: count,
1004
+ physicalIndex: unitsPhysicalIndex,
1005
+ component: { groupType: 'd66', role: 'units' },
1006
+ }, visualPresetSelector);
952
1007
  specs.push({
953
1008
  type: 'd6',
954
- geometryType: this.#getPresetGeometryType(preset),
955
- preset,
1009
+ geometryType: this.#getPresetGeometryType(unitsPreset),
1010
+ preset: unitsPreset,
956
1011
  termId,
957
1012
  expressionIndex,
958
1013
  dieIndex: count,
959
- physicalIndex: specs.length,
1014
+ physicalIndex: unitsPhysicalIndex,
960
1015
  component: { groupId, groupType: 'd66', role: 'units' },
961
1016
  });
962
1017
  }
963
1018
  }
964
1019
  return specs;
965
1020
  }
1021
+ #resolveVisualPreset(coordinates, selector) {
1022
+ const defaultPreset = this.getVisualPreset(coordinates.physicalDieType);
1023
+ if (selector === undefined)
1024
+ return defaultPreset;
1025
+ const context = Object.freeze({
1026
+ ...coordinates,
1027
+ ...(coordinates.component === undefined
1028
+ ? {}
1029
+ : { component: Object.freeze({ ...coordinates.component }) }),
1030
+ defaultPresetId: defaultPreset.id,
1031
+ });
1032
+ const selectedId = selector(context);
1033
+ if (selectedId === undefined)
1034
+ return defaultPreset;
1035
+ if (typeof selectedId !== 'string') {
1036
+ throw new TypeError('visualPresetSelector must return a preset ID or undefined');
1037
+ }
1038
+ const preset = this.#visualPresets.get(selectedId);
1039
+ if (preset === undefined)
1040
+ throw new RangeError(`Unknown visual preset: ${selectedId}`);
1041
+ if (preset.dieType !== coordinates.physicalDieType) {
1042
+ throw new RangeError(`Visual preset "${selectedId}" is for ${preset.dieType}, not ${coordinates.physicalDieType}`);
1043
+ }
1044
+ return preset;
1045
+ }
966
1046
  #placeDie(generated, index, total) {
967
1047
  if (total <= 4)
968
1048
  return generated;
package/dist/index.d.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  export { DiceEngine } from './dice-engine.js';
2
2
  export { DiceEngineDestroyedError, RollCancelledError, RollLimitExceededError, RollTimeoutError, TraceLimitExceededError, } from './errors.js';
3
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';
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, VisualPresetSelectionContext, VisualPresetSelector, } from './types.js';
5
5
  export { getStandardVisualPresetId, PHYSICAL_DIE_TYPES, STANDARD_VISUAL_PRESETS, type PhysicalDieType, } from './visual-presets.js';
6
6
  export { DICE_ENGINE_VERSION } from './version.js';
package/dist/types.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { DieResult, DieType, QuaternionLike, RandomSource, RollMode, RollResult, RollSession, Vector3Like } from '@dice-o-rolla/dice-core';
1
+ import type { DiceComponentRole, DieResult, DieType, PairedDiceType, 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';
@@ -11,15 +11,34 @@ export interface FrameScheduler {
11
11
  }
12
12
  export type DiceMaterialType = RendererTheme['material'];
13
13
  export type DiceTheme = RendererTheme;
14
+ export interface VisualPresetSelectionContext {
15
+ /** Physical polyhedron being allocated. Paired dice use d10 or d6 here. */
16
+ readonly physicalDieType: PhysicalDieType;
17
+ /** Notation-level die type. Paired components retain d100 or d66 here. */
18
+ readonly logicalDieType: PhysicalDieType | PairedDiceType;
19
+ readonly termId: string;
20
+ readonly expressionIndex: number;
21
+ readonly dieIndex: number;
22
+ readonly physicalIndex: number;
23
+ readonly defaultPresetId: string;
24
+ readonly component?: {
25
+ readonly groupType: PairedDiceType;
26
+ readonly role: DiceComponentRole;
27
+ };
28
+ }
29
+ /** Returns a registered preset ID, or undefined to retain the selected default. */
30
+ export type VisualPresetSelector = (context: VisualPresetSelectionContext) => string | undefined;
14
31
  export interface RollOptions {
15
32
  readonly mode?: RollMode;
16
33
  readonly signal?: AbortSignal;
34
+ readonly visualPresetSelector?: VisualPresetSelector;
17
35
  }
18
36
  export interface SimulateOptions {
19
37
  readonly seed: number;
20
38
  readonly captureFrames?: boolean;
21
39
  /** Capture one frame every N fixed simulation steps. */
22
40
  readonly frameIntervalSteps?: number;
41
+ readonly visualPresetSelector?: VisualPresetSelector;
23
42
  }
24
43
  export interface ReplayOptions {
25
44
  readonly theme?: Partial<DiceTheme>;
package/dist/version.d.ts CHANGED
@@ -1,2 +1,2 @@
1
1
  /** Runtime package version used in portable trace provenance. */
2
- export declare const DICE_ENGINE_VERSION = "0.3.0";
2
+ export declare const DICE_ENGINE_VERSION = "0.4.0";
package/dist/version.js CHANGED
@@ -1,2 +1,2 @@
1
1
  /** Runtime package version used in portable trace provenance. */
2
- export const DICE_ENGINE_VERSION = '0.3.0';
2
+ export const DICE_ENGINE_VERSION = '0.4.0';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dice-o-rolla/dice-engine",
3
- "version": "0.3.0",
3
+ "version": "0.4.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.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"
50
+ "@dice-o-rolla/dice-core": "0.4.0",
51
+ "@dice-o-rolla/dice-geometry": "0.4.0",
52
+ "@dice-o-rolla/dice-physics": "0.4.0",
53
+ "@dice-o-rolla/dice-physics-rapier": "0.4.0",
54
+ "@dice-o-rolla/dice-renderer": "0.4.0",
55
+ "@dice-o-rolla/dice-renderer-three": "0.4.0"
56
56
  },
57
57
  "engines": {
58
58
  "node": ">=20.0.0"