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.
Files changed (41) hide show
  1. package/README.md +20 -3
  2. package/bin/cli.cjs +85 -0
  3. package/dist/core/parser/TrackExtras.d.ts +150 -1
  4. package/dist/core/parser/TrackExtras.d.ts.map +1 -1
  5. package/dist/gateway-CDMPqFEH.js +1320 -0
  6. package/dist/gateway-DKa45Uz6.cjs +6 -0
  7. package/dist/gateway.d.ts +3 -1
  8. package/dist/gateway.d.ts.map +1 -1
  9. package/dist/gateway.js +1 -1
  10. package/dist/gateway.mjs +22 -14
  11. package/dist/index.d.ts +4 -3
  12. package/dist/index.d.ts.map +1 -1
  13. package/dist/playlist-data-engine.js +34 -34
  14. package/dist/playlist-data-engine.mjs +964 -1240
  15. package/dist/utils/engineDocs.d.ts +33 -0
  16. package/dist/utils/engineDocs.d.ts.map +1 -0
  17. package/dist/utils/playlistUtils.d.ts +37 -0
  18. package/dist/utils/playlistUtils.d.ts.map +1 -1
  19. package/dist/utils/validators.d.ts +27 -0
  20. package/dist/utils/validators.d.ts.map +1 -1
  21. package/docs/DATA_ENGINE_REFERENCE.md +6660 -0
  22. package/docs/USAGE_IN_OTHER_PROJECTS.md +587 -0
  23. package/docs/features/AUDIO_ANALYSIS.md +610 -0
  24. package/docs/features/BEAT_DETECTION.md +5250 -0
  25. package/docs/features/COMBAT_SYSTEM.md +1632 -0
  26. package/docs/features/CONTENT_PACKS.md +464 -0
  27. package/docs/features/CUSTOM_CONTENT.md +603 -0
  28. package/docs/features/ENEMY_GENERATION.md +1711 -0
  29. package/docs/features/EQUIPMENT_SYSTEM.md +2279 -0
  30. package/docs/features/EXTENSIBILITY_GUIDE.md +1106 -0
  31. package/docs/features/GATEWAY_RESOLUTION.md +725 -0
  32. package/docs/features/IRL_SENSORS.md +360 -0
  33. package/docs/features/PLAYLIST_PARSING.md +446 -0
  34. package/docs/features/PREREQUISITES.md +571 -0
  35. package/docs/features/ROLLS_AND_SEEDS.md +687 -0
  36. package/docs/features/XP_AND_STATS.md +1221 -0
  37. package/llms.txt +33 -0
  38. package/package.json +9 -2
  39. package/skills/playlist-data-engine/SKILL.md +69 -0
  40. package/dist/gateway-DUk4nCao.cjs +0 -1
  41. package/dist/gateway-DyR4M-uH.js +0 -681
