playlist-data-engine 1.7.3 → 1.8.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 +14 -0
- package/bin/cli.cjs +85 -0
- package/dist/gateway-CDMPqFEH.js +1320 -0
- package/dist/gateway-DKa45Uz6.cjs +6 -0
- package/dist/gateway.d.ts +1 -0
- package/dist/gateway.d.ts.map +1 -1
- package/dist/gateway.js +1 -1
- package/dist/gateway.mjs +22 -19
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/playlist-data-engine.js +4 -4
- package/dist/playlist-data-engine.mjs +30 -27
- package/dist/utils/engineDocs.d.ts +33 -0
- package/dist/utils/engineDocs.d.ts.map +1 -0
- package/docs/DATA_ENGINE_REFERENCE.md +6660 -0
- package/docs/USAGE_IN_OTHER_PROJECTS.md +587 -0
- package/docs/features/AUDIO_ANALYSIS.md +610 -0
- package/docs/features/BEAT_DETECTION.md +5250 -0
- package/docs/features/COMBAT_SYSTEM.md +1632 -0
- package/docs/features/CONTENT_PACKS.md +464 -0
- package/docs/features/CUSTOM_CONTENT.md +603 -0
- package/docs/features/ENEMY_GENERATION.md +1711 -0
- package/docs/features/EQUIPMENT_SYSTEM.md +2279 -0
- package/docs/features/EXTENSIBILITY_GUIDE.md +1106 -0
- package/docs/features/GATEWAY_RESOLUTION.md +725 -0
- package/docs/features/IRL_SENSORS.md +360 -0
- package/docs/features/PLAYLIST_PARSING.md +446 -0
- package/docs/features/PREREQUISITES.md +571 -0
- package/docs/features/ROLLS_AND_SEEDS.md +687 -0
- package/docs/features/XP_AND_STATS.md +1221 -0
- package/llms.txt +33 -0
- package/package.json +9 -2
- package/skills/playlist-data-engine/SKILL.md +69 -0
- package/dist/gateway-C_p9Ku3O.js +0 -1211
- package/dist/gateway-Ceg-5xug.cjs +0 -1
|
@@ -0,0 +1,687 @@
|
|
|
1
|
+
# Randomness Reference
|
|
2
|
+
|
|
3
|
+
Complete guide to the dice roller and seeded randomness in the Playlist Data Engine.
|
|
4
|
+
|
|
5
|
+
**For API details, see [DATA_ENGINE_REFERENCE.md](../DATA_ENGINE_REFERENCE.md)**
|
|
6
|
+
**For other usage examples, see [USAGE_IN_OTHER_PROJECTS.md](../USAGE_IN_OTHER_PROJECTS.md)**
|
|
7
|
+
|
|
8
|
+
---
|
|
9
|
+
|
|
10
|
+
## Table of Contents
|
|
11
|
+
|
|
12
|
+
1. [Hash Utilities and Deterministic Seeding](#hash-utilities-and-deterministic-seeding)
|
|
13
|
+
2. [Dice Roller](#dice-roller)
|
|
14
|
+
3. [Seeded Dice Roller](#seeded-dice-roller)
|
|
15
|
+
4. [Initiative Roller](#initiative-roller)
|
|
16
|
+
5. [Seeded RNG in Combat Simulations](#seeded-rng-in-combat-simulations)
|
|
17
|
+
|
|
18
|
+
---
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
## Hash Utilities and Deterministic Seeding
|
|
22
|
+
|
|
23
|
+
The hash utilities provide deterministic seed generation for reproducible character generation:
|
|
24
|
+
|
|
25
|
+
```typescript
|
|
26
|
+
import { generateSeed, hashSeedToFloat, hashSeedToInt, deriveSeed, SeededRNG } from 'playlist-data-engine';
|
|
27
|
+
|
|
28
|
+
// Generate a deterministic seed from blockchain data
|
|
29
|
+
// Takes THREE parameters: chainName, tokenAddress, tokenId
|
|
30
|
+
const seed = generateSeed('ethereum', '0x123abc...', '42');
|
|
31
|
+
console.log(seed); // "ethereum-0x123abc...-42"
|
|
32
|
+
|
|
33
|
+
// Hash seed to float (0.0 - 1.0)
|
|
34
|
+
const float = hashSeedToFloat(seed);
|
|
35
|
+
console.log(float); // e.g., 0.6423...
|
|
36
|
+
|
|
37
|
+
// Hash seed to integer in range
|
|
38
|
+
const stat = hashSeedToInt(seed, 8, 18); // Random stat between 8 and 17
|
|
39
|
+
console.log(stat); // e.g., 14
|
|
40
|
+
|
|
41
|
+
// Derive new seeds for related random values
|
|
42
|
+
const raceSeed = deriveSeed(seed, 'race');
|
|
43
|
+
const classSeed = deriveSeed(seed, 'class');
|
|
44
|
+
const statsSeed = deriveSeed(seed, 'stats');
|
|
45
|
+
console.log(raceSeed); // "ethereum-0x123abc...-42:race"
|
|
46
|
+
|
|
47
|
+
// Use SeededRNG for complex deterministic random operations
|
|
48
|
+
const rng = new SeededRNG(seed);
|
|
49
|
+
|
|
50
|
+
// Generate random float in [0.0, 1.0)
|
|
51
|
+
const randomValue = rng.random();
|
|
52
|
+
|
|
53
|
+
// Generate random integer in range [min, max)
|
|
54
|
+
const d20Roll = rng.randomInt(1, 21);
|
|
55
|
+
const damage = rng.randomInt(1, 9); // 1d8
|
|
56
|
+
|
|
57
|
+
// Pick random element from array
|
|
58
|
+
const races = ['Human', 'Elf', 'Dwarf', 'Halfling'];
|
|
59
|
+
const race = rng.randomChoice(races);
|
|
60
|
+
|
|
61
|
+
// Pick weighted random element (uses [value, weight] tuples)
|
|
62
|
+
const treasureOptions = [
|
|
63
|
+
['Gold', 50],
|
|
64
|
+
['Gem', 30],
|
|
65
|
+
['Artifact', 10]
|
|
66
|
+
];
|
|
67
|
+
const item = rng.weightedChoice(treasureOptions);
|
|
68
|
+
console.log(item); // 'Gold' (50% chance), 'Gem' (30% chance), or 'Artifact' (10% chance)
|
|
69
|
+
|
|
70
|
+
// Shuffle array deterministically
|
|
71
|
+
const cards = ['A', 'K', 'Q', 'J', '10', '9', '8', '7'];
|
|
72
|
+
const shuffled = rng.shuffle([...cards]);
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
**Common Use Case: Blockchain-Based Character Generation**
|
|
76
|
+
|
|
77
|
+
```typescript
|
|
78
|
+
import { generateSeed, CharacterGenerator } from 'playlist-data-engine';
|
|
79
|
+
import { AudioAnalyzer } from 'playlist-data-engine/analysis';
|
|
80
|
+
|
|
81
|
+
// Given an NFT's blockchain data
|
|
82
|
+
const nftData = {
|
|
83
|
+
chain: 'ethereum',
|
|
84
|
+
contractAddress: '0x1234567890abcdef...',
|
|
85
|
+
tokenId: '1234'
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
// Generate a deterministic seed
|
|
89
|
+
const seed = generateSeed(nftData.chain, nftData.contractAddress, nftData.tokenId);
|
|
90
|
+
|
|
91
|
+
// Generate character from seed and audio
|
|
92
|
+
const analyzer = new AudioAnalyzer();
|
|
93
|
+
const audio = await analyzer.extractSonicFingerprint(track.audio_url);
|
|
94
|
+
const character = CharacterGenerator.generate(seed, audio, track);
|
|
95
|
+
|
|
96
|
+
// The same NFT always generates the same character!
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
---
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
## Dice Roller
|
|
103
|
+
|
|
104
|
+
The `DiceRoller` class provides utility methods for D&D-style dice rolling. All methods are static—call them directly without instantiation.
|
|
105
|
+
|
|
106
|
+
```typescript
|
|
107
|
+
import { DiceRoller } from 'playlist-data-engine';
|
|
108
|
+
|
|
109
|
+
// Basic dice rolling
|
|
110
|
+
const d6Result = DiceRoller.rollDie(6); // Roll a single d6 (1-6)
|
|
111
|
+
const d20Result = DiceRoller.rollD20(); // Roll a d20 (1-20)
|
|
112
|
+
const threeD6 = DiceRoller.rollMultipleDice(3, 6); // Roll 3d6, returns [3, 5, 2]
|
|
113
|
+
const percentile = DiceRoller.rollPercentile(); // Roll d100 (1-100)
|
|
114
|
+
|
|
115
|
+
// Parse and roll dice formulas
|
|
116
|
+
const fireball = DiceRoller.parseDiceFormula('8d6+5');
|
|
117
|
+
console.log(`Fireball damage: ${fireball.total}`); // Sum of all rolls + modifier
|
|
118
|
+
console.log(`Individual rolls: ${fireball.rolls}`); // Array of each die result
|
|
119
|
+
|
|
120
|
+
// Advantage and disadvantage
|
|
121
|
+
const advRoll = DiceRoller.rollWithAdvantage();
|
|
122
|
+
console.log(`Rolled ${advRoll.roll1} and ${advRoll.roll2}, taking ${advRoll.result}`);
|
|
123
|
+
|
|
124
|
+
const disadvRoll = DiceRoller.rollWithDisadvantage();
|
|
125
|
+
console.log(`Rolled ${disadvRoll.roll1} and ${disadvRoll.roll2}, taking ${disadvRoll.result}`);
|
|
126
|
+
|
|
127
|
+
// Combat functions
|
|
128
|
+
const initiative = DiceRoller.rollInitiative(3); // d20 + DEX modifier (e.g., +3)
|
|
129
|
+
|
|
130
|
+
const damage = DiceRoller.calculateDamage('2d6', 2, false); // formula, modifier, critical?
|
|
131
|
+
console.log(`Damage: ${damage.total} (${damage.rolls} + ${damage.modifier})`);
|
|
132
|
+
|
|
133
|
+
const critDamage = DiceRoller.calculateDamage('2d6', 2, true); // Critical hit - dice doubled
|
|
134
|
+
console.log(`Critical damage: ${critDamage.total}`);
|
|
135
|
+
|
|
136
|
+
// Manual critical handling
|
|
137
|
+
const baseRolls = DiceRoller.rollMultipleDice(2, 6); // [4, 3]
|
|
138
|
+
const critRolls = DiceRoller.doubleDamage(baseRolls); // [4, 3, 4, 3]
|
|
139
|
+
|
|
140
|
+
// Saving throws and ability checks
|
|
141
|
+
const fortitudeSave = DiceRoller.rollSavingThrow(2, 2); // ability modifier + proficiency bonus
|
|
142
|
+
const athleticsCheck = DiceRoller.rollAbilityCheck(4, 0); // ability modifier only
|
|
143
|
+
|
|
144
|
+
// Critical detection
|
|
145
|
+
const attackRoll = DiceRoller.rollD20();
|
|
146
|
+
if (DiceRoller.isCriticalHit(attackRoll)) {
|
|
147
|
+
console.log('Critical hit! Double the damage dice!');
|
|
148
|
+
}
|
|
149
|
+
if (DiceRoller.isCriticalMiss(attackRoll)) {
|
|
150
|
+
console.log('Critical miss! Attack fails automatically.');
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// Seeded RNG for reproducible rolls
|
|
154
|
+
const seeded = DiceRoller.seededRoll(12345); // Same seed always produces same result
|
|
155
|
+
const anotherSeeded = DiceRoller.seededRoll(12345); // Will equal seeded
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
**Common Use Case: Custom Attack Resolution**
|
|
159
|
+
|
|
160
|
+
```typescript
|
|
161
|
+
import { DiceRoller } from 'playlist-data-engine';
|
|
162
|
+
|
|
163
|
+
function resolveAttack(attackBonus: number, targetAC: number, hasAdvantage: boolean, hasDisadvantage: boolean) {
|
|
164
|
+
let d20Roll: number;
|
|
165
|
+
let roll1: number | undefined;
|
|
166
|
+
let roll2: number | undefined;
|
|
167
|
+
let isCritical: boolean;
|
|
168
|
+
let isMiss: boolean;
|
|
169
|
+
|
|
170
|
+
if (hasAdvantage) {
|
|
171
|
+
const result = DiceRoller.rollWithAdvantage();
|
|
172
|
+
roll1 = result.roll1;
|
|
173
|
+
roll2 = result.roll2;
|
|
174
|
+
d20Roll = result.result;
|
|
175
|
+
// D&D 5e Sage Advice: With advantage, if EITHER die is a 20, it's a critical hit
|
|
176
|
+
isCritical = DiceRoller.isCriticalHit(roll1) || DiceRoller.isCriticalHit(roll2);
|
|
177
|
+
// With advantage, only check for fumble on the selected roll
|
|
178
|
+
isMiss = DiceRoller.isCriticalMiss(d20Roll);
|
|
179
|
+
console.log(`Advantage: rolled ${roll1} and ${roll2}, using ${d20Roll}`);
|
|
180
|
+
} else if (hasDisadvantage) {
|
|
181
|
+
const result = DiceRoller.rollWithDisadvantage();
|
|
182
|
+
roll1 = result.roll1;
|
|
183
|
+
roll2 = result.roll2;
|
|
184
|
+
d20Roll = result.result;
|
|
185
|
+
// D&D 5e Sage Advice: With disadvantage, if EITHER die is a 1, it's a critical miss
|
|
186
|
+
isMiss = DiceRoller.isCriticalMiss(roll1) || DiceRoller.isCriticalMiss(roll2);
|
|
187
|
+
// With disadvantage, only check for crit on the selected roll
|
|
188
|
+
isCritical = DiceRoller.isCriticalHit(d20Roll);
|
|
189
|
+
console.log(`Disadvantage: rolled ${roll1} and ${roll2}, using ${d20Roll}`);
|
|
190
|
+
} else {
|
|
191
|
+
d20Roll = DiceRoller.rollD20();
|
|
192
|
+
isCritical = DiceRoller.isCriticalHit(d20Roll);
|
|
193
|
+
isMiss = DiceRoller.isCriticalMiss(d20Roll);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const total = d20Roll + attackBonus;
|
|
197
|
+
const hit = !isMiss && (isCritical || total >= targetAC);
|
|
198
|
+
|
|
199
|
+
return { d20Roll, roll1, roll2, total, hit, isCritical, isMiss };
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
const attack = resolveAttack(7, 15, true, false);
|
|
203
|
+
console.log(`Attack roll: ${attack.d20Roll} + 7 = ${attack.total} vs AC 15`);
|
|
204
|
+
if (attack.isCritical) {
|
|
205
|
+
console.log('CRITICAL HIT!');
|
|
206
|
+
} else if (attack.isMiss) {
|
|
207
|
+
console.log('CRITICAL MISS!');
|
|
208
|
+
} else if (attack.hit) {
|
|
209
|
+
console.log('Hit!');
|
|
210
|
+
} else {
|
|
211
|
+
console.log('Miss!');
|
|
212
|
+
}
|
|
213
|
+
```
|
|
214
|
+
|
|
215
|
+
**Important:** D&D 5e rules for advantage/disadvantage with critical hits and misses:
|
|
216
|
+
- **Advantage:** If EITHER die shows a 20, it's a critical hit
|
|
217
|
+
- **Disadvantage:** If EITHER die shows a 1, it's a critical miss
|
|
218
|
+
- This follows the official D&D 5e Sage Advice ruling
|
|
219
|
+
|
|
220
|
+
---
|
|
221
|
+
|
|
222
|
+
## Seeded Dice Roller
|
|
223
|
+
|
|
224
|
+
The `SeededDiceRoller` class provides the same API as the static `DiceRoller` but produces **deterministic** results. It wraps `SeededRNG` internally, so the same seed and call sequence always produces identical rolls.
|
|
225
|
+
|
|
226
|
+
### Key Differences from DiceRoller
|
|
227
|
+
|
|
228
|
+
| Feature | `DiceRoller` (static) | `SeededDiceRoller` (instance) |
|
|
229
|
+
|---------|----------------------|------------------------------|
|
|
230
|
+
| Usage | Static methods: `DiceRoller.rollD20()` | Instance methods: `roller.rollD20()` |
|
|
231
|
+
| Randomness | `Math.random()` — non-deterministic | `SeededRNG` — deterministic |
|
|
232
|
+
| State | No state (each call independent) | Stateful counter (call order matters) |
|
|
233
|
+
| Use case | Live gameplay, one-off rolls | Simulations, reproducible tests |
|
|
234
|
+
|
|
235
|
+
### Creating a Seeded Roller
|
|
236
|
+
|
|
237
|
+
Three ways to create an instance:
|
|
238
|
+
|
|
239
|
+
```typescript
|
|
240
|
+
import { SeededDiceRoller, createSeededRoller, SeededRNG } from 'playlist-data-engine';
|
|
241
|
+
|
|
242
|
+
// 1. Factory function (recommended)
|
|
243
|
+
const roller = createSeededRoller('my-simulation-seed');
|
|
244
|
+
|
|
245
|
+
// 2. Constructor with seed string
|
|
246
|
+
const roller2 = new SeededDiceRoller('another-seed');
|
|
247
|
+
|
|
248
|
+
// 3. Constructor with existing SeededRNG instance
|
|
249
|
+
const rng = new SeededRNG('pre-configured-rng');
|
|
250
|
+
const roller3 = new SeededDiceRoller(rng);
|
|
251
|
+
```
|
|
252
|
+
|
|
253
|
+
The factory function `createSeededRoller()` is the standard way to create rollers — it's what `CombatSimulator` and `AICombatRunner` use internally.
|
|
254
|
+
|
|
255
|
+
### API Reference
|
|
256
|
+
|
|
257
|
+
All methods mirror the static `DiceRoller` API. The key difference is that results are deterministic given the same seed and call order.
|
|
258
|
+
|
|
259
|
+
| Method | Returns | Description |
|
|
260
|
+
|--------|---------|-------------|
|
|
261
|
+
| `rollDie(sides)` | `number` | Roll a single die (1 to sides) |
|
|
262
|
+
| `rollD20()` | `number` | Roll a d20 (1-20) |
|
|
263
|
+
| `rollMultipleDice(count, sides)` | `number[]` | Roll N dice, return individual results |
|
|
264
|
+
| `parseDiceFormula(formula)` | `{ diceCount, diceSides, modifier, rolls, total }` | Parse and roll formulas like `"2d6+3"` |
|
|
265
|
+
| `rollWithAdvantage()` | `{ roll1, roll2, result }` | Roll 2d20, take higher |
|
|
266
|
+
| `rollWithDisadvantage()` | `{ roll1, roll2, result }` | Roll 2d20, take lower |
|
|
267
|
+
| `rollInitiative(dexModifier)` | `number` | d20 + DEX modifier |
|
|
268
|
+
| `calculateDamage(formula, modifier, isCritical?)` | `{ diceFormula, rolls, modifier, total, isCritical }` | Roll damage with optional crit doubling |
|
|
269
|
+
| `rollSavingThrow(abilityMod, proficiency?)` | `number` | d20 + ability mod + proficiency |
|
|
270
|
+
| `rollAbilityCheck(abilityMod, proficiency?)` | `number` | d20 + ability mod + proficiency |
|
|
271
|
+
| `rollPercentile()` | `number` | Roll d100 (1-100) |
|
|
272
|
+
| `isCriticalHit(d20Roll)` | `boolean` | Check if roll is natural 20 |
|
|
273
|
+
| `isCriticalMiss(d20Roll)` | `boolean` | Check if roll is natural 1 |
|
|
274
|
+
| `doubleDamage(rolls)` | `number[]` | Double the dice array (for crits) |
|
|
275
|
+
|
|
276
|
+
### Usage Examples
|
|
277
|
+
|
|
278
|
+
**Basic deterministic rolling:**
|
|
279
|
+
|
|
280
|
+
```typescript
|
|
281
|
+
import { createSeededRoller } from 'playlist-data-engine';
|
|
282
|
+
|
|
283
|
+
const roller = createSeededRoller('combat-42');
|
|
284
|
+
|
|
285
|
+
// These are always the same for seed 'combat-42'
|
|
286
|
+
console.log(roller.rollD20()); // e.g., 14
|
|
287
|
+
console.log(roller.rollD20()); // e.g., 7 (next in sequence)
|
|
288
|
+
console.log(roller.rollDie(8)); // e.g., 3
|
|
289
|
+
|
|
290
|
+
// A different roller with the same seed starts from the beginning
|
|
291
|
+
const roller2 = createSeededRoller('combat-42');
|
|
292
|
+
console.log(roller2.rollD20()); // 14 (same as first call above)
|
|
293
|
+
```
|
|
294
|
+
|
|
295
|
+
**Full combat simulation with seeded dice:**
|
|
296
|
+
|
|
297
|
+
```typescript
|
|
298
|
+
import { createSeededRoller } from 'playlist-data-engine';
|
|
299
|
+
|
|
300
|
+
// Create a roller for this simulation run
|
|
301
|
+
const roller = createSeededRoller('balance-test-1-0');
|
|
302
|
+
|
|
303
|
+
// Use it for all combat rolls — every roll is deterministic
|
|
304
|
+
const attackRoll = roller.rollD20(); // Attack: 14
|
|
305
|
+
const damage = roller.calculateDamage('2d6', 3); // Damage: { total: 11, rolls: [4, 4] }
|
|
306
|
+
const savingThrow = roller.rollSavingThrow(-1, 2); // Save: 10
|
|
307
|
+
const initiative = roller.rollInitiative(2); // Initiative: 16
|
|
308
|
+
```
|
|
309
|
+
|
|
310
|
+
### Injecting into CombatEngine
|
|
311
|
+
|
|
312
|
+
Pass a `SeededDiceRoller` to `CombatEngine` to make all combat rolls deterministic:
|
|
313
|
+
|
|
314
|
+
```typescript
|
|
315
|
+
import { CombatEngine, createSeededRoller } from 'playlist-data-engine';
|
|
316
|
+
|
|
317
|
+
// Non-deterministic (live gameplay)
|
|
318
|
+
const liveEngine = new CombatEngine({ maxTurnsBeforeDraw: 50 });
|
|
319
|
+
// Uses Math.random() internally
|
|
320
|
+
|
|
321
|
+
// Deterministic (simulation)
|
|
322
|
+
const simEngine = new CombatEngine({}, createSeededRoller('sim-seed'));
|
|
323
|
+
// All attack rolls, damage, saving throws, and initiative are deterministic
|
|
324
|
+
```
|
|
325
|
+
|
|
326
|
+
The injected roller flows to all combat subsystems:
|
|
327
|
+
|
|
328
|
+
```
|
|
329
|
+
CombatEngine (receives roller)
|
|
330
|
+
├── AttackResolver → attack rolls, damage rolls, hit/miss
|
|
331
|
+
├── InitiativeRoller → initiative ordering
|
|
332
|
+
└── SpellCaster → spell attack rolls, saving throws
|
|
333
|
+
```
|
|
334
|
+
|
|
335
|
+
### How CombatSimulator Manages Seeding
|
|
336
|
+
|
|
337
|
+
The `CombatSimulator` creates a fresh `SeededDiceRoller` for each simulation run, ensuring no state leaks between runs. Each run gets a unique seed derived from the base seed:
|
|
338
|
+
|
|
339
|
+
```typescript
|
|
340
|
+
// Internal logic (simplified):
|
|
341
|
+
for (let i = 0; i < config.runCount; i++) {
|
|
342
|
+
const runSeed = `${config.baseSeed}-${i}`;
|
|
343
|
+
const roller = createSeededRoller(runSeed);
|
|
344
|
+
const runner = new AICombatRunner();
|
|
345
|
+
const result = runner.runFullCombat(players, enemies, aiConfig, combatConfig, roller);
|
|
346
|
+
aggregator.aggregateRun(result, i, runSeed);
|
|
347
|
+
}
|
|
348
|
+
```
|
|
349
|
+
|
|
350
|
+
This means:
|
|
351
|
+
- **Run 0** gets seed `"my-base-seed-0"` — completely independent RNG state
|
|
352
|
+
- **Run 1** gets seed `"my-base-seed-1"` — fresh roller, no carry-over from Run 0
|
|
353
|
+
- Changing `baseSeed` changes all runs; changing `runCount` adds/removes runs without affecting existing ones
|
|
354
|
+
|
|
355
|
+
> **Note:** The enemy generation seed (used by `EnemyGenerator`) is separate from the simulation seed. Changing the simulation seed does not change which enemies are generated — only how the combat dice fall. See [Seeded RNG in Combat Simulations](#seeded-rng-in-combat-simulations) for the full seeding architecture.
|
|
356
|
+
|
|
357
|
+
---
|
|
358
|
+
|
|
359
|
+
## Initiative Roller
|
|
360
|
+
|
|
361
|
+
The `InitiativeRoller` class manages the D&D 5e initiative system for combat. It handles rolling initiative, sorting combatants by turn order, and managing turn progression.
|
|
362
|
+
|
|
363
|
+
```typescript
|
|
364
|
+
import { InitiativeRoller } from 'playlist-data-engine';
|
|
365
|
+
|
|
366
|
+
const roller = new InitiativeRoller();
|
|
367
|
+
```
|
|
368
|
+
|
|
369
|
+
### Rolling Initiative
|
|
370
|
+
|
|
371
|
+
Roll initiative for a single combatant:
|
|
372
|
+
|
|
373
|
+
```typescript
|
|
374
|
+
const combatant = {
|
|
375
|
+
id: 'hero-1',
|
|
376
|
+
character: {
|
|
377
|
+
name: 'Aragorn',
|
|
378
|
+
ability_modifiers: { dexterity: 3 }
|
|
379
|
+
},
|
|
380
|
+
initiative: 0
|
|
381
|
+
};
|
|
382
|
+
|
|
383
|
+
const result = roller.rollInitiativeForCombatant(combatant);
|
|
384
|
+
console.log(`${result.combatant.character.name} rolls ${result.d20Roll} + ${result.dexModifier} = ${result.initiativeTotal}`);
|
|
385
|
+
// Output: "Aragorn rolls 15 + 3 = 18"
|
|
386
|
+
```
|
|
387
|
+
|
|
388
|
+
Roll initiative for all combatants at once:
|
|
389
|
+
|
|
390
|
+
```typescript
|
|
391
|
+
const combatants = [
|
|
392
|
+
{ id: 'hero-1', character: { name: 'Aragorn', ability_modifiers: { dexterity: 3 } }, initiative: 0 },
|
|
393
|
+
{ id: 'hero-2', character: { name: 'Gimli', ability_modifiers: { dexterity: -1 } }, initiative: 0 },
|
|
394
|
+
{ id: 'enemy-1', character: { name: 'Orc', ability_modifiers: { dexterity: 0 } }, initiative: 0 }
|
|
395
|
+
];
|
|
396
|
+
|
|
397
|
+
const { results, sortedCombatants } = roller.rollInitiativeForAll(combatants);
|
|
398
|
+
|
|
399
|
+
console.log('Initiative Results:');
|
|
400
|
+
results.forEach(r => {
|
|
401
|
+
console.log(` ${r.combatant.character.name}: ${r.d20Roll} + ${r.dexModifier} = ${r.initiativeTotal}`);
|
|
402
|
+
});
|
|
403
|
+
|
|
404
|
+
console.log('\nTurn Order:');
|
|
405
|
+
sortedCombatants.forEach((c, i) => {
|
|
406
|
+
console.log(` ${i + 1}. ${c.character.name} (Initiative: ${c.initiative})`);
|
|
407
|
+
});
|
|
408
|
+
```
|
|
409
|
+
|
|
410
|
+
### Turn Management
|
|
411
|
+
|
|
412
|
+
Get the next combatant in turn order:
|
|
413
|
+
|
|
414
|
+
```typescript
|
|
415
|
+
let currentIndex = 0;
|
|
416
|
+
const combatants = sortedCombatants; // from rollInitiativeForAll
|
|
417
|
+
|
|
418
|
+
const { combatant, index, isNewRound } = roller.getNextCombatant(combatants, currentIndex);
|
|
419
|
+
console.log(`Next up: ${combatant.character.name}${isNewRound ? ' (NEW ROUND!)' : ''}`);
|
|
420
|
+
currentIndex = index;
|
|
421
|
+
```
|
|
422
|
+
|
|
423
|
+
Get formatted initiative order for display:
|
|
424
|
+
|
|
425
|
+
```typescript
|
|
426
|
+
const order = roller.getInitiativeOrder(combatants);
|
|
427
|
+
order.forEach(line => console.log(line));
|
|
428
|
+
// Output:
|
|
429
|
+
// 1. Aragorn (Initiative: 18, DEX: 3)
|
|
430
|
+
// 2. Orc (Initiative: 12, DEX: 0)
|
|
431
|
+
// 3. Gimli (Initiative: 8, DEX: -1)
|
|
432
|
+
```
|
|
433
|
+
|
|
434
|
+
### Mid-Combat Changes
|
|
435
|
+
|
|
436
|
+
Re-roll initiative for a specific combatant (e.g., after a DEX-changing effect):
|
|
437
|
+
|
|
438
|
+
```typescript
|
|
439
|
+
const newInitiative = roller.rerollInitiativeForCombatant(combatant);
|
|
440
|
+
console.log(`${combatant.character.name} rerolls initiative: ${newInitiative}`);
|
|
441
|
+
```
|
|
442
|
+
|
|
443
|
+
Delay a combatant's turn (used with the "Ready" action):
|
|
444
|
+
|
|
445
|
+
```typescript
|
|
446
|
+
const delayedOrder = roller.delayTurn(combatants, 'hero-1');
|
|
447
|
+
// hero-1 moves to the next position in the initiative order
|
|
448
|
+
```
|
|
449
|
+
|
|
450
|
+
Re-sort combatants by initiative (e.g., when new combatants join mid-fight):
|
|
451
|
+
|
|
452
|
+
```typescript
|
|
453
|
+
combatants.push(newCombatant);
|
|
454
|
+
const resortOrder = roller.resortByInitiative(combatants);
|
|
455
|
+
// All combatants re-sorted by their current initiative values
|
|
456
|
+
```
|
|
457
|
+
|
|
458
|
+
### Full Combat Workflow Example
|
|
459
|
+
|
|
460
|
+
```typescript
|
|
461
|
+
import { InitiativeRoller, DiceRoller } from 'playlist-data-engine';
|
|
462
|
+
|
|
463
|
+
// Setup combat
|
|
464
|
+
const roller = new InitiativeRoller();
|
|
465
|
+
const combatants = [
|
|
466
|
+
{ id: 'fighter', character: { name: 'Fighter', ability_modifiers: { dexterity: 2 } }, initiative: 0 },
|
|
467
|
+
{ id: 'goblin', character: { name: 'Goblin', ability_modifiers: { dexterity: 2 } }, initiative: 0 },
|
|
468
|
+
{ id: 'wizard', character: { name: 'Wizard', ability_modifiers: { dexterity: 0 } }, initiative: 0 }
|
|
469
|
+
];
|
|
470
|
+
|
|
471
|
+
// Roll initiative!
|
|
472
|
+
const { sortedCombatants } = roller.rollInitiativeForAll(combatants);
|
|
473
|
+
|
|
474
|
+
// Display turn order
|
|
475
|
+
console.log('=== INITIATIVE ORDER ===');
|
|
476
|
+
roller.getInitiativeOrder(sortedCombatants).forEach(line => console.log(line));
|
|
477
|
+
|
|
478
|
+
// Run combat rounds
|
|
479
|
+
let currentRound = 1;
|
|
480
|
+
let currentTurnIndex = 0;
|
|
481
|
+
|
|
482
|
+
while (combatants.length > 1) {
|
|
483
|
+
const { combatant, index, isNewRound } = roller.getNextCombatant(sortedCombatants, currentTurnIndex);
|
|
484
|
+
|
|
485
|
+
if (isNewRound) {
|
|
486
|
+
currentRound++;
|
|
487
|
+
console.log(`\n--- ROUND ${currentRound} ---`);
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
console.log(`${combatant.character.name}'s turn...`);
|
|
491
|
+
|
|
492
|
+
// Combat logic here (attacks, spells, etc.)
|
|
493
|
+
// Use DiceRoller for all dice rolling
|
|
494
|
+
const attackRoll = DiceRoller.rollD20();
|
|
495
|
+
|
|
496
|
+
currentTurnIndex = index;
|
|
497
|
+
}
|
|
498
|
+
```
|
|
499
|
+
|
|
500
|
+
---
|
|
501
|
+
|
|
502
|
+
## Seeded RNG in Combat Simulations
|
|
503
|
+
|
|
504
|
+
Combat simulations use seeded RNG at two layers: **enemy generation** (via `SeededRNG`) and **combat dice rolls** (via `SeededDiceRoller`). Understanding how seeds flow through the system is essential for writing reproducible balance tests and simulation pipelines.
|
|
505
|
+
|
|
506
|
+
### Two Layers of Seeded Randomness
|
|
507
|
+
|
|
508
|
+
| Layer | Class | Purpose | Seed Source |
|
|
509
|
+
|-------|-------|---------|-------------|
|
|
510
|
+
| Enemy generation | `SeededRNG` | Deterministic character stats, equipment, spell selection | User-provided seed string |
|
|
511
|
+
| Combat dice rolls | `SeededDiceRoller` | Deterministic attack rolls, damage, saving throws, initiative | Derived per simulation run |
|
|
512
|
+
|
|
513
|
+
These are independent — the combat roller does not share state with the generation RNG. A single `SeededRNG` instance is used during enemy generation (stats, spells, equipment), then discarded. A fresh `SeededDiceRoller` is created for each simulation run.
|
|
514
|
+
|
|
515
|
+
### How Seeds Flow in a Simulation
|
|
516
|
+
|
|
517
|
+
The `CombatSimulator` manages seeding across multiple runs. Given a `baseSeed` and `runCount`, each run gets a unique deterministic seed:
|
|
518
|
+
|
|
519
|
+
```
|
|
520
|
+
baseSeed: "balance-test-1"
|
|
521
|
+
runCount: 1000
|
|
522
|
+
|
|
523
|
+
Run 0 → seed: "balance-test-1-0"
|
|
524
|
+
Run 1 → seed: "balance-test-1-1"
|
|
525
|
+
Run 2 → seed: "balance-test-1-2"
|
|
526
|
+
...
|
|
527
|
+
Run 999 → seed: "balance-test-1-999"
|
|
528
|
+
```
|
|
529
|
+
|
|
530
|
+
The seed is formatted as `${baseSeed}-${runIndex}`. Each seed produces a completely independent `SeededDiceRoller` instance, ensuring no state leaks between runs.
|
|
531
|
+
|
|
532
|
+
### Determinism Guarantees
|
|
533
|
+
|
|
534
|
+
**Same base seed + same config = identical results, always.**
|
|
535
|
+
|
|
536
|
+
This holds because the entire system is deterministic:
|
|
537
|
+
- `SeededRNG` uses MurmurHash V3 via `deriveSeed()` + `hashSeedToFloat()` with a stateful counter
|
|
538
|
+
- `SeededDiceRoller` wraps `SeededRNG` — same seed, same call sequence = same rolls
|
|
539
|
+
- `CombatEngine` receives the roller via constructor injection, passing it to `AttackResolver`, `InitiativeRoller`, and `SpellCaster`
|
|
540
|
+
- `AICombatRunner` creates a fresh `CombatEngine` per run with the run-specific roller
|
|
541
|
+
- AI decisions are deterministic given the same combatant states (no random AI choices)
|
|
542
|
+
|
|
543
|
+
```typescript
|
|
544
|
+
// Proof of determinism
|
|
545
|
+
const simulator = new CombatSimulator();
|
|
546
|
+
const resultsA = simulator.run(party, enemies, { runCount: 500, baseSeed: 'test-42', aiConfig });
|
|
547
|
+
const resultsB = simulator.run(party, enemies, { runCount: 500, baseSeed: 'test-42', aiConfig });
|
|
548
|
+
|
|
549
|
+
// These are identical — same summary, same per-combatant metrics, same histograms
|
|
550
|
+
console.log(resultsA.summary.playerWinRate === resultsB.summary.playerWinRate); // true
|
|
551
|
+
```
|
|
552
|
+
|
|
553
|
+
### What Changes Between Seeds
|
|
554
|
+
|
|
555
|
+
Changing any of these produces different results:
|
|
556
|
+
- **`baseSeed`** — different random rolls across all runs
|
|
557
|
+
- **`runCount`** — more/fewer data points, different aggregate statistics
|
|
558
|
+
- **`aiConfig`** — different AI decisions change combat flow
|
|
559
|
+
- **`combatConfig.maxTurnsBeforeDraw`** — changes how stalemates resolve
|
|
560
|
+
|
|
561
|
+
```typescript
|
|
562
|
+
// Different seed → different results
|
|
563
|
+
const results1 = simulator.run(party, enemies, { runCount: 500, baseSeed: 'seed-A', aiConfig });
|
|
564
|
+
const results2 = simulator.run(party, enemies, { runCount: 500, baseSeed: 'seed-B', aiConfig });
|
|
565
|
+
console.log(results1.summary.playerWinRate === results2.summary.playerWinRate); // almost certainly false
|
|
566
|
+
```
|
|
567
|
+
|
|
568
|
+
### Seed Strategy for Balance Testing
|
|
569
|
+
|
|
570
|
+
When testing balance across multiple configurations (e.g., parameter sweeps), use a shared base seed so the only variable is the parameter being tested:
|
|
571
|
+
|
|
572
|
+
```typescript
|
|
573
|
+
import { CombatSimulator, ParameterSweep } from 'playlist-data-engine';
|
|
574
|
+
|
|
575
|
+
// ParameterSweep uses this strategy internally:
|
|
576
|
+
// Each data point gets seed `${baseSeed}-${parameterValue}`
|
|
577
|
+
// This ensures each CR level is tested with independent but reproducible RNG
|
|
578
|
+
|
|
579
|
+
const sweep = new ParameterSweep();
|
|
580
|
+
const results = sweep.sweep(
|
|
581
|
+
party, baseEnemy,
|
|
582
|
+
{
|
|
583
|
+
variable: 'cr',
|
|
584
|
+
range: { min: 1, max: 10, step: 1 },
|
|
585
|
+
simulationsPerPoint: 500,
|
|
586
|
+
aiConfig: { playerStyle: 'normal', enemyStyle: 'aggressive' },
|
|
587
|
+
baseSeed: 'cr-sweep-v1',
|
|
588
|
+
}
|
|
589
|
+
);
|
|
590
|
+
// Each CR value (1 through 10) gets 500 runs with deterministic seeds
|
|
591
|
+
```
|
|
592
|
+
|
|
593
|
+
### Relationship Between SeededRNG and SeededDiceRoller
|
|
594
|
+
|
|
595
|
+
`SeededRNG` is the low-level PRNG (pseudorandom number generator). `SeededDiceRoller` is a D&D-specific wrapper that translates RNG output into game mechanics:
|
|
596
|
+
|
|
597
|
+
```
|
|
598
|
+
SeededRNG SeededDiceRoller
|
|
599
|
+
───────── ─────────────────
|
|
600
|
+
random() → 0.0-1.0 rollDie(20) → 1-20
|
|
601
|
+
randomInt(a,b) → integer rollD20() → 1-20
|
|
602
|
+
randomChoice() → element rollWithAdvantage() → {roll1, roll2, result}
|
|
603
|
+
weightedChoice()→ element calculateDamage() → {total, rolls, modifier}
|
|
604
|
+
shuffle() → shuffled rollSavingThrow() → d20 + mod + prof
|
|
605
|
+
```
|
|
606
|
+
|
|
607
|
+
`SeededDiceRoller` is **instance-based** (not static like `DiceRoller`). Each simulation run creates its own instance with its own `SeededRNG` state. This is what makes per-run isolation possible.
|
|
608
|
+
|
|
609
|
+
### Injecting a Seeded Roller into CombatEngine
|
|
610
|
+
|
|
611
|
+
For live combat (random dice), use `CombatEngine` with no roller — it uses `Math.random()` via the static `DiceRoller`:
|
|
612
|
+
|
|
613
|
+
```typescript
|
|
614
|
+
const engine = new CombatEngine({ maxTurnsBeforeDraw: 50 });
|
|
615
|
+
// Uses Math.random() — non-deterministic
|
|
616
|
+
```
|
|
617
|
+
|
|
618
|
+
For simulation combat (deterministic dice), inject a `SeededDiceRoller`:
|
|
619
|
+
|
|
620
|
+
```typescript
|
|
621
|
+
import { createSeededRoller } from 'playlist-data-engine';
|
|
622
|
+
|
|
623
|
+
const engine = new CombatEngine({}, createSeededRoller('my-seed'));
|
|
624
|
+
// Every d20 roll, damage roll, and saving throw is deterministic
|
|
625
|
+
```
|
|
626
|
+
|
|
627
|
+
The injected roller flows to all combat subsystems:
|
|
628
|
+
|
|
629
|
+
```
|
|
630
|
+
CombatEngine
|
|
631
|
+
├── AttackResolver (receives roller) → attack rolls, damage rolls
|
|
632
|
+
├── InitiativeRoller (receives roller) → initiative rolls
|
|
633
|
+
└── SpellCaster (receives roller) → spell attack rolls, saving throws
|
|
634
|
+
```
|
|
635
|
+
|
|
636
|
+
### Full Simulation Pipeline with Seeding
|
|
637
|
+
|
|
638
|
+
```typescript
|
|
639
|
+
import {
|
|
640
|
+
CombatSimulator,
|
|
641
|
+
createSeededRoller,
|
|
642
|
+
SeededRNG,
|
|
643
|
+
EnemyGenerator,
|
|
644
|
+
} from 'playlist-data-engine';
|
|
645
|
+
|
|
646
|
+
// Step 1: Generate enemies deterministically (SeededRNG for generation)
|
|
647
|
+
const enemyGen = new EnemyGenerator();
|
|
648
|
+
const enemy = enemyGen.generate({
|
|
649
|
+
seed: 'encounter-v3',
|
|
650
|
+
cr: 5,
|
|
651
|
+
rarity: 'elite',
|
|
652
|
+
category: 'humanoid',
|
|
653
|
+
archetype: 'brute',
|
|
654
|
+
});
|
|
655
|
+
|
|
656
|
+
// Step 2: Run simulations (SeededDiceRoller per run, managed by CombatSimulator)
|
|
657
|
+
const simulator = new CombatSimulator();
|
|
658
|
+
const results = simulator.run(
|
|
659
|
+
party, [enemy],
|
|
660
|
+
{
|
|
661
|
+
runCount: 1000,
|
|
662
|
+
baseSeed: 'balance-test-3', // Each run gets "balance-test-3-0" through "balance-test-3-999"
|
|
663
|
+
aiConfig: {
|
|
664
|
+
playerStyle: 'normal',
|
|
665
|
+
enemyStyle: 'aggressive',
|
|
666
|
+
},
|
|
667
|
+
onProgress: (done, total) => {
|
|
668
|
+
console.log(`Progress: ${done}/${total} (${((done/total)*100).toFixed(1)}%)`);
|
|
669
|
+
},
|
|
670
|
+
}
|
|
671
|
+
);
|
|
672
|
+
|
|
673
|
+
console.log(`Win rate: ${(results.summary.playerWinRate * 100).toFixed(1)}%`);
|
|
674
|
+
console.log(`Avg rounds: ${results.summary.averageRounds.toFixed(1)}`);
|
|
675
|
+
console.log(`Player deaths: ${results.summary.totalPlayerDeaths} across ${results.summary.totalRuns} runs`);
|
|
676
|
+
```
|
|
677
|
+
|
|
678
|
+
**Key point:** The enemy generation seed (`encounter-v3`) and the simulation base seed (`balance-test-3`) are independent. Changing the simulation seed does not change which enemies are generated — only how the combat dice fall. This separation lets you test the same encounter composition across many different combat scenarios.
|
|
679
|
+
|
|
680
|
+
---
|
|
681
|
+
|
|
682
|
+
## See Also
|
|
683
|
+
|
|
684
|
+
- [DATA_ENGINE_REFERENCE.md](../DATA_ENGINE_REFERENCE.md) - Complete API reference
|
|
685
|
+
- [USAGE_IN_OTHER_PROJECTS.md](../USAGE_IN_OTHER_PROJECTS.md) - Usage examples
|
|
686
|
+
- [COMBAT_SYSTEM.md](COMBAT_SYSTEM.md) - Combat system (uses seeded dice for simulations)
|
|
687
|
+
- [ENEMY_GENERATION.md](ENEMY_GENERATION.md) - Balance validation and simulation-based analysis
|