playlist-data-engine 1.7.3 → 1.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +14 -0
- package/bin/cli.cjs +85 -0
- package/dist/gateway-CDMPqFEH.js +1320 -0
- package/dist/gateway-DKa45Uz6.cjs +6 -0
- package/dist/gateway.d.ts +1 -0
- package/dist/gateway.d.ts.map +1 -1
- package/dist/gateway.js +1 -1
- package/dist/gateway.mjs +22 -19
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/playlist-data-engine.js +4 -4
- package/dist/playlist-data-engine.mjs +30 -27
- package/dist/utils/engineDocs.d.ts +33 -0
- package/dist/utils/engineDocs.d.ts.map +1 -0
- package/docs/DATA_ENGINE_REFERENCE.md +6660 -0
- package/docs/USAGE_IN_OTHER_PROJECTS.md +587 -0
- package/docs/features/AUDIO_ANALYSIS.md +610 -0
- package/docs/features/BEAT_DETECTION.md +5250 -0
- package/docs/features/COMBAT_SYSTEM.md +1632 -0
- package/docs/features/CONTENT_PACKS.md +464 -0
- package/docs/features/CUSTOM_CONTENT.md +603 -0
- package/docs/features/ENEMY_GENERATION.md +1711 -0
- package/docs/features/EQUIPMENT_SYSTEM.md +2279 -0
- package/docs/features/EXTENSIBILITY_GUIDE.md +1106 -0
- package/docs/features/GATEWAY_RESOLUTION.md +725 -0
- package/docs/features/IRL_SENSORS.md +360 -0
- package/docs/features/PLAYLIST_PARSING.md +446 -0
- package/docs/features/PREREQUISITES.md +571 -0
- package/docs/features/ROLLS_AND_SEEDS.md +687 -0
- package/docs/features/XP_AND_STATS.md +1221 -0
- package/llms.txt +33 -0
- package/package.json +9 -2
- package/skills/playlist-data-engine/SKILL.md +69 -0
- package/dist/gateway-C_p9Ku3O.js +0 -1211
- package/dist/gateway-Ceg-5xug.cjs +0 -1
|
@@ -0,0 +1,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
|