@dice-o-rolla/dice-engine 0.2.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +38 -0
- package/dist/dice-engine.d.ts +3 -1
- package/dist/dice-engine.js +773 -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,227 @@ export class DiceEngine extends TypedEventEmitter {
|
|
|
209
306
|
return Promise.reject(error);
|
|
210
307
|
}
|
|
211
308
|
}
|
|
309
|
+
async simulate(notation, options) {
|
|
310
|
+
this.#assertReady();
|
|
311
|
+
this.#assertExclusiveOperationAvailable('simulate');
|
|
312
|
+
if (notation.length > this.#limits.maxNotationLength) {
|
|
313
|
+
throw new RollLimitExceededError('notation-length', this.#limits.maxNotationLength, notation.length);
|
|
314
|
+
}
|
|
315
|
+
const parsed = parseNotation(notation);
|
|
316
|
+
this.#assertSupportedAndWithinLimits(parsed);
|
|
317
|
+
const frameIntervalSteps = options.frameIntervalSteps ?? 1;
|
|
318
|
+
assertPositiveSafeInteger(frameIntervalSteps, 'frameIntervalSteps');
|
|
319
|
+
const random = new SeededRandomSource(options.seed);
|
|
320
|
+
const throwGenerator = new ThrowGenerator(random, this.#throwOptions);
|
|
321
|
+
const simulationId = `simulation-${this.#nextSimulationId++}`;
|
|
322
|
+
this.#removeDisplayedDice();
|
|
323
|
+
const dice = [];
|
|
324
|
+
let trace;
|
|
325
|
+
let failure;
|
|
326
|
+
try {
|
|
327
|
+
this.#physics.setCollisionEventsEnabled(true);
|
|
328
|
+
this.#physics.drainCollisionEvents();
|
|
329
|
+
this.#physics.drainImpactEvents();
|
|
330
|
+
dice.push(...this.#createSimulationDice(parsed, simulationId, throwGenerator));
|
|
331
|
+
const captureFrames = options.captureFrames === true;
|
|
332
|
+
const frames = [];
|
|
333
|
+
const events = [];
|
|
334
|
+
if (captureFrames)
|
|
335
|
+
this.#appendTraceFrame(frames, dice, 0);
|
|
336
|
+
let elapsedSeconds = 0;
|
|
337
|
+
let stepIndex = 0;
|
|
338
|
+
while (dice.some((die) => die.result === undefined)) {
|
|
339
|
+
for (const die of dice)
|
|
340
|
+
die.previous = die.current;
|
|
341
|
+
this.#physics.step(this.#fixedStepSeconds);
|
|
342
|
+
stepIndex += 1;
|
|
343
|
+
elapsedSeconds += this.#fixedStepSeconds;
|
|
344
|
+
for (const collision of this.#physics.drainCollisionEvents()) {
|
|
345
|
+
this.#appendTraceEvent(events, {
|
|
346
|
+
kind: 'collision',
|
|
347
|
+
elapsedSeconds,
|
|
348
|
+
dieId: collision.dieId,
|
|
349
|
+
...(collision.otherDieId === undefined ? {} : { otherDieId: collision.otherDieId }),
|
|
350
|
+
started: collision.started,
|
|
351
|
+
});
|
|
352
|
+
}
|
|
353
|
+
for (const impact of this.#physics.drainImpactEvents()) {
|
|
354
|
+
this.#appendTraceEvent(events, {
|
|
355
|
+
kind: 'impact',
|
|
356
|
+
elapsedSeconds,
|
|
357
|
+
dieId: impact.dieId,
|
|
358
|
+
...(impact.otherDieId === undefined ? {} : { otherDieId: impact.otherDieId }),
|
|
359
|
+
force: impact.force,
|
|
360
|
+
});
|
|
361
|
+
}
|
|
362
|
+
for (const die of dice) {
|
|
363
|
+
die.current = die.body.getState();
|
|
364
|
+
if (die.result !== undefined)
|
|
365
|
+
continue;
|
|
366
|
+
const settling = die.detector.update(die.current, this.#fixedStepSeconds * 1_000);
|
|
367
|
+
if (settling === 'timed-out')
|
|
368
|
+
throw new RollTimeoutError(simulationId);
|
|
369
|
+
if (settling === 'settled') {
|
|
370
|
+
const geometry = getDieGeometry(die.geometryType);
|
|
371
|
+
die.result = this.#createDieResult(die, resolveFace(geometry, die.current.quaternion));
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
const allSettled = dice.every((die) => die.result !== undefined);
|
|
375
|
+
if (captureFrames && (stepIndex % frameIntervalSteps === 0 || allSettled)) {
|
|
376
|
+
if (frames.at(-1)?.elapsedSeconds !== elapsedSeconds) {
|
|
377
|
+
this.#appendTraceFrame(frames, dice, elapsedSeconds);
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
if (!captureFrames)
|
|
382
|
+
this.#appendTraceFrame(frames, dice, elapsedSeconds);
|
|
383
|
+
const result = createRollResult({
|
|
384
|
+
id: simulationId,
|
|
385
|
+
notation,
|
|
386
|
+
dice: this.#applyRollRules(dice),
|
|
387
|
+
modifier: getNotationModifier(parsed),
|
|
388
|
+
startedAt: 0,
|
|
389
|
+
completedAt: elapsedSeconds * 1_000,
|
|
390
|
+
});
|
|
391
|
+
trace = Object.freeze({
|
|
392
|
+
version: 1,
|
|
393
|
+
producer: Object.freeze({
|
|
394
|
+
name: '@dice-o-rolla/dice-engine',
|
|
395
|
+
version: DICE_ENGINE_VERSION,
|
|
396
|
+
}),
|
|
397
|
+
notation,
|
|
398
|
+
seed: options.seed,
|
|
399
|
+
fixedStepSeconds: this.#fixedStepSeconds,
|
|
400
|
+
frameIntervalSteps,
|
|
401
|
+
durationSeconds: elapsedSeconds,
|
|
402
|
+
profile: snapshotTraceProfile({
|
|
403
|
+
fixedStepSeconds: this.#fixedStepSeconds,
|
|
404
|
+
settling: this.#settling,
|
|
405
|
+
throw: this.#throwOptions,
|
|
406
|
+
tray: this.#tray,
|
|
407
|
+
diceMaterial: this.#diceMaterial,
|
|
408
|
+
}),
|
|
409
|
+
dice: Object.freeze(dice.map((die) => Object.freeze({
|
|
410
|
+
id: die.id,
|
|
411
|
+
type: die.type,
|
|
412
|
+
presetId: die.preset.id,
|
|
413
|
+
geometryId: die.geometryType,
|
|
414
|
+
definitionFingerprint: definitionFingerprint(getDieGeometry(die.geometryType), die.preset, die.faceLabels),
|
|
415
|
+
scale: die.preset.scale ?? 1,
|
|
416
|
+
...(die.faceLabels === undefined
|
|
417
|
+
? {}
|
|
418
|
+
: { faceLabels: Object.freeze({ ...die.faceLabels }) }),
|
|
419
|
+
initial: freezeThrowParameters(die.initial),
|
|
420
|
+
}))),
|
|
421
|
+
frames: Object.freeze(frames),
|
|
422
|
+
events: Object.freeze(events),
|
|
423
|
+
result,
|
|
424
|
+
});
|
|
425
|
+
}
|
|
426
|
+
catch (error) {
|
|
427
|
+
failure = error;
|
|
428
|
+
}
|
|
429
|
+
const cleanupErrors = this.#removeSimulationDice(dice);
|
|
430
|
+
try {
|
|
431
|
+
this.#physics.setCollisionEventsEnabled(this.#collisionEvents.enabled);
|
|
432
|
+
}
|
|
433
|
+
catch (error) {
|
|
434
|
+
cleanupErrors.push(error);
|
|
435
|
+
}
|
|
436
|
+
if (failure !== undefined) {
|
|
437
|
+
if (cleanupErrors.length === 0)
|
|
438
|
+
throw failure;
|
|
439
|
+
throw new AggregateError([failure, ...cleanupErrors], `Simulation ${simulationId} failed`, {
|
|
440
|
+
cause: failure,
|
|
441
|
+
});
|
|
442
|
+
}
|
|
443
|
+
if (cleanupErrors.length > 0) {
|
|
444
|
+
throw new AggregateError(cleanupErrors, `Failed to clean up ${simulationId}`);
|
|
445
|
+
}
|
|
446
|
+
if (trace === undefined)
|
|
447
|
+
throw new Error(`Simulation ${simulationId} produced no trace`);
|
|
448
|
+
this.#assertValidTrace(trace);
|
|
449
|
+
return trace;
|
|
450
|
+
}
|
|
451
|
+
replay(trace, options = {}) {
|
|
452
|
+
try {
|
|
453
|
+
this.#assertReady();
|
|
454
|
+
this.#assertExclusiveOperationAvailable('replay');
|
|
455
|
+
this.#assertValidTrace(trace);
|
|
456
|
+
if (options.signal?.aborted === true) {
|
|
457
|
+
return Promise.reject(new RollCancelledError(trace.result.id));
|
|
458
|
+
}
|
|
459
|
+
this.#removeDisplayedDice();
|
|
460
|
+
if (options.theme !== undefined)
|
|
461
|
+
this.setTheme(options.theme);
|
|
462
|
+
const firstFrame = trace.frames[0];
|
|
463
|
+
const createdIds = [];
|
|
464
|
+
try {
|
|
465
|
+
for (const die of trace.dice) {
|
|
466
|
+
const frameDie = getFrameDie(firstFrame, die.id);
|
|
467
|
+
this.#renderer.createDie({
|
|
468
|
+
id: die.id,
|
|
469
|
+
presetId: die.presetId,
|
|
470
|
+
geometryId: die.geometryId,
|
|
471
|
+
scale: die.scale,
|
|
472
|
+
...(die.faceLabels === undefined ? {} : { faceLabels: die.faceLabels }),
|
|
473
|
+
previous: frameDie,
|
|
474
|
+
current: frameDie,
|
|
475
|
+
});
|
|
476
|
+
createdIds.push(die.id);
|
|
477
|
+
const preset = this.#visualPresets.get(die.presetId);
|
|
478
|
+
const event = Object.freeze({
|
|
479
|
+
sessionId: trace.result.id,
|
|
480
|
+
dieId: die.id,
|
|
481
|
+
dieType: die.type,
|
|
482
|
+
presetId: die.presetId,
|
|
483
|
+
...(preset.skinId === undefined ? {} : { skinId: preset.skinId }),
|
|
484
|
+
...(preset.soundPackId === undefined ? {} : { soundPackId: preset.soundPackId }),
|
|
485
|
+
});
|
|
486
|
+
this.#dieEvents.set(die.id, event);
|
|
487
|
+
this.emit('die:spawn', event);
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
catch (error) {
|
|
491
|
+
for (const id of createdIds) {
|
|
492
|
+
this.#dieEvents.delete(id);
|
|
493
|
+
this.#renderer.removeDie(id);
|
|
494
|
+
}
|
|
495
|
+
throw error;
|
|
496
|
+
}
|
|
497
|
+
let resolve;
|
|
498
|
+
let reject;
|
|
499
|
+
const promise = new Promise((resolvePromise, rejectPromise) => {
|
|
500
|
+
resolve = resolvePromise;
|
|
501
|
+
reject = rejectPromise;
|
|
502
|
+
});
|
|
503
|
+
const abortListener = options.signal === undefined ? undefined : () => this.#cancelReplay();
|
|
504
|
+
if (abortListener !== undefined) {
|
|
505
|
+
options.signal?.addEventListener('abort', abortListener, { once: true });
|
|
506
|
+
}
|
|
507
|
+
this.#replay = {
|
|
508
|
+
trace,
|
|
509
|
+
resolve,
|
|
510
|
+
reject,
|
|
511
|
+
createdIds,
|
|
512
|
+
nextEventIndex: 0,
|
|
513
|
+
...(options.signal === undefined ? {} : { signal: options.signal }),
|
|
514
|
+
...(abortListener === undefined ? {} : { abortListener }),
|
|
515
|
+
};
|
|
516
|
+
this.#scheduleReplayFrame();
|
|
517
|
+
return promise;
|
|
518
|
+
}
|
|
519
|
+
catch (error) {
|
|
520
|
+
return Promise.reject(error);
|
|
521
|
+
}
|
|
522
|
+
}
|
|
212
523
|
cancel(sessionId) {
|
|
213
524
|
this.#assertAlive();
|
|
525
|
+
if (this.#replay !== undefined &&
|
|
526
|
+
(sessionId === undefined || this.#replay.trace.result.id === sessionId)) {
|
|
527
|
+
this.#cancelReplay();
|
|
528
|
+
return true;
|
|
529
|
+
}
|
|
214
530
|
if (this.#active !== undefined &&
|
|
215
531
|
(sessionId === undefined || this.#active.task.session.id === sessionId)) {
|
|
216
532
|
const task = this.#active.task;
|
|
@@ -226,6 +542,8 @@ export class DiceEngine extends TypedEventEmitter {
|
|
|
226
542
|
}
|
|
227
543
|
clear() {
|
|
228
544
|
this.#assertAlive();
|
|
545
|
+
if (this.#replay !== undefined)
|
|
546
|
+
this.#cancelReplay();
|
|
229
547
|
this.#frameToken?.cancel();
|
|
230
548
|
this.#frameToken = undefined;
|
|
231
549
|
if (this.#active !== undefined) {
|
|
@@ -321,6 +639,8 @@ export class DiceEngine extends TypedEventEmitter {
|
|
|
321
639
|
this.#destroyed = true;
|
|
322
640
|
this.#frameToken?.cancel();
|
|
323
641
|
this.#frameToken = undefined;
|
|
642
|
+
if (this.#replay !== undefined)
|
|
643
|
+
this.#cancelReplay();
|
|
324
644
|
if (this.#active !== undefined) {
|
|
325
645
|
const task = this.#active.task;
|
|
326
646
|
this.#active = undefined;
|
|
@@ -404,7 +724,7 @@ export class DiceEngine extends TypedEventEmitter {
|
|
|
404
724
|
#createDice(task) {
|
|
405
725
|
const dice = [];
|
|
406
726
|
let index = 0;
|
|
407
|
-
const specs = this.#createPhysicalSpecs(task);
|
|
727
|
+
const specs = this.#createPhysicalSpecs(task.parsed, task.session.id);
|
|
408
728
|
const totalDice = specs.length;
|
|
409
729
|
try {
|
|
410
730
|
for (const spec of specs) {
|
|
@@ -432,11 +752,15 @@ export class DiceEngine extends TypedEventEmitter {
|
|
|
432
752
|
type: spec.type,
|
|
433
753
|
geometryType: spec.geometryType,
|
|
434
754
|
preset: spec.preset,
|
|
755
|
+
termId: spec.termId,
|
|
435
756
|
expressionIndex: spec.expressionIndex,
|
|
757
|
+
dieIndex: spec.dieIndex,
|
|
758
|
+
physicalIndex: spec.physicalIndex,
|
|
436
759
|
...(spec.selection === undefined ? {} : { selection: spec.selection }),
|
|
437
760
|
...(spec.scoreRules === undefined ? {} : { scoreRules: spec.scoreRules }),
|
|
438
761
|
...(spec.component === undefined ? {} : { component: spec.component }),
|
|
439
762
|
...(faceLabels === undefined ? {} : { faceLabels }),
|
|
763
|
+
initial: generated,
|
|
440
764
|
body,
|
|
441
765
|
detector: new SettlingDetector(this.#settling),
|
|
442
766
|
previous: state,
|
|
@@ -469,12 +793,101 @@ export class DiceEngine extends TypedEventEmitter {
|
|
|
469
793
|
});
|
|
470
794
|
}
|
|
471
795
|
}
|
|
472
|
-
#
|
|
796
|
+
#createSimulationDice(parsed, simulationId, throwGenerator) {
|
|
797
|
+
const dice = [];
|
|
798
|
+
const specs = this.#createPhysicalSpecs(parsed, simulationId);
|
|
799
|
+
const totalDice = specs.length;
|
|
800
|
+
try {
|
|
801
|
+
for (const [index, spec] of specs.entries()) {
|
|
802
|
+
const geometry = getDieGeometry(spec.geometryType);
|
|
803
|
+
const faceLabels = mergeFaceLabels(spec.preset.faceLabels, spec.faceLabels);
|
|
804
|
+
const id = `${simulationId}:die-${index}`;
|
|
805
|
+
const generated = throwGenerator.generate();
|
|
806
|
+
const position = this.#placeDie(generated.position, index, totalDice);
|
|
807
|
+
const body = this.#physics.createDie({
|
|
808
|
+
id,
|
|
809
|
+
type: spec.geometryType,
|
|
810
|
+
collider: {
|
|
811
|
+
kind: 'convex-hull',
|
|
812
|
+
vertices: geometry.vertices.map(([x, y, z]) => ({ x, y, z })),
|
|
813
|
+
},
|
|
814
|
+
scale: spec.preset.scale ?? 1,
|
|
815
|
+
mass: 1,
|
|
816
|
+
material: this.#diceMaterial,
|
|
817
|
+
position,
|
|
818
|
+
quaternion: generated.quaternion,
|
|
819
|
+
});
|
|
820
|
+
const state = body.getState();
|
|
821
|
+
const die = {
|
|
822
|
+
id,
|
|
823
|
+
type: spec.type,
|
|
824
|
+
geometryType: spec.geometryType,
|
|
825
|
+
preset: spec.preset,
|
|
826
|
+
termId: spec.termId,
|
|
827
|
+
expressionIndex: spec.expressionIndex,
|
|
828
|
+
dieIndex: spec.dieIndex,
|
|
829
|
+
physicalIndex: spec.physicalIndex,
|
|
830
|
+
...(spec.selection === undefined ? {} : { selection: spec.selection }),
|
|
831
|
+
...(spec.scoreRules === undefined ? {} : { scoreRules: spec.scoreRules }),
|
|
832
|
+
...(spec.component === undefined ? {} : { component: spec.component }),
|
|
833
|
+
...(faceLabels === undefined ? {} : { faceLabels }),
|
|
834
|
+
initial: generated,
|
|
835
|
+
body,
|
|
836
|
+
detector: new SettlingDetector(this.#settling),
|
|
837
|
+
previous: state,
|
|
838
|
+
current: state,
|
|
839
|
+
};
|
|
840
|
+
dice.push(die);
|
|
841
|
+
body.applyImpulse(generated.impulse, generated.torqueImpulse);
|
|
842
|
+
}
|
|
843
|
+
return dice;
|
|
844
|
+
}
|
|
845
|
+
catch (error) {
|
|
846
|
+
const cleanupErrors = this.#removeSimulationDice(dice);
|
|
847
|
+
if (cleanupErrors.length === 0)
|
|
848
|
+
throw error;
|
|
849
|
+
throw new AggregateError([error, ...cleanupErrors], 'Failed to create simulation dice', {
|
|
850
|
+
cause: error,
|
|
851
|
+
});
|
|
852
|
+
}
|
|
853
|
+
}
|
|
854
|
+
#removeSimulationDice(dice) {
|
|
855
|
+
const errors = [];
|
|
856
|
+
for (const die of dice) {
|
|
857
|
+
try {
|
|
858
|
+
this.#physics.removeDie(die.id);
|
|
859
|
+
}
|
|
860
|
+
catch (error) {
|
|
861
|
+
errors.push(error);
|
|
862
|
+
}
|
|
863
|
+
}
|
|
864
|
+
return errors;
|
|
865
|
+
}
|
|
866
|
+
#appendTraceFrame(frames, dice, elapsedSeconds) {
|
|
867
|
+
const frameCount = frames.length + 1;
|
|
868
|
+
if (frameCount > this.#traceLimits.maxFrames) {
|
|
869
|
+
throw new TraceLimitExceededError('frames', this.#traceLimits.maxFrames, frameCount);
|
|
870
|
+
}
|
|
871
|
+
const sampleCount = frameCount * dice.length;
|
|
872
|
+
if (sampleCount > this.#traceLimits.maxSamples) {
|
|
873
|
+
throw new TraceLimitExceededError('samples', this.#traceLimits.maxSamples, sampleCount);
|
|
874
|
+
}
|
|
875
|
+
frames.push(snapshotFrame(dice, elapsedSeconds));
|
|
876
|
+
}
|
|
877
|
+
#appendTraceEvent(events, event) {
|
|
878
|
+
const eventCount = events.length + 1;
|
|
879
|
+
if (eventCount > this.#traceLimits.maxEvents) {
|
|
880
|
+
throw new TraceLimitExceededError('events', this.#traceLimits.maxEvents, eventCount);
|
|
881
|
+
}
|
|
882
|
+
events.push(Object.freeze(event));
|
|
883
|
+
}
|
|
884
|
+
#createPhysicalSpecs(parsed, sessionId) {
|
|
473
885
|
const specs = [];
|
|
474
886
|
let groupIndex = 0;
|
|
475
|
-
for (const [expressionIndex, expression] of
|
|
887
|
+
for (const [expressionIndex, expression] of parsed.expressions.entries()) {
|
|
476
888
|
if (expression.kind === 'modifier')
|
|
477
889
|
continue;
|
|
890
|
+
const termId = `term-${expressionIndex}`;
|
|
478
891
|
if (expression.kind === 'dice') {
|
|
479
892
|
const type = `d${expression.sides}`;
|
|
480
893
|
if (!isDieType(type))
|
|
@@ -487,7 +900,10 @@ export class DiceEngine extends TypedEventEmitter {
|
|
|
487
900
|
type,
|
|
488
901
|
geometryType: this.#getPresetGeometryType(preset),
|
|
489
902
|
preset,
|
|
903
|
+
termId,
|
|
490
904
|
expressionIndex,
|
|
905
|
+
dieIndex: count,
|
|
906
|
+
physicalIndex: specs.length,
|
|
491
907
|
...(expression.selection === undefined ? {} : { selection: expression.selection }),
|
|
492
908
|
...(expression.score === undefined ? {} : { scoreRules: expression.score }),
|
|
493
909
|
});
|
|
@@ -495,14 +911,17 @@ export class DiceEngine extends TypedEventEmitter {
|
|
|
495
911
|
continue;
|
|
496
912
|
}
|
|
497
913
|
for (let count = 0; count < expression.count; count += 1) {
|
|
498
|
-
const groupId = `${
|
|
914
|
+
const groupId = `${sessionId}:group-${groupIndex++}`;
|
|
499
915
|
if (expression.type === 'd100') {
|
|
500
916
|
const preset = this.getVisualPreset('d10');
|
|
501
917
|
specs.push({
|
|
502
918
|
type: 'd100',
|
|
503
919
|
geometryType: this.#getPresetGeometryType(preset),
|
|
504
920
|
preset,
|
|
921
|
+
termId,
|
|
505
922
|
expressionIndex,
|
|
923
|
+
dieIndex: count,
|
|
924
|
+
physicalIndex: specs.length,
|
|
506
925
|
component: { groupId, groupType: 'd100', role: 'tens' },
|
|
507
926
|
faceLabels: D100_TENS_LABELS,
|
|
508
927
|
});
|
|
@@ -510,7 +929,10 @@ export class DiceEngine extends TypedEventEmitter {
|
|
|
510
929
|
type: 'd10',
|
|
511
930
|
geometryType: this.#getPresetGeometryType(preset),
|
|
512
931
|
preset,
|
|
932
|
+
termId,
|
|
513
933
|
expressionIndex,
|
|
934
|
+
dieIndex: count,
|
|
935
|
+
physicalIndex: specs.length,
|
|
514
936
|
component: { groupId, groupType: 'd100', role: 'units' },
|
|
515
937
|
});
|
|
516
938
|
continue;
|
|
@@ -520,7 +942,10 @@ export class DiceEngine extends TypedEventEmitter {
|
|
|
520
942
|
type: 'd6',
|
|
521
943
|
geometryType: this.#getPresetGeometryType(preset),
|
|
522
944
|
preset,
|
|
945
|
+
termId,
|
|
523
946
|
expressionIndex,
|
|
947
|
+
dieIndex: count,
|
|
948
|
+
physicalIndex: specs.length,
|
|
524
949
|
component: { groupId, groupType: 'd66', role: 'tens' },
|
|
525
950
|
faceLabels: D66_TENS_LABELS,
|
|
526
951
|
});
|
|
@@ -528,7 +953,10 @@ export class DiceEngine extends TypedEventEmitter {
|
|
|
528
953
|
type: 'd6',
|
|
529
954
|
geometryType: this.#getPresetGeometryType(preset),
|
|
530
955
|
preset,
|
|
956
|
+
termId,
|
|
531
957
|
expressionIndex,
|
|
958
|
+
dieIndex: count,
|
|
959
|
+
physicalIndex: specs.length,
|
|
532
960
|
component: { groupId, groupType: 'd66', role: 'units' },
|
|
533
961
|
});
|
|
534
962
|
}
|
|
@@ -558,6 +986,130 @@ export class DiceEngine extends TypedEventEmitter {
|
|
|
558
986
|
this.#runFrame(timestampMs);
|
|
559
987
|
});
|
|
560
988
|
}
|
|
989
|
+
#scheduleReplayFrame() {
|
|
990
|
+
if (this.#replay === undefined || this.#frameToken !== undefined)
|
|
991
|
+
return;
|
|
992
|
+
this.#frameToken = this.#scheduler.request((timestampMs) => {
|
|
993
|
+
this.#frameToken = undefined;
|
|
994
|
+
this.#runReplayFrame(timestampMs);
|
|
995
|
+
});
|
|
996
|
+
}
|
|
997
|
+
#runReplayFrame(timestampMs) {
|
|
998
|
+
const replay = this.#replay;
|
|
999
|
+
if (replay === undefined)
|
|
1000
|
+
return;
|
|
1001
|
+
try {
|
|
1002
|
+
replay.startMs ??= timestampMs;
|
|
1003
|
+
const elapsedSeconds = Math.max(0, (timestampMs - replay.startMs) / 1_000);
|
|
1004
|
+
const frames = replay.trace.frames;
|
|
1005
|
+
const finalFrame = frames.at(-1);
|
|
1006
|
+
if (frames.length === 1 || elapsedSeconds >= finalFrame.elapsedSeconds) {
|
|
1007
|
+
this.#dispatchReplayEvents(replay, Number.POSITIVE_INFINITY);
|
|
1008
|
+
this.#renderReplayPair(replay.trace, finalFrame, finalFrame, 1);
|
|
1009
|
+
this.#completeReplay(replay);
|
|
1010
|
+
return;
|
|
1011
|
+
}
|
|
1012
|
+
let nextIndex = frames.findIndex((frame) => frame.elapsedSeconds > elapsedSeconds);
|
|
1013
|
+
if (nextIndex < 0)
|
|
1014
|
+
nextIndex = frames.length - 1;
|
|
1015
|
+
const previous = frames[Math.max(0, nextIndex - 1)];
|
|
1016
|
+
const current = frames[nextIndex];
|
|
1017
|
+
const duration = current.elapsedSeconds - previous.elapsedSeconds;
|
|
1018
|
+
const alpha = duration <= 0 ? 1 : (elapsedSeconds - previous.elapsedSeconds) / duration;
|
|
1019
|
+
this.#dispatchReplayEvents(replay, elapsedSeconds);
|
|
1020
|
+
this.#renderReplayPair(replay.trace, previous, current, alpha);
|
|
1021
|
+
this.#scheduleReplayFrame();
|
|
1022
|
+
}
|
|
1023
|
+
catch (error) {
|
|
1024
|
+
this.#finishReplay(replay, error);
|
|
1025
|
+
}
|
|
1026
|
+
}
|
|
1027
|
+
#renderReplayPair(trace, previous, current, alpha) {
|
|
1028
|
+
for (const die of trace.dice) {
|
|
1029
|
+
this.#renderer.updateDie({
|
|
1030
|
+
id: die.id,
|
|
1031
|
+
presetId: die.presetId,
|
|
1032
|
+
geometryId: die.geometryId,
|
|
1033
|
+
scale: die.scale,
|
|
1034
|
+
...(die.faceLabels === undefined ? {} : { faceLabels: die.faceLabels }),
|
|
1035
|
+
previous: getFrameDie(previous, die.id),
|
|
1036
|
+
current: getFrameDie(current, die.id),
|
|
1037
|
+
});
|
|
1038
|
+
}
|
|
1039
|
+
this.#renderer.render(alpha);
|
|
1040
|
+
}
|
|
1041
|
+
#dispatchReplayEvents(replay, elapsedSeconds) {
|
|
1042
|
+
while (replay.nextEventIndex < replay.trace.events.length) {
|
|
1043
|
+
const traceEvent = replay.trace.events[replay.nextEventIndex];
|
|
1044
|
+
if (traceEvent.elapsedSeconds > elapsedSeconds)
|
|
1045
|
+
return;
|
|
1046
|
+
replay.nextEventIndex += 1;
|
|
1047
|
+
const visual = this.#dieEvents.get(traceEvent.dieId);
|
|
1048
|
+
if (visual === undefined)
|
|
1049
|
+
continue;
|
|
1050
|
+
if (traceEvent.kind === 'collision') {
|
|
1051
|
+
this.emit('die:collision', Object.freeze({
|
|
1052
|
+
...visual,
|
|
1053
|
+
...(traceEvent.otherDieId === undefined ? {} : { otherDieId: traceEvent.otherDieId }),
|
|
1054
|
+
started: traceEvent.started,
|
|
1055
|
+
}));
|
|
1056
|
+
}
|
|
1057
|
+
else {
|
|
1058
|
+
this.emit('die:impact', Object.freeze({
|
|
1059
|
+
...visual,
|
|
1060
|
+
...(traceEvent.otherDieId === undefined ? {} : { otherDieId: traceEvent.otherDieId }),
|
|
1061
|
+
force: traceEvent.force,
|
|
1062
|
+
}));
|
|
1063
|
+
}
|
|
1064
|
+
}
|
|
1065
|
+
}
|
|
1066
|
+
#completeReplay(replay) {
|
|
1067
|
+
this.#finishReplay(replay);
|
|
1068
|
+
}
|
|
1069
|
+
#cancelReplay() {
|
|
1070
|
+
const replay = this.#replay;
|
|
1071
|
+
if (replay === undefined)
|
|
1072
|
+
return;
|
|
1073
|
+
this.#frameToken?.cancel();
|
|
1074
|
+
this.#frameToken = undefined;
|
|
1075
|
+
this.#finishReplay(replay, new RollCancelledError(replay.trace.result.id));
|
|
1076
|
+
}
|
|
1077
|
+
#finishReplay(replay, error) {
|
|
1078
|
+
if (this.#replay !== replay)
|
|
1079
|
+
return;
|
|
1080
|
+
this.#replay = undefined;
|
|
1081
|
+
if (replay.signal !== undefined && replay.abortListener !== undefined) {
|
|
1082
|
+
replay.signal.removeEventListener('abort', replay.abortListener);
|
|
1083
|
+
}
|
|
1084
|
+
if (error === undefined) {
|
|
1085
|
+
for (const id of replay.createdIds)
|
|
1086
|
+
this.#displayedDieIds.add(id);
|
|
1087
|
+
replay.resolve();
|
|
1088
|
+
return;
|
|
1089
|
+
}
|
|
1090
|
+
const reason = error instanceof RollCancelledError ? 'cancelled' : 'failed';
|
|
1091
|
+
const cleanupErrors = this.#removeDiceByIdSafely(replay.createdIds, reason);
|
|
1092
|
+
if (cleanupErrors.length === 0)
|
|
1093
|
+
replay.reject(error);
|
|
1094
|
+
else {
|
|
1095
|
+
replay.reject(new AggregateError([error, ...cleanupErrors], 'Trace replay failed', { cause: error }));
|
|
1096
|
+
}
|
|
1097
|
+
}
|
|
1098
|
+
#removeDiceByIdSafely(ids, reason) {
|
|
1099
|
+
const errors = [];
|
|
1100
|
+
for (const id of ids) {
|
|
1101
|
+
try {
|
|
1102
|
+
this.#removeDie(id, reason);
|
|
1103
|
+
}
|
|
1104
|
+
catch (error) {
|
|
1105
|
+
if (error instanceof AggregateError)
|
|
1106
|
+
errors.push(...error.errors);
|
|
1107
|
+
else
|
|
1108
|
+
errors.push(error);
|
|
1109
|
+
}
|
|
1110
|
+
}
|
|
1111
|
+
return errors;
|
|
1112
|
+
}
|
|
561
1113
|
#runFrame(timestampMs) {
|
|
562
1114
|
const active = this.#active;
|
|
563
1115
|
if (active === undefined)
|
|
@@ -631,10 +1183,23 @@ export class DiceEngine extends TypedEventEmitter {
|
|
|
631
1183
|
}
|
|
632
1184
|
#createDieResult(die, faceValue) {
|
|
633
1185
|
const mappedValue = die.preset.valueMap?.[faceValue] ?? faceValue;
|
|
1186
|
+
const provenance = {
|
|
1187
|
+
termId: die.termId,
|
|
1188
|
+
termIndex: die.expressionIndex,
|
|
1189
|
+
dieIndex: die.dieIndex,
|
|
1190
|
+
physicalIndex: die.physicalIndex,
|
|
1191
|
+
state: 'included',
|
|
1192
|
+
faceValue: mappedValue,
|
|
1193
|
+
};
|
|
634
1194
|
if (die.component === undefined) {
|
|
635
1195
|
if (die.type === 'd100')
|
|
636
1196
|
throw new Error('A d100 result requires percentile component data');
|
|
637
|
-
return Object.freeze({
|
|
1197
|
+
return Object.freeze({
|
|
1198
|
+
id: die.id,
|
|
1199
|
+
type: die.type,
|
|
1200
|
+
value: mappedValue,
|
|
1201
|
+
provenance: Object.freeze({ ...provenance, contribution: mappedValue }),
|
|
1202
|
+
});
|
|
638
1203
|
}
|
|
639
1204
|
const { groupId, groupType, role } = die.component;
|
|
640
1205
|
const digit = groupType === 'd100' ? mappedValue % 10 : mappedValue;
|
|
@@ -643,6 +1208,7 @@ export class DiceEngine extends TypedEventEmitter {
|
|
|
643
1208
|
type: die.type,
|
|
644
1209
|
value: role === 'tens' ? digit * 10 : digit,
|
|
645
1210
|
component: Object.freeze({ groupId, groupType, role, faceValue: mappedValue }),
|
|
1211
|
+
provenance: Object.freeze(provenance),
|
|
646
1212
|
});
|
|
647
1213
|
}
|
|
648
1214
|
#completeActive(active) {
|
|
@@ -709,10 +1275,18 @@ export class DiceEngine extends TypedEventEmitter {
|
|
|
709
1275
|
if (die.result.component !== undefined) {
|
|
710
1276
|
throw new Error(`Paired die ${die.id} cannot use keep/drop or score rules`);
|
|
711
1277
|
}
|
|
1278
|
+
const provenance = die.result.provenance;
|
|
1279
|
+
if (provenance === undefined)
|
|
1280
|
+
throw new Error(`Die ${die.id} has no result provenance`);
|
|
712
1281
|
return Object.freeze({
|
|
713
1282
|
...die.result,
|
|
714
1283
|
...(included === undefined ? {} : { included }),
|
|
715
1284
|
...(score === undefined ? {} : { score }),
|
|
1285
|
+
provenance: Object.freeze({
|
|
1286
|
+
...provenance,
|
|
1287
|
+
state: included === false ? 'discarded' : 'included',
|
|
1288
|
+
contribution: included === false ? 0 : (score ?? die.result.value),
|
|
1289
|
+
}),
|
|
716
1290
|
});
|
|
717
1291
|
});
|
|
718
1292
|
}
|
|
@@ -839,6 +1413,197 @@ export class DiceEngine extends TypedEventEmitter {
|
|
|
839
1413
|
task.signal.removeEventListener('abort', task.abortListener);
|
|
840
1414
|
}
|
|
841
1415
|
}
|
|
1416
|
+
#assertExclusiveOperationAvailable(operation) {
|
|
1417
|
+
if (this.#active !== undefined || this.#queue.length > 0 || this.#replay !== undefined) {
|
|
1418
|
+
throw new Error(`Cannot ${operation} while the engine is busy`);
|
|
1419
|
+
}
|
|
1420
|
+
}
|
|
1421
|
+
#assertValidTrace(trace) {
|
|
1422
|
+
if (trace.version !== 1)
|
|
1423
|
+
throw new TypeError(`Unsupported physical roll trace version`);
|
|
1424
|
+
if (trace.producer.name !== '@dice-o-rolla/dice-engine') {
|
|
1425
|
+
throw new TypeError('Trace producer metadata is invalid');
|
|
1426
|
+
}
|
|
1427
|
+
if (trace.producer.version !== DICE_ENGINE_VERSION) {
|
|
1428
|
+
throw new TypeError(`Trace producer version ${trace.producer.version} is incompatible with ${DICE_ENGINE_VERSION}`);
|
|
1429
|
+
}
|
|
1430
|
+
if (!Number.isSafeInteger(trace.seed))
|
|
1431
|
+
throw new TypeError('Trace seed must be a safe integer');
|
|
1432
|
+
assertPositive(trace.fixedStepSeconds, 'trace.fixedStepSeconds');
|
|
1433
|
+
assertPositiveSafeInteger(trace.frameIntervalSteps, 'trace.frameIntervalSteps');
|
|
1434
|
+
if (trace.profile.fixedStepSeconds !== trace.fixedStepSeconds) {
|
|
1435
|
+
throw new TypeError('Trace profile fixed step does not match its envelope');
|
|
1436
|
+
}
|
|
1437
|
+
const profileValidators = [
|
|
1438
|
+
new SettlingDetector(trace.profile.settling),
|
|
1439
|
+
new ThrowGenerator(new SeededRandomSource(trace.seed), trace.profile.throw),
|
|
1440
|
+
];
|
|
1441
|
+
void profileValidators;
|
|
1442
|
+
for (const [name, value] of [
|
|
1443
|
+
['tray.width', trace.profile.tray.width],
|
|
1444
|
+
['tray.depth', trace.profile.tray.depth],
|
|
1445
|
+
['tray.wallHeight', trace.profile.tray.wallHeight],
|
|
1446
|
+
['tray.wallThickness', trace.profile.tray.wallThickness],
|
|
1447
|
+
]) {
|
|
1448
|
+
assertPositive(value, `trace.profile.${name}`);
|
|
1449
|
+
}
|
|
1450
|
+
for (const [name, value] of [
|
|
1451
|
+
['tray.material.friction', trace.profile.tray.material.friction],
|
|
1452
|
+
['tray.material.restitution', trace.profile.tray.material.restitution],
|
|
1453
|
+
['diceMaterial.friction', trace.profile.diceMaterial.friction],
|
|
1454
|
+
['diceMaterial.restitution', trace.profile.diceMaterial.restitution],
|
|
1455
|
+
['diceMaterial.linearDamping', trace.profile.diceMaterial.linearDamping],
|
|
1456
|
+
['diceMaterial.angularDamping', trace.profile.diceMaterial.angularDamping],
|
|
1457
|
+
]) {
|
|
1458
|
+
if (!Number.isFinite(value) || value < 0) {
|
|
1459
|
+
throw new RangeError(`trace.profile.${name} must be a non-negative finite number`);
|
|
1460
|
+
}
|
|
1461
|
+
}
|
|
1462
|
+
if (!Number.isFinite(trace.durationSeconds) || trace.durationSeconds < 0) {
|
|
1463
|
+
throw new RangeError('trace.durationSeconds must be a non-negative finite number');
|
|
1464
|
+
}
|
|
1465
|
+
if (trace.frames.length === 0)
|
|
1466
|
+
throw new TypeError('Trace must contain at least one frame');
|
|
1467
|
+
if (trace.dice.length === 0)
|
|
1468
|
+
throw new TypeError('Trace must contain at least one die');
|
|
1469
|
+
if (trace.frames.length > this.#traceLimits.maxFrames) {
|
|
1470
|
+
throw new TraceLimitExceededError('frames', this.#traceLimits.maxFrames, trace.frames.length);
|
|
1471
|
+
}
|
|
1472
|
+
const sampleCount = trace.frames.length * trace.dice.length;
|
|
1473
|
+
if (sampleCount > this.#traceLimits.maxSamples) {
|
|
1474
|
+
throw new TraceLimitExceededError('samples', this.#traceLimits.maxSamples, sampleCount);
|
|
1475
|
+
}
|
|
1476
|
+
if (trace.events.length > this.#traceLimits.maxEvents) {
|
|
1477
|
+
throw new TraceLimitExceededError('events', this.#traceLimits.maxEvents, trace.events.length);
|
|
1478
|
+
}
|
|
1479
|
+
if (trace.result.notation !== trace.notation) {
|
|
1480
|
+
throw new TypeError('Trace result notation does not match its envelope');
|
|
1481
|
+
}
|
|
1482
|
+
const ids = new Set();
|
|
1483
|
+
for (const die of trace.dice) {
|
|
1484
|
+
if (ids.has(die.id))
|
|
1485
|
+
throw new TypeError(`Trace contains duplicate die id "${die.id}"`);
|
|
1486
|
+
ids.add(die.id);
|
|
1487
|
+
if (!isDieType(die.type) || !isDieType(die.geometryId) || !hasDieGeometry(die.geometryId)) {
|
|
1488
|
+
throw new TypeError(`Trace die "${die.id}" has an unsupported type or geometry`);
|
|
1489
|
+
}
|
|
1490
|
+
assertPositive(die.scale, `trace die "${die.id}" scale`);
|
|
1491
|
+
const preset = this.#visualPresets.get(die.presetId);
|
|
1492
|
+
if (preset === undefined) {
|
|
1493
|
+
throw new TypeError(`Trace requires unregistered visual preset "${die.presetId}"`);
|
|
1494
|
+
}
|
|
1495
|
+
if (preset.geometryId !== die.geometryId) {
|
|
1496
|
+
throw new TypeError(`Trace geometry does not match visual preset "${die.presetId}"`);
|
|
1497
|
+
}
|
|
1498
|
+
if ((preset.scale ?? 1) !== die.scale) {
|
|
1499
|
+
throw new TypeError(`Trace scale does not match visual preset "${die.presetId}"`);
|
|
1500
|
+
}
|
|
1501
|
+
const fingerprint = definitionFingerprint(getDieGeometry(die.geometryId), preset, die.faceLabels);
|
|
1502
|
+
if (die.definitionFingerprint !== fingerprint) {
|
|
1503
|
+
throw new TypeError(`Trace definition fingerprint mismatch for "${die.id}"`);
|
|
1504
|
+
}
|
|
1505
|
+
this.#assertFiniteTraceTransform(die.initial, `trace die "${die.id}" initial`);
|
|
1506
|
+
for (const [name, value] of Object.entries({
|
|
1507
|
+
impulseX: die.initial.impulse.x,
|
|
1508
|
+
impulseY: die.initial.impulse.y,
|
|
1509
|
+
impulseZ: die.initial.impulse.z,
|
|
1510
|
+
torqueX: die.initial.torqueImpulse.x,
|
|
1511
|
+
torqueY: die.initial.torqueImpulse.y,
|
|
1512
|
+
torqueZ: die.initial.torqueImpulse.z,
|
|
1513
|
+
})) {
|
|
1514
|
+
if (!Number.isFinite(value))
|
|
1515
|
+
throw new RangeError(`${name} must be finite`);
|
|
1516
|
+
}
|
|
1517
|
+
}
|
|
1518
|
+
let previousElapsed = -1;
|
|
1519
|
+
for (const frame of trace.frames) {
|
|
1520
|
+
if (!Number.isFinite(frame.elapsedSeconds) ||
|
|
1521
|
+
frame.elapsedSeconds < 0 ||
|
|
1522
|
+
frame.elapsedSeconds < previousElapsed) {
|
|
1523
|
+
throw new RangeError('Trace frame times must be finite, non-negative, and ordered');
|
|
1524
|
+
}
|
|
1525
|
+
previousElapsed = frame.elapsedSeconds;
|
|
1526
|
+
if (frame.dice.length !== trace.dice.length) {
|
|
1527
|
+
throw new TypeError('Every trace frame must contain every die exactly once');
|
|
1528
|
+
}
|
|
1529
|
+
const frameIds = new Set();
|
|
1530
|
+
for (const die of frame.dice) {
|
|
1531
|
+
if (!ids.has(die.id) || frameIds.has(die.id)) {
|
|
1532
|
+
throw new TypeError(`Trace frame contains an unknown or duplicate die id "${die.id}"`);
|
|
1533
|
+
}
|
|
1534
|
+
frameIds.add(die.id);
|
|
1535
|
+
this.#assertFiniteTraceTransform(die, `trace frame die "${die.id}"`);
|
|
1536
|
+
}
|
|
1537
|
+
}
|
|
1538
|
+
const finalFrame = trace.frames.at(-1);
|
|
1539
|
+
if (Math.abs(finalFrame.elapsedSeconds - trace.durationSeconds) > 1e-9) {
|
|
1540
|
+
throw new RangeError('Trace final frame must match trace duration');
|
|
1541
|
+
}
|
|
1542
|
+
let previousEventTime = -1;
|
|
1543
|
+
for (const event of trace.events) {
|
|
1544
|
+
if (event.kind !== 'collision' && event.kind !== 'impact') {
|
|
1545
|
+
throw new TypeError('Trace event kind is invalid');
|
|
1546
|
+
}
|
|
1547
|
+
if (!ids.has(event.dieId) || (event.otherDieId !== undefined && !ids.has(event.otherDieId))) {
|
|
1548
|
+
throw new TypeError('Trace event references an unknown die');
|
|
1549
|
+
}
|
|
1550
|
+
if (!Number.isFinite(event.elapsedSeconds) ||
|
|
1551
|
+
event.elapsedSeconds < previousEventTime ||
|
|
1552
|
+
event.elapsedSeconds > trace.durationSeconds) {
|
|
1553
|
+
throw new RangeError('Trace event times must be finite, ordered, and inside the trace');
|
|
1554
|
+
}
|
|
1555
|
+
previousEventTime = event.elapsedSeconds;
|
|
1556
|
+
if (event.kind === 'collision' && typeof event.started !== 'boolean') {
|
|
1557
|
+
throw new TypeError('Trace collision state must be boolean');
|
|
1558
|
+
}
|
|
1559
|
+
if (event.kind === 'impact' && (!Number.isFinite(event.force) || event.force < 0)) {
|
|
1560
|
+
throw new RangeError('Trace impact force must be a non-negative finite number');
|
|
1561
|
+
}
|
|
1562
|
+
}
|
|
1563
|
+
const resultDice = new Map(trace.result.dice.map((die) => [die.id, die]));
|
|
1564
|
+
if (resultDice.size !== trace.dice.length || trace.result.dice.length !== trace.dice.length) {
|
|
1565
|
+
throw new TypeError('Trace result must contain every physical die exactly once');
|
|
1566
|
+
}
|
|
1567
|
+
for (const die of trace.dice) {
|
|
1568
|
+
const result = resultDice.get(die.id);
|
|
1569
|
+
if (result === undefined)
|
|
1570
|
+
throw new TypeError(`Trace result is missing die "${die.id}"`);
|
|
1571
|
+
if (!isDieType(die.geometryId)) {
|
|
1572
|
+
throw new TypeError(`Trace die "${die.id}" has an unsupported geometry`);
|
|
1573
|
+
}
|
|
1574
|
+
const preset = this.#visualPresets.get(die.presetId);
|
|
1575
|
+
const resolved = resolveFace(getDieGeometry(die.geometryId), getFrameDie(finalFrame, die.id).quaternion);
|
|
1576
|
+
const mapped = preset.valueMap?.[resolved] ?? resolved;
|
|
1577
|
+
const reportedFace = result.component?.faceValue ?? result.value;
|
|
1578
|
+
if (mapped !== reportedFace) {
|
|
1579
|
+
throw new TypeError(`Trace final orientation does not match result die "${die.id}"`);
|
|
1580
|
+
}
|
|
1581
|
+
}
|
|
1582
|
+
const verifiedResult = createRollResult({
|
|
1583
|
+
id: trace.result.id,
|
|
1584
|
+
notation: trace.result.notation,
|
|
1585
|
+
dice: trace.result.dice,
|
|
1586
|
+
modifier: trace.result.modifier,
|
|
1587
|
+
startedAt: trace.result.startedAt,
|
|
1588
|
+
completedAt: trace.result.completedAt,
|
|
1589
|
+
});
|
|
1590
|
+
if (verifiedResult.total !== trace.result.total) {
|
|
1591
|
+
throw new TypeError('Trace result total does not match its dice and modifier');
|
|
1592
|
+
}
|
|
1593
|
+
}
|
|
1594
|
+
#assertFiniteTraceTransform(transform, description) {
|
|
1595
|
+
if (![
|
|
1596
|
+
transform.position.x,
|
|
1597
|
+
transform.position.y,
|
|
1598
|
+
transform.position.z,
|
|
1599
|
+
transform.quaternion.x,
|
|
1600
|
+
transform.quaternion.y,
|
|
1601
|
+
transform.quaternion.z,
|
|
1602
|
+
transform.quaternion.w,
|
|
1603
|
+
].every(Number.isFinite)) {
|
|
1604
|
+
throw new RangeError(`${description} transform must contain finite values`);
|
|
1605
|
+
}
|
|
1606
|
+
}
|
|
842
1607
|
#assertSupportedAndWithinLimits(parsed) {
|
|
843
1608
|
let logicalDice = 0;
|
|
844
1609
|
let physicalDice = 0;
|
package/dist/errors.d.ts
CHANGED
|
@@ -16,3 +16,10 @@ export declare class RollLimitExceededError extends RangeError {
|
|
|
16
16
|
readonly actual: number;
|
|
17
17
|
constructor(limit: RollLimit, maximum: number, actual: number);
|
|
18
18
|
}
|
|
19
|
+
export type TraceLimit = 'events' | 'frames' | 'samples';
|
|
20
|
+
export declare class TraceLimitExceededError extends RangeError {
|
|
21
|
+
readonly limit: TraceLimit;
|
|
22
|
+
readonly maximum: number;
|
|
23
|
+
readonly actual: number;
|
|
24
|
+
constructor(limit: TraceLimit, maximum: number, actual: number);
|
|
25
|
+
}
|
package/dist/errors.js
CHANGED
|
@@ -32,3 +32,15 @@ export class RollLimitExceededError extends RangeError {
|
|
|
32
32
|
this.actual = actual;
|
|
33
33
|
}
|
|
34
34
|
}
|
|
35
|
+
export class TraceLimitExceededError extends RangeError {
|
|
36
|
+
limit;
|
|
37
|
+
maximum;
|
|
38
|
+
actual;
|
|
39
|
+
constructor(limit, maximum, actual) {
|
|
40
|
+
super(`Physical roll trace exceeds ${limit} limit of ${maximum} (received ${actual})`);
|
|
41
|
+
this.name = 'TraceLimitExceededError';
|
|
42
|
+
this.limit = limit;
|
|
43
|
+
this.maximum = maximum;
|
|
44
|
+
this.actual = actual;
|
|
45
|
+
}
|
|
46
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
export { DiceEngine } from './dice-engine.js';
|
|
2
|
-
export { DiceEngineDestroyedError, RollCancelledError, RollLimitExceededError, RollTimeoutError, } from './errors.js';
|
|
3
|
-
export type { RollLimit } from './errors.js';
|
|
4
|
-
export type { DiceEngineEvents, DiceEngineFacade, DiceEngineLimits, DiceEngineOptions, DiceCollisionEvent, DiceImpactEvent, DiceCollisionEventOptions, DiceMaterialType, DiceRemovalReason, DiceRemoveEvent, DiceTheme, DiceVisualEvent, FrameScheduler, FrameToken, RegisterEngineVisualPresetOptions, RollOptions, } from './types.js';
|
|
2
|
+
export { DiceEngineDestroyedError, RollCancelledError, RollLimitExceededError, RollTimeoutError, TraceLimitExceededError, } from './errors.js';
|
|
3
|
+
export type { RollLimit, TraceLimit } from './errors.js';
|
|
4
|
+
export type { DiceEngineEvents, DiceEngineFacade, DiceEngineLimits, DiceEngineOptions, DiceCollisionEvent, DiceImpactEvent, DiceCollisionEventOptions, DiceMaterialType, DiceRemovalReason, DiceRemoveEvent, DiceTheme, DiceTraceLimits, DiceVisualEvent, FrameScheduler, FrameToken, PhysicalRollFrame, PhysicalRollFrameDie, PhysicalRollTrace, PhysicalRollTraceCollisionEvent, PhysicalRollTraceDie, PhysicalRollTraceEvent, PhysicalRollTraceImpactEvent, PhysicalRollTraceProducer, PhysicalRollTraceProfile, RegisterEngineVisualPresetOptions, ReplayOptions, RollOptions, SimulateOptions, } from './types.js';
|
|
5
5
|
export { getStandardVisualPresetId, PHYSICAL_DIE_TYPES, STANDARD_VISUAL_PRESETS, type PhysicalDieType, } from './visual-presets.js';
|
|
6
|
+
export { DICE_ENGINE_VERSION } from './version.js';
|
package/dist/index.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
1
|
export { DiceEngine } from './dice-engine.js';
|
|
2
|
-
export { DiceEngineDestroyedError, RollCancelledError, RollLimitExceededError, RollTimeoutError, } from './errors.js';
|
|
2
|
+
export { DiceEngineDestroyedError, RollCancelledError, RollLimitExceededError, RollTimeoutError, TraceLimitExceededError, } from './errors.js';
|
|
3
3
|
export { getStandardVisualPresetId, PHYSICAL_DIE_TYPES, STANDARD_VISUAL_PRESETS, } from './visual-presets.js';
|
|
4
|
+
export { DICE_ENGINE_VERSION } from './version.js';
|
package/dist/types.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { DieResult, RandomSource, RollMode, RollResult, RollSession } from '@dice-o-rolla/dice-core';
|
|
1
|
+
import type { DieResult, DieType, QuaternionLike, RandomSource, RollMode, RollResult, RollSession, Vector3Like } from '@dice-o-rolla/dice-core';
|
|
2
2
|
import type { DicePhysicsMaterial, PhysicsWorld, SettlingOptions, ThrowGeneratorOptions, TrayOptions } from '@dice-o-rolla/dice-physics';
|
|
3
3
|
import type { DiceRenderer, RendererTheme, RendererViewport } from '@dice-o-rolla/dice-renderer';
|
|
4
4
|
import type { RegisterVisualPresetOptions, VisualPresetDescriptor } from '@dice-o-rolla/dice-renderer';
|
|
@@ -15,6 +15,86 @@ export interface RollOptions {
|
|
|
15
15
|
readonly mode?: RollMode;
|
|
16
16
|
readonly signal?: AbortSignal;
|
|
17
17
|
}
|
|
18
|
+
export interface SimulateOptions {
|
|
19
|
+
readonly seed: number;
|
|
20
|
+
readonly captureFrames?: boolean;
|
|
21
|
+
/** Capture one frame every N fixed simulation steps. */
|
|
22
|
+
readonly frameIntervalSteps?: number;
|
|
23
|
+
}
|
|
24
|
+
export interface ReplayOptions {
|
|
25
|
+
readonly theme?: Partial<DiceTheme>;
|
|
26
|
+
readonly signal?: AbortSignal;
|
|
27
|
+
}
|
|
28
|
+
export interface PhysicalRollFrameDie {
|
|
29
|
+
readonly id: string;
|
|
30
|
+
readonly position: Vector3Like;
|
|
31
|
+
readonly quaternion: QuaternionLike;
|
|
32
|
+
}
|
|
33
|
+
export interface PhysicalRollFrame {
|
|
34
|
+
readonly elapsedSeconds: number;
|
|
35
|
+
readonly dice: readonly PhysicalRollFrameDie[];
|
|
36
|
+
}
|
|
37
|
+
export interface PhysicalRollTraceDie {
|
|
38
|
+
readonly id: string;
|
|
39
|
+
readonly type: DieType;
|
|
40
|
+
readonly presetId: string;
|
|
41
|
+
readonly geometryId: string;
|
|
42
|
+
readonly definitionFingerprint: string;
|
|
43
|
+
readonly scale: number;
|
|
44
|
+
readonly faceLabels?: Readonly<Record<number, string | number>>;
|
|
45
|
+
readonly initial: {
|
|
46
|
+
readonly position: Vector3Like;
|
|
47
|
+
readonly quaternion: QuaternionLike;
|
|
48
|
+
readonly impulse: Vector3Like;
|
|
49
|
+
readonly torqueImpulse: Vector3Like;
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
export interface PhysicalRollTraceCollisionEvent {
|
|
53
|
+
readonly kind: 'collision';
|
|
54
|
+
readonly elapsedSeconds: number;
|
|
55
|
+
readonly dieId: string;
|
|
56
|
+
readonly otherDieId?: string;
|
|
57
|
+
readonly started: boolean;
|
|
58
|
+
}
|
|
59
|
+
export interface PhysicalRollTraceImpactEvent {
|
|
60
|
+
readonly kind: 'impact';
|
|
61
|
+
readonly elapsedSeconds: number;
|
|
62
|
+
readonly dieId: string;
|
|
63
|
+
readonly otherDieId?: string;
|
|
64
|
+
readonly force: number;
|
|
65
|
+
}
|
|
66
|
+
export type PhysicalRollTraceEvent = PhysicalRollTraceCollisionEvent | PhysicalRollTraceImpactEvent;
|
|
67
|
+
export interface PhysicalRollTraceProfile {
|
|
68
|
+
readonly fixedStepSeconds: number;
|
|
69
|
+
readonly settling: SettlingOptions;
|
|
70
|
+
readonly throw: ThrowGeneratorOptions;
|
|
71
|
+
readonly tray: TrayOptions;
|
|
72
|
+
readonly diceMaterial: DicePhysicsMaterial;
|
|
73
|
+
}
|
|
74
|
+
export interface PhysicalRollTraceProducer {
|
|
75
|
+
readonly name: '@dice-o-rolla/dice-engine';
|
|
76
|
+
readonly version: string;
|
|
77
|
+
}
|
|
78
|
+
/** JSON-serializable, renderer-neutral output of a deterministic physical simulation. */
|
|
79
|
+
export interface PhysicalRollTrace {
|
|
80
|
+
readonly version: 1;
|
|
81
|
+
readonly producer: PhysicalRollTraceProducer;
|
|
82
|
+
readonly notation: string;
|
|
83
|
+
readonly seed: number;
|
|
84
|
+
readonly fixedStepSeconds: number;
|
|
85
|
+
readonly frameIntervalSteps: number;
|
|
86
|
+
readonly durationSeconds: number;
|
|
87
|
+
readonly profile: PhysicalRollTraceProfile;
|
|
88
|
+
readonly dice: readonly PhysicalRollTraceDie[];
|
|
89
|
+
readonly frames: readonly PhysicalRollFrame[];
|
|
90
|
+
readonly events: readonly PhysicalRollTraceEvent[];
|
|
91
|
+
readonly result: RollResult;
|
|
92
|
+
}
|
|
93
|
+
export interface DiceTraceLimits {
|
|
94
|
+
readonly maxFrames: number;
|
|
95
|
+
readonly maxSamples: number;
|
|
96
|
+
readonly maxEvents: number;
|
|
97
|
+
}
|
|
18
98
|
export interface RegisterEngineVisualPresetOptions extends RegisterVisualPresetOptions {
|
|
19
99
|
readonly makeDefault?: boolean;
|
|
20
100
|
}
|
|
@@ -80,6 +160,7 @@ export interface DiceEngineOptions {
|
|
|
80
160
|
readonly diceMaterial?: DicePhysicsMaterial;
|
|
81
161
|
readonly theme?: Partial<DiceTheme>;
|
|
82
162
|
readonly limits?: Partial<DiceEngineLimits>;
|
|
163
|
+
readonly traceLimits?: Partial<DiceTraceLimits>;
|
|
83
164
|
readonly visualPresets?: readonly VisualPresetDescriptor[];
|
|
84
165
|
readonly visualPresetIds?: Partial<Readonly<Record<PhysicalDieType, string>>>;
|
|
85
166
|
readonly collisionEvents?: Partial<DiceCollisionEventOptions>;
|
|
@@ -87,6 +168,8 @@ export interface DiceEngineOptions {
|
|
|
87
168
|
export interface DiceEngineFacade {
|
|
88
169
|
initialize(): Promise<void>;
|
|
89
170
|
roll(notation: string, options?: RollOptions): Promise<RollResult>;
|
|
171
|
+
simulate(notation: string, options: SimulateOptions): Promise<PhysicalRollTrace>;
|
|
172
|
+
replay(trace: PhysicalRollTrace, options?: ReplayOptions): Promise<void>;
|
|
90
173
|
cancel(sessionId?: string): boolean;
|
|
91
174
|
clear(): void;
|
|
92
175
|
resize(viewport: RendererViewport): void;
|
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.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.
|
|
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.0",
|
|
51
|
+
"@dice-o-rolla/dice-geometry": "0.3.0",
|
|
52
|
+
"@dice-o-rolla/dice-physics": "0.3.0",
|
|
53
|
+
"@dice-o-rolla/dice-physics-rapier": "0.3.0",
|
|
54
|
+
"@dice-o-rolla/dice-renderer": "0.3.0",
|
|
55
|
+
"@dice-o-rolla/dice-renderer-three": "0.3.0"
|
|
56
56
|
},
|
|
57
57
|
"engines": {
|
|
58
58
|
"node": ">=20.0.0"
|