playlist-data-engine 1.7.2 → 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 +20 -3
- package/bin/cli.cjs +85 -0
- package/dist/core/parser/TrackExtras.d.ts +150 -1
- package/dist/core/parser/TrackExtras.d.ts.map +1 -1
- package/dist/gateway-CDMPqFEH.js +1320 -0
- package/dist/gateway-DKa45Uz6.cjs +6 -0
- package/dist/gateway.d.ts +3 -1
- package/dist/gateway.d.ts.map +1 -1
- package/dist/gateway.js +1 -1
- package/dist/gateway.mjs +22 -14
- package/dist/index.d.ts +4 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/playlist-data-engine.js +34 -34
- package/dist/playlist-data-engine.mjs +964 -1240
- package/dist/utils/engineDocs.d.ts +33 -0
- package/dist/utils/engineDocs.d.ts.map +1 -0
- package/dist/utils/playlistUtils.d.ts +37 -0
- package/dist/utils/playlistUtils.d.ts.map +1 -1
- package/dist/utils/validators.d.ts +27 -0
- package/dist/utils/validators.d.ts.map +1 -1
- 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-DUk4nCao.cjs +0 -1
- package/dist/gateway-DyR4M-uH.js +0 -681
|
@@ -0,0 +1,1711 @@
|
|
|
1
|
+
# Enemy Generation System
|
|
2
|
+
|
|
3
|
+
Complete guide to generating enemies and encounters in the Playlist Data Engine.
|
|
4
|
+
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
## Table of Contents
|
|
8
|
+
|
|
9
|
+
1. [Overview](#overview)
|
|
10
|
+
2. [Quick Start](#quick-start)
|
|
11
|
+
3. [Generation Modes](#generation-modes)
|
|
12
|
+
4. [Rarity Tiers](#rarity-tiers)
|
|
13
|
+
5. [Leader Promotion](#leader-promotion)
|
|
14
|
+
6. [Mixed Enemy Types](#mixed-enemy-types)
|
|
15
|
+
7. [Template System](#template-system)
|
|
16
|
+
8. [Audio Integration](#audio-integration)
|
|
17
|
+
9. [Encounter Balance](#encounter-balance)
|
|
18
|
+
10. [Simulation-Based Balance Validation](#simulation-based-balance-validation)
|
|
19
|
+
11. [Parameter Sweep](#parameter-sweep)
|
|
20
|
+
12. [Comparative Analysis](#comparative-analysis)
|
|
21
|
+
13. [Difficulty Calculator](#difficulty-calculator)
|
|
22
|
+
14. [Recommended Simulation Counts](#recommended-simulation-counts)
|
|
23
|
+
15. [API Reference](#api-reference)
|
|
24
|
+
|
|
25
|
+
---
|
|
26
|
+
|
|
27
|
+
## Overview
|
|
28
|
+
|
|
29
|
+
The Enemy Generation System creates balanced combat encounters through:
|
|
30
|
+
|
|
31
|
+
- **Deterministic Generation**: Seeded RNG ensures reproducible enemies
|
|
32
|
+
- **Rarity Scaling**: Four tiers (Common → Boss) with stat/ability scaling
|
|
33
|
+
- **Template-Based**: Predefined enemy types with signature abilities
|
|
34
|
+
- **Audio-Influenced**: Music profiles affect template selection
|
|
35
|
+
- **Party-Balanced**: D&D 5e XP budgets for fair encounters
|
|
36
|
+
|
|
37
|
+
### Design Philosophy
|
|
38
|
+
|
|
39
|
+
- **Elegant over complex**: Reuses FeatureQuery for abilities rather than parallel systems
|
|
40
|
+
- **Infinite scaling**: Any template scales from Common to Boss tier
|
|
41
|
+
- **Signature + extras**: Every enemy has ONE signature ability (scaled by rarity) plus extra abilities from the feature pool
|
|
42
|
+
- **Audio-influenced**: Audio affects both template selection AND stat distribution (V2)
|
|
43
|
+
- **CR vs Rarity independence**: CR determines power (level, stats), Rarity determines complexity (abilities, resistances). Any combination is valid.
|
|
44
|
+
|
|
45
|
+
---
|
|
46
|
+
|
|
47
|
+
## Quick Start
|
|
48
|
+
|
|
49
|
+
```typescript
|
|
50
|
+
import { EnemyGenerator } from 'playlist-data-engine';
|
|
51
|
+
import { AudioAnalyzer } from 'playlist-data-engine/analysis';
|
|
52
|
+
|
|
53
|
+
// ═══════════════════════════════════════════════════════════════
|
|
54
|
+
// SINGLE ENEMY
|
|
55
|
+
// ═══════════════════════════════════════════════════════════════
|
|
56
|
+
|
|
57
|
+
// By template ID
|
|
58
|
+
const orc = EnemyGenerator.generate({
|
|
59
|
+
seed: 'dungeon-entrance',
|
|
60
|
+
templateId: 'orc',
|
|
61
|
+
cr: 5, // Power level (determines level/stats)
|
|
62
|
+
rarity: 'elite' // Complexity (determines abilities)
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
// By category/archetype (random from matching templates)
|
|
66
|
+
const enemy = EnemyGenerator.generate({
|
|
67
|
+
seed: 'random',
|
|
68
|
+
category: 'humanoid',
|
|
69
|
+
archetype: 'brute',
|
|
70
|
+
cr: 3,
|
|
71
|
+
rarity: 'uncommon'
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
// ═══════════════════════════════════════════════════════════════
|
|
75
|
+
// ENCOUNTERS
|
|
76
|
+
// ═══════════════════════════════════════════════════════════════
|
|
77
|
+
|
|
78
|
+
// Party-balanced (uses D&D 5e XP budgets)
|
|
79
|
+
const party = [player1, player2, player3, player4];
|
|
80
|
+
const encounter = EnemyGenerator.generateEncounter(party, {
|
|
81
|
+
seed: 'room-3',
|
|
82
|
+
difficulty: 'medium',
|
|
83
|
+
count: 5
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
// CR-based (no party needed)
|
|
87
|
+
const crEncounter = EnemyGenerator.generateEncounterByCR({
|
|
88
|
+
seed: 'cr5-group',
|
|
89
|
+
targetCR: 5,
|
|
90
|
+
count: 3
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
// Custom composition
|
|
94
|
+
const customMix = EnemyGenerator.generateEncounterByCR({
|
|
95
|
+
seed: 'patrol',
|
|
96
|
+
targetCR: 3,
|
|
97
|
+
enemyMix: 'custom',
|
|
98
|
+
templates: ['orc', 'orc', 'goblin-archer', 'shaman']
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
// ═══════════════════════════════════════════════════════════════
|
|
102
|
+
// AUDIO-INFLUENCED
|
|
103
|
+
// ═══════════════════════════════════════════════════════════════
|
|
104
|
+
|
|
105
|
+
const analyzer = new AudioAnalyzer();
|
|
106
|
+
const audioProfile = await analyzer.extractSonicFingerprint(track.audio_url);
|
|
107
|
+
|
|
108
|
+
const audioEncounter = EnemyGenerator.generateEncounter(party, {
|
|
109
|
+
seed: 'audio-room',
|
|
110
|
+
audioProfile,
|
|
111
|
+
track,
|
|
112
|
+
difficulty: 'hard',
|
|
113
|
+
count: 4
|
|
114
|
+
});
|
|
115
|
+
// Bass-heavy → more brutes (Orc, Bear)
|
|
116
|
+
// Treble-heavy → more archers (Hunter, Goblin Archer)
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
---
|
|
120
|
+
|
|
121
|
+
## Generation Modes
|
|
122
|
+
|
|
123
|
+
The system supports two distinct generation modes:
|
|
124
|
+
|
|
125
|
+
### Party-Based Mode
|
|
126
|
+
|
|
127
|
+
Analyzes party strength → calculates balanced encounter → generates appropriate enemies.
|
|
128
|
+
|
|
129
|
+
```typescript
|
|
130
|
+
const enemies = EnemyGenerator.generateEncounter(
|
|
131
|
+
party: CharacterSheet[], // Required - party members
|
|
132
|
+
options: {
|
|
133
|
+
seed: string, // Required - for determinism
|
|
134
|
+
count: number, // Required - number of enemies
|
|
135
|
+
difficulty?: 'easy' | 'medium' | 'hard' | 'deadly',
|
|
136
|
+
difficultyMultiplier?: number, // Fine-tune difficulty (default: 1.0)
|
|
137
|
+
category?: 'humanoid' | 'beast', // Filter by category
|
|
138
|
+
archetype?: 'brute' | 'archer' | 'support', // Filter by archetype
|
|
139
|
+
templateId?: string, // Force specific template
|
|
140
|
+
enemyMix?: 'uniform' | 'custom',
|
|
141
|
+
templates?: string[], // For custom mix
|
|
142
|
+
audioProfile?: AudioProfile,
|
|
143
|
+
track?: PlaylistTrack, // Required if audioProfile provided
|
|
144
|
+
enableLeaderPromotion?: boolean // Default: true for groups > 3
|
|
145
|
+
}
|
|
146
|
+
): CharacterSheet[]
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
**How it works:**
|
|
150
|
+
1. Analyzes party levels and calculates XP budget using D&D 5e tables
|
|
151
|
+
2. Applies encounter multiplier for group fights
|
|
152
|
+
3. Divides budget across requested enemy count
|
|
153
|
+
4. Generates enemies at calculated CR
|
|
154
|
+
5. Applies leader promotion if enabled
|
|
155
|
+
|
|
156
|
+
### CR-Based Mode
|
|
157
|
+
|
|
158
|
+
Specify target Challenge Rating directly → no party analysis needed.
|
|
159
|
+
|
|
160
|
+
```typescript
|
|
161
|
+
const enemies = EnemyGenerator.generateEncounterByCR({
|
|
162
|
+
seed: string, // Required
|
|
163
|
+
count: number, // Required - number of enemies
|
|
164
|
+
targetCR: number, // Target Challenge Rating per enemy
|
|
165
|
+
baseRarity?: 'common', // Starting rarity before promotion
|
|
166
|
+
difficultyMultiplier?: number, // Fine-tune (default: 1.0)
|
|
167
|
+
category?: EnemyCategory,
|
|
168
|
+
archetype?: EnemyArchetype,
|
|
169
|
+
templateId?: string,
|
|
170
|
+
enemyMix?: 'uniform' | 'custom',
|
|
171
|
+
templates?: string[],
|
|
172
|
+
audioProfile?: AudioProfile,
|
|
173
|
+
track?: PlaylistTrack,
|
|
174
|
+
enableLeaderPromotion?: boolean
|
|
175
|
+
}): CharacterSheet[]
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
**How it works:**
|
|
179
|
+
1. Uses targetCR directly instead of party analysis
|
|
180
|
+
2. Applies encounter multiplier for group adjustments
|
|
181
|
+
3. Generates enemies at calculated rarity
|
|
182
|
+
4. Applies leader promotion if enabled
|
|
183
|
+
|
|
184
|
+
---
|
|
185
|
+
|
|
186
|
+
## CR vs Rarity: Two Independent Axes
|
|
187
|
+
|
|
188
|
+
The enemy generation system uses **two independent axes** to create diverse enemies:
|
|
189
|
+
|
|
190
|
+
| Concept | Determines | Examples |
|
|
191
|
+
|---------|------------|----------|
|
|
192
|
+
| **Challenge Rating (CR)** | Power level (stats, HP, level, proficiency) | Weak beast vs. ancient dragon |
|
|
193
|
+
| **Rarity** | Complexity (abilities, resistances, legendary actions) | Simple guard vs. complex spellcaster |
|
|
194
|
+
|
|
195
|
+
### Key Design Principle
|
|
196
|
+
|
|
197
|
+
**Any CR can combine with any rarity:**
|
|
198
|
+
|
|
199
|
+
| CR | Rarity | Result | Example |
|
|
200
|
+
|----|--------|--------|---------|
|
|
201
|
+
| 0.25 | Common | Weak, simple | Goblin grunt |
|
|
202
|
+
| 0.25 | Boss | Weak, complex | Goblin chieftain |
|
|
203
|
+
| 5 | Common | Strong, simple | Dire wolf |
|
|
204
|
+
| 5 | Boss | Strong, complex | Werewolf alpha |
|
|
205
|
+
| 20 | Common | Epic, simple | Ancient purple worm |
|
|
206
|
+
| 20 | Boss | Epic, complex | Ancient red dragon |
|
|
207
|
+
|
|
208
|
+
### How It Works
|
|
209
|
+
|
|
210
|
+
1. **CR determines power**: Level is derived from CR using `CRLevelConverter.crToLevel()`. Higher CR = higher level = stronger stats.
|
|
211
|
+
2. **Rarity determines complexity**: Rarity controls ability count, signature die size, and special features. Higher rarity = more complex = more abilities.
|
|
212
|
+
3. **Fractional CRs** (0.25, 0.5) get reduced base stats (75-85%) to represent "sub-level" enemies.
|
|
213
|
+
|
|
214
|
+
### Example: CR vs Rarity Independence
|
|
215
|
+
|
|
216
|
+
```typescript
|
|
217
|
+
// SAME rarity, DIFFERENT power (CR determines stats/level)
|
|
218
|
+
const grunt = EnemyGenerator.generate({ seed: 'a', templateId: 'goblin', cr: 0.25, rarity: 'common' });
|
|
219
|
+
const beast = EnemyGenerator.generate({ seed: 'b', templateId: 'purple-worm', cr: 20, rarity: 'common' });
|
|
220
|
+
// Both have 1d6 signature, 0 extras — but beast has 20x the stats
|
|
221
|
+
|
|
222
|
+
// SAME power, DIFFERENT complexity (rarity determines abilities)
|
|
223
|
+
const simple = EnemyGenerator.generate({ seed: 'c', templateId: 'dire-wolf', cr: 5, rarity: 'common' });
|
|
224
|
+
const complex = EnemyGenerator.generate({ seed: 'd', templateId: 'werewolf', cr: 5, rarity: 'boss' });
|
|
225
|
+
// Both are level 5 — but boss has 1d12 signature, 3 extras, legendary actions
|
|
226
|
+
```
|
|
227
|
+
|
|
228
|
+
---
|
|
229
|
+
|
|
230
|
+
## Rarity Tiers
|
|
231
|
+
|
|
232
|
+
Every enemy template can be generated at four rarity tiers. Higher rarities have meaningfully stronger ability scores and dramatically more HP, making them feel distinctly more powerful.
|
|
233
|
+
|
|
234
|
+
| Rarity | Stat Multiplier | HP Multiplier | Signature Die | Extra Abilities | Resistances |
|
|
235
|
+
|--------|-----------------|---------------|----------------|-----------------|-------------|
|
|
236
|
+
| **Common** | 1.0× (base) | 1.0× | 1d6 | 0 | None |
|
|
237
|
+
| **Uncommon** | 1.08× (+8%) | 1.3× | 1d8 | 1 | None |
|
|
238
|
+
| **Elite** | 1.15× (+15%) | 1.7× | d10 | 2 | Type-based |
|
|
239
|
+
| **Boss** | 1.25× (+25%) | 2.2× | d12 | 3 | Type-based |
|
|
240
|
+
|
|
241
|
+
**Stat vs HP multipliers:** `statMultiplier` scales base ability scores, while `hpMultiplier` provides a separate, more dramatic HP boost. A boss goblin (baseHP 15) at CR 30 would reach ~1056 HP thanks to the combined effect of power-curve level scaling and the 2.2× HP multiplier.
|
|
242
|
+
|
|
243
|
+
### Signature Ability Scaling
|
|
244
|
+
|
|
245
|
+
The signature ability is the core ability that defines an enemy type. It scales by rarity:
|
|
246
|
+
|
|
247
|
+
| Rarity | Die Damage | Example (Orc Savage Strike) |
|
|
248
|
+
|--------|-------------|----------------------------|
|
|
249
|
+
| Common | 1d6 + 2 | 1d6 + 2 slashing damage |
|
|
250
|
+
| Uncommon | 1d8 + 3 | 1d8 + 3 slashing damage |
|
|
251
|
+
| Elite | 1d10 + 4 | 1d10 + 4 slashing damage |
|
|
252
|
+
| Boss | 1d12 + 6 | 1d12 + 6 slashing damage |
|
|
253
|
+
|
|
254
|
+
The damage bonus (+2/+3/+4/+6) represents the increasing ability modifier based on rarity complexity. Note that the die size increases with rarity, while CR determines the underlying power level.
|
|
255
|
+
|
|
256
|
+
### Extra Abilities
|
|
257
|
+
|
|
258
|
+
Higher rarity enemies draw additional abilities from the FeatureQuery pool:
|
|
259
|
+
|
|
260
|
+
| Rarity | Extra Abilities | Source |
|
|
261
|
+
|---------|----------------|---------|
|
|
262
|
+
| Common | 0 | None (signature only) |
|
|
263
|
+
| Uncommon | 1 | FeatureQuery (archetype-filtered) |
|
|
264
|
+
| Elite | 2 | FeatureQuery (archetype-filtered) |
|
|
265
|
+
| Boss | 3 | FeatureQuery (archetype-filtered) |
|
|
266
|
+
|
|
267
|
+
Abilities are selected based on archetype tags:
|
|
268
|
+
- **Brute**: combat, damage, defense, melee, durability
|
|
269
|
+
- **Archer**: combat, ranged, accuracy, mobility, stealth
|
|
270
|
+
- **Support**: support, healing, buff, control, utility
|
|
271
|
+
|
|
272
|
+
### Resistances
|
|
273
|
+
|
|
274
|
+
Elite and Boss enemies gain type-appropriate resistances:
|
|
275
|
+
|
|
276
|
+
| Template | Elite+ Resistances |
|
|
277
|
+
|-----------|-------------------|
|
|
278
|
+
| Orc | poison |
|
|
279
|
+
| Bandit | none |
|
|
280
|
+
| Hunter | none |
|
|
281
|
+
| Goblin Archer | none |
|
|
282
|
+
| Shaman | necrotic |
|
|
283
|
+
| Cultist | necrotic |
|
|
284
|
+
| Bear | cold |
|
|
285
|
+
| Boar | none |
|
|
286
|
+
| Giant Spider | poison |
|
|
287
|
+
| Stirge | none |
|
|
288
|
+
|
|
289
|
+
---
|
|
290
|
+
|
|
291
|
+
## Leader Promotion
|
|
292
|
+
|
|
293
|
+
When generating encounters with **more than 3 enemies**, the system automatically promotes one or more enemies to higher rarity tiers as "leaders":
|
|
294
|
+
|
|
295
|
+
| Enemy Count | Leader Rule |
|
|
296
|
+
|-------------|-------------|
|
|
297
|
+
| 1-3 | No leader, all same rarity |
|
|
298
|
+
| 4-6 | 1 enemy promoted to next rarity tier |
|
|
299
|
+
| 7-9 | 1 enemy promoted two tiers up |
|
|
300
|
+
| 10+ | 2 enemies promoted (1 one tier, 1 two tiers) |
|
|
301
|
+
|
|
302
|
+
**Example:**
|
|
303
|
+
```typescript
|
|
304
|
+
// Generate 5 common orcs
|
|
305
|
+
const enemies = EnemyGenerator.generateEncounter(party, {
|
|
306
|
+
seed: 'goblin-camp',
|
|
307
|
+
count: 5,
|
|
308
|
+
baseRarity: 'common'
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
// Result:
|
|
312
|
+
// - 4 Common Orcs (15 HP, 1d6 signature)
|
|
313
|
+
// - 1 Uncommon Orc leader (17 HP, 1d8 signature, +1 extra ability)
|
|
314
|
+
```
|
|
315
|
+
|
|
316
|
+
**Promotion is capped at Boss rarity** - if promotion would exceed boss, the enemy becomes a boss.
|
|
317
|
+
|
|
318
|
+
**Disable leader promotion:**
|
|
319
|
+
```typescript
|
|
320
|
+
const enemies = EnemyGenerator.generateEncounter(party, {
|
|
321
|
+
seed: 'no-leaders',
|
|
322
|
+
count: 5,
|
|
323
|
+
enableLeaderPromotion: false
|
|
324
|
+
});
|
|
325
|
+
// All 5 enemies remain at base rarity
|
|
326
|
+
```
|
|
327
|
+
|
|
328
|
+
---
|
|
329
|
+
|
|
330
|
+
## CR-Based Gradual Rarity Scaling (Opt-In)
|
|
331
|
+
|
|
332
|
+
By default, rarity is independent of CR. However, you can enable automatic rarity scaling based on CR by setting `scaleRarityWithCR: true`.
|
|
333
|
+
|
|
334
|
+
### How It Works
|
|
335
|
+
|
|
336
|
+
When enabled, the system distributes rarity upgrades across enemies based on the target CR:
|
|
337
|
+
|
|
338
|
+
| CR Tier | CR Range | Upgrade Points | Party of 3 Result |
|
|
339
|
+
|---------|----------|----------------|-------------------|
|
|
340
|
+
| Low | 0-2 | 0 | [common, common, common] |
|
|
341
|
+
| Low-Medium | 3-5 | 1 | [uncommon, common, common] |
|
|
342
|
+
| Medium | 6-10 | 2 | [uncommon, uncommon, common] |
|
|
343
|
+
| Medium-High | 11-15 | 3 | [uncommon, uncommon, uncommon] |
|
|
344
|
+
| High | 16-20 | 4 | [elite, uncommon, uncommon] |
|
|
345
|
+
| Very High | 21-30 | 5 | [elite, elite, uncommon] |
|
|
346
|
+
| Epic | 30+ | 6 | [elite, elite, elite] |
|
|
347
|
+
|
|
348
|
+
**Upgrade Path:** common → uncommon → elite (per enemy)
|
|
349
|
+
|
|
350
|
+
### Example Usage
|
|
351
|
+
|
|
352
|
+
```typescript
|
|
353
|
+
// Default behavior: rarity is independent of CR
|
|
354
|
+
const lowCR = EnemyGenerator.generateEncounterByCR({
|
|
355
|
+
seed: 'low-cr',
|
|
356
|
+
targetCR: 2,
|
|
357
|
+
count: 3,
|
|
358
|
+
baseRarity: 'common' // All enemies are common
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
// With scaling enabled: rarity increases with CR
|
|
362
|
+
const highCR = EnemyGenerator.generateEncounterByCR({
|
|
363
|
+
seed: 'high-cr',
|
|
364
|
+
targetCR: 18,
|
|
365
|
+
count: 3,
|
|
366
|
+
scaleRarityWithCR: true // Results in [elite, uncommon, uncommon]
|
|
367
|
+
});
|
|
368
|
+
```
|
|
369
|
+
|
|
370
|
+
### Boss Rule
|
|
371
|
+
|
|
372
|
+
When rarity is 'boss', count is automatically enforced to 1. Bosses are always 1vparty encounters.
|
|
373
|
+
|
|
374
|
+
```typescript
|
|
375
|
+
// This will generate only 1 enemy, not 3
|
|
376
|
+
const boss = EnemyGenerator.generateEncounterByCR({
|
|
377
|
+
seed: 'boss-encounter',
|
|
378
|
+
targetCR: 10,
|
|
379
|
+
count: 3, // Will be overridden to 1
|
|
380
|
+
baseRarity: 'boss' // Forces count = 1
|
|
381
|
+
});
|
|
382
|
+
```
|
|
383
|
+
|
|
384
|
+
---
|
|
385
|
+
|
|
386
|
+
## Mixed Enemy Types
|
|
387
|
+
|
|
388
|
+
Encounters can contain different enemy types using the `enemyMix` option:
|
|
389
|
+
|
|
390
|
+
### Uniform Mode (Default)
|
|
391
|
+
|
|
392
|
+
All enemies use the same randomly-selected template.
|
|
393
|
+
|
|
394
|
+
```typescript
|
|
395
|
+
const enemies = EnemyGenerator.generateEncounter(party, {
|
|
396
|
+
seed: 'uniform-group',
|
|
397
|
+
count: 5,
|
|
398
|
+
category: 'humanoid',
|
|
399
|
+
archetype: 'brute'
|
|
400
|
+
// enemyMix defaults to 'uniform'
|
|
401
|
+
});
|
|
402
|
+
|
|
403
|
+
// Result: 5 enemies, all the same type (e.g., 5 Orcs or 5 Bandits)
|
|
404
|
+
```
|
|
405
|
+
|
|
406
|
+
### Custom Mode
|
|
407
|
+
|
|
408
|
+
Specify the exact template mix for the encounter.
|
|
409
|
+
|
|
410
|
+
```typescript
|
|
411
|
+
const enemies = EnemyGenerator.generateEncounter(party, {
|
|
412
|
+
seed: 'custom-composition',
|
|
413
|
+
count: 6,
|
|
414
|
+
enemyMix: 'custom',
|
|
415
|
+
templates: ['orc', 'orc', 'goblin-archer', 'goblin-archer', 'shaman', 'cultist']
|
|
416
|
+
});
|
|
417
|
+
|
|
418
|
+
// Result: 2 Orcs, 2 Goblin Archers, 1 Shaman, 1 Cultist
|
|
419
|
+
// Templates cycle if count exceeds array length
|
|
420
|
+
```
|
|
421
|
+
|
|
422
|
+
### Category Mode
|
|
423
|
+
|
|
424
|
+
Random mix from templates within the same category.
|
|
425
|
+
|
|
426
|
+
```typescript
|
|
427
|
+
const enemies = EnemyGenerator.generateEncounter(party, {
|
|
428
|
+
seed: 'undead-crypt',
|
|
429
|
+
count: 6,
|
|
430
|
+
category: 'undead',
|
|
431
|
+
enemyMix: 'category'
|
|
432
|
+
});
|
|
433
|
+
|
|
434
|
+
// Result: Random mix of undead enemies (Skeleton, Zombie, Wight, Ghost)
|
|
435
|
+
// Could produce: 2 Skeletons, 2 Zombies, 1 Wight, 1 Ghost
|
|
436
|
+
```
|
|
437
|
+
|
|
438
|
+
**Rules:**
|
|
439
|
+
- All enemies come from the specified category
|
|
440
|
+
- Each enemy is independently randomized from templates in that category
|
|
441
|
+
- Audio weighting applies if `audioProfile` provided
|
|
442
|
+
- Requires `category` option to be specified
|
|
443
|
+
|
|
444
|
+
### Random Mode
|
|
445
|
+
|
|
446
|
+
Completely random mix from all available templates.
|
|
447
|
+
|
|
448
|
+
```typescript
|
|
449
|
+
const enemies = EnemyGenerator.generateEncounter(party, {
|
|
450
|
+
seed: 'chaos-encounter',
|
|
451
|
+
count: 4,
|
|
452
|
+
enemyMix: 'random'
|
|
453
|
+
});
|
|
454
|
+
|
|
455
|
+
// Result: Completely random enemies
|
|
456
|
+
// Could produce: 1 Orc, 1 Fire Elemental, 1 Imp, 1 Basilisk
|
|
457
|
+
```
|
|
458
|
+
|
|
459
|
+
**Warning:** This can create thematically disjoint encounters. Use with intent.
|
|
460
|
+
|
|
461
|
+
---
|
|
462
|
+
|
|
463
|
+
## Template System
|
|
464
|
+
|
|
465
|
+
Templates are the foundation of enemy generation. Each template defines:
|
|
466
|
+
|
|
467
|
+
| Property | Description |
|
|
468
|
+
|-----------|-------------|
|
|
469
|
+
| `id` | Unique identifier (e.g., `'orc'`, `'goblin-archer'`) |
|
|
470
|
+
| `name` | Display name (used as enemy name when generated) |
|
|
471
|
+
| `category` | Type classification: `'humanoid'` or `'beast'` |
|
|
472
|
+
| `archetype` | Combat role: `'brute'`, `'archer'`, or `'support'` |
|
|
473
|
+
| `signatureAbility` | Core ability shared across all rarities |
|
|
474
|
+
| `baseStats` | Ability scores before rarity scaling |
|
|
475
|
+
| `baseHP` | Hit points before rarity scaling |
|
|
476
|
+
| `baseAC` | Armor class before DEX modifier |
|
|
477
|
+
| `baseSpeed` | Movement speed in feet |
|
|
478
|
+
| `audioPreference` | Weights for audio-influenced selection |
|
|
479
|
+
| `resistances` | Damage resistances/immunities for Elite+ tier |
|
|
480
|
+
|
|
481
|
+
### Available Templates
|
|
482
|
+
|
|
483
|
+
The system includes 40+ templates across 8 categories. Full template definitions are in the source:
|
|
484
|
+
|
|
485
|
+
| Category | Source File | Templates |
|
|
486
|
+
|----------|-------------|-----------|
|
|
487
|
+
| **Humanoid, Beast** | [DefaultEnemies.ts](src/constants/DefaultEnemies.ts) | Orc, Bandit, Hunter, Goblin Archer, Shaman, Cultist, Bear, Boar, Giant Spider, Stirge |
|
|
488
|
+
| **Undead** | [EnemyTemplates/Undead.ts](src/constants/EnemyTemplates/Undead.ts) | Skeleton, Ghost, Zombie, Wight |
|
|
489
|
+
| **Fiend** | [EnemyTemplates/Fiend.ts](src/constants/EnemyTemplates/Fiend.ts) | Imp, Lemure, Demon, Quasit |
|
|
490
|
+
| **Elemental** | [EnemyTemplates/Elemental.ts](src/constants/EnemyTemplates/Elemental.ts) | Fire, Earth, Air, Water Elementals |
|
|
491
|
+
| **Construct** | [EnemyTemplates/Construct.ts](src/constants/EnemyTemplates/Construct.ts) | Animated Armor, Golem, Flying Sword, Shield Guardian |
|
|
492
|
+
| **Dragon** | [EnemyTemplates/Dragon.ts](src/constants/EnemyTemplates/Dragon.ts) | Young Red Dragon, Young Blue Dragon, Wyrmling, Drake |
|
|
493
|
+
| **Monstrosity** | [EnemyTemplates/Monstrosity.ts](src/constants/EnemyTemplates/Monstrosity.ts) | Owlbear, Mimic, Griffin, Basilisk |
|
|
494
|
+
|
|
495
|
+
Each template includes: `id`, `name`, `category`, `archetype`, `signatureAbility`, `baseStats`, `baseHP`, `baseAC`, `baseSpeed`, `audioPreference`, and `resistances`.
|
|
496
|
+
|
|
497
|
+
### Get Template by ID
|
|
498
|
+
|
|
499
|
+
```typescript
|
|
500
|
+
import { EnemyGenerator } from 'playlist-data-engine';
|
|
501
|
+
|
|
502
|
+
const template = EnemyGenerator.getTemplateById('orc');
|
|
503
|
+
if (template) {
|
|
504
|
+
console.log(template.name); // 'Orc'
|
|
505
|
+
console.log(template.baseStats); // { STR: 16, DEX: 12, ... }
|
|
506
|
+
}
|
|
507
|
+
```
|
|
508
|
+
|
|
509
|
+
---
|
|
510
|
+
|
|
511
|
+
## Audio Integration
|
|
512
|
+
|
|
513
|
+
Audio profiles influence enemy generation in two ways:
|
|
514
|
+
|
|
515
|
+
### 1. Template Selection
|
|
516
|
+
|
|
517
|
+
When generating random enemies, the system weights template selection based on audio characteristics:
|
|
518
|
+
|
|
519
|
+
| Audio Characteristic | Favors Templates |
|
|
520
|
+
|---------------------|------------------|
|
|
521
|
+
| High bass dominance | Brute templates (Orc, Bear) |
|
|
522
|
+
| High treble dominance | Archer templates (Hunter, Goblin Archer) |
|
|
523
|
+
| Balanced (mid-heavy) | Support templates (Shaman, Cultist) |
|
|
524
|
+
|
|
525
|
+
**How it works:**
|
|
526
|
+
```typescript
|
|
527
|
+
// Dot product: audio values × template weights = selection score
|
|
528
|
+
const score =
|
|
529
|
+
audioProfile.bass_dominance * template.audioPreference.bass +
|
|
530
|
+
audioProfile.mid_dominance * template.audioPreference.mid +
|
|
531
|
+
audioProfile.treble_dominance * template.audioPreference.treble;
|
|
532
|
+
```
|
|
533
|
+
|
|
534
|
+
Higher scores = more likely to be selected.
|
|
535
|
+
|
|
536
|
+
### 2. Stat Distribution (V2)
|
|
537
|
+
|
|
538
|
+
Audio profiles now also influence individual ability scores:
|
|
539
|
+
|
|
540
|
+
| Audio Characteristic | Stat Bonuses |
|
|
541
|
+
|---------------------|---------------|
|
|
542
|
+
| High bass dominance | +1 to STR and CON |
|
|
543
|
+
| High treble dominance | +1 to DEX |
|
|
544
|
+
| High mid dominance | +1 to WIS and CHA |
|
|
545
|
+
| Balanced (all similar) | +1 to all stats (smaller bonus) |
|
|
546
|
+
|
|
547
|
+
**Maximum bonus:** +2 to any single stat from audio influence
|
|
548
|
+
|
|
549
|
+
This is additive to rarity scaling, not multiplicative.
|
|
550
|
+
|
|
551
|
+
---
|
|
552
|
+
|
|
553
|
+
## V2 Features
|
|
554
|
+
|
|
555
|
+
### Equipment Generation (V2)
|
|
556
|
+
|
|
557
|
+
Enemies are now equipped with actual weapons and armor based on their archetype and rarity.
|
|
558
|
+
|
|
559
|
+
**EquipmentGenerator** provides:
|
|
560
|
+
- Weapons selected by archetype (brute/archer/support)
|
|
561
|
+
- Armor scaled by rarity (common→boss gets better gear)
|
|
562
|
+
- Shields for applicable archetypes (archers don't get shields)
|
|
563
|
+
|
|
564
|
+
#### Equipment by Archetype
|
|
565
|
+
|
|
566
|
+
| Archetype | Weapons | Armor |
|
|
567
|
+
|-----------|---------|-------|
|
|
568
|
+
| **Brute** | Greataxe, Longsword, Handaxe, Mace | Scale Mail, Chain Mail, Plate Armor (boss only) |
|
|
569
|
+
| **Archer** | Longbow, Light Crossbow, Shortsword, Dagger | Leather Armor (light for mobility) |
|
|
570
|
+
| **Support** | Quarterstaff, Mace, Dagger | Scale Mail, Chain Mail, Shield available |
|
|
571
|
+
|
|
572
|
+
#### Shield Probability by Rarity
|
|
573
|
+
|
|
574
|
+
| Rarity | Shield Chance |
|
|
575
|
+
|--------|---------------|
|
|
576
|
+
| Common | 25% |
|
|
577
|
+
| Uncommon | 50% |
|
|
578
|
+
| Elite | 75% |
|
|
579
|
+
| Boss | 100% |
|
|
580
|
+
|
|
581
|
+
**Example:**
|
|
582
|
+
```typescript
|
|
583
|
+
// Generate an elite brute with equipment
|
|
584
|
+
const enemy = EnemyGenerator.generate({
|
|
585
|
+
seed: 'elite-orc-1',
|
|
586
|
+
templateId: 'orc',
|
|
587
|
+
rarity: 'elite'
|
|
588
|
+
});
|
|
589
|
+
|
|
590
|
+
// Equipment is stored on the enemy
|
|
591
|
+
console.log(enemy.equipment_config?.weapon?.name); // 'Greataxe'
|
|
592
|
+
console.log(enemy.equipment_config?.armor?.name); // 'Chain Mail'
|
|
593
|
+
console.log(enemy.equipment_config?.shield?.name); // 'Shield' (75% chance)
|
|
594
|
+
```
|
|
595
|
+
|
|
596
|
+
---
|
|
597
|
+
|
|
598
|
+
### Spellcasting System (V2)
|
|
599
|
+
|
|
600
|
+
Some enemies gain innate spellcasting abilities based on archetype and rarity.
|
|
601
|
+
|
|
602
|
+
#### Spell Availability
|
|
603
|
+
|
|
604
|
+
| Archetype | Common | Uncommon | Elite | Boss |
|
|
605
|
+
|-----------|--------|----------|-------|------|
|
|
606
|
+
| **Support** | ✅ Always | ✅ Always | ✅ Always | ✅ Always |
|
|
607
|
+
| **Archer** | ❌ No | ❌ No | ✅ Elite+ | ✅ Elite+ |
|
|
608
|
+
| **Brute** | ❌ No | ❌ No | ✅ Elite+ | ✅ Elite+ |
|
|
609
|
+
|
|
610
|
+
#### Spell Slots by CR
|
|
611
|
+
|
|
612
|
+
| CR | Level 1 | Level 2 | Level 3 | Level 4 |
|
|
613
|
+
|----|----------|----------|----------|----------|
|
|
614
|
+
| 0-0.5 | 0 | 0 | 0 | 0 |
|
|
615
|
+
| 1 | 3 | 0 | 0 | 0 |
|
|
616
|
+
| 2 | 4 | 0 | 0 | 0 |
|
|
617
|
+
| 3 | 4 | 2 | 0 | 0 |
|
|
618
|
+
| 4 | 4 | 3 | 0 | 0 |
|
|
619
|
+
| 5 | 4 | 3 | 2 | 0 |
|
|
620
|
+
| 6-7 | 4 | 3 | 3 | 0 |
|
|
621
|
+
| 8+ | 4 | 3 | 3 | 1-2 |
|
|
622
|
+
|
|
623
|
+
#### Spell List Examples
|
|
624
|
+
|
|
625
|
+
**Support Spells:**
|
|
626
|
+
- Cantrips: Sacred Flame, Guidance, Resistance
|
|
627
|
+
- Level 1: Bless, Bane, Cure Wounds, Healing Word, Command
|
|
628
|
+
- Level 2: Aid, Lesser Restoration, Spiritual Weapon, Shatter
|
|
629
|
+
- Level 3: Spirit Guardians, Revivify, Mass Healing Word
|
|
630
|
+
|
|
631
|
+
**Archer Spells:**
|
|
632
|
+
- Cantrips: Ray of Frost, Shocking Grasp, True Strike
|
|
633
|
+
- Level 1: Misty Step, Hold Person, Ray of Sickness, Thunderwave
|
|
634
|
+
- Level 2: Web, Invisibility, Melf's Acid Arrow, Scorching Ray
|
|
635
|
+
- Level 3: Fly, Lightning Bolt, Gaseous Form
|
|
636
|
+
|
|
637
|
+
**Brute Spells:**
|
|
638
|
+
- Cantrips: Fire Bolt, Shillelagh, Thorn Whip
|
|
639
|
+
- Level 1: Burning Hands, Divine Favor, Magic Stone, Zephyr Strike
|
|
640
|
+
- Level 2: Shatter, Branding Smite, Spiritual Weapon, Flame Blade
|
|
641
|
+
- Level 3: Call Lightning, Elemental Weapon, Blur
|
|
642
|
+
|
|
643
|
+
**Example:**
|
|
644
|
+
```typescript
|
|
645
|
+
// Generate an elite shaman with spellcasting
|
|
646
|
+
const enemy = EnemyGenerator.generate({
|
|
647
|
+
seed: 'elite-shaman',
|
|
648
|
+
templateId: 'shaman',
|
|
649
|
+
rarity: 'elite'
|
|
650
|
+
});
|
|
651
|
+
|
|
652
|
+
// Spells are converted to Features with isSpell: true
|
|
653
|
+
console.log(enemy.class_features.filter(f => f.isSpell)); // 2 cantrips + 3 spells
|
|
654
|
+
```
|
|
655
|
+
|
|
656
|
+
---
|
|
657
|
+
|
|
658
|
+
### Legendary System (V2)
|
|
659
|
+
|
|
660
|
+
Boss-tier enemies gain legendary actions and resistances.
|
|
661
|
+
|
|
662
|
+
#### Legendary Actions
|
|
663
|
+
|
|
664
|
+
Bosses receive **3 legendary actions** selected from their archetype pool:
|
|
665
|
+
|
|
666
|
+
| Archetype | Sample Actions |
|
|
667
|
+
|-----------|----------------|
|
|
668
|
+
| **Brute** | Tail Attack (1), Devour (3), Trample (2), Charge (1) |
|
|
669
|
+
| **Archer** | Snipe (1), Volley Shot (2), Shadow Step (2), Multi-Shot (3) |
|
|
670
|
+
| **Support** | Rally (1), Frightful Presence (1), Healing Aura (2), Command Ally (2) |
|
|
671
|
+
| **Universal** | Teleport (2), Detect (1) |
|
|
672
|
+
|
|
673
|
+
*Costs are in legendary action points (typically 3 per round)*
|
|
674
|
+
|
|
675
|
+
#### Legendary Resistances
|
|
676
|
+
|
|
677
|
+
Bosses gain legendary resistances per day based on CR:
|
|
678
|
+
|
|
679
|
+
| CR Range | Resistances/Day |
|
|
680
|
+
|----------|-----------------|
|
|
681
|
+
| CR 1-4 | 3 |
|
|
682
|
+
| CR 5-10 | 3 |
|
|
683
|
+
| CR 11-15 | 4 |
|
|
684
|
+
| CR 16-20 | 5 |
|
|
685
|
+
| CR 21+ | 6 |
|
|
686
|
+
|
|
687
|
+
#### Boss Enhancements
|
|
688
|
+
|
|
689
|
+
Boss enemies also receive:
|
|
690
|
+
- **Enhanced Signature Ability:** 2x damage dice (d12 → 2d12)
|
|
691
|
+
- **Ultimate Ability:** One special ability usable once per encounter
|
|
692
|
+
- **Epic Name:** Title added to name (e.g., "Grognak the Destroyer")
|
|
693
|
+
|
|
694
|
+
**Example:**
|
|
695
|
+
```typescript
|
|
696
|
+
// Generate a boss with legendary system
|
|
697
|
+
const enemy = EnemyGenerator.generate({
|
|
698
|
+
seed: 'boss-dragon',
|
|
699
|
+
templateId: 'young-red-dragon',
|
|
700
|
+
rarity: 'boss'
|
|
701
|
+
});
|
|
702
|
+
|
|
703
|
+
console.log(enemy.legendary_config?.resistances); // 3 per day
|
|
704
|
+
console.log(enemy.legendary_config?.actions); // 3 legendary actions
|
|
705
|
+
console.log(enemy.name.includes('the')); // true (epic title added)
|
|
706
|
+
```
|
|
707
|
+
|
|
708
|
+
---
|
|
709
|
+
|
|
710
|
+
### Fractional CR Stat Reduction
|
|
711
|
+
|
|
712
|
+
When generating enemies with fractional CR values (0.25, 0.5), the system applies automatic stat reduction to represent "sub-level" enemies. The reduction uses a continuous linear ramp from 0.5× at CR 0 to 1.0× at CR 1:
|
|
713
|
+
|
|
714
|
+
| CR | Stat Multiplier | Description |
|
|
715
|
+
|----|-----------------|-------------|
|
|
716
|
+
| 0.125 | ~56% | Sub-level enemy |
|
|
717
|
+
| 0.25 | ~63% | Sub-level enemy (e.g., goblin grunt) |
|
|
718
|
+
| 0.5 | ~75% | Sub-level enemy (e.g., giant rat) |
|
|
719
|
+
| 1+ | 100% | Full stats (standard enemy) |
|
|
720
|
+
|
|
721
|
+
**Formula:** `0.5 + 0.5 * CR` (for CR < 1)
|
|
722
|
+
|
|
723
|
+
```typescript
|
|
724
|
+
const grunt = EnemyGenerator.generate({ seed: 'weak', templateId: 'goblin', cr: 0.25, rarity: 'common' });
|
|
725
|
+
const warrior = EnemyGenerator.generate({ seed: 'strong', templateId: 'goblin', cr: 5, rarity: 'common' });
|
|
726
|
+
// Same template, same rarity — grunt has ~63% stats, warrior has 100%
|
|
727
|
+
```
|
|
728
|
+
|
|
729
|
+
---
|
|
730
|
+
|
|
731
|
+
### CR/Level Conversion (V2)
|
|
732
|
+
|
|
733
|
+
Dedicated functions for converting between Challenge Rating and character level. **The EnemyGenerator now uses `CRLevelConverter.crToLevel()` for all CR → level conversions.**
|
|
734
|
+
|
|
735
|
+
#### CR → Level Mapping
|
|
736
|
+
|
|
737
|
+
The enemy generation system uses the following mapping:
|
|
738
|
+
|
|
739
|
+
| CR | Level | Stat Multiplier | Notes |
|
|
740
|
+
|----|-------|-----------------|-------|
|
|
741
|
+
| 0.25 | 0.25 | ~63% | Sub-level enemy (0.5 + 0.5 × CR) |
|
|
742
|
+
| 0.5 | 0.5 | ~75% | Sub-level enemy |
|
|
743
|
+
| 1 | 1 | 100% | Standard enemy |
|
|
744
|
+
| 5 | 5 | 100% | Standard enemy |
|
|
745
|
+
| 10 | 10 | 100% | Standard enemy |
|
|
746
|
+
| 20 | 20 | 100% | Standard enemy |
|
|
747
|
+
|
|
748
|
+
**Key insight:** CR ≈ level in D&D 5e. A CR 5 enemy is roughly equivalent to a level 5 character.
|
|
749
|
+
|
|
750
|
+
#### Conversion Functions
|
|
751
|
+
|
|
752
|
+
```typescript
|
|
753
|
+
import { crToLevel, levelToCR, roundLevel, roundCR } from 'playlist-data-engine';
|
|
754
|
+
|
|
755
|
+
// CR to Level
|
|
756
|
+
crToLevel(1); // 1
|
|
757
|
+
crToLevel(0.25); // 0.25
|
|
758
|
+
crToLevel(5); // 5
|
|
759
|
+
|
|
760
|
+
// Level to CR (inverse)
|
|
761
|
+
levelToCR(5); // 5
|
|
762
|
+
|
|
763
|
+
// Round to valid values
|
|
764
|
+
roundLevel(0.7); // 1 (nearest integer)
|
|
765
|
+
roundCR(0.3); // 0.25 (nearest CR step)
|
|
766
|
+
|
|
767
|
+
// Format for display
|
|
768
|
+
formatLevel(0.25); // "0 (1/4)"
|
|
769
|
+
formatCR(0.25); // "1/4"
|
|
770
|
+
```
|
|
771
|
+
|
|
772
|
+
#### Tuning Configuration
|
|
773
|
+
|
|
774
|
+
Customize conversion with tuning parameters:
|
|
775
|
+
|
|
776
|
+
```typescript
|
|
777
|
+
import { createCRTuning } from 'playlist-data-engine';
|
|
778
|
+
|
|
779
|
+
// Adjust difficulty by changing how CR maps to level
|
|
780
|
+
const tuning = createCRTuning({
|
|
781
|
+
baseMultiplier: 1.2 // CR 5 → Level 6 (harder enemies)
|
|
782
|
+
// baseMultiplier: 0.8 // CR 5 → Level 4 (easier enemies)
|
|
783
|
+
// customCurve: new Map([[5, 7], [10, 15]]) // Specific breakpoints
|
|
784
|
+
});
|
|
785
|
+
```
|
|
786
|
+
|
|
787
|
+
---
|
|
788
|
+
|
|
789
|
+
## Encounter Balance
|
|
790
|
+
|
|
791
|
+
The system uses D&D 5e official encounter building tables for balance.
|
|
792
|
+
|
|
793
|
+
### XP Budget by Level and Difficulty
|
|
794
|
+
|
|
795
|
+
Each character level has XP thresholds for each difficulty:
|
|
796
|
+
|
|
797
|
+
| Level | Easy | Medium | Hard | Deadly |
|
|
798
|
+
|--------|--------|---------|-------|---------|
|
|
799
|
+
| 1 | 25 | 50 | 75 | 100 |
|
|
800
|
+
| 3 | 75 | 150 | 225 | 300 |
|
|
801
|
+
| 5 | 250 | 500 | 750 | 1,000 |
|
|
802
|
+
| 10 | 600 | 1,200 | 1,800 | 2,400 |
|
|
803
|
+
| 15 | 1,600 | 3,200 | 4,800 | 6,400 |
|
|
804
|
+
| 20 | 5,000 | 10,000 | 15,000 | 20,000 |
|
|
805
|
+
|
|
806
|
+
**Party budget = Sum of individual character budgets**
|
|
807
|
+
|
|
808
|
+
### Encounter Multipliers
|
|
809
|
+
|
|
810
|
+
Groups of enemies are more dangerous due to action economy:
|
|
811
|
+
|
|
812
|
+
| Enemy Count | Multiplier |
|
|
813
|
+
|-------------|-------------|
|
|
814
|
+
| 1 | 1.0× |
|
|
815
|
+
| 2 | 1.5× |
|
|
816
|
+
| 3-6 | 2.0× |
|
|
817
|
+
| 7-10 | 1.5× |
|
|
818
|
+
| 11-14 | 1.0× |
|
|
819
|
+
| 15+ | 1.0× |
|
|
820
|
+
|
|
821
|
+
**Applied to adjusted XP total** - accounts for crowd control effectiveness.
|
|
822
|
+
|
|
823
|
+
### CR to XP Conversion
|
|
824
|
+
|
|
825
|
+
Challenge Rating maps to XP for encounter calculations:
|
|
826
|
+
|
|
827
|
+
| CR | XP | CR | XP | CR | XP |
|
|
828
|
+
|-----|-----|-----|-----|-----|
|
|
829
|
+
| 0 | 10 | 1 | 200 | 5 | 1,800 |
|
|
830
|
+
| 1/8 | 25 | 2 | 450 | 10 | 5,900 |
|
|
831
|
+
| 1/4 | 50 | 3 | 700 | 15 | 13,000 |
|
|
832
|
+
| 1/2 | 100 | 4 | 1,100 | 20+ | 25,000+ |
|
|
833
|
+
|
|
834
|
+
### Difficulty Multiplier
|
|
835
|
+
|
|
836
|
+
Fine-tune encounter difficulty with `difficultyMultiplier`:
|
|
837
|
+
|
|
838
|
+
```typescript
|
|
839
|
+
const enemies = EnemyGenerator.generateEncounter(party, {
|
|
840
|
+
seed: 'harder-encounter',
|
|
841
|
+
difficulty: 'medium',
|
|
842
|
+
difficultyMultiplier: 1.2, // +20% difficulty
|
|
843
|
+
count: 4
|
|
844
|
+
});
|
|
845
|
+
```
|
|
846
|
+
|
|
847
|
+
- **1.0** = Standard difficulty
|
|
848
|
+
- **1.1-1.2** = Harder (for experienced players)
|
|
849
|
+
- **0.8-0.9** = Easier (for newer players)
|
|
850
|
+
|
|
851
|
+
---
|
|
852
|
+
|
|
853
|
+
## Simulation-Based Balance Validation
|
|
854
|
+
|
|
855
|
+
> **For the core simulation engine, Combat AI, and Monte Carlo simulation details, see [Monte Carlo Simulation](COMBAT_SYSTEM.md#monte-carlo-simulation) in COMBAT_SYSTEM.md.** This section covers balance validation, parameter sweeps, comparative analysis, and the difficulty calculator — tools built on top of the simulator.
|
|
856
|
+
|
|
857
|
+
XP budgets (above) are **theoretical** — they use D&D 5e tables to estimate encounter difficulty. Simulation-based validation **tests actual difficulty** by running hundreds of AI-controlled combats and measuring real outcomes.
|
|
858
|
+
|
|
859
|
+
### Why Simulation?
|
|
860
|
+
|
|
861
|
+
| Aspect | XP Budget (Theoretical) | Simulation (Empirical) |
|
|
862
|
+
|--------|------------------------|----------------------|
|
|
863
|
+
| **What it measures** | Expected threat level | Actual win/loss outcomes |
|
|
864
|
+
| **Accounts for** | CR, enemy count, party level | AI decisions, dice variance, action economy, abilities |
|
|
865
|
+
| **Accuracy** | Approximate (designed for tabletop) | Precise (matches this game's combat engine) |
|
|
866
|
+
| **Speed** | Instant (table lookup) | Seconds (hundreds of simulated combats) |
|
|
867
|
+
| **Output** | "This is a Medium encounter" | "Players win 73% of the time in ~4 rounds" |
|
|
868
|
+
|
|
869
|
+
**Use XP budgets for fast encounter generation, then validate with simulation when balance matters.**
|
|
870
|
+
|
|
871
|
+
### Expected Win Rates
|
|
872
|
+
|
|
873
|
+
The `BalanceValidator` uses these D&D 5e-inspired targets:
|
|
874
|
+
|
|
875
|
+
| Difficulty | Player Win Rate | Meaning |
|
|
876
|
+
|------------|----------------|---------|
|
|
877
|
+
| **Easy** | 90–100% | Party almost always wins. Low risk, resource-conservative. |
|
|
878
|
+
| **Medium** | 70–80% | Comfortable but not trivial. Occasional resource drain. |
|
|
879
|
+
| **Hard** | 50–60% | Challenging. Real risk of character death. |
|
|
880
|
+
| **Deadly** | 30–40% | Likely TPK. Major achievement to win. |
|
|
881
|
+
|
|
882
|
+
These values are tunable via the `EXPECTED_WIN_RATES` constant.
|
|
883
|
+
|
|
884
|
+
### BalanceValidator
|
|
885
|
+
|
|
886
|
+
Validates an encounter by comparing simulated win rate against the intended difficulty tier.
|
|
887
|
+
|
|
888
|
+
```typescript
|
|
889
|
+
import {
|
|
890
|
+
BalanceValidator,
|
|
891
|
+
CombatSimulator,
|
|
892
|
+
AIPlayStyle
|
|
893
|
+
} from 'playlist-data-engine';
|
|
894
|
+
|
|
895
|
+
// ═══════════════════════════════════════════════════════════════
|
|
896
|
+
// Option A: Validate from scratch (runs simulations internally)
|
|
897
|
+
// ═══════════════════════════════════════════════════════════════
|
|
898
|
+
const validator = new BalanceValidator();
|
|
899
|
+
const report = validator.validate(
|
|
900
|
+
party, // Player CharacterSheet[]
|
|
901
|
+
enemies, // Enemy CharacterSheet[]
|
|
902
|
+
'medium', // Intended difficulty
|
|
903
|
+
{
|
|
904
|
+
runCount: 500,
|
|
905
|
+
baseSeed: 'validation-seed',
|
|
906
|
+
aiConfig: {
|
|
907
|
+
playerStyle: 'normal',
|
|
908
|
+
enemyStyle: 'aggressive'
|
|
909
|
+
}
|
|
910
|
+
}
|
|
911
|
+
);
|
|
912
|
+
|
|
913
|
+
// ═══════════════════════════════════════════════════════════════
|
|
914
|
+
// Option B: Analyze existing simulation results
|
|
915
|
+
// ═══════════════════════════════════════════════════════════════
|
|
916
|
+
const simulator = new CombatSimulator();
|
|
917
|
+
const results = simulator.run(party, enemies, {
|
|
918
|
+
runCount: 500,
|
|
919
|
+
baseSeed: 'validation-seed',
|
|
920
|
+
aiConfig: {
|
|
921
|
+
playerStyle: 'normal',
|
|
922
|
+
enemyStyle: 'aggressive'
|
|
923
|
+
}
|
|
924
|
+
});
|
|
925
|
+
|
|
926
|
+
const report2 = validator.analyze(results, 'medium');
|
|
927
|
+
```
|
|
928
|
+
|
|
929
|
+
### BalanceReport Output
|
|
930
|
+
|
|
931
|
+
The `validate()` and `analyze()` methods return a `BalanceReport`:
|
|
932
|
+
|
|
933
|
+
```typescript
|
|
934
|
+
interface BalanceReport {
|
|
935
|
+
intendedDifficulty: 'easy' | 'medium' | 'hard' | 'deadly';
|
|
936
|
+
actualDifficulty: 'easy' | 'medium' | 'hard' | 'deadly';
|
|
937
|
+
balanceScore: number; // 0–100 (100 = perfect match)
|
|
938
|
+
playerWinRate: number; // e.g., 0.73
|
|
939
|
+
expectedWinRate: { min: number; max: number }; // e.g., { min: 0.70, max: 0.80 }
|
|
940
|
+
difficultyVariance: 'underpowered' | 'balanced' | 'overpowered';
|
|
941
|
+
confidence: number; // 0–1 (based on run count)
|
|
942
|
+
recommendations: BalanceRecommendation[];
|
|
943
|
+
averagePlayerHPPercentRemaining: number; // 0–100
|
|
944
|
+
totalRuns: number;
|
|
945
|
+
}
|
|
946
|
+
```
|
|
947
|
+
|
|
948
|
+
| Field | Description |
|
|
949
|
+
|-------|-------------|
|
|
950
|
+
| `balanceScore` | 100 = win rate hits the midpoint of expected range. Decreases with deviation. |
|
|
951
|
+
| `difficultyVariance` | `'underpowered'` = encounter too easy (win rate above expected), `'overpowered'` = too hard (below expected), `'balanced'` = within range. |
|
|
952
|
+
| `confidence` | Statistical confidence based on run count: `1 - 1/√n`. 100 runs ≈ 0.90, 500 runs ≈ 0.96. |
|
|
953
|
+
| `recommendations` | Actionable suggestions for adjusting difficulty (see below). |
|
|
954
|
+
|
|
955
|
+
### Interpreting Results
|
|
956
|
+
|
|
957
|
+
```typescript
|
|
958
|
+
// Check if encounter is balanced for its intended difficulty
|
|
959
|
+
if (report.difficultyVariance === 'balanced') {
|
|
960
|
+
console.log(`Well-balanced! Score: ${report.balanceScore}/100`);
|
|
961
|
+
}
|
|
962
|
+
|
|
963
|
+
// Check recommendations
|
|
964
|
+
for (const rec of report.recommendations) {
|
|
965
|
+
console.log(`${rec.description} (${rec.expectedImpact})`);
|
|
966
|
+
}
|
|
967
|
+
// Example outputs:
|
|
968
|
+
// "Reduce enemy CR by 1 level" (+8-12% player win rate)
|
|
969
|
+
// "Add 1-2 additional enemies" (-6-10% player win rate)
|
|
970
|
+
// "Encounter is well-balanced. No changes needed." (None — encounter is within target range)
|
|
971
|
+
```
|
|
972
|
+
|
|
973
|
+
### Recommendations
|
|
974
|
+
|
|
975
|
+
The validator generates context-aware recommendations based on how far the win rate deviates:
|
|
976
|
+
|
|
977
|
+
| Situation | Gap | Recommendations |
|
|
978
|
+
|-----------|-----|----------------|
|
|
979
|
+
| Way too hard | >30% below target | Reduce CR by 1-2, reduce enemy count |
|
|
980
|
+
| Moderately too hard | 15-30% below | Reduce CR by 1 |
|
|
981
|
+
| Slightly too hard | <15% below | Reduce CR by 1 or remove one ability |
|
|
982
|
+
| Slightly too easy | <15% above | Increase CR by 1 or add one enemy |
|
|
983
|
+
| Way too easy | >15% above | Increase CR by 1-2, add 1-2 enemies |
|
|
984
|
+
| Balanced + high HP | Within range | Consider increasing difficulty slightly |
|
|
985
|
+
| Balanced + low HP | Within range | Consider reducing enemy damage slightly |
|
|
986
|
+
|
|
987
|
+
Each recommendation includes `expectedImpact` (estimated win rate change) and `confidence` (0-1).
|
|
988
|
+
|
|
989
|
+
### AI Strategy Impact
|
|
990
|
+
|
|
991
|
+
The AI style used during simulation significantly affects results:
|
|
992
|
+
|
|
993
|
+
```typescript
|
|
994
|
+
// Normal vs Normal — baseline difficulty measurement
|
|
995
|
+
const normalReport = validator.validate(party, enemies, 'medium', {
|
|
996
|
+
runCount: 500,
|
|
997
|
+
aiConfig: { playerStyle: 'normal', enemyStyle: 'normal' }
|
|
998
|
+
});
|
|
999
|
+
|
|
1000
|
+
// Normal players vs Aggressive enemies — maximum threat ceiling
|
|
1001
|
+
const aggressiveReport = validator.validate(party, enemies, 'medium', {
|
|
1002
|
+
runCount: 500,
|
|
1003
|
+
aiConfig: { playerStyle: 'normal', enemyStyle: 'aggressive' }
|
|
1004
|
+
});
|
|
1005
|
+
```
|
|
1006
|
+
|
|
1007
|
+
- **Normal enemies** — balanced combat, basic attacks, conservative resources. Measures baseline difficulty.
|
|
1008
|
+
- **Aggressive enemies** — maximum effort, burns all spell slots and abilities. Measures difficulty ceiling.
|
|
1009
|
+
|
|
1010
|
+
Comparing Normal vs Aggressive enemy results reveals how much enemy resource usage affects the encounter.
|
|
1011
|
+
|
|
1012
|
+
### Parameter Sweep
|
|
1013
|
+
|
|
1014
|
+
A **parameter sweep** systematically varies a single encounter parameter across a range and runs simulations at each data point. This answers questions like *"What CR makes this a Medium encounter?"* or *"How does adding more enemies change the difficulty curve?"*
|
|
1015
|
+
|
|
1016
|
+
```typescript
|
|
1017
|
+
import { ParameterSweep } from 'playlist-data-engine';
|
|
1018
|
+
|
|
1019
|
+
const sweeper = new ParameterSweep();
|
|
1020
|
+
|
|
1021
|
+
// ═══════════════════════════════════════════════════════════════
|
|
1022
|
+
// Sweep CR from 1 to 10 to find the sweet spot for Medium
|
|
1023
|
+
// ═══════════════════════════════════════════════════════════════
|
|
1024
|
+
const results = sweeper.sweep(
|
|
1025
|
+
party, // Player CharacterSheet[]
|
|
1026
|
+
{ cr: 3, rarity: 'elite', category: 'humanoid', archetype: 'brute' },
|
|
1027
|
+
{
|
|
1028
|
+
variable: 'cr',
|
|
1029
|
+
range: { min: 1, max: 10, step: 1 }, // 10 data points
|
|
1030
|
+
simulationsPerPoint: 200,
|
|
1031
|
+
aiConfig: {
|
|
1032
|
+
playerStyle: 'normal',
|
|
1033
|
+
enemyStyle: 'aggressive'
|
|
1034
|
+
},
|
|
1035
|
+
baseSeed: 'cr-sweep',
|
|
1036
|
+
},
|
|
1037
|
+
(completed, total) => console.log(`Sweep ${completed}/${total}`)
|
|
1038
|
+
);
|
|
1039
|
+
|
|
1040
|
+
// Each data point has a parameter value and simulation summary
|
|
1041
|
+
for (const point of results.dataPoints) {
|
|
1042
|
+
console.log(
|
|
1043
|
+
`CR ${point.parameterValue}: ` +
|
|
1044
|
+
`${(point.playerWinRate * 100).toFixed(1)}% win rate, ` +
|
|
1045
|
+
`${point.averageRounds.toFixed(1)} avg rounds`
|
|
1046
|
+
);
|
|
1047
|
+
}
|
|
1048
|
+
```
|
|
1049
|
+
|
|
1050
|
+
#### SweepParams
|
|
1051
|
+
|
|
1052
|
+
| Field | Type | Description |
|
|
1053
|
+
|-------|------|-------------|
|
|
1054
|
+
| `variable` | `SweepVariable` | Which parameter to vary (see table below) |
|
|
1055
|
+
| `range` | `{ min, max, step }` | Range of values to sweep across |
|
|
1056
|
+
| `simulationsPerPoint` | `number` | Number of simulations at each data point |
|
|
1057
|
+
| `aiConfig` | `AIConfig` | AI strategy for all simulations in the sweep |
|
|
1058
|
+
| `combatConfig?` | `CombatConfig` | Optional combat engine overrides |
|
|
1059
|
+
| `baseSeed?` | `string` | Seed prefix — each point gets `baseSeed-value` |
|
|
1060
|
+
| `abortSignal?` | `AbortSignal` | Cancel the sweep mid-execution |
|
|
1061
|
+
|
|
1062
|
+
#### SweepVariable — What You Can Sweep
|
|
1063
|
+
|
|
1064
|
+
| Variable | Effect | Example Range |
|
|
1065
|
+
|----------|--------|---------------|
|
|
1066
|
+
| `'cr'` | Varies enemy Challenge Rating | `{ min: 1, max: 10, step: 1 }` |
|
|
1067
|
+
| `'enemyCount'` | Varies number of enemies generated | `{ min: 1, max: 8, step: 1 }` |
|
|
1068
|
+
| `'partyLevel'` | Scales all player levels (simplified) | `{ min: 1, max: 20, step: 1 }` |
|
|
1069
|
+
| `'difficultyMultiplier'` | Scales enemy stats proportionally | `{ min: 0.5, max: 2.0, step: 0.1 }` |
|
|
1070
|
+
| `'rarity'` | Maps 0–3 to common/uncommon/elite/boss | `{ min: 0, max: 3, step: 1 }` |
|
|
1071
|
+
| `'hpLevel'` | Overrides enemy HP to a different effective level | `{ min: 1, max: 20, step: 1 }` |
|
|
1072
|
+
| `'attackLevel'` | Overrides enemy attack to a different effective level | `{ min: 1, max: 20, step: 1 }` |
|
|
1073
|
+
| `'defenseLevel'` | Overrides enemy defense to a different effective level | `{ min: 1, max: 20, step: 1 }` |
|
|
1074
|
+
|
|
1075
|
+
#### SweepResults
|
|
1076
|
+
|
|
1077
|
+
The `sweep()` method returns a `SweepResults` object with one `SweepDataPoint` per value in the range, ordered from lowest to highest:
|
|
1078
|
+
|
|
1079
|
+
```typescript
|
|
1080
|
+
interface SweepResults {
|
|
1081
|
+
variable: SweepVariable; // Which parameter was swept
|
|
1082
|
+
range: SweepRange; // The range that was swept
|
|
1083
|
+
simulationsPerPoint: number; // Sims per data point
|
|
1084
|
+
dataPoints: SweepDataPoint[]; // One per value in the range
|
|
1085
|
+
wasCancelled: boolean; // True if cancelled before completion
|
|
1086
|
+
}
|
|
1087
|
+
```
|
|
1088
|
+
|
|
1089
|
+
Each `SweepDataPoint` contains:
|
|
1090
|
+
|
|
1091
|
+
| Field | Type | Description |
|
|
1092
|
+
|-------|------|-------------|
|
|
1093
|
+
| `parameterValue` | `number` | The value of the sweep parameter at this point |
|
|
1094
|
+
| `playerWinRate` | `number` | Player win rate (0.0–1.0) |
|
|
1095
|
+
| `averageRounds` | `number` | Average rounds to combat resolution |
|
|
1096
|
+
| `medianRounds` | `number` | Median rounds to combat resolution |
|
|
1097
|
+
| `averageHPRemaining` | `number` | Average player HP remaining % on wins |
|
|
1098
|
+
| `totalPlayerDeaths` | `number` | Total player deaths across all sims |
|
|
1099
|
+
| `totalEnemyDeaths` | `number` | Total enemy deaths across all sims |
|
|
1100
|
+
|
|
1101
|
+
#### Interpreting Sweep Results
|
|
1102
|
+
|
|
1103
|
+
Plot `playerWinRate` against `parameterValue` to see the difficulty curve. Key patterns:
|
|
1104
|
+
|
|
1105
|
+
- **CR sweep**: Win rate should generally decrease as CR increases. The "sweet spot" for a given difficulty is where the win rate falls in the expected range.
|
|
1106
|
+
- **Enemy count sweep**: Similar to CR — more enemies means lower win rate. Watch for steep drop-offs (action economy tipping points).
|
|
1107
|
+
- **Difficulty multiplier sweep**: Produces the smoothest curves because it scales all enemy stats proportionally. Best for fine-tuning.
|
|
1108
|
+
- **Stat level sweeps** (`hpLevel`, `attackLevel`, `defenseLevel`): Reveal which stat axis has the most impact on difficulty. Useful for creating specialized enemies (tanks, glass cannons, brutes).
|
|
1109
|
+
|
|
1110
|
+
```typescript
|
|
1111
|
+
// Find the CR range that produces Medium difficulty (70-80% win rate)
|
|
1112
|
+
const mediumPoints = results.dataPoints.filter(
|
|
1113
|
+
p => p.playerWinRate >= 0.70 && p.playerWinRate <= 0.80
|
|
1114
|
+
);
|
|
1115
|
+
if (mediumPoints.length > 0) {
|
|
1116
|
+
console.log(
|
|
1117
|
+
`Medium difficulty CR range: ${mediumPoints[0].parameterValue}` +
|
|
1118
|
+
`–${mediumPoints[mediumPoints.length - 1].parameterValue}`
|
|
1119
|
+
);
|
|
1120
|
+
}
|
|
1121
|
+
|
|
1122
|
+
// Find the exact CR closest to 75% win rate
|
|
1123
|
+
const closest = results.dataPoints.reduce((best, p) =>
|
|
1124
|
+
Math.abs(p.playerWinRate - 0.75) < Math.abs(best.playerWinRate - 0.75)
|
|
1125
|
+
? p : best
|
|
1126
|
+
);
|
|
1127
|
+
console.log(`Best CR for Medium: ${closest.parameterValue} (${(closest.playerWinRate * 100).toFixed(1)}%)`);
|
|
1128
|
+
```
|
|
1129
|
+
|
|
1130
|
+
#### Cancellation
|
|
1131
|
+
|
|
1132
|
+
Like `CombatSimulator`, parameter sweeps support `AbortSignal` for cancellation. Partial results are returned:
|
|
1133
|
+
|
|
1134
|
+
```typescript
|
|
1135
|
+
const controller = new AbortController();
|
|
1136
|
+
|
|
1137
|
+
// Cancel after 3 seconds
|
|
1138
|
+
setTimeout(() => controller.abort(), 3000);
|
|
1139
|
+
|
|
1140
|
+
const partialResults = sweeper.sweep(party, encounter, {
|
|
1141
|
+
variable: 'cr',
|
|
1142
|
+
range: { min: 1, max: 20, step: 1 },
|
|
1143
|
+
simulationsPerPoint: 500,
|
|
1144
|
+
aiConfig: { playerStyle: 'normal', enemyStyle: 'aggressive' },
|
|
1145
|
+
abortSignal: controller.signal,
|
|
1146
|
+
});
|
|
1147
|
+
|
|
1148
|
+
console.log(`Completed ${partialResults.dataPoints.length} of 20 points`);
|
|
1149
|
+
```
|
|
1150
|
+
|
|
1151
|
+
### Comparative Analysis
|
|
1152
|
+
|
|
1153
|
+
**Comparative analysis** runs two encounter configurations with identical seed sequences, isolating the effect of a single variable change. This answers questions like *"How much does +2 AC improve win rate?"* or *"Is adding a 5th party member statistically significant?"*
|
|
1154
|
+
|
|
1155
|
+
Because both configurations use the same dice rolls (via deterministic seeding), any difference in outcomes is attributable to the configuration change itself — not random variance.
|
|
1156
|
+
|
|
1157
|
+
```typescript
|
|
1158
|
+
import { ComparativeAnalyzer, EnemyGenerator } from 'playlist-data-engine';
|
|
1159
|
+
|
|
1160
|
+
const analyzer = new ComparativeAnalyzer();
|
|
1161
|
+
|
|
1162
|
+
// ═══════════════════════════════════════════════════════════════
|
|
1163
|
+
// Compare: "+2 AC" vs "No AC bonus" for a level 5 party
|
|
1164
|
+
// ═══════════════════════════════════════════════════════════════
|
|
1165
|
+
const enemiesA = [
|
|
1166
|
+
EnemyGenerator.generate({ seed: 'base-enemy', cr: 3, rarity: 'elite' }),
|
|
1167
|
+
];
|
|
1168
|
+
const enemiesB = [
|
|
1169
|
+
EnemyGenerator.generate({ seed: 'base-enemy', cr: 3, rarity: 'elite' }),
|
|
1170
|
+
];
|
|
1171
|
+
|
|
1172
|
+
// Modify one config — e.g., boost enemy AC in config B
|
|
1173
|
+
enemiesB[0].ac! += 2;
|
|
1174
|
+
|
|
1175
|
+
const comparison = analyzer.compare(
|
|
1176
|
+
{ players: party, enemies: enemiesA, label: 'Base' },
|
|
1177
|
+
{ players: party, enemies: enemiesB, label: '+2 AC' },
|
|
1178
|
+
{
|
|
1179
|
+
runCount: 500,
|
|
1180
|
+
baseSeed: 'ac-comparison',
|
|
1181
|
+
aiConfig: {
|
|
1182
|
+
playerStyle: 'normal',
|
|
1183
|
+
enemyStyle: 'aggressive',
|
|
1184
|
+
},
|
|
1185
|
+
},
|
|
1186
|
+
);
|
|
1187
|
+
|
|
1188
|
+
console.log(`Win rate delta: ${(comparison.deltas.winRateDelta * 100).toFixed(1)}%`);
|
|
1189
|
+
console.log(`Significant: ${comparison.winRateSignificance.isSignificant}`);
|
|
1190
|
+
console.log(comparison.winRateSignificance.interpretation);
|
|
1191
|
+
```
|
|
1192
|
+
|
|
1193
|
+
#### Identical-Seed Methodology
|
|
1194
|
+
|
|
1195
|
+
Both configurations are simulated using the same seed sequence (`baseSeed-A` for config A, `baseSeed-B` for config B). This means:
|
|
1196
|
+
|
|
1197
|
+
- **Same dice rolls**: Each run index uses deterministic seeds derived from the same base, so both configs experience the same attack rolls, damage rolls, saving throws, and initiative order.
|
|
1198
|
+
- **Isolated variable**: The only difference in outcomes comes from the configuration change (e.g., +2 AC, different party size, different CR).
|
|
1199
|
+
- **Pair-wise comparison**: Results are comparable run-by-run, not just in aggregate.
|
|
1200
|
+
|
|
1201
|
+
This is more statistically powerful than running two independent simulations and comparing — the paired design eliminates dice variance as a confounding factor.
|
|
1202
|
+
|
|
1203
|
+
#### ComparisonConfig
|
|
1204
|
+
|
|
1205
|
+
Each side of the comparison is defined by a `ComparisonConfig`:
|
|
1206
|
+
|
|
1207
|
+
| Field | Type | Description |
|
|
1208
|
+
|-------|------|-------------|
|
|
1209
|
+
| `players` | `CharacterSheet[]` | Player characters for this config |
|
|
1210
|
+
| `enemies` | `CharacterSheet[]` | Enemy characters for this config |
|
|
1211
|
+
| `label?` | `string` | Display name (e.g., `"Base"`, `"+2 AC"`). Default: `"Config A"` / `"Config B"` |
|
|
1212
|
+
| `combatConfig?` | `CombatConfig` | Optional combat engine overrides |
|
|
1213
|
+
|
|
1214
|
+
#### ComparisonOptions
|
|
1215
|
+
|
|
1216
|
+
| Field | Type | Description |
|
|
1217
|
+
|-------|------|-------------|
|
|
1218
|
+
| `runCount` | `number` | Simulations per configuration (500+ recommended) |
|
|
1219
|
+
| `baseSeed` | `string` | Base seed — both configs use derived seeds from this |
|
|
1220
|
+
| `aiConfig` | `AIConfig` | AI strategy for all simulations |
|
|
1221
|
+
| `combatConfig?` | `CombatConfig` | Optional combat engine overrides (used if not set per-config) |
|
|
1222
|
+
| `significanceThreshold?` | `number` | Alpha level for significance test (default: `0.05`) |
|
|
1223
|
+
| `abortSignal?` | `AbortSignal` | Cancel the comparison mid-execution |
|
|
1224
|
+
| `onProgress?` | `(completed, total, side) => void` | Progress callback per side |
|
|
1225
|
+
|
|
1226
|
+
#### ComparisonResult
|
|
1227
|
+
|
|
1228
|
+
The `compare()` method returns a `ComparisonResult` with full data for both sides:
|
|
1229
|
+
|
|
1230
|
+
| Field | Type | Description |
|
|
1231
|
+
|-------|------|-------------|
|
|
1232
|
+
| `labelA` | `string` | Label for configuration A |
|
|
1233
|
+
| `labelB` | `string` | Label for configuration B |
|
|
1234
|
+
| `resultsA` | `SimulationResults` | Full simulation results for config A |
|
|
1235
|
+
| `resultsB` | `SimulationResults` | Full simulation results for config B |
|
|
1236
|
+
| `summaryA` | `SimulationSummary` | Summary for config A |
|
|
1237
|
+
| `summaryB` | `SimulationSummary` | Summary for config B |
|
|
1238
|
+
| `deltas` | `DeltaMetrics` | Aggregate difference metrics |
|
|
1239
|
+
| `combatantDeltas` | `CombatantDelta[]` | Per-combatant differences |
|
|
1240
|
+
| `winRateSignificance` | `SignificanceResult` | Statistical significance of win rate difference |
|
|
1241
|
+
| `wasCancelled` | `boolean` | Whether comparison was cancelled |
|
|
1242
|
+
|
|
1243
|
+
#### DeltaMetrics
|
|
1244
|
+
|
|
1245
|
+
Aggregate differences between configurations. **Positive values favor config A** (A is better for players):
|
|
1246
|
+
|
|
1247
|
+
| Field | Description |
|
|
1248
|
+
|-------|-------------|
|
|
1249
|
+
| `winRateDelta` | Win rate difference (e.g., `+0.15` = A wins 15% more) |
|
|
1250
|
+
| `averageRoundsDelta` | Average rounds difference |
|
|
1251
|
+
| `averageHPRemainingDelta` | Average player HP remaining % difference |
|
|
1252
|
+
| `totalPlayerDeathsDelta` | Player death count difference (negative = fewer deaths in A) |
|
|
1253
|
+
| `totalEnemyDeathsDelta` | Enemy death count difference |
|
|
1254
|
+
| `medianRoundsDelta` | Median rounds difference |
|
|
1255
|
+
|
|
1256
|
+
#### CombatantDelta
|
|
1257
|
+
|
|
1258
|
+
Per-combatant differences, matched by side and index position:
|
|
1259
|
+
|
|
1260
|
+
| Field | Description |
|
|
1261
|
+
|-------|-------------|
|
|
1262
|
+
| `name` | Combatant name (from config A) |
|
|
1263
|
+
| `side` | `'player'` or `'enemy'` |
|
|
1264
|
+
| `dprDelta` | Damage per round difference |
|
|
1265
|
+
| `damageDealtDelta` | Average total damage dealt difference |
|
|
1266
|
+
| `damageTakenDelta` | Average total damage taken difference |
|
|
1267
|
+
| `survivalRateDelta` | Survival rate difference |
|
|
1268
|
+
| `killRateDelta` | Kill rate difference |
|
|
1269
|
+
| `criticalHitRateDelta` | Critical hit rate difference |
|
|
1270
|
+
| `healingDoneDelta` | Average healing done difference |
|
|
1271
|
+
|
|
1272
|
+
Unmatched combatants (different party sizes) are marked with `(only in A)` or `(only in B)`.
|
|
1273
|
+
|
|
1274
|
+
#### SignificanceResult
|
|
1275
|
+
|
|
1276
|
+
| Field | Type | Description |
|
|
1277
|
+
|-------|------|-------------|
|
|
1278
|
+
| `isSignificant` | `boolean` | Whether the difference is statistically significant |
|
|
1279
|
+
| `pValue` | `number` | Approximate p-value from the test |
|
|
1280
|
+
| `threshold` | `number` | The significance threshold used (alpha) |
|
|
1281
|
+
| `interpretation` | `string` | Human-readable explanation of the result |
|
|
1282
|
+
|
|
1283
|
+
Significance is tested using a **normal approximation for the difference of proportions** (two-tailed test). For small samples (n < 30), a conservative minimum detectable effect threshold is used instead.
|
|
1284
|
+
|
|
1285
|
+
#### Common Use Cases
|
|
1286
|
+
|
|
1287
|
+
**Comparing stat changes:**
|
|
1288
|
+
```typescript
|
|
1289
|
+
// Does +2 AC on enemies meaningfully increase difficulty?
|
|
1290
|
+
const baseEnemy = EnemyGenerator.generate({ seed: 'goblin', cr: 2 });
|
|
1291
|
+
const tankyEnemy = EnemyGenerator.generate({ seed: 'goblin', cr: 2 });
|
|
1292
|
+
tankyEnemy.ac! += 2;
|
|
1293
|
+
|
|
1294
|
+
const result = analyzer.compare(
|
|
1295
|
+
{ players: party, enemies: [baseEnemy], label: 'Base AC' },
|
|
1296
|
+
{ players: party, enemies: [tankyEnemy], label: '+2 AC' },
|
|
1297
|
+
{ runCount: 500, baseSeed: 'ac-test', aiConfig: { playerStyle: 'normal', enemyStyle: 'aggressive' } },
|
|
1298
|
+
);
|
|
1299
|
+
|
|
1300
|
+
// Negative winRateDelta means B is harder (AC increase hurt players)
|
|
1301
|
+
console.log(result.deltas.winRateDelta); // e.g., -0.12 (12% lower win rate)
|
|
1302
|
+
```
|
|
1303
|
+
|
|
1304
|
+
**Comparing party sizes:**
|
|
1305
|
+
```typescript
|
|
1306
|
+
// Is a 5th party member significantly impactful?
|
|
1307
|
+
const result = analyzer.compare(
|
|
1308
|
+
{ players: party4, enemies: encounter, label: '4 Players' },
|
|
1309
|
+
{ players: party5, enemies: encounter, label: '5 Players' },
|
|
1310
|
+
{ runCount: 500, baseSeed: 'party-size', aiConfig: { playerStyle: 'normal', enemyStyle: 'aggressive' } },
|
|
1311
|
+
);
|
|
1312
|
+
|
|
1313
|
+
console.log(result.winRateSignificance.interpretation);
|
|
1314
|
+
// "Config 5 Players has a statistically significant 18.2% higher win rate (p=0.0012, n=500)"
|
|
1315
|
+
```
|
|
1316
|
+
|
|
1317
|
+
**Comparing enemy CR:**
|
|
1318
|
+
```typescript
|
|
1319
|
+
// Is CR 5 meaningfully harder than CR 3?
|
|
1320
|
+
const enemies3 = Array.from({ length: 3 }, (_, i) =>
|
|
1321
|
+
EnemyGenerator.generate({ seed: `cr3-${i}`, cr: 3, rarity: 'uncommon' })
|
|
1322
|
+
);
|
|
1323
|
+
const enemies5 = Array.from({ length: 3 }, (_, i) =>
|
|
1324
|
+
EnemyGenerator.generate({ seed: `cr5-${i}`, cr: 5, rarity: 'uncommon' })
|
|
1325
|
+
);
|
|
1326
|
+
|
|
1327
|
+
const result = analyzer.compare(
|
|
1328
|
+
{ players: party, enemies: enemies3, label: 'CR 3' },
|
|
1329
|
+
{ players: party, enemies: enemies5, label: 'CR 5' },
|
|
1330
|
+
{ runCount: 500, baseSeed: 'cr-compare', aiConfig: { playerStyle: 'normal', enemyStyle: 'aggressive' } },
|
|
1331
|
+
);
|
|
1332
|
+
```
|
|
1333
|
+
|
|
1334
|
+
---
|
|
1335
|
+
|
|
1336
|
+
## Difficulty Calculator
|
|
1337
|
+
|
|
1338
|
+
> **"What CR should I use for a Hard encounter with this level 5 party?"**
|
|
1339
|
+
|
|
1340
|
+
The `DifficultyCalculator` answers this question by combining **XP-budget theory** (D&D 5e encounter building math) with **simulation-driven refinement**. It runs actual combats at different CR values and uses binary search to find the CR that produces the target win rate for your desired difficulty.
|
|
1341
|
+
|
|
1342
|
+
### How It Works
|
|
1343
|
+
|
|
1344
|
+
The search uses a two-phase approach:
|
|
1345
|
+
|
|
1346
|
+
1. **XP Budget Estimate** — `getXPBudgetForParty()` calculates the theoretical XP budget for the party at the target difficulty, adjusts for enemy count via encounter multiplier, then converts to a CR estimate via `getCRFromXP()`. This gives a reasonable starting point.
|
|
1347
|
+
|
|
1348
|
+
2. **Simulation-Driven Refinement** — binary search over CR values. At each step, the calculator:
|
|
1349
|
+
- Generates enemies from the template at the current CR
|
|
1350
|
+
- Runs N simulations (default: 200 per probe)
|
|
1351
|
+
- Checks if the player win rate falls within the target range
|
|
1352
|
+
- Adjusts CR up (win rate too high → encounter too easy) or down (win rate too low → encounter too hard)
|
|
1353
|
+
|
|
1354
|
+
The search converges when the win rate is within the expected range for the target difficulty, or after 10 iterations (whichever comes first).
|
|
1355
|
+
|
|
1356
|
+
### CR Rounding
|
|
1357
|
+
|
|
1358
|
+
CR values are rounded to standard D&D 5e steps: `0.125`, `0.25`, `0.5`, or integers. The search uses fractional CRs internally but reports rounded values in the final suggestion.
|
|
1359
|
+
|
|
1360
|
+
### Confidence Intervals
|
|
1361
|
+
|
|
1362
|
+
Each suggestion includes a **margin of error** calculated via normal approximation for proportions (z = 1.96, 95% confidence). This is expressed as a human-readable string:
|
|
1363
|
+
|
|
1364
|
+
```
|
|
1365
|
+
"72% ± 5%"
|
|
1366
|
+
```
|
|
1367
|
+
|
|
1368
|
+
The margin of error decreases with more simulations per probe. For higher confidence, increase `simulationsPerProbe`.
|
|
1369
|
+
|
|
1370
|
+
### Usage
|
|
1371
|
+
|
|
1372
|
+
```typescript
|
|
1373
|
+
import { DifficultyCalculator } from 'playlist-data-engine';
|
|
1374
|
+
|
|
1375
|
+
const calculator = new DifficultyCalculator();
|
|
1376
|
+
|
|
1377
|
+
const suggestion = calculator.suggest(
|
|
1378
|
+
party, // Player CharacterSheet[]
|
|
1379
|
+
{ rarity: 'elite', category: 'humanoid', archetype: 'brute' },
|
|
1380
|
+
'hard', // Target difficulty
|
|
1381
|
+
{
|
|
1382
|
+
aiConfig: {
|
|
1383
|
+
playerStyle: 'normal',
|
|
1384
|
+
enemyStyle: 'aggressive',
|
|
1385
|
+
},
|
|
1386
|
+
baseSeed: 'my-search',
|
|
1387
|
+
simulationsPerProbe: 200,
|
|
1388
|
+
enemyCount: 1,
|
|
1389
|
+
onProgress: (iteration, maxIterations, currentCR) => {
|
|
1390
|
+
console.log(`Probe ${iteration}/${maxIterations} — testing CR ${currentCR}`);
|
|
1391
|
+
},
|
|
1392
|
+
},
|
|
1393
|
+
);
|
|
1394
|
+
|
|
1395
|
+
console.log(suggestion.recommendedCR); // e.g., 3
|
|
1396
|
+
console.log(suggestion.winRate); // e.g., 0.73
|
|
1397
|
+
console.log(suggestion.confidenceInterval); // e.g., "73% ± 5%"
|
|
1398
|
+
console.log(suggestion.converged); // true
|
|
1399
|
+
console.log(suggestion.suggestedEnemy); // Generated CharacterSheet at CR 3
|
|
1400
|
+
```
|
|
1401
|
+
|
|
1402
|
+
### DifficultyCalculatorOptions
|
|
1403
|
+
|
|
1404
|
+
| Field | Type | Default | Description |
|
|
1405
|
+
|-------|------|---------|-------------|
|
|
1406
|
+
| `aiConfig` | `AIConfig` | *(required)* | AI configuration for simulations |
|
|
1407
|
+
| `combatConfig` | `CombatConfig` | `undefined` | Optional combat engine overrides |
|
|
1408
|
+
| `baseSeed` | `string` | `'difficulty-calc'` | Base seed — each probe gets a derived seed |
|
|
1409
|
+
| `simulationsPerProbe` | `number` | `200` | Simulation runs per CR probe |
|
|
1410
|
+
| `maxIterations` | `number` | `10` | Maximum binary search iterations |
|
|
1411
|
+
| `enemyCount` | `number` | `1` | Number of enemies in the encounter |
|
|
1412
|
+
| `abortSignal` | `AbortSignal` | `undefined` | Cancellation support |
|
|
1413
|
+
| `onProgress` | `function` | `undefined` | Callback: `(iteration, maxIterations, currentCR)` |
|
|
1414
|
+
|
|
1415
|
+
### DifficultyEnemyTemplate
|
|
1416
|
+
|
|
1417
|
+
Defines the "shape" of the enemy. CR is set automatically by the search — all other fields define the template.
|
|
1418
|
+
|
|
1419
|
+
| Field | Type | Default | Description |
|
|
1420
|
+
|-------|------|---------|-------------|
|
|
1421
|
+
| `rarity` | `EnemyRarity` | `'elite'` | Rarity tier |
|
|
1422
|
+
| `category` | `EnemyCategory` | `'humanoid'` | Enemy category |
|
|
1423
|
+
| `archetype` | `EnemyArchetype` | `'brute'` | Combat archetype |
|
|
1424
|
+
| `templateId` | `string` | `undefined` | Force a specific template |
|
|
1425
|
+
| `statLevels` | `StatLevelOverrides` | `undefined` | HP/attack/defense level overrides |
|
|
1426
|
+
| `difficultyMultiplier` | `number` | `undefined` | Fine-tune enemy difficulty |
|
|
1427
|
+
|
|
1428
|
+
### DifficultySuggestion
|
|
1429
|
+
|
|
1430
|
+
| Field | Type | Description |
|
|
1431
|
+
|-------|------|-------------|
|
|
1432
|
+
| `targetDifficulty` | `EncounterDifficulty` | The requested difficulty tier |
|
|
1433
|
+
| `recommendedCR` | `number` | Suggested CR (rounded to standard D&D step) |
|
|
1434
|
+
| `winRate` | `number` | Player win rate at the recommended CR (0–1) |
|
|
1435
|
+
| `expectedWinRateRange` | `{ min, max }` | Target win rate range for the difficulty |
|
|
1436
|
+
| `confidenceInterval` | `string` | Human-readable, e.g., `"73% ± 5%"` |
|
|
1437
|
+
| `marginOfError` | `number` | Statistical margin of error (±) |
|
|
1438
|
+
| `converged` | `boolean` | Whether the search found a CR within the target range |
|
|
1439
|
+
| `totalSimulationsRun` | `number` | Total simulations across all probes |
|
|
1440
|
+
| `iterationsUsed` | `number` | Number of probes actually performed |
|
|
1441
|
+
| `probes` | `DifficultyProbe[]` | Full search history (CR, win rate, etc. per probe) |
|
|
1442
|
+
| `initialCREstimate` | `number` | XP-budget-based CR estimate (before simulation) |
|
|
1443
|
+
| `suggestedEnemy` | `CharacterSheet` | Generated enemy at the recommended CR |
|
|
1444
|
+
| `wasCancelled` | `boolean` | Whether the search was cancelled |
|
|
1445
|
+
|
|
1446
|
+
### DifficultyProbe
|
|
1447
|
+
|
|
1448
|
+
Each entry in the `probes` array records one step of the binary search.
|
|
1449
|
+
|
|
1450
|
+
| Field | Type | Description |
|
|
1451
|
+
|-------|------|-------------|
|
|
1452
|
+
| `cr` | `number` | CR value tested |
|
|
1453
|
+
| `winRate` | `number` | Player win rate from simulation |
|
|
1454
|
+
| `totalRuns` | `number` | Number of simulation runs |
|
|
1455
|
+
| `averageRounds` | `number` | Average rounds to resolution |
|
|
1456
|
+
| `averageHPRemaining` | `number` | Average player HP % remaining on wins |
|
|
1457
|
+
|
|
1458
|
+
### Multi-Enemy Encounters
|
|
1459
|
+
|
|
1460
|
+
For encounters with multiple enemies, set `enemyCount` to the desired number. The calculator adjusts the XP budget using the encounter multiplier (1 enemy = 1×, 2 enemies = 1.5×, 3–6 = 2×, 7–10 = 2.5×, 11–14 = 3×) and searches for a per-enemy CR that produces the target difficulty.
|
|
1461
|
+
|
|
1462
|
+
```typescript
|
|
1463
|
+
const suggestion = calculator.suggest(
|
|
1464
|
+
party,
|
|
1465
|
+
{ rarity: 'common', archetype: 'archer' },
|
|
1466
|
+
'medium',
|
|
1467
|
+
{ aiConfig, enemyCount: 3, baseSeed: 'mob-search' },
|
|
1468
|
+
);
|
|
1469
|
+
|
|
1470
|
+
// Recommended CR is per-enemy — all 3 enemies use this CR
|
|
1471
|
+
console.log(suggestion.recommendedCR); // e.g., 0.5
|
|
1472
|
+
```
|
|
1473
|
+
|
|
1474
|
+
### Cancellation
|
|
1475
|
+
|
|
1476
|
+
Like other long-running tools, the calculator supports `AbortController` for cancellation. Partial results are returned with `wasCancelled: true`.
|
|
1477
|
+
|
|
1478
|
+
```typescript
|
|
1479
|
+
const controller = new AbortController();
|
|
1480
|
+
|
|
1481
|
+
// Cancel after 3 seconds
|
|
1482
|
+
setTimeout(() => controller.abort(), 3000);
|
|
1483
|
+
|
|
1484
|
+
const suggestion = calculator.suggest(
|
|
1485
|
+
party, template, 'hard',
|
|
1486
|
+
{ aiConfig, baseSeed: 'quick', abortSignal: controller.signal },
|
|
1487
|
+
);
|
|
1488
|
+
|
|
1489
|
+
console.log(suggestion.wasCancelled); // true
|
|
1490
|
+
console.log(suggestion.probes.length); // 1–2 (partial results)
|
|
1491
|
+
```
|
|
1492
|
+
|
|
1493
|
+
### When to Use
|
|
1494
|
+
|
|
1495
|
+
| Scenario | Tool |
|
|
1496
|
+
|----------|------|
|
|
1497
|
+
| "Is this encounter balanced?" | `BalanceValidator` (validate existing config) |
|
|
1498
|
+
| "How does difficulty change across CR 1–10?" | `ParameterSweep` (curve exploration) |
|
|
1499
|
+
| "What CR should I use for Medium difficulty?" | **`DifficultyCalculator`** (inverse problem) |
|
|
1500
|
+
| "Is +2 AC meaningfully different?" | `ComparativeAnalyzer` (A/B testing) |
|
|
1501
|
+
|
|
1502
|
+
---
|
|
1503
|
+
|
|
1504
|
+
## Recommended Simulation Counts
|
|
1505
|
+
|
|
1506
|
+
Choosing the right number of simulation runs balances confidence against speed. More runs = tighter confidence intervals, but diminishing returns set in quickly.
|
|
1507
|
+
|
|
1508
|
+
### Quick Reference
|
|
1509
|
+
|
|
1510
|
+
| Purpose | Runs | Speed* | Confidence | Margin of Error | Use When |
|
|
1511
|
+
|---------|------|--------|------------|-----------------|----------|
|
|
1512
|
+
| Quick exploration | 100 | <0.1s | Rough estimate | ~±10% | Prototyping, sanity checks, early iteration |
|
|
1513
|
+
| Standard analysis | 500 | <0.5s | Reasonable | ~±4% | Most balance decisions, parameter sweeps |
|
|
1514
|
+
| Thorough validation | 2,000 | ~1s | High | ~±2% | Final balance passes, shipping decisions |
|
|
1515
|
+
| Publication-quality | 5,000+ | ~2s | Very high | ~±1.5% | Documentation, balance patches, release notes |
|
|
1516
|
+
|
|
1517
|
+
*\*Speed estimates based on standard 4v1 party-vs-encounter composition. Actual speed depends on encounter size and combat duration.*
|
|
1518
|
+
|
|
1519
|
+
### How Margin of Error Works
|
|
1520
|
+
|
|
1521
|
+
The simulator reports results as percentages (e.g., "72% player win rate"). With fewer runs, that percentage has more uncertainty:
|
|
1522
|
+
|
|
1523
|
+
- **100 runs**: A reported 72% win rate means the true value is likely between 62–82% (±10%)
|
|
1524
|
+
- **500 runs**: A reported 72% win rate means the true value is likely between 68–76% (±4%)
|
|
1525
|
+
- **2,000 runs**: A reported 72% win rate means the true value is likely between 70–74% (±2%)
|
|
1526
|
+
|
|
1527
|
+
The formula: `margin of error ≈ 1 / √(totalRuns)` (at 95% confidence).
|
|
1528
|
+
|
|
1529
|
+
### Recommendations by Tool
|
|
1530
|
+
|
|
1531
|
+
| Tool | Recommended Runs | Why |
|
|
1532
|
+
|------|-----------------|-----|
|
|
1533
|
+
| `BalanceValidator` | 500 | Single data point — 500 gives ±4% which is enough to classify difficulty |
|
|
1534
|
+
| `ParameterSweep` | 100–250 per point | Sweeps generate many data points — lower per-point counts are acceptable for curve shape |
|
|
1535
|
+
| `ComparativeAnalyzer` | 500–1,000 per config | Statistical significance testing needs sufficient sample size per config |
|
|
1536
|
+
| `DifficultyCalculator` | 250 per probe | Binary search makes multiple probes — lower per-probe counts with convergence checking |
|
|
1537
|
+
|
|
1538
|
+
### When to Use More Runs
|
|
1539
|
+
|
|
1540
|
+
- **Close to a difficulty boundary**: If win rate is near a threshold (e.g., 68–72% for Medium), use more runs to determine which side it falls on
|
|
1541
|
+
- **Comparing similar configurations**: Small differences need larger samples to detect as statistically significant
|
|
1542
|
+
- **Boss encounters**: Boss fights have higher variance (legendary actions, more abilities) — use 2× the normal count
|
|
1543
|
+
- **Final ship decisions**: Always validate with 2,000+ runs before committing balance changes
|
|
1544
|
+
|
|
1545
|
+
### When Fewer Runs Are Fine
|
|
1546
|
+
|
|
1547
|
+
- **Early iteration**: While adjusting CR, enemy count, or templates, 100 runs gives enough signal to guide direction
|
|
1548
|
+
- **Obviously broken balance**: A 5% or 95% win rate won't change meaningfully with more runs
|
|
1549
|
+
- **Sweep exploration**: A 20-point CR sweep at 100 runs/point completes in under a second and shows the trend clearly
|
|
1550
|
+
|
|
1551
|
+
---
|
|
1552
|
+
|
|
1553
|
+
## API Reference
|
|
1554
|
+
|
|
1555
|
+
### EnemyGenerator
|
|
1556
|
+
|
|
1557
|
+
Static class - no instantiation required.
|
|
1558
|
+
|
|
1559
|
+
#### generate()
|
|
1560
|
+
|
|
1561
|
+
Generate a single enemy.
|
|
1562
|
+
|
|
1563
|
+
```typescript
|
|
1564
|
+
static generate(options: EnemyGenerationOptions): CharacterSheet
|
|
1565
|
+
```
|
|
1566
|
+
|
|
1567
|
+
**Parameters:**
|
|
1568
|
+
- `seed` (required): Seed for deterministic generation
|
|
1569
|
+
- `templateId` (optional): Force specific template by ID
|
|
1570
|
+
- `rarity` (optional): Rarity tier (default: `'common'`)
|
|
1571
|
+
- `difficultyMultiplier` (optional): Fine-tune HP/damage (default: 1.0)
|
|
1572
|
+
- `audioProfile` (optional): Audio profile for template selection
|
|
1573
|
+
- `track` (optional): Track data (required if audioProfile provided)
|
|
1574
|
+
|
|
1575
|
+
**Returns:** `CharacterSheet` representing the enemy
|
|
1576
|
+
|
|
1577
|
+
**Throws:**
|
|
1578
|
+
- `Error` if templateId not found
|
|
1579
|
+
- `Error` if audioProfile provided without track
|
|
1580
|
+
|
|
1581
|
+
#### generateEncounter()
|
|
1582
|
+
|
|
1583
|
+
Generate balanced encounter for a party.
|
|
1584
|
+
|
|
1585
|
+
```typescript
|
|
1586
|
+
static generateEncounter(
|
|
1587
|
+
party: CharacterSheet[],
|
|
1588
|
+
options: EncounterGenerationOptions
|
|
1589
|
+
): CharacterSheet[]
|
|
1590
|
+
```
|
|
1591
|
+
|
|
1592
|
+
**Parameters:** All options from `EncounterGenerationOptions` interface
|
|
1593
|
+
|
|
1594
|
+
**Returns:** Array of generated enemies
|
|
1595
|
+
|
|
1596
|
+
#### generateEncounterByCR()
|
|
1597
|
+
|
|
1598
|
+
Generate encounter by target CR (no party analysis).
|
|
1599
|
+
|
|
1600
|
+
```typescript
|
|
1601
|
+
static generateEncounterByCR(
|
|
1602
|
+
options: EncounterGenerationOptions
|
|
1603
|
+
): CharacterSheet[]
|
|
1604
|
+
```
|
|
1605
|
+
|
|
1606
|
+
**Returns:** Array of generated enemies
|
|
1607
|
+
|
|
1608
|
+
**Note:** Must include `targetCR` in options
|
|
1609
|
+
|
|
1610
|
+
#### getTemplateById()
|
|
1611
|
+
|
|
1612
|
+
Look up a template by ID.
|
|
1613
|
+
|
|
1614
|
+
```typescript
|
|
1615
|
+
static getTemplateById(id: string): EnemyTemplate | undefined
|
|
1616
|
+
```
|
|
1617
|
+
|
|
1618
|
+
**Returns:** Template object or `undefined` if not found
|
|
1619
|
+
|
|
1620
|
+
### Type Reference
|
|
1621
|
+
|
|
1622
|
+
#### EnemyGenerationOptions
|
|
1623
|
+
|
|
1624
|
+
```typescript
|
|
1625
|
+
interface EnemyGenerationOptions {
|
|
1626
|
+
seed: string; // Required
|
|
1627
|
+
cr?: number; // Recommended - target Challenge Rating (determines level/stats)
|
|
1628
|
+
templateId?: string; // Optional - force template
|
|
1629
|
+
rarity?: EnemyRarity; // Optional - default 'common' (determines complexity)
|
|
1630
|
+
difficultyMultiplier?: number; // Optional - default 1.0
|
|
1631
|
+
audioProfile?: AudioProfile; // Optional
|
|
1632
|
+
track?: PlaylistTrack; // Required if audioProfile
|
|
1633
|
+
category?: EnemyCategory; // Optional
|
|
1634
|
+
archetype?: EnemyArchetype; // Optional
|
|
1635
|
+
level?: number; // Optional - overrides CR-based level (rarely needed)
|
|
1636
|
+
}
|
|
1637
|
+
```
|
|
1638
|
+
|
|
1639
|
+
**Important:** The `cr` parameter determines the enemy's power level (level and base stats). The `rarity` parameter determines complexity (abilities, signature die, resistances). These are independent - any CR can combine with any rarity.
|
|
1640
|
+
|
|
1641
|
+
#### EncounterGenerationOptions
|
|
1642
|
+
|
|
1643
|
+
```typescript
|
|
1644
|
+
interface EncounterGenerationOptions {
|
|
1645
|
+
seed: string; // Required
|
|
1646
|
+
count: number; // Required
|
|
1647
|
+
difficulty?: EncounterDifficulty; // Party-based mode
|
|
1648
|
+
targetCR?: number; // CR-based mode - determines power level
|
|
1649
|
+
baseRarity?: EnemyRarity; // Optional - default 'common'
|
|
1650
|
+
scaleRarityWithCR?: boolean; // Optional - default false (opt-in CR-based rarity scaling)
|
|
1651
|
+
difficultyMultiplier?: number; // Optional - default 1.0
|
|
1652
|
+
category?: EnemyCategory; // Optional
|
|
1653
|
+
archetype?: EnemyArchetype; // Optional
|
|
1654
|
+
templateId?: string; // Optional
|
|
1655
|
+
enemyMix?: 'uniform' | 'custom' | 'category' | 'random'; // V2: added category, random
|
|
1656
|
+
templates?: string[]; // For custom mix
|
|
1657
|
+
audioProfile?: AudioProfile; // Optional
|
|
1658
|
+
track?: PlaylistTrack; // Required if audioProfile
|
|
1659
|
+
enableLeaderPromotion?: boolean; // Optional - default true
|
|
1660
|
+
// V2 additions:
|
|
1661
|
+
allowMixedCategories?: boolean; // For 'random' mode validation
|
|
1662
|
+
lairFeatures?: boolean; // Include lair actions for bosses
|
|
1663
|
+
minRarity?: EnemyRarity; // Force minimum rarity
|
|
1664
|
+
maxRarity?: EnemyRarity; // Cap maximum rarity
|
|
1665
|
+
statLevels?: StatLevelOverrides; // HP/attack/defense level overrides
|
|
1666
|
+
}
|
|
1667
|
+
```
|
|
1668
|
+
|
|
1669
|
+
**CR vs Rarity:** By default, `targetCR` and `baseRarity` are independent. Set `scaleRarityWithCR: true` to opt-in to automatic rarity scaling based on CR (higher CR = higher average rarity).
|
|
1670
|
+
|
|
1671
|
+
**Boss Encounters:** When rarity is 'boss', count is automatically enforced to 1 (bosses are always 1vparty).
|
|
1672
|
+
|
|
1673
|
+
#### EnemyRarity
|
|
1674
|
+
|
|
1675
|
+
```typescript
|
|
1676
|
+
type EnemyRarity = 'common' | 'uncommon' | 'elite' | 'boss';
|
|
1677
|
+
```
|
|
1678
|
+
|
|
1679
|
+
#### EnemyCategory
|
|
1680
|
+
|
|
1681
|
+
```typescript
|
|
1682
|
+
type EnemyCategory =
|
|
1683
|
+
| 'humanoid'
|
|
1684
|
+
| 'beast'
|
|
1685
|
+
| 'undead'
|
|
1686
|
+
| 'dragon'
|
|
1687
|
+
| 'fiend'
|
|
1688
|
+
| 'construct'
|
|
1689
|
+
| 'elemental'
|
|
1690
|
+
| 'monstrosity';
|
|
1691
|
+
```
|
|
1692
|
+
|
|
1693
|
+
#### EnemyArchetype
|
|
1694
|
+
|
|
1695
|
+
```typescript
|
|
1696
|
+
type EnemyArchetype = 'brute' | 'archer' | 'support';
|
|
1697
|
+
```
|
|
1698
|
+
|
|
1699
|
+
#### EncounterDifficulty
|
|
1700
|
+
|
|
1701
|
+
```typescript
|
|
1702
|
+
type EncounterDifficulty = 'easy' | 'medium' | 'hard' | 'deadly';
|
|
1703
|
+
```
|
|
1704
|
+
|
|
1705
|
+
---
|
|
1706
|
+
|
|
1707
|
+
## See Also
|
|
1708
|
+
|
|
1709
|
+
- [COMBAT_SYSTEM.md](COMBAT_SYSTEM.md) - Combat system reference
|
|
1710
|
+
- [DATA_ENGINE_REFERENCE.md](DATA_ENGINE_REFERENCE.md) - Complete API reference
|
|
1711
|
+
- [specs/001-core-engine/SPEC.md](specs/001-core-engine/SPEC.md) - Core engine specification
|