@dice-o-rolla/dice-engine 0.3.1 → 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;
@@ -328,7 +328,7 @@ export class DiceEngine extends TypedEventEmitter {
328
328
  this.#physics.setCollisionEventsEnabled(true);
329
329
  this.#physics.drainCollisionEvents();
330
330
  this.#physics.drainImpactEvents();
331
- dice.push(...this.#createSimulationDice(parsed, simulationId, throwGenerator));
331
+ dice.push(...this.#createSimulationDice(parsed, simulationId, throwGenerator, options.visualPresetSelector));
332
332
  const captureFrames = options.captureFrames === true;
333
333
  const frames = [];
334
334
  const events = [];
@@ -677,7 +677,8 @@ export class DiceEngine extends TypedEventEmitter {
677
677
  this.#renderer.setTheme(this.#theme);
678
678
  this.#initialized = true;
679
679
  }
680
- #createTask(notation, parsed, signal) {
680
+ #createTask(notation, parsed, options) {
681
+ const { signal, visualPresetSelector } = options;
681
682
  const session = {
682
683
  id: `roll-${this.#nextSessionId++}`,
683
684
  notation,
@@ -701,6 +702,7 @@ export class DiceEngine extends TypedEventEmitter {
701
702
  reject,
702
703
  ...(signal === undefined ? {} : { signal }),
703
704
  ...(abortListener === undefined ? {} : { abortListener }),
705
+ ...(visualPresetSelector === undefined ? {} : { visualPresetSelector }),
704
706
  };
705
707
  }
706
708
  #startNext() {
@@ -731,7 +733,7 @@ export class DiceEngine extends TypedEventEmitter {
731
733
  #createDice(task) {
732
734
  const dice = [];
733
735
  let index = 0;
734
- const specs = this.#createPhysicalSpecs(task.parsed, task.session.id);
736
+ const specs = this.#createPhysicalSpecs(task.parsed, task.session.id, task.visualPresetSelector);
735
737
  const totalDice = specs.length;
736
738
  try {
737
739
  for (const spec of specs) {
@@ -800,9 +802,9 @@ export class DiceEngine extends TypedEventEmitter {
800
802
  });
801
803
  }
802
804
  }
803
- #createSimulationDice(parsed, simulationId, throwGenerator) {
805
+ #createSimulationDice(parsed, simulationId, throwGenerator, visualPresetSelector) {
804
806
  const dice = [];
805
- const specs = this.#createPhysicalSpecs(parsed, simulationId);
807
+ const specs = this.#createPhysicalSpecs(parsed, simulationId, visualPresetSelector);
806
808
  const totalDice = specs.length;
807
809
  try {
808
810
  for (const [index, spec] of specs.entries()) {
@@ -888,7 +890,7 @@ export class DiceEngine extends TypedEventEmitter {
888
890
  }
889
891
  events.push(Object.freeze(event));
890
892
  }
891
- #createPhysicalSpecs(parsed, sessionId) {
893
+ #createPhysicalSpecs(parsed, sessionId, visualPresetSelector) {
892
894
  const specs = [];
893
895
  let groupIndex = 0;
894
896
  for (const [expressionIndex, expression] of parsed.expressions.entries()) {
@@ -901,8 +903,16 @@ export class DiceEngine extends TypedEventEmitter {
901
903
  throw new RangeError(`${type} is not a standard die type`);
902
904
  if (!isPhysicalDieType(type))
903
905
  throw new RangeError(`${type} is not a physical die type`);
904
- const preset = this.getVisualPreset(type);
905
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);
906
916
  specs.push({
907
917
  type,
908
918
  geometryType: this.#getPresetGeometryType(preset),
@@ -910,7 +920,7 @@ export class DiceEngine extends TypedEventEmitter {
910
920
  termId,
911
921
  expressionIndex,
912
922
  dieIndex: count,
913
- physicalIndex: specs.length,
923
+ physicalIndex,
914
924
  ...(expression.selection === undefined ? {} : { selection: expression.selection }),
915
925
  ...(expression.score === undefined ? {} : { scoreRules: expression.score }),
916
926
  });
@@ -920,56 +930,119 @@ export class DiceEngine extends TypedEventEmitter {
920
930
  for (let count = 0; count < expression.count; count += 1) {
921
931
  const groupId = `${sessionId}:group-${groupIndex++}`;
922
932
  if (expression.type === 'd100') {
923
- 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);
924
943
  specs.push({
925
944
  type: 'd100',
926
- geometryType: this.#getPresetGeometryType(preset),
927
- preset,
945
+ geometryType: this.#getPresetGeometryType(tensPreset),
946
+ preset: tensPreset,
928
947
  termId,
929
948
  expressionIndex,
930
949
  dieIndex: count,
931
- physicalIndex: specs.length,
950
+ physicalIndex: tensPhysicalIndex,
932
951
  component: { groupId, groupType: 'd100', role: 'tens' },
933
952
  faceLabels: D100_TENS_LABELS,
934
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);
935
964
  specs.push({
936
965
  type: 'd10',
937
- geometryType: this.#getPresetGeometryType(preset),
938
- preset,
966
+ geometryType: this.#getPresetGeometryType(unitsPreset),
967
+ preset: unitsPreset,
939
968
  termId,
940
969
  expressionIndex,
941
970
  dieIndex: count,
942
- physicalIndex: specs.length,
971
+ physicalIndex: unitsPhysicalIndex,
943
972
  component: { groupId, groupType: 'd100', role: 'units' },
944
973
  });
945
974
  continue;
946
975
  }
947
- 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);
948
986
  specs.push({
949
987
  type: 'd6',
950
- geometryType: this.#getPresetGeometryType(preset),
951
- preset,
988
+ geometryType: this.#getPresetGeometryType(tensPreset),
989
+ preset: tensPreset,
952
990
  termId,
953
991
  expressionIndex,
954
992
  dieIndex: count,
955
- physicalIndex: specs.length,
993
+ physicalIndex: tensPhysicalIndex,
956
994
  component: { groupId, groupType: 'd66', role: 'tens' },
957
995
  faceLabels: D66_TENS_LABELS,
958
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);
959
1007
  specs.push({
960
1008
  type: 'd6',
961
- geometryType: this.#getPresetGeometryType(preset),
962
- preset,
1009
+ geometryType: this.#getPresetGeometryType(unitsPreset),
1010
+ preset: unitsPreset,
963
1011
  termId,
964
1012
  expressionIndex,
965
1013
  dieIndex: count,
966
- physicalIndex: specs.length,
1014
+ physicalIndex: unitsPhysicalIndex,
967
1015
  component: { groupId, groupType: 'd66', role: 'units' },
968
1016
  });
969
1017
  }
970
1018
  }
971
1019
  return specs;
972
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
+ }
973
1046
  #placeDie(generated, index, total) {
974
1047
  if (total <= 4)
975
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.1";
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.1';
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.1",
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.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"
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"