@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 +38 -0
- package/dist/dice-engine.d.ts +3 -1
- package/dist/dice-engine.js +780 -8
- package/dist/errors.d.ts +7 -0
- package/dist/errors.js +12 -0
- package/dist/index.d.ts +4 -3
- package/dist/index.js +2 -1
- package/dist/types.d.ts +84 -1
- package/dist/version.d.ts +2 -0
- package/dist/version.js +2 -0
- package/package.json +7 -7
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
|
|
package/dist/dice-engine.d.ts
CHANGED
|
@@ -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;
|
package/dist/dice-engine.js
CHANGED
|
@@ -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.#
|
|
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
|
-
#
|
|
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
|
|
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 = `${
|
|
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({
|
|
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;
|
package/dist/version.js
ADDED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dice-o-rolla/dice-engine",
|
|
3
|
-
"version": "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.
|
|
51
|
-
"@dice-o-rolla/dice-geometry": "0.
|
|
52
|
-
"@dice-o-rolla/dice-physics": "0.
|
|
53
|
-
"@dice-o-rolla/dice-physics-rapier": "0.
|
|
54
|
-
"@dice-o-rolla/dice-renderer": "0.
|
|
55
|
-
"@dice-o-rolla/dice-renderer-three": "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"
|