@@ -0,0 +1,1632 @@
1
+ # Combat System Reference
2
+
3
+ Complete guide to the combat system in the Playlist Data Engine.
4
+
5
+ ---
6
+
7
+ ## Table of Contents
8
+
9
+ 1. [Combat System](#combat-system)
10
+ - [Enemy Generation](#enemy-generation)
11
+ - [Treasure](#treasure)
12
+ - [Box Rewards](#box-rewards)
13
+ - [Awarding Boxes as Treasure](#awarding-boxes-as-treasure)
14
+ - [Opening Box Rewards After Combat](#opening-box-rewards-after-combat)
15
+ - [Box Behavior in Combat Rewards](#box-behavior-in-combat-rewards)
16
+ - [Locked Box Rewards](#locked-box-rewards)
17
+ - [Checking if a Reward Is a Box](#checking-if-a-reward-is-a-box)
18
+ - [Example: Boss Loot Box Configuration](#example-boss-loot-box-configuration)
19
+ - [Spell Casting](#spell-casting)
20
+ - [Combat Actions](#combat-actions)
21
+ - [HP Management](#hp-management)
22
+ - [Query Methods](#query-methods)
23
+ - [Action Economy](#action-economy)
24
+ - [Status Effects](#status-effects)
25
+ - [StatusEffect Interface](#statuseffect-interface)
26
+ - [StatusEffectMechanics Interface](#statuseffectmechanics-interface)
27
+ - [Mechanically Enforced Conditions](#mechanically-enforced-conditions)
28
+ - [Duration Tracking Lifecycle](#duration-tracking-lifecycle)
29
+ - [Applying Status Effects](#applying-status-effects)
30
+ - [Stacking Rules](#stacking-rules)
31
+ - [Concentration Tracking](#concentration-tracking)
32
+ - [Removing Expired Effects](#removing-expired-effects)
33
+ - [Spell-Based Status Effects](#spell-based-status-effects)
34
+ - [Advantage/Disadvantage from Effects](#advantagedisadvantage-from-effects)
35
+ - [Combat History](#combat-history)
36
+ - [Spell Slots](#spell-slots)
37
+ - [Multiple Equipped Weapons](#multiple-equipped-weapons)
38
+ - [Unarmed Combat](#unarmed-combat)
39
+ - [Manual Attack Objects](#manual-attack-objects)
40
+ - [Hit Modes](#hit-modes)
41
+ - [Legendary Actions](#legendary-actions)
42
+ - [LegendaryAction Interface](#legendaryaction-interface)
43
+ - [Combatant Legendary Tracking](#combatant-legendary-tracking)
44
+ - [Action Point Tracking](#action-point-tracking)
45
+ - [Executing Legendary Actions](#executing-legendary-actions)
46
+ - [Legendary Resistances](#legendary-resistances)
47
+ - [AI and Legendary Actions](#ai-and-legendary-actions)
48
+ - [Combat AI](#combat-ai)
49
+ - [AIPlayStyle](#aiplaystyle)
50
+ - [AIConfig](#aiconfig)
51
+ - [AIDecision](#aidecision)
52
+ - [Decision-Making Process](#decision-making-process)
53
+ - [Target Selection](#target-selection)
54
+ - [Weapon Selection](#weapon-selection)
55
+ - [Spell Selection](#spell-selection)
56
+ - [Support Archetype AI](#support-archetype-ai)
57
+ - [AIThreatAssessment](#aithreatassessment)
58
+ - [AICombatRunner](#aicombatrunner)
59
+ - [CombatantMetrics](#combatantmetrics)
60
+ - [Monte Carlo Simulation](#monte-carlo-simulation)
61
+ - [CombatSimulator](#combatsimulator)
62
+ - [SimulationConfig](#simulationconfig)
63
+ - [SimulationResults](#simulationresults)
64
+ - [SimulationSummary](#simulationsummary)
65
+ - [CombatantSimulationMetrics](#combatantsimulationmetrics)
66
+ - [HistogramBucket](#histogrambucket)
67
+ - [Detailed Run Logs](#detailed-run-logs)
68
+ - [Code Examples](#code-examples)
69
+ - [Determinism](#determinism)
70
+ - [Recommended Run Counts](#recommended-run-counts)
71
+ 2. [See Also](#see-also)
72
+
73
+ ---
74
+
75
+ ## Combat System
76
+
77
+ ```typescript
78
+ import {
79
+ CombatEngine,
80
+ CharacterGenerator,
81
+ EnemyGenerator
82
+ } from 'playlist-data-engine';
83
+ import { AudioAnalyzer } from 'playlist-data-engine/analysis';
84
+
85
+ // Initialize combat engine (optional configuration)
86
+ const combat = new CombatEngine({
87
+ useEnvironment: true, // Apply environmental bonuses
88
+ useMusic: false, // Apply music bonuses (requires audio context)
89
+ tacticalMode: false, // Enable advanced tactical rules
90
+ maxTurnsBeforeDraw: 100, // Max turns before draw
91
+ seed: 'my-seed' // Seed for deterministic treasure generation (optional)
92
+ });
93
+
94
+ // Generate player character from audio
95
+ const analyzer = new AudioAnalyzer();
96
+ const audioProfile = await analyzer.extractSonicFingerprint(track.audio_url);
97
+ const playerCharacter = CharacterGenerator.generate(track.id, audioProfile, track);
98
+
99
+ // Create enemy characters (manually or from a database)
100
+ const enemy1 = { /* CharacterSheet */ };
101
+ const enemy2 = { /* CharacterSheet */ };
102
+
103
+ // Start combat - rolls initiative, establishes turn order
104
+ const combatInstance = combat.startCombat(
105
+ [playerCharacter], // Player characters
106
+ [enemy1, enemy2], // Enemies
107
+ environmentalContext // Optional environmental modifiers
108
+ );
109
+
110
+ // Execute combat turns
111
+ while (combatInstance.isActive) {
112
+ const current = combat.getCurrentCombatant(combatInstance);
113
+
114
+ // Attack with equipped weapon - engine finds it automatically
115
+ const target = combat.getLivingCombatants(combatInstance).find(c => c.id !== current.id);
116
+
117
+ if (target) {
118
+ // Simple: just say who's attacking and who's getting hit
119
+ const action = combat.executeWeaponAttack(combatInstance, current, target);
120
+ console.log(action.result.description);
121
+
122
+ if (target.isDefeated) {
123
+ console.log(`${target.character.name} has been defeated!`);
124
+ }
125
+ }
126
+
127
+ // Move to next turn
128
+ combat.nextTurn(combatInstance);
129
+
130
+ // Check if combat ended
131
+ const result = combat.getCombatResult(combatInstance);
132
+ if (result) {
133
+ console.log(`Combat ended: ${result.description}`);
134
+ console.log(`XP awarded: ${result.xpAwarded}`);
135
+ console.log(`Rounds elapsed: ${result.roundsElapsed}`);
136
+
137
+ // Treasure awarded (if seed configured)
138
+ console.log(`Gold: ${result.treasureAwarded.gold}`);
139
+ console.log(`Items: ${result.treasureAwarded.items}`);
140
+ break;
141
+ }
142
+ }
143
+ ```
144
+
145
+ ### Enemy Generation
146
+
147
+ Generate balanced combat encounters automatically:
148
+
149
+ ```typescript
150
+ import {
151
+ CombatEngine,
152
+ EnemyGenerator
153
+ } from 'playlist-data-engine';
154
+
155
+ // Generate a specific enemy by template
156
+ const boss = EnemyGenerator.generate({
157
+ seed: 'dungeon-boss-001',
158
+ templateId: 'orc',
159
+ rarity: 'boss'
160
+ });
161
+ // → Orc Boss with d12 signature ability, +50% stats, 3 extra abilities
162
+
163
+ // Generate a random enemy matching criteria
164
+ const randomEnemy = EnemyGenerator.generate({
165
+ seed: 'wild-encounter-1',
166
+ category: 'humanoid',
167
+ archetype: 'brute',
168
+ rarity: 'elite'
169
+ });
170
+ // → Random humanoid brute (Orc or Bandit) at elite tier
171
+
172
+ // Generate encounter balanced for party
173
+ const party = [player1, player2, player3, player4];
174
+ const enemies = EnemyGenerator.generateEncounter(party, {
175
+ seed: 'dungeon-1-room-5',
176
+ difficulty: 'medium',
177
+ count: 5,
178
+ category: 'beast'
179
+ });
180
+ // → 5 beast enemies at appropriate CR for party level
181
+ // One enemy auto-promotes to elite leader (groups > 3)
182
+
183
+ // Generate encounter by CR (no party needed)
184
+ const cr3Enemies = EnemyGenerator.generateEncounterByCR({
185
+ seed: 'cr3-encounter',
186
+ targetCR: 3,
187
+ count: 4
188
+ });
189
+ // → 4 enemies at approximately CR 3 each
190
+
191
+ // Audio-influenced generation
192
+ const audioEnemies = EnemyGenerator.generateEncounter(party, {
193
+ seed: 'music-combat',
194
+ audioProfile: audioProfile,
195
+ track: currentTrack,
196
+ count: 4
197
+ });
198
+ // → Template selection weighted by audio characteristics
199
+ // Bass-heavy → more brutes, Treble-heavy → more archers
200
+ ```
201
+
202
+ Start combat with generated enemies:
203
+
204
+ ```typescript
205
+ const combat = new CombatEngine();
206
+ const combatInstance = combat.startCombat(
207
+ party,
208
+ enemies,
209
+ environmentalContext
210
+ );
211
+ ```
212
+
213
+ > **For complete documentation:** See [ENEMY_GENERATION.md](../../ENEMY_GENERATION.md) for full details on rarity tiers, leader promotion, encounter balance, template system, and API reference.
214
+
215
+ ### Treasure
216
+
217
+ Configure custom loot rewards:
218
+
219
+ ```typescript
220
+ // Fixed gold amount (no randomness)
221
+ const combat = new CombatEngine({
222
+ treasure: { gold: 500 }
223
+ });
224
+
225
+ // Gold range with seeded RNG (inclusive)
226
+ const combat = new CombatEngine({
227
+ seed: 'dragon-hoard',
228
+ treasure: { gold: { min: 1000, max: 5000 } }
229
+ });
230
+
231
+ // Custom items (weapons, armor, or any Equipment type including boxes)
232
+ const combat = new CombatEngine({
233
+ treasure: {
234
+ gold: 100,
235
+ items: [
236
+ { id: 'sword-1', name: 'Longsword +1', type: 'weapon', rarity: 'uncommon', weight: 3 },
237
+ { id: 'potion-1', name: 'Potion of Healing', type: 'item', rarity: 'common', weight: 0.5 }
238
+ ]
239
+ }
240
+ });
241
+
242
+ // Default: 0-99 gold with seeded RNG
243
+ const combat = new CombatEngine({ seed: 'goblin-lair-001' });
244
+ ```
245
+
246
+ The `treasure` config supports:
247
+ - `gold: number` - Fixed amount (always rewards exactly this much)
248
+ - `gold: { min, max }` - Random range (uses seed for determinism)
249
+ - `items: Equipment[]` - Custom item rewards (weapons, armor, items, or **boxes**)
250
+
251
+ > **Note:** Treasure rewards are not automatically distributed to any character. The combat engine reports what was earned; it's up to your game to handle inventory management, gold splitting among party members, or whether loot is awarded at all.
252
+
253
+ ### Box Rewards
254
+
255
+ Boxes are a special `'box'` equipment type that contain other items. They can be awarded as treasure and are passed through the combat system **unopened** — your game code decides when and how to open them.
256
+
257
+ #### Awarding Boxes as Treasure
258
+
259
+ ```typescript
260
+ import { CombatEngine } from 'playlist-data-engine';
261
+
262
+ // Award a box directly in treasure config
263
+ const combat = new CombatEngine({
264
+ seed: 'goblin-cave-007',
265
+ treasure: {
266
+ gold: 50,
267
+ items: [
268
+ {
269
+ id: 'goblin-chest-1',
270
+ name: 'Goblin Treasure Chest',
271
+ type: 'box',
272
+ rarity: 'uncommon',
273
+ weight: 5,
274
+ boxContents: {
275
+ drops: [{
276
+ pool: [
277
+ { weight: 40, itemName: 'Shortsword' },
278
+ { weight: 30, itemName: 'Leather Armor' },
279
+ { weight: 20, itemName: "Thieves' Tools" },
280
+ { weight: 10, gold: 50 }
281
+ ]
282
+ }]
283
+ },
284
+ tags: ['loot', 'treasure', 'goblin'],
285
+ description: 'A small chest from a goblin hoard.'
286
+ }
287
+ ]
288
+ }
289
+ });
290
+ ```
291
+
292
+ #### Opening Box Rewards After Combat
293
+
294
+ After combat ends, use `EquipmentSpawnHelper.openBoxForCharacter()` to open any boxes in the character's inventory:
295
+
296
+ ```typescript
297
+ import {
298
+ CombatEngine,
299
+ EquipmentSpawnHelper,
300
+ SeededRNG
301
+ } from 'playlist-data-engine';
302
+
303
+ // Combat ends - check result for awarded items
304
+ const combatResult = combat.getCombatResult(combatInstance);
305
+ if (combatResult) {
306
+ // Add awarded items to the character's inventory first
307
+ for (const item of combatResult.treasureAwarded.items) {
308
+ EquipmentSpawnHelper.addToCharacter(character, item);
309
+ }
310
+
311
+ // Now open any boxes the character received
312
+ const rng = new SeededRNG('open-rewards');
313
+ const openResult = EquipmentSpawnHelper.openBoxForCharacter(
314
+ character,
315
+ 'Goblin Treasure Chest',
316
+ rng
317
+ );
318
+
319
+ if (openResult) {
320
+ character = openResult.character; // Updated character (box removed, contents added)
321
+ console.log(`Gold from chest: ${openResult.result.gold}`);
322
+ console.log(`Items from chest: ${openResult.result.items.map(i => i.name).join(', ')}`);
323
+ }
324
+ }
325
+ ```
326
+
327
+ #### Box Behavior in Combat Rewards
328
+
329
+ - **Boxes are always awarded unopened.** The combat engine never auto-opens boxes.
330
+ - **Nested boxes stay nested.** If a box contains another box, the inner box is added to inventory unopened.
331
+ - **Consumed on open by default.** When opened, boxes are removed from inventory unless `consumeOnOpen: false` is set.
332
+ - **Deterministic.** Opening with the same seed and box produces identical contents every time.
333
+ - **Locked boxes require items to open.** Boxes with `openRequirements` need specific items (keys, gold coins, etc.) to be consumed from inventory when opened.
334
+
335
+ #### Locked Box Rewards
336
+
337
+ Some treasure boxes have **opening requirements** — items that must be consumed from the character's inventory before the box can be opened. This adds strategic decisions: should the player open a locked chest now, save the key for later, or trade the unopened chest?
338
+
339
+ ##### Awarding Locked Boxes as Loot
340
+
341
+ Locked boxes are configured with `openRequirements` in their `boxContents`:
342
+
343
+ ```typescript
344
+ import { CombatEngine } from 'playlist-data-engine';
345
+
346
+ // A locked chest that requires a key to open
347
+ const lockedDungeonChest = {
348
+ id: 'dungeon-chest-001',
349
+ name: 'Dungeon Chest',
350
+ type: 'box' as const,
351
+ rarity: 'uncommon' as const,
352
+ weight: 10,
353
+ boxContents: {
354
+ openRequirements: [
355
+ { itemName: 'Iron Key' } // Consumes 1 Iron Key when opened
356
+ ],
357
+ drops: [
358
+ { pool: [{ weight: 100, gold: 100 }] },
359
+ { pool: [
360
+ { weight: 40, itemName: 'Longsword' },
361
+ { weight: 35, itemName: 'Chain Mail' },
362
+ { weight: 25, itemName: 'Medical Supply', quantity: 3 }
363
+ ]}
364
+ ]
365
+ },
366
+ tags: ['loot', 'treasure', 'locked', 'dungeon'],
367
+ description: 'A heavy iron-bound chest. Requires an Iron Key to open.'
368
+ };
369
+
370
+ const combat = new CombatEngine({
371
+ seed: 'dungeon-boss-001',
372
+ treasure: {
373
+ gold: { min: 50, max: 150 },
374
+ items: [lockedDungeonChest]
375
+ }
376
+ });
377
+ ```
378
+
379
+ ##### Handling Locked Box Opens After Combat
380
+
381
+ When a character tries to open a locked box from combat rewards, requirements are checked automatically:
382
+
383
+ ```typescript
384
+ import {
385
+ CombatEngine,
386
+ EquipmentSpawnHelper,
387
+ BoxOpener,
388
+ SeededRNG
389
+ } from 'playlist-data-engine';
390
+
391
+ // After combat - add items to inventory first
392
+ const combatResult = combat.getCombatResult(combatInstance);
393
+ if (combatResult) {
394
+ // Add all awarded items to character inventory
395
+ for (const item of combatResult.treasureAwarded.items) {
396
+ EquipmentSpawnHelper.addToCharacter(character, item);
397
+ }
398
+ }
399
+
400
+ // Later, when player wants to open the chest
401
+ const rng = new SeededRNG('open-chest');
402
+ const outcome = EquipmentSpawnHelper.openBoxForCharacter(
403
+ character,
404
+ 'Dungeon Chest',
405
+ rng
406
+ );
407
+
408
+ if (outcome) {
409
+ if (outcome.result.success) {
410
+ character = outcome.character;
411
+ console.log('Chest opened!');
412
+ console.log('Gold received:', outcome.result.gold);
413
+ console.log('Items received:', outcome.result.items.map(i => i.name));
414
+ console.log('Items consumed:', outcome.result.consumedItems);
415
+ // → Items consumed: [{ name: 'Iron Key', quantity: 1 }]
416
+ } else {
417
+ // Requirements not met
418
+ console.log('Cannot open:', outcome.result.error?.message);
419
+ // → "Cannot open: Missing required item: Iron Key"
420
+ }
421
+ }
422
+ ```
423
+
424
+ ##### Checking Requirements Before Opening
425
+
426
+ Use `BoxOpener.canOpen()` to check if the character has the required items:
427
+
428
+ ```typescript
429
+ // Check if character can open the box (for UI: enable/disable button)
430
+ const canOpen = BoxOpener.canOpen(
431
+ dungeonChest,
432
+ character.equipment.items
433
+ );
434
+
435
+ if (!canOpen) {
436
+ // Show what's needed
437
+ const desc = BoxOpener.getRequirementsDescription(dungeonChest);
438
+ console.log(desc); // "Requires: Iron Key"
439
+ }
440
+
441
+ // Get detailed error info if requirements not met
442
+ const error = BoxOpener.checkRequirements(dungeonChest, character.equipment.items);
443
+ if (error) {
444
+ console.log(error.code); // 'MISSING_ITEM' or 'INSUFFICIENT_QUANTITY'
445
+ console.log(error.message); // Human-readable message
446
+ }
447
+ ```
448
+
449
+ ##### Multiple Requirements
450
+
451
+ Boxes can require multiple different items. All requirements must be satisfied:
452
+
453
+ ```typescript
454
+ const royalTreasuryBox = {
455
+ name: 'Royal Treasury Box',
456
+ type: 'box' as const,
457
+ rarity: 'very_rare' as const,
458
+ weight: 20,
459
+ boxContents: {
460
+ openRequirements: [
461
+ { itemName: 'Golden Key' }, // 1 Golden Key
462
+ { itemName: 'Gold Coin', quantity: 200 } // 200 Gold Coins
463
+ ],
464
+ drops: [
465
+ { pool: [{ weight: 100, gold: 1000 }] },
466
+ { pool: [
467
+ { weight: 30, itemName: 'Plate Armor' },
468
+ { weight: 25, itemName: 'Greataxe' },
469
+ { weight: 25, itemName: 'Longsword' },
470
+ { weight: 20, itemName: 'Chain Mail' }
471
+ ]}
472
+ ]
473
+ },
474
+ description: 'A royal treasury box. Requires a Golden Key and 200 Gold Coins.'
475
+ };
476
+
477
+ // Preview requirements for UI
478
+ const preview = BoxOpener.previewContents(royalTreasuryBox);
479
+ console.log(preview.openRequirements);
480
+ // → [{ itemName: 'Golden Key' }, { itemName: 'Gold Coin', quantity: 200 }]
481
+ ```
482
+
483
+ ##### Strategic Loot Design
484
+
485
+ Consider these patterns when designing locked box rewards:
486
+
487
+ | Box Type | Requirements | Use Case |
488
+ |----------|--------------|----------|
489
+ | Key-locked | Single key item | Standard treasure rooms, miniboss drops |
490
+ | Gold-locked | Gold Coins (quantity) | Gambling-style boxes, shops |
491
+ | Multi-locked | Key + Gold | High-value boss loot, rare treasures |
492
+ | Quantity-locked | Multiple of same item | Skill-based rewards (e.g., 3 Lockpicks) |
493
+
494
+ > **See also:** [EQUIPMENT_SYSTEM.md — Opening Requirements](EQUIPMENT_SYSTEM.md#opening-requirements) for complete documentation of `BoxOpenRequirement`, `BoxOpenError`, and all `BoxOpener` methods.
495
+
496
+ #### Checking if a Reward Is a Box
497
+
498
+ ```typescript
499
+ import { BoxOpener } from 'playlist-data-engine';
500
+
501
+ for (const item of combatResult.treasureAwarded.items) {
502
+ if (BoxOpener.isBox(item)) {
503
+ console.log(`${item.name} is a box — open it to see contents`);
504
+
505
+ // Preview possible contents without opening
506
+ const preview = BoxOpener.previewContents(item);
507
+ console.log(`Possible items: ${preview.possibleItems.join(', ')}`);
508
+ console.log(`Gold range: ${preview.possibleGold.min}–${preview.possibleGold.max}`);
509
+ console.log(`Number of drops: ${preview.totalDrops}`);
510
+ }
511
+ }
512
+ ```
513
+
514
+ #### Example: Boss Loot Box Configuration
515
+
516
+ ```typescript
517
+ // Dragon hoard - guaranteed gold + rare item drop
518
+ const dragonHoard = {
519
+ id: 'dragon-hoard-1',
520
+ name: 'Dragon Hoard Chest',
521
+ type: 'box' as const,
522
+ rarity: 'rare' as const,
523
+ weight: 10,
524
+ boxContents: {
525
+ drops: [
526
+ { pool: [{ weight: 100, gold: 500 }] }, // Always 500 gold
527
+ { pool: [{ weight: 100, itemName: 'Potion of Healing' }] }, // Always a potion
528
+ {
529
+ pool: [ // Random rare item
530
+ { weight: 35, itemName: 'Longsword +1' },
531
+ { weight: 35, itemName: 'Chain Mail +1' },
532
+ { weight: 20, itemName: 'Ring of Protection' },
533
+ { weight: 10, itemName: 'Dragon Slayer Sword' }
534
+ ]
535
+ }
536
+ ]
537
+ },
538
+ tags: ['loot', 'treasure', 'dragon', 'boss'],
539
+ description: "A chest from a dragon's hoard."
540
+ };
541
+
542
+ const combat = new CombatEngine({
543
+ seed: 'dragon-fight-001',
544
+ treasure: { items: [dragonHoard] }
545
+ });
546
+ ```
547
+
548
+ > **See also:** [EQUIPMENT_SYSTEM.md — Box Equipment Type](EQUIPMENT_SYSTEM.md#box-equipment-type) for complete `BoxDropPool`, `BoxDrop`, `BoxContents`, and `BoxOpenResult` interface documentation.
549
+
550
+ ### Spell Casting
551
+
552
+ Cast spells with automatic slot consumption:
553
+
554
+ ```typescript
555
+ // Cast a spell at one or more targets
556
+ const spell = { name: 'Fireball', level: 3, damage: { dice: '8d6', type: 'fire' } };
557
+ const targets = [enemy1, enemy2];
558
+ const action = combat.executeCastSpell(combatInstance, current, spell, targets);
559
+
560
+ console.log(action.result.description);
561
+ // "Cast Fireball dealing 24 fire damage to Goblin"
562
+
563
+ // SpellCastResult properties
564
+ action.result.success; // boolean
565
+ action.result.spellName; // 'Fireball'
566
+ action.result.caster; // Combatant who cast
567
+ action.result.targets; // Target combatants
568
+ action.result.saveDC; // Difficulty class (if applicable)
569
+ action.result.damage; // { total: 24, rolls: [...] }
570
+ action.result.effectsApplied; // StatusEffect[] (e.g., Burning)
571
+ action.result.spellSlotUsed; // Slot level consumed (3)
572
+ ```
573
+
574
+ Spell slots are consumed automatically based on spell level. Cantrips (`level: 0`) consume no slots. Multi-target spells apply effects to all targets in the array.
575
+
576
+ ### Combat Actions
577
+
578
+ Defensive and tactical actions:
579
+
580
+ ```typescript
581
+ // Dodge: +2 AC until your next turn starts
582
+ combat.executeDodge(combatInstance, current);
583
+ // "Aragorn takes the Dodge action (AC increased until next turn)"
584
+
585
+ // Dash: Double your movement speed for this turn
586
+ combat.executeDash(combatInstance, current);
587
+ // "Aragorn takes the Dash action (double movement)"
588
+
589
+ // Disengage: Move without provoking opportunity attacks
590
+ combat.executeDisengage(combatInstance, current);
591
+ // "Aragorn takes the Disengage action (no opportunity attacks provoked)"
592
+
593
+ // Flee: Leave combat (requires allowFleeing config)
594
+ const fleeAction = combat.executeFlee(combatInstance, current);
595
+ // "Aragorn flees from combat"
596
+ // Combatant removed from active combat, added to history
597
+ ```
598
+
599
+ Fleeing requires `allowFleeing: true` in CombatEngine config. The combatant is removed from the active combat instance and a `'flee'` action is recorded in history.
600
+
601
+ ### HP Management
602
+
603
+ Direct hit point manipulation:
604
+
605
+ ```typescript
606
+ // Apply damage - temporary HP is depleted first
607
+ combat.applyDamage(target, 15); // Returns damage actually dealt
608
+
609
+ // Heal combatant - caps at max HP
610
+ combat.healCombatant(current, 10); // Returns actual healing (capped at max)
611
+
612
+ // Apply temporary HP - does NOT stack, uses higher value
613
+ combat.applyTemporaryHP(current, 5); // Sets temp HP to 5
614
+ combat.applyTemporaryHP(current, 8); // Sets temp HP to 8 (replaces)
615
+ ```
616
+
617
+ ### Query Methods
618
+
619
+ Retrieve combat state information:
620
+
621
+ ```typescript
622
+ // Get all combatants with HP > 0
623
+ const living = combat.getLivingCombatants(combatInstance);
624
+ // [{ combatant, isDefeated: false, ... }, ...]
625
+
626
+ // Get all defeated combatants (HP ≤ 0)
627
+ const defeated = combat.getDefeatedCombatants(combatInstance);
628
+ // [{ combatant, isDefeated: true, ... }, ...]
629
+
630
+ // Get formatted status summary of current combat state
631
+ const summary = combat.getCombatSummary(combatInstance);
632
+ // "Combatants: 3 (Living: 2, Defeated: 1)\nRound: 5, Turn: 12"
633
+
634
+ // Get current combatant whose turn it is
635
+ const current = combat.getCurrentCombatant(combatInstance);
636
+ // { combatant: {...}, isDefeated: false, ... }
637
+ ```
638
+
639
+ ### Action Economy
640
+
641
+ Each combatant tracks action usage per turn:
642
+
643
+ ```typescript
644
+ // Check action economy state
645
+ const combatant = combatInstance.combatants[0];
646
+ combatant.actionUsed; // boolean - action consumed this turn
647
+ combatant.bonusActionUsed; // boolean - bonus action consumed
648
+ combatant.reactionUsed; // boolean - reaction consumed
649
+
650
+ // One action, one bonus action, one reaction per turn
651
+ // Flags automatically reset when nextTurn() is called
652
+
653
+ // Most actions (attacks, spells) consume the main action
654
+ // Bonus actions require explicit tracking via executeBonusAction()
655
+ // Reactions (opportunity attacks) consume reaction flag
656
+ ```
657
+
658
+ Action economy enforcement is manual - check flags before executing actions that should consume specific action types.
659
+
660
+ ### Status Effects
661
+
662
+ Status effects are temporary conditions that modify how a combatant functions in combat. They can deal damage, impose advantage/disadvantage, skip turns, and more. The engine tracks durations, enforces mechanical effects, and manages concentration.
663
+
664
+ #### StatusEffect Interface
665
+
666
+ ```typescript
667
+ interface StatusEffect {
668
+ name: string; // e.g., 'Burning', 'Charmed', 'Stunned'
669
+ description: string; // Human-readable description
670
+ duration: number; // Rounds remaining (decremented each turn)
671
+ source?: string; // Combatant ID that applied the effect
672
+ hasConcentration?: boolean; // Requires caster to maintain concentration
673
+
674
+ icon?: string; // Optional icon URL for UI display
675
+ image?: string; // Optional image URL for larger display
676
+
677
+ damage?: number; // Damage dealt at start of each of the affected combatant's turns
678
+ damageType?: DamageType; // Damage type for the effect's damage (e.g., 'fire')
679
+
680
+ mechanicalEffects?: StatusEffectMechanics; // Combat rules enforced by the engine
681
+ }
682
+ ```
683
+
684
+ #### StatusEffectMechanics Interface
685
+
686
+ The `mechanicalEffects` field controls what the engine enforces automatically:
687
+
688
+ ```typescript
689
+ interface StatusEffectMechanics {
690
+ disadvantageOnAttackNonSource?: boolean; // Charmed: disadvantage on attacks vs non-source
691
+ disadvantageOnAttack?: boolean; // Frightened/Prone: disadvantage on all attacks
692
+ disadvantageOnAbilityChecks?: boolean; // Frightened: disadvantage on ability checks
693
+ advantageOnMeleeAttackAgainst?: boolean; // Prone: melee attacks against this target have advantage
694
+ advantageOnRangedAttackAgainst?: boolean; // Prone: ranged attacks against this target have advantage
695
+ disadvantageOnDexSaves?: boolean; // Stunned/Paralyzed/Restrained: disadvantage on DEX saves
696
+ speedZero?: boolean; // Stunned/Paralyzed/Restrained: speed set to 0
697
+ skipTurn?: boolean; // Stunned/Paralyzed: skip turn entirely
698
+ damageImmunity?: DamageType; // Immune to a specific damage type
699
+ damageResistance?: DamageType; // Resist a specific damage type (half damage)
700
+ damageVulnerability?: DamageType; // Vulnerable to a specific damage type (double damage)
701
+ }
702
+ ```
703
+
704
+ #### Mechanically Enforced Conditions
705
+
706
+ The engine automatically enforces combat rules for the following conditions:
707
+
708
+ | Condition | Concentration | Mechanical Effects |
709
+ |-----------|:---:|---|
710
+ | **Charmed** | Yes | Disadvantage on attack rolls against targets other than the source |
711
+ | **Frightened** | Yes | Disadvantage on attack rolls and ability checks |
712
+ | **Stunned** | No | Disadvantage on DEX saves, speed 0, skip turn entirely |
713
+ | **Paralyzed** | No | Disadvantage on DEX saves, speed 0, skip turn entirely |
714
+ | **Restrained** | Yes | Disadvantage on DEX saves, speed 0 |
715
+ | **Poisoned** | No | Disadvantage on attack rolls and ability checks |
716
+ | **Blinded** | No | Disadvantage on attack rolls and ability checks |
717
+ | **Deafened** | No | (No mechanical effects — tagged for spell system use) |
718
+ | **Burning** | No | Deals `damage` fire damage at start of each turn |
719
+
720
+ > **Note:** Prone is listed in D&D 5e rules but is not currently mapped as a tag-based status effect. It can be applied manually with `advantageOnMeleeAttackAgainst`, `advantageOnRangedAttackAgainst`, and `disadvantageOnAttack` flags.
721
+
722
+ #### Duration Tracking Lifecycle
723
+
724
+ Status effects follow this lifecycle during combat:
725
+
726
+ ```
727
+ Applied → Active (each turn: damage → skip check → decrement) → Expired → Removed
728
+ ```
729
+
730
+ Each combatant's turn in `nextTurn()` processes in this order:
731
+
732
+ 1. **Start-of-turn damage** — effects with `damage > 0` deal damage (Burning, Poison)
733
+ 2. **Skip-turn check** — if any effect has `mechanicalEffects.skipTurn`, the turn is skipped entirely. Incapacitated combatants also lose concentration.
734
+ 3. **Duration decrement** — all effect durations are decremented by 1
735
+ 4. **Expiration removal** — effects with `duration <= 0` are removed. If a concentrated effect expires, `concentratingOn` is cleared.
736
+
737
+ A `statusEffectTick` action is recorded in combat history whenever effects expire, damage is dealt, turns are skipped, or concentration is lost.
738
+
739
+ #### Applying Status Effects
740
+
741
+ Use `CombatEngine.applyStatusEffect()` to apply effects. This handles stacking, concentration tracking, and one-concentration-per-combatant rules:
742
+
743
+ ```typescript
744
+ // Apply a Burning effect with damage
745
+ combat.applyStatusEffect(target, {
746
+ name: 'Burning',
747
+ description: 'On fire from Fireball',
748
+ duration: 3,
749
+ source: caster.id,
750
+ damage: 6,
751
+ damageType: 'fire',
752
+ mechanicalEffects: {
753
+ // Burning has no mechanical effects beyond damage
754
+ }
755
+ });
756
+
757
+ // Apply a concentration effect (e.g., Charmed)
758
+ combat.applyStatusEffect(target, {
759
+ name: 'Charmed',
760
+ description: `Charmed by ${caster.character.name}`,
761
+ duration: 1,
762
+ source: caster.id,
763
+ hasConcentration: true,
764
+ mechanicalEffects: {
765
+ disadvantageOnAttackNonSource: true,
766
+ }
767
+ });
768
+ ```
769
+
770
+ #### Stacking Rules
771
+
772
+ When an effect with the **same name** already exists on the combatant:
773
+
774
+ - **Duration** — refreshed to the higher of the existing and new durations
775
+ - **Damage** — keeps the higher damage value
776
+ - **Mechanical effects** — merged (new flags overwrite existing)
777
+ - **Source** — updated to the new source
778
+ - **Damage type** — updated to the new type
779
+ - **Concentration** — new concentration effect replaces old concentration effect
780
+
781
+ Different-named effects stack independently — a combatant can be both Charmed and Frightened simultaneously.
782
+
783
+ #### Concentration Tracking
784
+
785
+ A combatant can maintain concentration on **one effect at a time**. The `Combatant.concentratingOn` field tracks the name of the concentrated effect.
786
+
787
+ **Concentration is broken when:**
788
+ - The concentrating combatant takes damage and fails a CON save (DC 10 or half damage, whichever is higher)
789
+ - A new concentration spell is cast (replaces the old one)
790
+ - The combatant becomes incapacitated (Stunned, Paralyzed)
791
+ - The combatant is defeated (HP reaches 0)
792
+ - The concentrated effect expires naturally
793
+
794
+ ```typescript
795
+ // Check if a combatant is concentrating
796
+ combatant.concentratingOn; // e.g., 'Charmed' or undefined
797
+
798
+ // Check concentration when damage is taken (automatic via executeAttack)
799
+ // Or check manually:
800
+ const broken = combat.checkConcentration(combatInstance, combatant, 15);
801
+ // Returns true if concentration was broken
802
+
803
+ // Drop concentration manually
804
+ const dropped = combat.dropConcentration(combatant, 'Voluntarily ended');
805
+ // Returns the dropped StatusEffect or undefined
806
+ ```
807
+
808
+ #### Removing Expired Effects
809
+
810
+ ```typescript
811
+ // Remove effects with duration <= 0
812
+ const expired = combat.removeExpiredStatusEffects(combatant);
813
+ // Returns array of removed StatusEffect objects
814
+ // Also clears combatant.concentratingOn if the concentrated effect expired
815
+ ```
816
+
817
+ This is called automatically by `nextTurn()` during the duration tick-down step. You typically don't need to call it manually.
818
+
819
+ #### Spell-Based Status Effects
820
+
821
+ `SpellCaster` maps spell tags to status effects via the `TAG_STATUS_EFFECTS` constant:
822
+
823
+ | Tag | Effect Name | Concentration |
824
+ |-----|-------------|:---:|
825
+ | `charm` | Charmed | Yes |
826
+ | `frighten` | Frightened | Yes |
827
+ | `stun` | Stunned | No |
828
+ | `paralyze` | Paralyzed | No |
829
+ | `restrain` | Restrained | Yes |
830
+ | `poison` | Poisoned | No |
831
+ | `blind` | Blinded | No |
832
+ | `deafen` | Deafened | No |
833
+ | `burn` | Burning | No |
834
+
835
+ Spells can also apply status effects via keyword matching on `spell.description` and `spell.effect` text (fallback when no tags are present). Tag-based matching takes priority over text matching to prevent duplicates.
836
+
837
+ #### Advantage/Disadvantage from Effects
838
+
839
+ The engine checks status effects automatically during `executeAttack()` and `castSpell()`:
840
+
841
+ - **`disadvantageOnAttackNonSource`** — attacker has disadvantage if the target is not the source combatant (Charmed)
842
+ - **`disadvantageOnAttack`** — attacker has disadvantage unconditionally (Frightened, Poisoned, Blinded)
843
+ - **`advantageOnMeleeAttackAgainst`** — melee attacks against this target have advantage (Prone)
844
+ - **`advantageOnRangedAttackAgainst`** — ranged attacks against this target have advantage (Prone)
845
+ - **`disadvantageOnDexSaves`** — target rolls DEX saving throws with disadvantage (Stunned, Paralyzed, Restrained)
846
+
847
+ Per D&D 5e rules, advantage and disadvantage cancel each other out — if a combatant has both advantage and disadvantage on a roll, the roll is made normally.
848
+
849
+ ### Combat History
850
+
851
+ Every action is recorded in `CombatInstance.history`:
852
+
853
+ ```typescript
854
+ // Access complete combat log
855
+ combatInstance.history;
856
+ // [
857
+ // { type: 'attack', actor: {...}, target: {...}, attack: {...}, result: {...} },
858
+ // { type: 'spell', actor: {...}, targets: [...], spell: {...}, result: {...} },
859
+ // { type: 'dodge', actor: {...}, result: {...} },
860
+ // { type: 'flee', actor: {...}, result: {...} },
861
+ // ...
862
+ // ]
863
+
864
+ // CombatAction properties
865
+ action.type; // 'attack' | 'spell' | 'dodge' | 'dash' | 'disengage' | 'help' | 'hide' | 'ready' | 'flee' | 'useItem' | 'legendaryAction' | 'statusEffectTick'
866
+ action.actor; // Combatant who performed the action
867
+ action.target; // Single target (for attacks)
868
+ action.targets; // Multiple targets (for spells)
869
+ action.attack; // Attack details (weapon, roll, damage)
870
+ action.spell; // Spell details (name, level, school)
871
+ action.result; // { success, roll?, damage?, description }
872
+
873
+ // Useful for combat logs, replay systems, and analytics
874
+ ```
875
+
876
+ ### Spell Slots
877
+
878
+ Automatically initialized for spellcasting classes:
879
+
880
+ ```typescript
881
+ // Spell slots are auto-initialized on combatant creation
882
+ const wizard = combatInstance.combatants.find(c => c.character.class === 'Wizard');
883
+ wizard.spellSlots; // { 1: 2, 2: 0, 3: 0, ... } based on character level
884
+
885
+ // Supported spellcasting classes:
886
+ // 'Wizard', 'Cleric', 'Sorcerer', 'Bard', 'Druid', 'Warlock', 'Paladin', 'Ranger'
887
+
888
+ // Slot allocation follows D&D 5e progression by character level
889
+ // Level 1: [2, 0, 0, 0, 0, 0, 0, 0, 0] (2 level-1 slots)
890
+ // Level 3: [4, 2, 0, 0, 0, 0, 0, 0, 0] (4 level-1, 2 level-2)
891
+ // Level 5: [4, 3, 2, 0, 0, 0, 0, 0, 0] (4 level-1, 3 level-2, 2 level-3)
892
+ // ... up to level 20
893
+
894
+ // SpellCaster class handles slot consumption and restoration
895
+ // Slots consumed via executeCastSpell(), restored via long rest mechanics
896
+ ```
897
+
898
+ ### Multiple Equipped Weapons
899
+
900
+ If a character has multiple equipped weapons, specify which one:
901
+
902
+ ```typescript
903
+ // Attack with a specific equipped weapon
904
+ combat.executeWeaponAttack(combatInstance, current, target, 'Longsword');
905
+
906
+ // Or just use the first equipped weapon (default)
907
+ combat.executeWeaponAttack(combatInstance, current, target);
908
+ ```
909
+
910
+ ### Unarmed Combat
911
+
912
+ Attack without weapons using fists or natural weapons:
913
+
914
+ ```typescript
915
+ // Explicitly use unarmed strike
916
+ combat.executeWeaponAttack(combatInstance, current, target, 'unarmed');
917
+
918
+ // Unarmed is used automatically when no weapon equipped
919
+ combat.executeWeaponAttack(combatInstance, current, target);
920
+ // "Aragorn punches Goblin for 3 damage"
921
+
922
+ // Default unarmed: 1 + STR modifier damage, proficiency bonus applies
923
+ ```
924
+
925
+ ### Manual Attack Objects
926
+
927
+ For special cases, you can still manually construct `Attack` objects using `executeAttack()` directly. See `Attack` type in DATA_ENGINE_REFERENCE.md for all available properties.
928
+
929
+ ### Hit Modes
930
+
931
+ The combat engine supports two hit resolution modes, configured via `CombatConfig.hitMode`. Each mode also uses a different damage formula.
932
+
933
+ #### `'dnd'` — Classic D&D 5e (threshold-based)
934
+
935
+ The default D&D 5e system: d20 + attack bonus is compared to the target's AC.
936
+
937
+ - **Hit:** totalRoll >= AC (natural 20 always hits)
938
+ - **Miss:** totalRoll < AC (natural 1 always misses)
939
+ - **Critical:** Natural 20 — double damage dice
940
+
941
+ **Damage formula (dnd):** Rolls weapon dice + ability modifier. Finesse and ranged weapons use DEX; melee weapons use STR. Crits double the dice (not the modifier).
942
+
943
+ ```typescript
944
+ const engine = new CombatEngine({ hitMode: 'dnd' });
945
+ ```
946
+
947
+ #### `'scaled'` — Damage Scaling (default)
948
+
949
+ AC reduces damage instead of determining hit/miss. This creates a smoother combat experience where every attack deals at least some damage.
950
+
951
+ - **Only natural 1 misses** (5% chance, regardless of AC)
952
+ - **Natural 20 always crits** at full damage
953
+ - **All other rolls hit**, but damage is scaled based on how far below AC:
954
+ - Each point below AC reduces damage by 10%
955
+ - Minimum damage is always 1
956
+ - `damageScale` on `AttackRoll` indicates the multiplier (1.0 = full, 0.05–0.95 = scaled)
957
+
958
+ | Roll vs AC | damageScale | Effect |
959
+ |-----------|-------------|--------|
960
+ | >= AC | 1.0 | Full damage |
961
+ | AC - 1 | 0.95 | 95% damage |
962
+ | AC - 5 | 0.50 | 50% damage |
963
+ | AC - 9 | 0.10 | 10% damage (minimum) |
964
+
965
+ **Damage formula (scaled):** No dice rolls. `max(1, floor(level * 2 + (STR - AC) * 0.3))` + flat weapon bonus from die size tier (d4→1, d6→1, d8→2, d10→2, d12→3, 2d6→3, 2d8→4). Level is the primary damage driver. Crits multiply the level base by 1.5x (weapon bonus unaffected).
966
+
967
+ ```typescript
968
+ // Scaled mode is the default — no config needed
969
+ const engine = new CombatEngine();
970
+
971
+ // Explicit (same result)
972
+ const engine = new CombatEngine({ hitMode: 'scaled' });
973
+ ```
974
+
975
+ **Why scaled mode?** In classic D&D, a high-AC character can make lower-level enemies effectively harmless (every attack misses). Scaled mode ensures those enemies still deal reduced damage, making encounter balance feel more granular.
976
+
977
+ ### Legendary Actions
978
+
979
+ Legendary actions are special abilities available to boss-tier enemies. Unlike regular actions, legendary actions are taken **outside the boss's own turn** — other creatures can take legendary actions at the end of another creature's turn. A boss starts each round with 3 legendary action points and spends them to use its abilities. The cost of each action varies (1, 2, or 3 points).
980
+
981
+ #### LegendaryAction Interface
982
+
983
+ Each legendary action is defined on the boss's `character.legendary_config.actions` array:
984
+
985
+ ```typescript
986
+ interface LegendaryAction {
987
+ id: string; // Unique identifier (e.g., 'tail_sweep')
988
+ name: string; // Display name (e.g., 'Tail Sweep')
989
+ description: string; // What the action does
990
+ cost: number; // Action points consumed (1, 2, or 3)
991
+ effect: string; // Combat system effect description
992
+ damage?: string; // Dice formula if it deals damage (e.g., '2d8 + 5')
993
+ damageType?: string; // Damage type (e.g., 'bludgeoning')
994
+ archetypes: EnemyArchetype[];
995
+ tags?: string[]; // Tags for AI filtering (e.g., 'damage', 'control', 'healing')
996
+ }
997
+
998
+ interface LegendaryConfig {
999
+ resistances: number; // Legendary resistances per day
1000
+ actions: LegendaryAction[]; // Available legendary actions
1001
+ lairActionHint?: string; // Optional lair action hint
1002
+ }
1003
+ ```
1004
+
1005
+ #### Combatant Legendary Tracking
1006
+
1007
+ Boss combatants have two additional fields for tracking legendary resources:
1008
+
1009
+ ```typescript
1010
+ interface Combatant {
1011
+ // ... standard fields ...
1012
+ legendaryActionsRemaining?: number; // Reset to 3 at the start of each round
1013
+ legendaryResistancesRemaining?: number; // Per-day resource, set from config at combat start
1014
+ }
1015
+ ```
1016
+
1017
+ These are initialized automatically by `CombatEngine.createCombatant()` when a character has `legendary_config`.
1018
+
1019
+ #### Action Point Tracking
1020
+
1021
+ - Each round, all non-defeated boss combatants have their legendary action points reset to **3**
1022
+ - Reset happens at the start of each new round (when `nextTurn()` wraps around to turn index 0)
1023
+ - Points are spent when `executeLegendaryAction()` is called, deducted by the action's `cost`
1024
+ - If a boss doesn't have enough points for an action, the engine throws an error
1025
+
1026
+ ```
1027
+ Round 1 starts → boss gets 3 points
1028
+ Player turn ends → boss uses "Tail Sweep" (cost 2) → 1 point remaining
1029
+ Player turn ends → boss uses "Frightening Presence" (cost 1) → 0 points remaining
1030
+ Player turn ends → boss tries action → throws: not enough points
1031
+ Round 2 starts → boss gets 3 points again
1032
+ ...
1033
+ ```
1034
+
1035
+ #### Executing Legendary Actions
1036
+
1037
+ Use `CombatEngine.executeLegendaryAction()` to have a boss use a legendary action:
1038
+
1039
+ ```typescript
1040
+ // Find a boss combatant
1041
+ const boss = combatInstance.combatants.find(c => c.character.legendary_config);
1042
+ const target = combat.getLivingCombatants(combatInstance).find(c => c.id !== boss.id);
1043
+
1044
+ // Get a legendary action from the boss's config
1045
+ const action = boss.character.legendary_config.actions[0];
1046
+ // e.g., { id: 'tail_sweep', name: 'Tail Sweep', cost: 2, damage: '2d8+5', ... }
1047
+
1048
+ // Execute the legendary action
1049
+ const legendaryAction = combat.executeLegendaryAction(combatInstance, boss, action, target);
1050
+
1051
+ console.log(legendaryAction.result.description);
1052
+ // "Dragon Lord uses Tail Sweep on Aragorn for 12 bludgeoning damage (2 action points spent, 1 remaining)"
1053
+
1054
+ // Check remaining points
1055
+ boss.legendaryActionsRemaining; // 1
1056
+ ```
1057
+
1058
+ **Parameters:**
1059
+
1060
+ | Parameter | Type | Description |
1061
+ |-----------|------|-------------|
1062
+ | `combat` | `CombatInstance` | The active combat instance |
1063
+ | `bossCombatant` | `Combatant` | The boss using the legendary action |
1064
+ | `action` | `{ id, name, cost, effect, damage?, damage_type?, ... }` | The action to execute (must belong to the boss) |
1065
+ | `target?` | `Combatant` | Optional target for damaging actions |
1066
+
1067
+ **What the engine does:**
1068
+ 1. Validates the action exists on the boss's `legendary_config.actions`
1069
+ 2. Checks that enough action points are available
1070
+ 3. Spends the action points (deducts `cost` from `legendaryActionsRemaining`)
1071
+ 4. If the action has a `damage` formula and a target is provided, rolls damage and applies it
1072
+ 5. Records a `legendaryAction` entry in combat history
1073
+ 6. Checks if the target was defeated (updates combat status)
1074
+
1075
+ #### Legendary Resistances
1076
+
1077
+ Legendary resistances allow a boss to automatically succeed on a failed saving throw. This is a **per-day resource** (not per-round).
1078
+
1079
+ ```typescript
1080
+ // Boss fails a saving throw against a spell
1081
+ const failedSave = false;
1082
+
1083
+ if (!failedSave) {
1084
+ // Use legendary resistance to succeed instead
1085
+ const used = combat.useLegendaryResistance(combatInstance, boss);
1086
+ if (used) {
1087
+ console.log(`${boss.character.name} used legendary resistance!`);
1088
+ console.log(`Remaining today: ${boss.legendaryResistancesRemaining}`);
1089
+ } else {
1090
+ console.log('No legendary resistances remaining — boss suffers the effect');
1091
+ }
1092
+ }
1093
+ ```
1094
+
1095
+ - Returns `true` if a resistance was available and consumed
1096
+ - Returns `false` if no resistances remain (`legendaryResistancesRemaining` is 0)
1097
+ - Resistant count is set from `legendary_config.resistances` at combat start
1098
+ - Resistant count does **not** reset between rounds (per-day, not per-round)
1099
+ - Usage is recorded in combat history
1100
+
1101
+ #### AI and Legendary Actions
1102
+
1103
+ The combat AI (`CombatAI`) automatically selects and uses legendary actions for boss enemies via `selectLegendaryAction()`. After each non-boss turn, the `AICombatRunner` checks if any boss has remaining legendary action points and chains actions until the budget is exhausted or no valid actions remain.
1104
+
1105
+ - **Normal AI**: prefers lowest-cost damage actions (spread points across the round for sustained pressure)
1106
+ - **Aggressive AI**: prefers highest-cost damage actions (maximize immediate impact)
1107
+
1108
+ ### Combat AI
1109
+
1110
+ The combat AI controls both player characters and enemies during simulated combat. It produces a decision for each combatant's turn based on a threat assessment of the battlefield and a configurable play style. The AI is **deterministic** — given the same combat state, it always makes the same decision. All randomness comes from the dice roller when the `AICombatRunner` executes the decision.
1111
+
1112
+ #### AIPlayStyle
1113
+
1114
+ Two fundamental strategies drive all AI decisions:
1115
+
1116
+ ```typescript
1117
+ type AIPlayStyle = 'normal' | 'aggressive';
1118
+ ```
1119
+
1120
+ | Style | Philosophy | Targeting | Spells | Resources | Defensive |
1121
+ |-------|-----------|-----------|--------|-----------|-----------|
1122
+ | **Normal** | Baseline difficulty measurement | Lowest AC (easiest to hit) | Cantrips preferred; leveled only when clearly better | Conserves spell slots; saves for later rounds | Dodges when isolated + low HP |
1123
+ | **Aggressive** | Maximum threat ceiling | Lowest HP (finish them off) | Always highest damage; burns all slots | No conservation; proactive healing to maintain max HP | Never dodges, never flees |
1124
+
1125
+ Comparing Normal vs Aggressive results reveals the true difficulty range of an encounter.
1126
+
1127
+ #### AIConfig
1128
+
1129
+ Controls how both sides fight. Each side can have a different style, and individual combatants can be overridden:
1130
+
1131
+ ```typescript
1132
+ import { CombatAI, AICombatRunner } from 'playlist-data-engine';
1133
+
1134
+ // Everyone plays the same style
1135
+ const balancedConfig = {
1136
+ playerStyle: 'normal',
1137
+ enemyStyle: 'normal',
1138
+ };
1139
+
1140
+ // Mixed: players are cautious, enemies go all-out
1141
+ const threatConfig = {
1142
+ playerStyle: 'normal',
1143
+ enemyStyle: 'aggressive',
1144
+ };
1145
+
1146
+ // Per-combatant overrides (combatant ID → style)
1147
+ const customConfig = {
1148
+ playerStyle: 'normal',
1149
+ enemyStyle: 'normal',
1150
+ overrides: new Map([
1151
+ ['enemy_4', 'aggressive'], // This specific boss fights aggressively
1152
+ ]),
1153
+ };
1154
+
1155
+ // Optional: enable class features (Sneak Attack, Divine Smite, etc.)
1156
+ const classFeaturesConfig = {
1157
+ playerStyle: 'aggressive',
1158
+ enemyStyle: 'aggressive',
1159
+ enableClassFeatures: true,
1160
+ };
1161
+ ```
1162
+
1163
+ #### AIDecision
1164
+
1165
+ The output of the AI for each turn. Contains everything needed to execute one combatant's action:
1166
+
1167
+ ```typescript
1168
+ interface AIDecision {
1169
+ action: 'attack' | 'castSpell' | 'dodge' | 'dash' | 'disengage'
1170
+ | 'flee' | 'useItem' | 'legendaryAction' | 'skip';
1171
+ target?: string; // Single-target combatant ID
1172
+ targetIds?: string[]; // Multi-target spell combatant IDs
1173
+ weaponName?: string; // Weapon to attack with
1174
+ spellName?: string; // Spell to cast
1175
+ itemName?: string; // Consumable to use
1176
+ legendaryActionId?: string; // Legendary action to execute
1177
+ reasoning?: string; // Human-readable explanation
1178
+ }
1179
+ ```
1180
+
1181
+ The `reasoning` field explains why the AI chose its action — useful for debugging and UI tooltips.
1182
+
1183
+ #### Decision-Making Process
1184
+
1185
+ Each turn, the AI follows a priority chain:
1186
+
1187
+ ```
1188
+ 1. Assess Threat
1189
+ └─ Evaluate: HP%, ally/enemy counts, spell slots, items, round number
1190
+
1191
+ 2. Spell Selection (highest priority)
1192
+ ├─ Healing needed? → Cast heal on lowest-HP ally
1193
+ ├─ Damage spell better than attack? → Cast it
1194
+ ├─ Control spell useful? (2+ enemies, normal style) → Cast it
1195
+ └─ Buff available? (healthy, normal style) → Buff strongest ally
1196
+
1197
+ 3. Item Usage
1198
+ └─ Low HP + no spell slots? → Use healing item
1199
+
1200
+ 4. Defensive Actions
1201
+ └─ Isolated + low HP + multiple enemies (normal only)? → Dodge
1202
+
1203
+ 5. Weapon Attack (fallback)
1204
+ └─ Pick best weapon → Attack selected target
1205
+ ```
1206
+
1207
+ The AI never wastes a turn. If no spell/item/defensive action is warranted, it always falls through to a weapon attack.
1208
+
1209
+ #### Target Selection
1210
+
1211
+ | Style | Strategy | Rationale |
1212
+ |-------|----------|-----------|
1213
+ | **Normal** | Lowest AC enemy | Consistent damage output — easier to hit |
1214
+ | **Aggressive** | Lowest HP enemy | Action economy — removing enemies reduces incoming damage |
1215
+
1216
+ #### Weapon Selection
1217
+
1218
+ Evaluates all equipped weapons + unarmed strike. Scores based on expected damage and attack bonus:
1219
+
1220
+ ```typescript
1221
+ // Normal: balanced score (damage + small bonus for attack accuracy)
1222
+ score = expectedDamage + attackBonus * 0.1
1223
+
1224
+ // Aggressive: pure damage
1225
+ score = expectedDamage
1226
+ ```
1227
+
1228
+ Ranged weapons use DEX, melee weapons use STR for attack bonus calculation.
1229
+
1230
+ #### Spell Selection
1231
+
1232
+ Spells are evaluated by tag (via `SpellCaster` static helpers) and expected damage:
1233
+
1234
+ | Category | Tag Detection | Normal Behavior | Aggressive Behavior |
1235
+ |----------|--------------|-----------------|-------------------|
1236
+ | **Damage** | `damage` | Cantrips preferred; leveled only if 50%+ better | Always highest damage spell |
1237
+ | **Healing** | `healing`, `ally`, `self` | Heal allies below 50%; self when below 25% | Heal anyone below 75% |
1238
+ | **Control** | `control`, `debuff` | Use when 2+ enemies | Never (wastes damage turns) |
1239
+ | **Buff** | `buff` | Buff strongest ally when healthy | Never (wastes damage turns) |
1240
+ | **AoE/Multi** | `aoe`, `multi-target` | Damage × target count (cap 4) | Always if available |
1241
+
1242
+ AoE and multi-target spells get an expected damage multiplier based on enemy count. Leveled spells get a 1.5× bonus over cantrips to account for their resource cost.
1243
+
1244
+ #### Support Archetype AI
1245
+
1246
+ The AI detects support combatants (healers/buffers) by checking spell tags:
1247
+
1248
+ ```typescript
1249
+ const ai = new CombatAI(config);
1250
+ const isSupport = ai.isSupportArchetype(combatant);
1251
+ // true if combatant has healing or buff spells
1252
+ ```
1253
+
1254
+ Support AI differences:
1255
+ - Prioritizes healing the lowest-HP ally over dealing damage
1256
+ - Normal support only heals allies below 50% HP; aggressive heals everyone below 75%
1257
+ - Buff spells target the ally with highest STR/DEX (best damage dealer)
1258
+
1259
+ #### AIThreatAssessment
1260
+
1261
+ Computed each turn to drive all decisions. Provides a battlefield snapshot:
1262
+
1263
+ ```typescript
1264
+ interface AIThreatAssessment {
1265
+ myHPPercent: number; // 0.0 – 1.0
1266
+ myAC: number; // Armor class
1267
+ lowestAllyHPPercent: number; // 1.0 if no allies
1268
+ lowestEnemyHP: number; // Infinity if no enemies
1269
+ highestEnemyDamage: number; // Estimated enemy DPR
1270
+ partySize: number; // Living allies (incl. self)
1271
+ enemyCount: number; // Living enemies
1272
+ roundNumber: number; // Current round
1273
+ isLowHP: boolean; // Below 25%
1274
+ isCriticalHP: boolean; // Below 10%
1275
+ hasHealingItems: boolean; // Usable items in inventory
1276
+ hasSpellSlots: boolean; // Remaining leveled spell slots
1277
+ hasRemainingLimitedAbilities: boolean; // Legendary actions/resistances
1278
+ }
1279
+ ```
1280
+
1281
+ Access the assessment directly for custom AI logic:
1282
+
1283
+ ```typescript
1284
+ const ai = new CombatAI(config);
1285
+ const threat = ai.assessThreat(combatant, combatInstance);
1286
+ console.log(`${combatant.character.name}: ${Math.round(threat.myHPPercent * 100)}% HP, ${threat.enemyCount} enemies`);
1287
+ ```
1288
+
1289
+ #### AICombatRunner
1290
+
1291
+ The `AICombatRunner` orchestrates full combat encounters. It bridges the gap between `CombatAI` (decisions) and `CombatEngine` (execution):
1292
+
1293
+ ```typescript
1294
+ import { AICombatRunner, createSeededRoller } from 'playlist-data-engine';
1295
+
1296
+ const runner = new AICombatRunner();
1297
+
1298
+ const { combat, result, metrics } = runner.runFullCombat(
1299
+ players, // CharacterSheet[]
1300
+ enemies, // CharacterSheet[]
1301
+ {
1302
+ playerStyle: 'normal',
1303
+ enemyStyle: 'aggressive',
1304
+ },
1305
+ { maxTurnsBeforeDraw: 50 }, // Optional combat config
1306
+ createSeededRoller('sim-seed-42'), // Optional: deterministic rolls
1307
+ );
1308
+
1309
+ console.log(result.winnerSide); // 'player' | 'enemy' | 'draw'
1310
+ console.log(result.roundsElapsed); // Number of rounds
1311
+ console.log(result.xpAwarded); // XP from defeated enemies
1312
+
1313
+ // Per-combatant metrics
1314
+ for (const [id, m] of metrics) {
1315
+ console.log(`${m.name}: ${m.totalDamageDealt} damage, ${m.roundsSurvived} rounds, survived=${m.survived}`);
1316
+ }
1317
+ ```
1318
+
1319
+ **AICombatResult interface:**
1320
+
1321
+ | Field | Type | Description |
1322
+ |-------|------|-------------|
1323
+ | `combat` | `CombatInstance` | Full combat instance with complete action history |
1324
+ | `result` | `CombatResult` | Final result (winner, XP, rounds, treasure) |
1325
+ | `metrics` | `Map<string, CombatantMetrics>` | Per-combatant stats computed from history |
1326
+
1327
+ **Combat lifecycle inside the runner:**
1328
+
1329
+ ```
1330
+ startCombat()
1331
+ └─ For each turn while combat is active:
1332
+ ├─ Skip defeated combatants
1333
+ ├─ Skip stunned/unconscious combatants (skipTurn effects)
1334
+ ├─ AI decides → executeDecision()
1335
+ │ ├─ attack → executeWeaponAttack()
1336
+ │ ├─ castSpell → executeCastSpell()
1337
+ │ ├─ dodge/dash/disengage → engine methods
1338
+ │ ├─ flee → executeFlee() (fallback to attack if disabled)
1339
+ │ ├─ useItem → log in history (no mechanical effect yet)
1340
+ │ ├─ legendaryAction → executeLegendaryAction()
1341
+ │ └─ skip → log and advance
1342
+ ├─ Process boss legendary actions (chain until budget exhausted)
1343
+ └─ nextTurn()
1344
+ └─ getCombatResult() → computeMetrics()
1345
+ ```
1346
+
1347
+ **Without a seeded roller**, the runner uses `Math.random()` — suitable for live gameplay:
1348
+
1349
+ ```typescript
1350
+ // Random combat (live gameplay)
1351
+ const { combat, result } = runner.runFullCombat(players, enemies, {
1352
+ playerStyle: 'normal',
1353
+ enemyStyle: 'normal',
1354
+ });
1355
+ ```
1356
+
1357
+ #### CombatantMetrics
1358
+
1359
+ Per-combatant statistics computed from combat history by `CombatMetricsTracker`. `roundsSurvived` reflects the total combat duration (`combat.roundNumber`) for all combatants. DPR (`damagePerRound`) divides total damage by turns taken rather than rounds survived, which correctly handles combatants that don't act every round (e.g., in asymmetric fights).
1360
+
1361
+ ```typescript
1362
+ interface CombatantMetrics {
1363
+ combatantId: string;
1364
+ name: string;
1365
+ side: 'player' | 'enemy';
1366
+ totalDamageDealt: number; // All damage sources (attacks + spells + legendary)
1367
+ totalDamageTaken: number;
1368
+ totalHealingDone: number;
1369
+ spellsCast: number;
1370
+ itemsUsed: number;
1371
+ criticalHits: number;
1372
+ hits: number; // Successful attack/spell hits
1373
+ misses: number; // Missed attack/spell attempts
1374
+ kills: number; // Enemies/opponents this combatant defeated
1375
+ roundsSurvived: number; // Total combat rounds (from combat.roundNumber)
1376
+ survived: boolean;
1377
+ actionsByType: Record<string, number>; // e.g., { attack: 12, spell: 3, dodge: 1 }
1378
+ damagePerRound: number[]; // [avg damage per turn] totalDamageDealt / turns taken
1379
+ }
1380
+ ```
1381
+
1382
+ These metrics are the foundation for Monte Carlo simulation aggregation — the simulator averages them across hundreds of runs to produce per-combatant DPR, survival rate, and kill rate.
1383
+
1384
+ ### Monte Carlo Simulation
1385
+
1386
+ The `CombatSimulator` runs N independent combat encounters using AI-controlled combatants, each with a unique seeded RNG. It aggregates the outcomes into statistical summaries and per-combatant metrics. This is the core analysis tool — it answers *"Given this party and these enemies, how often does each side win, how many rounds do fights last, and how much damage does each combatant deal?"*
1387
+
1388
+ The simulator is stateless between `run()` calls. Each call produces a fresh, independent set of results.
1389
+
1390
+ #### CombatSimulator
1391
+
1392
+ ```typescript
1393
+ import { CombatSimulator } from 'playlist-data-engine';
1394
+
1395
+ const simulator = new CombatSimulator();
1396
+
1397
+ const results = simulator.run(
1398
+ party, // CharacterSheet[]
1399
+ enemies, // CharacterSheet[]
1400
+ {
1401
+ runCount: 1000,
1402
+ baseSeed: 'encounter-analysis',
1403
+ aiConfig: { playerStyle: 'normal', enemyStyle: 'aggressive' },
1404
+ }
1405
+ );
1406
+
1407
+ console.log(`Player win rate: ${(results.summary.playerWinRate * 100).toFixed(1)}%`);
1408
+ console.log(`Average rounds: ${results.summary.averageRounds.toFixed(1)}`);
1409
+ console.log(`Player deaths: ${results.summary.totalPlayerDeaths}`);
1410
+ ```
1411
+
1412
+ Internally, each run creates a fresh `SeededDiceRoller` and `AICombatRunner`. The seed for run `i` is `"${baseSeed}-${i}"`, ensuring every run is independent and the full simulation is reproducible.
1413
+
1414
+ #### SimulationConfig
1415
+
1416
+ | Field | Type | Default | Description |
1417
+ |-------|------|---------|-------------|
1418
+ | `runCount` | `number` | required | Number of simulations to run (100–10000 recommended) |
1419
+ | `baseSeed` | `string` | required | Base seed — each run gets `baseSeed-index` |
1420
+ | `aiConfig` | `AIConfig` | required | AI play styles per side |
1421
+ | `combatConfig` | `CombatConfig` | — | Optional combat engine overrides (max turns, flee, etc.) |
1422
+ | `collectDetailedLogs` | `boolean` | `false` | Save full combat log per run (memory-intensive for large runCount) |
1423
+ | `enemyRegeneration` | `EncounterGenerationOptions` | — | Regenerate enemies per run to capture generation variance; each run gets seed `enemyRegeneration.seed-runIndex` |
1424
+ | `onProgress` | `(completed, total) => void` | — | Progress callback after each run |
1425
+ | `abortSignal` | `AbortSignal` | — | Cancel long-running simulations; returns partial results |
1426
+
1427
+ #### SimulationResults
1428
+
1429
+ The top-level result object returned by `simulator.run()`:
1430
+
1431
+ ```typescript
1432
+ interface SimulationResults {
1433
+ config: SimulationConfig; // Input config echo
1434
+ summary: SimulationSummary; // Aggregate statistics
1435
+ party: PartyConfig; // Party snapshot (memberCount, averageLevel, names)
1436
+ encounter: EncounterConfig; // Enemy snapshot (enemyCount, averageCR, names)
1437
+ perCombatantMetrics: Map<string, CombatantSimulationMetrics>; // Per-combatant stats
1438
+ enemyGenerationStats?: EnemyGenerationRecord[]; // Per-enemy-type stats (only if enemyRegeneration)
1439
+ runDetails?: SimulationRunDetail[]; // Per-run data (only if collectDetailedLogs)
1440
+ wasCancelled: boolean; // true if aborted before completion
1441
+ }
1442
+ ```
1443
+
1444
+ When `enemyRegeneration` is enabled, `enemyGenerationStats` provides aggregated per-enemy-type statistics across all runs. Each unique enemy name that appeared gets an `EnemyGenerationRecord`:
1445
+
1446
+ | Field | Type | Description |
1447
+ |-------|------|-------------|
1448
+ | `name` | `string` | Enemy display name (e.g., "Goblin Scout") |
1449
+ | `count` | `number` | Number of runs this enemy appeared in |
1450
+ | `hpRange` | `{ min, max, avg }` | HP values observed across runs |
1451
+ | `acRange` | `{ min, max, avg }` | AC values observed across runs |
1452
+ | `crRange` | `{ min, max, avg }` | CR values observed across runs |
1453
+
1454
+ Results are sorted by `count` descending (most common enemies first).
1455
+
1456
+ #### SimulationSummary
1457
+
1458
+ Aggregate statistics across all runs — the core output for balance decisions:
1459
+
1460
+ | Field | Type | Description |
1461
+ |-------|------|-------------|
1462
+ | `totalRuns` | `number` | Completed simulation runs |
1463
+ | `playerWins` | `number` | Runs where all enemies were defeated |
1464
+ | `enemyWins` | `number` | Runs where all players were defeated |
1465
+ | `draws` | `number` | Runs ending in a draw (max turns, mutual kill) |
1466
+ | `playerWinRate` | `number` | Player win rate (0.0–1.0) |
1467
+ | `averageRounds` | `number` | Average rounds across all runs |
1468
+ | `medianRounds` | `number` | Median rounds across all runs |
1469
+ | `averageRoundsOnWin` | `number` | Average rounds in player-win runs |
1470
+ | `averageRoundsOnLoss` | `number` | Average rounds in player-loss runs |
1471
+ | `averagePlayerHPPercentRemaining` | `number` | Average remaining HP % for players in winning runs |
1472
+ | `totalPlayerDeaths` | `number` | Total player deaths across all runs |
1473
+ | `averageRoundsPerPlayerDeath` | `number` | Average round at which each player death occurred |
1474
+ | `totalEnemyDeaths` | `number` | Total enemy deaths across all runs |
1475
+ | `averageRoundsPerEnemyDeath` | `number` | Average round at which each enemy death occurred |
1476
+
1477
+ Invariant: `playerWins + enemyWins + draws = totalRuns`.
1478
+
1479
+ #### CombatantSimulationMetrics
1480
+
1481
+ Per-combatant aggregate stats across all simulation runs. Keyed by combatant ID in `perCombatantMetrics`:
1482
+
1483
+ | Field | Type | Description |
1484
+ |-------|------|-------------|
1485
+ | `combatantId` | `string` | Combatant ID (matches CombatEngine scheme) |
1486
+ | `name` | `string` | Display name |
1487
+ | `side` | `'player' \| 'enemy'` | Which side this combatant was on |
1488
+ | `averageDamagePerRound` | `number` | Average DPR across all runs |
1489
+ | `medianDamagePerRound` | `number` | Median DPR across all runs |
1490
+ | `averageTotalDamageDealt` | `number` | Average total damage dealt per run |
1491
+ | `averageTotalDamageTaken` | `number` | Average total damage taken per run |
1492
+ | `averageHealingDone` | `number` | Average healing per run |
1493
+ | `averageRoundsSurvived` | `number` | Average rounds survived per run |
1494
+ | `survivalRate` | `number` | Survival rate (0.0–1.0) |
1495
+ | `killRate` | `number` | Final blow rate (0.0–1.0) |
1496
+ | `criticalHitRate` | `number` | Crit rate across all attack actions (0.0–1.0) |
1497
+ | `averageHitRate` | `number` | Average hit rate across all runs (0.0–1.0) |
1498
+ | `averageHitsPerRun` | `number` | Average number of hits per run |
1499
+ | `averageMissesPerRun` | `number` | Average number of misses per run |
1500
+ | `averageSpellSlotsUsed` | `number` | Average spell slots consumed per run |
1501
+ | `mostUsedAction` | `string` | Most frequent action type (`attack`, `castSpell`, etc.) |
1502
+ | `damageDistribution` | `HistogramBucket[]` | DPR distribution for visualization |
1503
+ | `hpRemainingDistribution` | `HistogramBucket[]` | HP remaining distribution for visualization |
1504
+
1505
+ #### HistogramBucket
1506
+
1507
+ Distribution data for chart visualization. Used in `damageDistribution` and `hpRemainingDistribution`:
1508
+
1509
+ ```typescript
1510
+ interface HistogramBucket {
1511
+ rangeStart: number; // Start of range (inclusive)
1512
+ rangeEnd: number; // End of range (exclusive, except last bucket)
1513
+ count: number; // Data points in this bucket
1514
+ percent: number; // Percentage of total (0–100)
1515
+ }
1516
+ ```
1517
+
1518
+ Histograms are built with 20 buckets by default. Bucket percentages sum to 100%. When all values are identical, a single bucket is returned.
1519
+
1520
+ #### Detailed Run Logs
1521
+
1522
+ When `collectDetailedLogs: true`, each run produces a `SimulationRunDetail`:
1523
+
1524
+ ```typescript
1525
+ interface SimulationRunDetail {
1526
+ runIndex: number; // 0-based run index
1527
+ seed: string; // Seed used for this run
1528
+ result: CombatResult; // Final combat result
1529
+ metrics: Map<string, CombatantMetrics>; // Per-combatant metrics for this run
1530
+ }
1531
+ ```
1532
+
1533
+ Detailed logs are memory-intensive — a 1000-run simulation with 8 combatants stores 1000 full combat histories. Use for small-scale analysis or debugging, not for large sweeps.
1534
+
1535
+ #### Code Examples
1536
+
1537
+ **Basic simulation with progress:**
1538
+
1539
+ ```typescript
1540
+ import { CombatSimulator } from 'playlist-data-engine';
1541
+
1542
+ const simulator = new CombatSimulator();
1543
+
1544
+ const results = simulator.run(party, enemies, {
1545
+ runCount: 500,
1546
+ baseSeed: 'balance-test',
1547
+ aiConfig: {
1548
+ playerStyle: 'normal',
1549
+ enemyStyle: 'normal',
1550
+ },
1551
+ onProgress: (completed, total) => {
1552
+ console.log(`${completed}/${total} runs complete`);
1553
+ },
1554
+ });
1555
+
1556
+ console.log(`Win rate: ${(results.summary.playerWinRate * 100).toFixed(1)}%`);
1557
+ console.log(`Avg rounds: ${results.summary.averageRounds.toFixed(1)}`);
1558
+ console.log(`Player HP remaining: ${results.summary.averagePlayerHPPercentRemaining.toFixed(0)}%`);
1559
+ ```
1560
+
1561
+ **Cancellation with AbortController:**
1562
+
1563
+ ```typescript
1564
+ const controller = new AbortController();
1565
+
1566
+ // Cancel after 2 seconds
1567
+ setTimeout(() => controller.abort(), 2000);
1568
+
1569
+ const results = simulator.run(party, enemies, {
1570
+ runCount: 10000,
1571
+ baseSeed: 'long-sim',
1572
+ aiConfig: { playerStyle: 'aggressive', enemyStyle: 'aggressive' },
1573
+ abortSignal: controller.signal,
1574
+ });
1575
+
1576
+ console.log(`Completed ${results.summary.totalRuns} of 10000 runs`);
1577
+ console.log(`Was cancelled: ${results.wasCancelled}`);
1578
+ // Results are still valid — partial data is usable
1579
+ ```
1580
+
1581
+ **Collecting detailed logs for debugging:**
1582
+
1583
+ ```typescript
1584
+ const results = simulator.run(party, enemies, {
1585
+ runCount: 50,
1586
+ baseSeed: 'debug-run',
1587
+ aiConfig: { playerStyle: 'normal', enemyStyle: 'normal' },
1588
+ collectDetailedLogs: true,
1589
+ });
1590
+
1591
+ // Inspect a specific run
1592
+ const run3 = results.runDetails![3];
1593
+ console.log(`Run 3 seed: ${run3.seed}`);
1594
+ console.log(`Run 3 winner: ${run3.result.winnerSide}`);
1595
+ for (const [id, m] of run3.metrics) {
1596
+ console.log(` ${m.name}: ${m.totalDamageDealt} damage, survived=${m.survived}`);
1597
+ }
1598
+ ```
1599
+
1600
+ #### Determinism
1601
+
1602
+ The simulator guarantees reproducibility:
1603
+
1604
+ ```typescript
1605
+ // Same inputs → byte-identical results
1606
+ const a = simulator.run(party, enemies, { runCount: 100, baseSeed: 'test', aiConfig });
1607
+ const b = simulator.run(party, enemies, { runCount: 100, baseSeed: 'test', aiConfig });
1608
+ // a.summary === b.summary (all fields identical)
1609
+ // a.perCombatantMetrics === b.perCombatantMetrics (all fields identical)
1610
+
1611
+ // Different seeds → different results
1612
+ const c = simulator.run(party, enemies, { runCount: 100, baseSeed: 'other', aiConfig });
1613
+ // c.summary.playerWinRate !== a.summary.playerWinRate (almost certainly)
1614
+ ```
1615
+
1616
+ This extends to full combat history: the same party, enemies, seed, and AI config produce an identical combat history entry-by-entry within each run.
1617
+
1618
+ #### Recommended Run Counts
1619
+
1620
+ > For detailed guidance on choosing simulation run counts, see [Recommended Simulation Counts](ENEMY_GENERATION.md#recommended-simulation-counts) in the Enemy Generation guide.
1621
+
1622
+ ---
1623
+
1624
+ ## See Also
1625
+
1626
+ - [DATA_ENGINE_REFERENCE.md](../DATA_ENGINE_REFERENCE.md) - Complete API reference
1627
+ - [ROLLS_AND_SEEDS.md](ROLLS_AND_SEEDS.md) - Seeded dice rolling and RNG (canonical)
1628
+ - [ENEMY_GENERATION.md](ENEMY_GENERATION.md) - Balance validation, parameter sweeps, comparative analysis, difficulty calculator
1629
+ - [USAGE_IN_OTHER_PROJECTS.md](../USAGE_IN_OTHER_PROJECTS.md) - Usage examples
1630
+ - [EQUIPMENT_SYSTEM.md](EQUIPMENT_SYSTEM.md) - Weapons and armor in combat
1631
+ - [XP_AND_STATS.md](XP_AND_STATS.md) - Combat rewards and XP
1632
+ - [PREREQUISITES.md](PREREQUISITES.md) - Feature prerequisites for combat abilities