@dice-o-rolla/dice-engine 0.1.1 → 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 +65 -2
- package/dist/dice-engine.d.ts +9 -2
- package/dist/dice-engine.js +1155 -45
- package/dist/errors.d.ts +7 -0
- package/dist/errors.js +12 -0
- package/dist/index.d.ts +5 -3
- package/dist/index.js +3 -1
- package/dist/types.d.ts +124 -1
- package/dist/version.d.ts +2 -0
- package/dist/version.js +2 -0
- package/dist/visual-presets.d.ts +7 -0
- package/dist/visual-presets.js +20 -0
- package/package.json +7 -7
package/dist/dice-engine.js
CHANGED
|
@@ -1,8 +1,11 @@
|
|
|
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
|
+
import { createVisualPresetDescriptor, VisualPresetRegistry, } from '@dice-o-rolla/dice-renderer';
|
|
4
5
|
import { DEFAULT_THEME, defaultFrameScheduler } from './defaults.js';
|
|
5
|
-
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';
|
|
8
|
+
import { getStandardVisualPresetId, isPhysicalDieType, PHYSICAL_DIE_TYPES, STANDARD_VISUAL_PRESETS, } from './visual-presets.js';
|
|
6
9
|
const DEFAULT_SETTLING = {
|
|
7
10
|
linearVelocityThreshold: 0.08,
|
|
8
11
|
angularVelocityThreshold: 0.08,
|
|
@@ -45,6 +48,12 @@ const DEFAULT_LIMITS = Object.freeze({
|
|
|
45
48
|
maxPhysicalDice: 50,
|
|
46
49
|
maxQueuedRolls: 8,
|
|
47
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
|
+
});
|
|
48
57
|
const D100_TENS_LABELS = Object.freeze({
|
|
49
58
|
1: 10,
|
|
50
59
|
2: 20,
|
|
@@ -71,12 +80,97 @@ function snapshotSession(session) {
|
|
|
71
80
|
function toRenderState(die) {
|
|
72
81
|
return {
|
|
73
82
|
id: die.id,
|
|
83
|
+
presetId: die.preset.id,
|
|
74
84
|
geometryId: die.geometryType,
|
|
85
|
+
scale: die.preset.scale ?? 1,
|
|
75
86
|
...(die.faceLabels === undefined ? {} : { faceLabels: die.faceLabels }),
|
|
76
87
|
previous: die.previous,
|
|
77
88
|
current: die.current,
|
|
78
89
|
};
|
|
79
90
|
}
|
|
91
|
+
function mergeFaceLabels(preset, roll) {
|
|
92
|
+
if (preset === undefined)
|
|
93
|
+
return roll;
|
|
94
|
+
if (roll === undefined)
|
|
95
|
+
return preset;
|
|
96
|
+
return Object.freeze({ ...preset, ...roll });
|
|
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
|
+
}
|
|
80
174
|
function assertPositive(value, name) {
|
|
81
175
|
if (!Number.isFinite(value) || value <= 0) {
|
|
82
176
|
throw new RangeError(`${name} must be a positive finite number`);
|
|
@@ -95,17 +189,26 @@ export class DiceEngine extends TypedEventEmitter {
|
|
|
95
189
|
#fixedStepSeconds;
|
|
96
190
|
#maxFrameDeltaSeconds;
|
|
97
191
|
#settling;
|
|
192
|
+
#throwOptions;
|
|
98
193
|
#throwGenerator;
|
|
99
194
|
#tray;
|
|
100
195
|
#diceMaterial;
|
|
101
196
|
#limits;
|
|
197
|
+
#traceLimits;
|
|
198
|
+
#collisionEvents;
|
|
199
|
+
#visualPresets = new VisualPresetRegistry(STANDARD_VISUAL_PRESETS);
|
|
200
|
+
#visualPresetIds = new Map();
|
|
102
201
|
#queue = [];
|
|
103
202
|
#displayedDieIds = new Set();
|
|
203
|
+
#dieEvents = new Map();
|
|
104
204
|
#active;
|
|
205
|
+
#replay;
|
|
105
206
|
#frameToken;
|
|
106
207
|
#lastFrameMs = 0;
|
|
107
208
|
#accumulatorSeconds = 0;
|
|
108
209
|
#nextSessionId = 1;
|
|
210
|
+
#nextSimulationId = 1;
|
|
211
|
+
#initialization;
|
|
109
212
|
#initialized = false;
|
|
110
213
|
#destroyed = false;
|
|
111
214
|
#theme;
|
|
@@ -120,10 +223,17 @@ export class DiceEngine extends TypedEventEmitter {
|
|
|
120
223
|
assertPositive(this.#fixedStepSeconds, 'fixedStepSeconds');
|
|
121
224
|
assertPositive(this.#maxFrameDeltaSeconds, 'maxFrameDeltaSeconds');
|
|
122
225
|
this.#settling = options.settling ?? DEFAULT_SETTLING;
|
|
123
|
-
this.#
|
|
226
|
+
this.#throwOptions = options.throw ?? DEFAULT_THROW;
|
|
227
|
+
this.#throwGenerator = new ThrowGenerator(options.random ?? mathRandomSource, this.#throwOptions);
|
|
124
228
|
this.#tray = options.tray ?? DEFAULT_TRAY;
|
|
125
229
|
this.#diceMaterial = options.diceMaterial ?? DEFAULT_DICE_MATERIAL;
|
|
126
230
|
this.#limits = Object.freeze({ ...DEFAULT_LIMITS, ...options.limits });
|
|
231
|
+
this.#traceLimits = Object.freeze({ ...DEFAULT_TRACE_LIMITS, ...options.traceLimits });
|
|
232
|
+
this.#collisionEvents = Object.freeze({
|
|
233
|
+
...DEFAULT_COLLISION_EVENTS,
|
|
234
|
+
...options.collisionEvents,
|
|
235
|
+
});
|
|
236
|
+
assertPositiveSafeInteger(this.#collisionEvents.maxEventsPerFrame, 'collisionEvents.maxEventsPerFrame');
|
|
127
237
|
for (const [name, value] of [
|
|
128
238
|
['maxNotationLength', this.#limits.maxNotationLength],
|
|
129
239
|
['maxLogicalDice', this.#limits.maxLogicalDice],
|
|
@@ -132,20 +242,46 @@ export class DiceEngine extends TypedEventEmitter {
|
|
|
132
242
|
]) {
|
|
133
243
|
assertPositiveSafeInteger(value, `limits.${name}`);
|
|
134
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
|
+
}
|
|
252
|
+
for (const type of PHYSICAL_DIE_TYPES) {
|
|
253
|
+
this.#visualPresetIds.set(type, getStandardVisualPresetId(type));
|
|
254
|
+
}
|
|
255
|
+
for (const preset of options.visualPresets ?? [])
|
|
256
|
+
this.registerVisualPreset(preset);
|
|
257
|
+
for (const type of PHYSICAL_DIE_TYPES) {
|
|
258
|
+
const presetId = options.visualPresetIds?.[type];
|
|
259
|
+
if (presetId !== undefined)
|
|
260
|
+
this.setVisualPreset(type, presetId);
|
|
261
|
+
}
|
|
135
262
|
this.#theme = this.#mergeTheme(options.theme ?? {});
|
|
136
263
|
}
|
|
137
264
|
async initialize() {
|
|
138
265
|
this.#assertAlive();
|
|
139
266
|
if (this.#initialized)
|
|
140
267
|
return;
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
this.#
|
|
144
|
-
this.#
|
|
268
|
+
if (this.#initialization !== undefined)
|
|
269
|
+
return this.#initialization;
|
|
270
|
+
const initialization = this.#performInitialization();
|
|
271
|
+
this.#initialization = initialization;
|
|
272
|
+
try {
|
|
273
|
+
await initialization;
|
|
274
|
+
}
|
|
275
|
+
finally {
|
|
276
|
+
if (this.#initialization === initialization)
|
|
277
|
+
this.#initialization = undefined;
|
|
278
|
+
}
|
|
145
279
|
}
|
|
146
280
|
roll(notation, options = {}) {
|
|
147
281
|
try {
|
|
148
282
|
this.#assertReady();
|
|
283
|
+
if (this.#replay !== undefined)
|
|
284
|
+
throw new Error('Cannot roll while a trace replay is active');
|
|
149
285
|
if ((options.mode ?? 'queue') !== 'queue') {
|
|
150
286
|
throw new RangeError('Only queue roll mode is currently supported');
|
|
151
287
|
}
|
|
@@ -170,8 +306,227 @@ export class DiceEngine extends TypedEventEmitter {
|
|
|
170
306
|
return Promise.reject(error);
|
|
171
307
|
}
|
|
172
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
|
+
}
|
|
173
523
|
cancel(sessionId) {
|
|
174
524
|
this.#assertAlive();
|
|
525
|
+
if (this.#replay !== undefined &&
|
|
526
|
+
(sessionId === undefined || this.#replay.trace.result.id === sessionId)) {
|
|
527
|
+
this.#cancelReplay();
|
|
528
|
+
return true;
|
|
529
|
+
}
|
|
175
530
|
if (this.#active !== undefined &&
|
|
176
531
|
(sessionId === undefined || this.#active.task.session.id === sessionId)) {
|
|
177
532
|
const task = this.#active.task;
|
|
@@ -187,6 +542,8 @@ export class DiceEngine extends TypedEventEmitter {
|
|
|
187
542
|
}
|
|
188
543
|
clear() {
|
|
189
544
|
this.#assertAlive();
|
|
545
|
+
if (this.#replay !== undefined)
|
|
546
|
+
this.#cancelReplay();
|
|
190
547
|
this.#frameToken?.cancel();
|
|
191
548
|
this.#frameToken = undefined;
|
|
192
549
|
if (this.#active !== undefined) {
|
|
@@ -196,10 +553,8 @@ export class DiceEngine extends TypedEventEmitter {
|
|
|
196
553
|
}
|
|
197
554
|
for (const task of this.#queue.splice(0))
|
|
198
555
|
this.#rejectCancelled(task);
|
|
199
|
-
this.#physics.clear();
|
|
200
|
-
this.#renderer.clear();
|
|
201
|
-
this.#displayedDieIds.clear();
|
|
202
556
|
this.#accumulatorSeconds = 0;
|
|
557
|
+
this.#clearRenderedDice('cleared');
|
|
203
558
|
}
|
|
204
559
|
resize(viewport) {
|
|
205
560
|
this.#assertReady();
|
|
@@ -216,15 +571,104 @@ export class DiceEngine extends TypedEventEmitter {
|
|
|
216
571
|
get theme() {
|
|
217
572
|
return this.#theme;
|
|
218
573
|
}
|
|
574
|
+
registerVisualPreset(source, options = {}) {
|
|
575
|
+
this.#assertAlive();
|
|
576
|
+
const preset = createVisualPresetDescriptor(source);
|
|
577
|
+
this.#assertValidVisualPreset(preset);
|
|
578
|
+
if ([...this.#dieEvents.values()].some((event) => event.presetId === preset.id)) {
|
|
579
|
+
throw new Error(`Visual preset "${preset.id}" is currently in use`);
|
|
580
|
+
}
|
|
581
|
+
const existing = this.#visualPresets.get(preset.id);
|
|
582
|
+
if (existing !== undefined && options.replace !== true) {
|
|
583
|
+
throw new Error(`Visual preset "${preset.id}" is already registered`);
|
|
584
|
+
}
|
|
585
|
+
if (this.#initialized)
|
|
586
|
+
this.#renderer.registerPreset(preset);
|
|
587
|
+
this.#visualPresets.register(preset, options.replace === undefined ? {} : { replace: options.replace });
|
|
588
|
+
if (options.makeDefault === true) {
|
|
589
|
+
if (!isPhysicalDieType(preset.dieType)) {
|
|
590
|
+
throw new Error(`Visual preset has an invalid physical die type: ${preset.dieType}`);
|
|
591
|
+
}
|
|
592
|
+
this.#visualPresetIds.set(preset.dieType, preset.id);
|
|
593
|
+
}
|
|
594
|
+
return preset;
|
|
595
|
+
}
|
|
596
|
+
unregisterVisualPreset(id) {
|
|
597
|
+
this.#assertAlive();
|
|
598
|
+
if (STANDARD_VISUAL_PRESETS.some((preset) => preset.id === id)) {
|
|
599
|
+
throw new Error(`Built-in visual preset "${id}" cannot be unregistered`);
|
|
600
|
+
}
|
|
601
|
+
const preset = this.#visualPresets.unregister(id);
|
|
602
|
+
if (preset === undefined)
|
|
603
|
+
return false;
|
|
604
|
+
if (!isPhysicalDieType(preset.dieType)) {
|
|
605
|
+
throw new Error(`Visual preset has an invalid physical die type: ${preset.dieType}`);
|
|
606
|
+
}
|
|
607
|
+
const type = preset.dieType;
|
|
608
|
+
if (this.#visualPresetIds.get(type) === id) {
|
|
609
|
+
this.#visualPresetIds.set(type, getStandardVisualPresetId(type));
|
|
610
|
+
}
|
|
611
|
+
if (this.#initialized)
|
|
612
|
+
this.#renderer.unregisterPreset(id);
|
|
613
|
+
return true;
|
|
614
|
+
}
|
|
615
|
+
setVisualPreset(dieType, presetId) {
|
|
616
|
+
this.#assertAlive();
|
|
617
|
+
if (!PHYSICAL_DIE_TYPES.includes(dieType)) {
|
|
618
|
+
throw new RangeError(`${dieType} is not a physical die type`);
|
|
619
|
+
}
|
|
620
|
+
const preset = this.#visualPresets.get(presetId);
|
|
621
|
+
if (preset === undefined)
|
|
622
|
+
throw new RangeError(`Unknown visual preset: ${presetId}`);
|
|
623
|
+
if (preset.dieType !== dieType) {
|
|
624
|
+
throw new RangeError(`Visual preset "${presetId}" is for ${preset.dieType}, not ${dieType}`);
|
|
625
|
+
}
|
|
626
|
+
this.#visualPresetIds.set(dieType, presetId);
|
|
627
|
+
}
|
|
628
|
+
getVisualPreset(dieType) {
|
|
629
|
+
this.#assertAlive();
|
|
630
|
+
const id = this.#visualPresetIds.get(dieType);
|
|
631
|
+
const preset = id === undefined ? undefined : this.#visualPresets.get(id);
|
|
632
|
+
if (preset === undefined)
|
|
633
|
+
throw new Error(`No visual preset is selected for ${dieType}`);
|
|
634
|
+
return preset;
|
|
635
|
+
}
|
|
219
636
|
destroy() {
|
|
220
637
|
if (this.#destroyed)
|
|
221
638
|
return;
|
|
222
|
-
this
|
|
223
|
-
this.#
|
|
224
|
-
this.#
|
|
639
|
+
this.#destroyed = true;
|
|
640
|
+
this.#frameToken?.cancel();
|
|
641
|
+
this.#frameToken = undefined;
|
|
642
|
+
if (this.#replay !== undefined)
|
|
643
|
+
this.#cancelReplay();
|
|
644
|
+
if (this.#active !== undefined) {
|
|
645
|
+
const task = this.#active.task;
|
|
646
|
+
this.#active = undefined;
|
|
647
|
+
this.#rejectCancelled(task);
|
|
648
|
+
}
|
|
649
|
+
for (const task of this.#queue.splice(0))
|
|
650
|
+
this.#rejectCancelled(task);
|
|
651
|
+
this.#forgetRenderedDice('destroyed');
|
|
652
|
+
const cleanupErrors = this.#runCleanup([
|
|
653
|
+
() => this.#renderer.destroy(),
|
|
654
|
+
() => this.#physics.destroy(),
|
|
655
|
+
]);
|
|
225
656
|
super.clear();
|
|
657
|
+
this.#initialization = undefined;
|
|
226
658
|
this.#initialized = false;
|
|
227
|
-
|
|
659
|
+
if (cleanupErrors.length > 0) {
|
|
660
|
+
throw new AggregateError(cleanupErrors, 'DiceEngine teardown failed');
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
async #performInitialization() {
|
|
664
|
+
this.#physics.configureTray(this.#tray);
|
|
665
|
+
this.#physics.setCollisionEventsEnabled(this.#collisionEvents.enabled);
|
|
666
|
+
await this.#renderer.initialize();
|
|
667
|
+
this.#assertAlive();
|
|
668
|
+
for (const preset of this.#visualPresets.list())
|
|
669
|
+
this.#renderer.registerPreset(preset);
|
|
670
|
+
this.#renderer.setTheme(this.#theme);
|
|
671
|
+
this.#initialized = true;
|
|
228
672
|
}
|
|
229
673
|
#createTask(notation, parsed, signal) {
|
|
230
674
|
const session = {
|
|
@@ -280,11 +724,12 @@ export class DiceEngine extends TypedEventEmitter {
|
|
|
280
724
|
#createDice(task) {
|
|
281
725
|
const dice = [];
|
|
282
726
|
let index = 0;
|
|
283
|
-
const specs = this.#createPhysicalSpecs(task);
|
|
727
|
+
const specs = this.#createPhysicalSpecs(task.parsed, task.session.id);
|
|
284
728
|
const totalDice = specs.length;
|
|
285
729
|
try {
|
|
286
730
|
for (const spec of specs) {
|
|
287
731
|
const geometry = getDieGeometry(spec.geometryType);
|
|
732
|
+
const faceLabels = mergeFaceLabels(spec.preset.faceLabels, spec.faceLabels);
|
|
288
733
|
const id = `${task.session.id}:die-${index++}`;
|
|
289
734
|
const generated = this.#throwGenerator.generate();
|
|
290
735
|
const position = this.#placeDie(generated.position, index - 1, totalDice);
|
|
@@ -295,7 +740,7 @@ export class DiceEngine extends TypedEventEmitter {
|
|
|
295
740
|
kind: 'convex-hull',
|
|
296
741
|
vertices: geometry.vertices.map(([x, y, z]) => ({ x, y, z })),
|
|
297
742
|
},
|
|
298
|
-
scale: 1,
|
|
743
|
+
scale: spec.preset.scale ?? 1,
|
|
299
744
|
mass: 1,
|
|
300
745
|
material: this.#diceMaterial,
|
|
301
746
|
position,
|
|
@@ -306,8 +751,16 @@ export class DiceEngine extends TypedEventEmitter {
|
|
|
306
751
|
id,
|
|
307
752
|
type: spec.type,
|
|
308
753
|
geometryType: spec.geometryType,
|
|
754
|
+
preset: spec.preset,
|
|
755
|
+
termId: spec.termId,
|
|
756
|
+
expressionIndex: spec.expressionIndex,
|
|
757
|
+
dieIndex: spec.dieIndex,
|
|
758
|
+
physicalIndex: spec.physicalIndex,
|
|
759
|
+
...(spec.selection === undefined ? {} : { selection: spec.selection }),
|
|
760
|
+
...(spec.scoreRules === undefined ? {} : { scoreRules: spec.scoreRules }),
|
|
309
761
|
...(spec.component === undefined ? {} : { component: spec.component }),
|
|
310
|
-
...(
|
|
762
|
+
...(faceLabels === undefined ? {} : { faceLabels }),
|
|
763
|
+
initial: generated,
|
|
311
764
|
body,
|
|
312
765
|
detector: new SettlingDetector(this.#settling),
|
|
313
766
|
previous: state,
|
|
@@ -316,57 +769,194 @@ export class DiceEngine extends TypedEventEmitter {
|
|
|
316
769
|
dice.push(die);
|
|
317
770
|
this.#renderer.createDie(toRenderState(die));
|
|
318
771
|
body.applyImpulse(generated.impulse, generated.torqueImpulse);
|
|
772
|
+
const event = Object.freeze({
|
|
773
|
+
sessionId: task.session.id,
|
|
774
|
+
dieId: id,
|
|
775
|
+
dieType: spec.type,
|
|
776
|
+
presetId: spec.preset.id,
|
|
777
|
+
...(spec.preset.skinId === undefined ? {} : { skinId: spec.preset.skinId }),
|
|
778
|
+
...(spec.preset.soundPackId === undefined
|
|
779
|
+
? {}
|
|
780
|
+
: { soundPackId: spec.preset.soundPackId }),
|
|
781
|
+
});
|
|
782
|
+
this.#dieEvents.set(id, event);
|
|
783
|
+
this.emit('die:spawn', event);
|
|
319
784
|
}
|
|
320
785
|
return dice;
|
|
321
786
|
}
|
|
322
787
|
catch (error) {
|
|
323
|
-
|
|
788
|
+
const cleanupErrors = this.#removeDiceSafely(dice, 'failed');
|
|
789
|
+
if (cleanupErrors.length === 0)
|
|
790
|
+
throw error;
|
|
791
|
+
throw new AggregateError([error, ...cleanupErrors], 'Failed to create dice', {
|
|
792
|
+
cause: error,
|
|
793
|
+
});
|
|
794
|
+
}
|
|
795
|
+
}
|
|
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 {
|
|
324
858
|
this.#physics.removeDie(die.id);
|
|
325
|
-
this.#renderer.removeDie(die.id);
|
|
326
859
|
}
|
|
327
|
-
|
|
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);
|
|
328
881
|
}
|
|
882
|
+
events.push(Object.freeze(event));
|
|
329
883
|
}
|
|
330
|
-
#createPhysicalSpecs(
|
|
884
|
+
#createPhysicalSpecs(parsed, sessionId) {
|
|
331
885
|
const specs = [];
|
|
332
886
|
let groupIndex = 0;
|
|
333
|
-
for (const expression of
|
|
887
|
+
for (const [expressionIndex, expression] of parsed.expressions.entries()) {
|
|
334
888
|
if (expression.kind === 'modifier')
|
|
335
889
|
continue;
|
|
890
|
+
const termId = `term-${expressionIndex}`;
|
|
336
891
|
if (expression.kind === 'dice') {
|
|
337
892
|
const type = `d${expression.sides}`;
|
|
338
893
|
if (!isDieType(type))
|
|
339
894
|
throw new RangeError(`${type} is not a standard die type`);
|
|
895
|
+
if (!isPhysicalDieType(type))
|
|
896
|
+
throw new RangeError(`${type} is not a physical die type`);
|
|
897
|
+
const preset = this.getVisualPreset(type);
|
|
340
898
|
for (let count = 0; count < expression.count; count += 1) {
|
|
341
|
-
specs.push({
|
|
899
|
+
specs.push({
|
|
900
|
+
type,
|
|
901
|
+
geometryType: this.#getPresetGeometryType(preset),
|
|
902
|
+
preset,
|
|
903
|
+
termId,
|
|
904
|
+
expressionIndex,
|
|
905
|
+
dieIndex: count,
|
|
906
|
+
physicalIndex: specs.length,
|
|
907
|
+
...(expression.selection === undefined ? {} : { selection: expression.selection }),
|
|
908
|
+
...(expression.score === undefined ? {} : { scoreRules: expression.score }),
|
|
909
|
+
});
|
|
342
910
|
}
|
|
343
911
|
continue;
|
|
344
912
|
}
|
|
345
913
|
for (let count = 0; count < expression.count; count += 1) {
|
|
346
|
-
const groupId = `${
|
|
914
|
+
const groupId = `${sessionId}:group-${groupIndex++}`;
|
|
347
915
|
if (expression.type === 'd100') {
|
|
916
|
+
const preset = this.getVisualPreset('d10');
|
|
348
917
|
specs.push({
|
|
349
918
|
type: 'd100',
|
|
350
|
-
geometryType:
|
|
919
|
+
geometryType: this.#getPresetGeometryType(preset),
|
|
920
|
+
preset,
|
|
921
|
+
termId,
|
|
922
|
+
expressionIndex,
|
|
923
|
+
dieIndex: count,
|
|
924
|
+
physicalIndex: specs.length,
|
|
351
925
|
component: { groupId, groupType: 'd100', role: 'tens' },
|
|
352
926
|
faceLabels: D100_TENS_LABELS,
|
|
353
927
|
});
|
|
354
928
|
specs.push({
|
|
355
929
|
type: 'd10',
|
|
356
|
-
geometryType:
|
|
930
|
+
geometryType: this.#getPresetGeometryType(preset),
|
|
931
|
+
preset,
|
|
932
|
+
termId,
|
|
933
|
+
expressionIndex,
|
|
934
|
+
dieIndex: count,
|
|
935
|
+
physicalIndex: specs.length,
|
|
357
936
|
component: { groupId, groupType: 'd100', role: 'units' },
|
|
358
937
|
});
|
|
359
938
|
continue;
|
|
360
939
|
}
|
|
940
|
+
const preset = this.getVisualPreset('d6');
|
|
361
941
|
specs.push({
|
|
362
942
|
type: 'd6',
|
|
363
|
-
geometryType:
|
|
943
|
+
geometryType: this.#getPresetGeometryType(preset),
|
|
944
|
+
preset,
|
|
945
|
+
termId,
|
|
946
|
+
expressionIndex,
|
|
947
|
+
dieIndex: count,
|
|
948
|
+
physicalIndex: specs.length,
|
|
364
949
|
component: { groupId, groupType: 'd66', role: 'tens' },
|
|
365
950
|
faceLabels: D66_TENS_LABELS,
|
|
366
951
|
});
|
|
367
952
|
specs.push({
|
|
368
953
|
type: 'd6',
|
|
369
|
-
geometryType:
|
|
954
|
+
geometryType: this.#getPresetGeometryType(preset),
|
|
955
|
+
preset,
|
|
956
|
+
termId,
|
|
957
|
+
expressionIndex,
|
|
958
|
+
dieIndex: count,
|
|
959
|
+
physicalIndex: specs.length,
|
|
370
960
|
component: { groupId, groupType: 'd66', role: 'units' },
|
|
371
961
|
});
|
|
372
962
|
}
|
|
@@ -396,6 +986,130 @@ export class DiceEngine extends TypedEventEmitter {
|
|
|
396
986
|
this.#runFrame(timestampMs);
|
|
397
987
|
});
|
|
398
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
|
+
}
|
|
399
1113
|
#runFrame(timestampMs) {
|
|
400
1114
|
const active = this.#active;
|
|
401
1115
|
if (active === undefined)
|
|
@@ -404,10 +1118,39 @@ export class DiceEngine extends TypedEventEmitter {
|
|
|
404
1118
|
const frameDelta = Math.max(0, Math.min((timestampMs - this.#lastFrameMs) / 1_000, this.#maxFrameDeltaSeconds));
|
|
405
1119
|
this.#lastFrameMs = timestampMs;
|
|
406
1120
|
this.#accumulatorSeconds += frameDelta;
|
|
1121
|
+
let emittedCollisionEvents = 0;
|
|
407
1122
|
while (this.#accumulatorSeconds >= this.#fixedStepSeconds) {
|
|
408
1123
|
for (const die of active.dice)
|
|
409
1124
|
die.previous = die.current;
|
|
410
1125
|
this.#physics.step(this.#fixedStepSeconds);
|
|
1126
|
+
if (this.#collisionEvents.enabled) {
|
|
1127
|
+
for (const collision of this.#physics.drainCollisionEvents()) {
|
|
1128
|
+
if (emittedCollisionEvents >= this.#collisionEvents.maxEventsPerFrame)
|
|
1129
|
+
continue;
|
|
1130
|
+
const event = this.#dieEvents.get(collision.dieId);
|
|
1131
|
+
if (event === undefined)
|
|
1132
|
+
continue;
|
|
1133
|
+
this.emit('die:collision', Object.freeze({
|
|
1134
|
+
...event,
|
|
1135
|
+
...(collision.otherDieId === undefined ? {} : { otherDieId: collision.otherDieId }),
|
|
1136
|
+
started: collision.started,
|
|
1137
|
+
}));
|
|
1138
|
+
emittedCollisionEvents += 1;
|
|
1139
|
+
}
|
|
1140
|
+
for (const impact of this.#physics.drainImpactEvents()) {
|
|
1141
|
+
if (emittedCollisionEvents >= this.#collisionEvents.maxEventsPerFrame)
|
|
1142
|
+
continue;
|
|
1143
|
+
const event = this.#dieEvents.get(impact.dieId);
|
|
1144
|
+
if (event === undefined)
|
|
1145
|
+
continue;
|
|
1146
|
+
this.emit('die:impact', Object.freeze({
|
|
1147
|
+
...event,
|
|
1148
|
+
...(impact.otherDieId === undefined ? {} : { otherDieId: impact.otherDieId }),
|
|
1149
|
+
force: impact.force,
|
|
1150
|
+
}));
|
|
1151
|
+
emittedCollisionEvents += 1;
|
|
1152
|
+
}
|
|
1153
|
+
}
|
|
411
1154
|
this.#accumulatorSeconds -= this.#fixedStepSeconds;
|
|
412
1155
|
for (const die of active.dice) {
|
|
413
1156
|
die.current = die.body.getState();
|
|
@@ -439,18 +1182,33 @@ export class DiceEngine extends TypedEventEmitter {
|
|
|
439
1182
|
}
|
|
440
1183
|
}
|
|
441
1184
|
#createDieResult(die, faceValue) {
|
|
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
|
+
};
|
|
442
1194
|
if (die.component === undefined) {
|
|
443
1195
|
if (die.type === 'd100')
|
|
444
1196
|
throw new Error('A d100 result requires percentile component data');
|
|
445
|
-
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
|
+
});
|
|
446
1203
|
}
|
|
447
1204
|
const { groupId, groupType, role } = die.component;
|
|
448
|
-
const digit = groupType === 'd100' ?
|
|
1205
|
+
const digit = groupType === 'd100' ? mappedValue % 10 : mappedValue;
|
|
449
1206
|
return Object.freeze({
|
|
450
1207
|
id: die.id,
|
|
451
1208
|
type: die.type,
|
|
452
1209
|
value: role === 'tens' ? digit * 10 : digit,
|
|
453
|
-
component: Object.freeze({ groupId, groupType, role, faceValue }),
|
|
1210
|
+
component: Object.freeze({ groupId, groupType, role, faceValue: mappedValue }),
|
|
1211
|
+
provenance: Object.freeze(provenance),
|
|
454
1212
|
});
|
|
455
1213
|
}
|
|
456
1214
|
#completeActive(active) {
|
|
@@ -459,11 +1217,7 @@ export class DiceEngine extends TypedEventEmitter {
|
|
|
459
1217
|
session.completedAt = this.#now();
|
|
460
1218
|
if (session.startedAt === undefined)
|
|
461
1219
|
throw new Error('Active roll has no start time');
|
|
462
|
-
const diceResults = active.dice
|
|
463
|
-
if (die.result === undefined)
|
|
464
|
-
throw new Error(`Die ${die.id} has no settled result`);
|
|
465
|
-
return die.result;
|
|
466
|
-
});
|
|
1220
|
+
const diceResults = this.#applyRollRules(active.dice);
|
|
467
1221
|
const result = createRollResult({
|
|
468
1222
|
id: session.id,
|
|
469
1223
|
notation: session.notation,
|
|
@@ -480,14 +1234,78 @@ export class DiceEngine extends TypedEventEmitter {
|
|
|
480
1234
|
this.emit('roll:complete', result);
|
|
481
1235
|
this.#startNext();
|
|
482
1236
|
}
|
|
1237
|
+
#applyRollRules(dice) {
|
|
1238
|
+
const expressionGroups = new Map();
|
|
1239
|
+
for (const die of dice) {
|
|
1240
|
+
const group = expressionGroups.get(die.expressionIndex) ?? [];
|
|
1241
|
+
group.push(die);
|
|
1242
|
+
expressionGroups.set(die.expressionIndex, group);
|
|
1243
|
+
}
|
|
1244
|
+
const inclusion = new Map();
|
|
1245
|
+
for (const group of expressionGroups.values()) {
|
|
1246
|
+
const selection = group[0]?.selection;
|
|
1247
|
+
if (selection === undefined)
|
|
1248
|
+
continue;
|
|
1249
|
+
const ranked = group
|
|
1250
|
+
.map((die, index) => {
|
|
1251
|
+
if (die.result === undefined)
|
|
1252
|
+
throw new Error(`Die ${die.id} has no settled result`);
|
|
1253
|
+
return { die, index, value: die.result.value };
|
|
1254
|
+
})
|
|
1255
|
+
.toSorted((left, right) => {
|
|
1256
|
+
const highest = selection.operator === 'kh' || selection.operator === 'dh';
|
|
1257
|
+
const valueOrder = highest ? right.value - left.value : left.value - right.value;
|
|
1258
|
+
return valueOrder === 0 ? left.index - right.index : valueOrder;
|
|
1259
|
+
});
|
|
1260
|
+
const selected = new Set(ranked.slice(0, selection.count).map(({ die }) => die.id));
|
|
1261
|
+
const keepsSelected = selection.operator === 'kh' || selection.operator === 'kl';
|
|
1262
|
+
for (const die of group) {
|
|
1263
|
+
inclusion.set(die.id, keepsSelected ? selected.has(die.id) : !selected.has(die.id));
|
|
1264
|
+
}
|
|
1265
|
+
}
|
|
1266
|
+
return dice.map((die) => {
|
|
1267
|
+
if (die.result === undefined)
|
|
1268
|
+
throw new Error(`Die ${die.id} has no settled result`);
|
|
1269
|
+
const included = inclusion.get(die.id);
|
|
1270
|
+
const score = die.scoreRules === undefined
|
|
1271
|
+
? undefined
|
|
1272
|
+
: this.#scoreFace(die.result.value, die.scoreRules);
|
|
1273
|
+
if (included === undefined && score === undefined)
|
|
1274
|
+
return die.result;
|
|
1275
|
+
if (die.result.component !== undefined) {
|
|
1276
|
+
throw new Error(`Paired die ${die.id} cannot use keep/drop or score rules`);
|
|
1277
|
+
}
|
|
1278
|
+
const provenance = die.result.provenance;
|
|
1279
|
+
if (provenance === undefined)
|
|
1280
|
+
throw new Error(`Die ${die.id} has no result provenance`);
|
|
1281
|
+
return Object.freeze({
|
|
1282
|
+
...die.result,
|
|
1283
|
+
...(included === undefined ? {} : { included }),
|
|
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
|
+
}),
|
|
1290
|
+
});
|
|
1291
|
+
});
|
|
1292
|
+
}
|
|
1293
|
+
#scoreFace(value, rules) {
|
|
1294
|
+
return rules.find((rule) => value >= rule.minimum && value <= rule.maximum)?.score ?? 0;
|
|
1295
|
+
}
|
|
483
1296
|
#cancelActive(task) {
|
|
484
1297
|
this.#frameToken?.cancel();
|
|
485
1298
|
this.#frameToken = undefined;
|
|
486
1299
|
const active = this.#active;
|
|
487
1300
|
this.#active = undefined;
|
|
488
|
-
|
|
489
|
-
this.#removeDice(active.dice);
|
|
1301
|
+
const cleanupErrors = active === undefined ? [] : this.#removeDiceSafely(active.dice, 'cancelled');
|
|
490
1302
|
this.#rejectCancelled(task);
|
|
1303
|
+
if (cleanupErrors.length > 0) {
|
|
1304
|
+
this.emit('error', {
|
|
1305
|
+
session: snapshotSession(task.session),
|
|
1306
|
+
error: new AggregateError(cleanupErrors, `Failed to clean up ${task.session.id}`),
|
|
1307
|
+
});
|
|
1308
|
+
}
|
|
491
1309
|
this.#startNext();
|
|
492
1310
|
}
|
|
493
1311
|
#rejectCancelled(task) {
|
|
@@ -499,8 +1317,12 @@ export class DiceEngine extends TypedEventEmitter {
|
|
|
499
1317
|
}
|
|
500
1318
|
#failActive(active, error) {
|
|
501
1319
|
this.#active = undefined;
|
|
502
|
-
this.#
|
|
503
|
-
this.#failTask(active.task,
|
|
1320
|
+
const cleanupErrors = this.#removeDiceSafely(active.dice, 'failed');
|
|
1321
|
+
this.#failTask(active.task, cleanupErrors.length === 0
|
|
1322
|
+
? error
|
|
1323
|
+
: new AggregateError([error, ...cleanupErrors], `Roll ${active.task.session.id} failed`, {
|
|
1324
|
+
cause: error,
|
|
1325
|
+
}));
|
|
504
1326
|
}
|
|
505
1327
|
#failTask(task, error) {
|
|
506
1328
|
task.session.state = 'failed';
|
|
@@ -510,24 +1332,278 @@ export class DiceEngine extends TypedEventEmitter {
|
|
|
510
1332
|
this.emit('error', { session: snapshotSession(task.session), error });
|
|
511
1333
|
this.#startNext();
|
|
512
1334
|
}
|
|
513
|
-
#
|
|
1335
|
+
#removeDiceSafely(dice, reason) {
|
|
1336
|
+
const errors = [];
|
|
514
1337
|
for (const die of dice) {
|
|
515
|
-
|
|
516
|
-
|
|
1338
|
+
try {
|
|
1339
|
+
this.#removeDie(die.id, reason);
|
|
1340
|
+
}
|
|
1341
|
+
catch (error) {
|
|
1342
|
+
if (error instanceof AggregateError)
|
|
1343
|
+
errors.push(...error.errors);
|
|
1344
|
+
else
|
|
1345
|
+
errors.push(error);
|
|
1346
|
+
}
|
|
517
1347
|
}
|
|
1348
|
+
return errors;
|
|
518
1349
|
}
|
|
519
1350
|
#removeDisplayedDice() {
|
|
1351
|
+
const cleanupErrors = [];
|
|
520
1352
|
for (const id of this.#displayedDieIds) {
|
|
521
|
-
|
|
522
|
-
|
|
1353
|
+
try {
|
|
1354
|
+
this.#removeDie(id, 'replaced');
|
|
1355
|
+
}
|
|
1356
|
+
catch (error) {
|
|
1357
|
+
if (error instanceof AggregateError)
|
|
1358
|
+
cleanupErrors.push(...error.errors);
|
|
1359
|
+
else
|
|
1360
|
+
cleanupErrors.push(error);
|
|
1361
|
+
}
|
|
523
1362
|
}
|
|
524
1363
|
this.#displayedDieIds.clear();
|
|
1364
|
+
if (cleanupErrors.length > 0) {
|
|
1365
|
+
throw new AggregateError(cleanupErrors, 'Failed to remove displayed dice');
|
|
1366
|
+
}
|
|
1367
|
+
}
|
|
1368
|
+
#removeDie(id, reason) {
|
|
1369
|
+
const cleanupErrors = this.#runCleanup([
|
|
1370
|
+
() => this.#physics.removeDie(id),
|
|
1371
|
+
() => this.#renderer.removeDie(id),
|
|
1372
|
+
]);
|
|
1373
|
+
const event = this.#dieEvents.get(id);
|
|
1374
|
+
if (event !== undefined) {
|
|
1375
|
+
this.#dieEvents.delete(id);
|
|
1376
|
+
this.emit('die:remove', Object.freeze({ ...event, reason }));
|
|
1377
|
+
}
|
|
1378
|
+
if (cleanupErrors.length > 0) {
|
|
1379
|
+
throw new AggregateError(cleanupErrors, `Failed to remove die ${id}`);
|
|
1380
|
+
}
|
|
1381
|
+
}
|
|
1382
|
+
#clearRenderedDice(reason) {
|
|
1383
|
+
const cleanupErrors = this.#runCleanup([
|
|
1384
|
+
() => this.#physics.clear(),
|
|
1385
|
+
() => this.#renderer.clear(),
|
|
1386
|
+
]);
|
|
1387
|
+
this.#forgetRenderedDice(reason);
|
|
1388
|
+
if (cleanupErrors.length > 0) {
|
|
1389
|
+
throw new AggregateError(cleanupErrors, 'Failed to clear rendered dice');
|
|
1390
|
+
}
|
|
1391
|
+
}
|
|
1392
|
+
#forgetRenderedDice(reason) {
|
|
1393
|
+
for (const event of this.#dieEvents.values()) {
|
|
1394
|
+
this.emit('die:remove', Object.freeze({ ...event, reason }));
|
|
1395
|
+
}
|
|
1396
|
+
this.#dieEvents.clear();
|
|
1397
|
+
this.#displayedDieIds.clear();
|
|
1398
|
+
}
|
|
1399
|
+
#runCleanup(actions) {
|
|
1400
|
+
const errors = [];
|
|
1401
|
+
for (const action of actions) {
|
|
1402
|
+
try {
|
|
1403
|
+
action();
|
|
1404
|
+
}
|
|
1405
|
+
catch (error) {
|
|
1406
|
+
errors.push(error);
|
|
1407
|
+
}
|
|
1408
|
+
}
|
|
1409
|
+
return errors;
|
|
525
1410
|
}
|
|
526
1411
|
#detachAbort(task) {
|
|
527
1412
|
if (task.signal !== undefined && task.abortListener !== undefined) {
|
|
528
1413
|
task.signal.removeEventListener('abort', task.abortListener);
|
|
529
1414
|
}
|
|
530
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
|
+
}
|
|
531
1607
|
#assertSupportedAndWithinLimits(parsed) {
|
|
532
1608
|
let logicalDice = 0;
|
|
533
1609
|
let physicalDice = 0;
|
|
@@ -571,6 +1647,40 @@ export class DiceEngine extends TypedEventEmitter {
|
|
|
571
1647
|
}
|
|
572
1648
|
return merged;
|
|
573
1649
|
}
|
|
1650
|
+
#assertValidVisualPreset(preset) {
|
|
1651
|
+
if (!isDieType(preset.dieType) || !isPhysicalDieType(preset.dieType)) {
|
|
1652
|
+
throw new RangeError(`Visual preset die type is not supported: ${preset.dieType}`);
|
|
1653
|
+
}
|
|
1654
|
+
if (!isDieType(preset.geometryId) || !hasDieGeometry(preset.geometryId)) {
|
|
1655
|
+
throw new RangeError(`Visual preset geometry is not registered: ${preset.geometryId}`);
|
|
1656
|
+
}
|
|
1657
|
+
const geometry = getDieGeometry(preset.geometryId);
|
|
1658
|
+
const faceValues = new Set(geometry.faces.map((face) => face.value));
|
|
1659
|
+
const logicalSides = Number(preset.dieType.slice(1));
|
|
1660
|
+
if (preset.valueMap !== undefined) {
|
|
1661
|
+
const mappedFaces = Object.keys(preset.valueMap).map(Number);
|
|
1662
|
+
if (mappedFaces.length !== faceValues.size ||
|
|
1663
|
+
mappedFaces.some((face) => !faceValues.has(face))) {
|
|
1664
|
+
throw new RangeError('Visual preset valueMap must map every geometry face exactly once');
|
|
1665
|
+
}
|
|
1666
|
+
}
|
|
1667
|
+
for (const face of faceValues) {
|
|
1668
|
+
const value = preset.valueMap?.[face] ?? face;
|
|
1669
|
+
if (!Number.isSafeInteger(value) || value < 1 || value > logicalSides) {
|
|
1670
|
+
throw new RangeError(`Visual preset maps geometry face ${face} outside ${preset.dieType}`);
|
|
1671
|
+
}
|
|
1672
|
+
}
|
|
1673
|
+
if (preset.faceLabels !== undefined &&
|
|
1674
|
+
Object.keys(preset.faceLabels).some((face) => !faceValues.has(Number(face)))) {
|
|
1675
|
+
throw new RangeError('Visual preset faceLabels contain a face absent from its geometry');
|
|
1676
|
+
}
|
|
1677
|
+
}
|
|
1678
|
+
#getPresetGeometryType(preset) {
|
|
1679
|
+
if (!isDieType(preset.geometryId) || !hasDieGeometry(preset.geometryId)) {
|
|
1680
|
+
throw new RangeError(`Visual preset geometry is not registered: ${preset.geometryId}`);
|
|
1681
|
+
}
|
|
1682
|
+
return preset.geometryId;
|
|
1683
|
+
}
|
|
574
1684
|
#assertReady() {
|
|
575
1685
|
this.#assertAlive();
|
|
576
1686
|
if (!this.#initialized)
|