@@ -68,6 +68,10 @@ export interface CombatParticipant {
6868 deathSaveFailures ?: number ; // 0-3, 3 = dead
6969 isStabilized ?: boolean ; // Unconscious but won't die
7070 isDead ?: boolean ; // Permanently defeated
71+ // COMBAT STATS (Auto-resolution)
72+ ac ?: number ; // Armor Class
73+ attackDamage ?: string ; // Default attack damage (e.g., "1d6+2")
74+ attackBonus ?: number ; // Default attack bonus used if none provided
7175}
7276
7377/**
@@ -193,6 +197,9 @@ export class CombatEngine {
193197 const rolledInitiative = this . rng . d20 ( p . initiativeBonus ) ;
194198 return {
195199 ...p ,
200+ ac : p . ac ,
201+ attackDamage : p . attackDamage ,
202+ attackBonus : p . attackBonus ,
196203 initiative : rolledInitiative ,
197204 // Auto-detect isEnemy if not explicitly set
198205 isEnemy : p . isEnemy ?? this . detectIsEnemy ( p . id , p . name ) ,
@@ -258,30 +265,45 @@ export class CombatEngine {
258265
259266 /**
260267 * Auto-detect if a participant is an enemy based on ID/name patterns
268+ *
269+ * IMPORTANT: UUIDs (like "9e48fa16-0ee4-4b99-a1e0-a162528d1e24") are typically
270+ * player characters created via the UI. Pattern-based IDs (like "goblin-1",
271+ * "orc-archer-2") are typically spawned enemies. Default to false for UUIDs.
261272 */
262273 private detectIsEnemy ( id : string , name : string ) : boolean {
263274 const idLower = id . toLowerCase ( ) ;
264275 const nameLower = name . toLowerCase ( ) ;
265276
266- // Common enemy patterns
277+ // Common enemy patterns - check NAME first (most reliable for determination)
267278 const enemyPatterns = [
268279 'goblin' , 'orc' , 'wolf' , 'bandit' , 'skeleton' , 'zombie' ,
269280 'dragon' , 'troll' , 'ogre' , 'kobold' , 'gnoll' , 'demon' ,
270281 'devil' , 'undead' , 'enemy' , 'monster' , 'creature' , 'beast' ,
271282 'spider' , 'rat' , 'bat' , 'slime' , 'ghost' , 'wraith' ,
272- 'dracolich' , 'lich' , 'vampire' , 'golem' , 'elemental'
283+ 'dracolich' , 'lich' , 'vampire' , 'golem' , 'elemental' ,
284+ 'cultist' , 'thug' , 'assassin' , 'minion' , 'guard' , 'scout' ,
285+ 'warrior' , 'archer' , 'mage' , 'shaman' , 'warlord' , 'boss'
273286 ] ;
274287
275- // Check if ID or name contains enemy patterns
288+ // Check NAME for enemy patterns (more reliable since IDs can be UUIDs)
276289 for ( const pattern of enemyPatterns ) {
277- if ( idLower . includes ( pattern ) || nameLower . includes ( pattern ) ) {
290+ if ( nameLower . includes ( pattern ) ) {
291+ return true ;
292+ }
293+ }
294+
295+ // Check ID for enemy patterns (for pattern-based IDs like "goblin-1")
296+ for ( const pattern of enemyPatterns ) {
297+ if ( idLower . includes ( pattern ) ) {
278298 return true ;
279299 }
280300 }
281301
282302 // Common player/ally patterns (not enemies)
283303 const allyPatterns = [
284- 'hero' , 'player' , 'pc' , 'ally' , 'companion' , 'npc-friendly'
304+ 'hero' , 'player' , 'pc' , 'ally' , 'companion' , 'npc-friendly' ,
305+ 'party' , 'adventurer' , 'cleric' , 'paladin' , 'ranger' , 'rogue' ,
306+ 'wizard' , 'sorcerer' , 'warlock' , 'bard' , 'druid' , 'monk' , 'fighter'
285307 ] ;
286308
287309 for ( const pattern of allyPatterns ) {
@@ -290,8 +312,23 @@ export class CombatEngine {
290312 }
291313 }
292314
293- // Default: assume it's an enemy if not clearly a player
294- return ! idLower . startsWith ( 'player' ) && ! idLower . startsWith ( 'hero' ) ;
315+ // Check if ID looks like a UUID (player characters created via UI have UUIDs)
316+ const uuidPattern = / ^ [ 0 - 9 a - f ] { 8 } - [ 0 - 9 a - f ] { 4 } - [ 0 - 9 a - f ] { 4 } - [ 0 - 9 a - f ] { 4 } - [ 0 - 9 a - f ] { 12 } $ / i;
317+ if ( uuidPattern . test ( id ) ) {
318+ // UUIDs are typically player characters - default to NOT enemy
319+ return false ;
320+ }
321+
322+ // Default: for non-UUID IDs that don't match patterns, check if ID starts with enemy pattern
323+ // This catches pattern-based IDs like "enemy-1" or "mob-3"
324+ if ( idLower . startsWith ( 'enemy' ) || idLower . startsWith ( 'mob' ) || idLower . startsWith ( 'hostile' ) ) {
325+ return true ;
326+ }
327+
328+ // Fallback default: unknown entities default to NOT enemy
329+ // Reasoning: It's safer to have an enemy show as friendly (player corrects it)
330+ // than to have a player character show as enemy (breaks immersion)
331+ return false ;
295332 }
296333
297334 /**
@@ -511,7 +548,7 @@ export class CombatEngine {
511548 targetId : string ,
512549 attackBonus : number ,
513550 dc : number ,
514- damage : number ,
551+ damage : number | string ,
515552 damageType ?: string // HIGH-002: Optional damage type for resistance calculation
516553 ) : CombatActionResult {
517554 if ( ! this . state ) throw new Error ( 'No active combat' ) ;
@@ -530,10 +567,26 @@ export class CombatEngine {
530567 let damageDealt = 0 ;
531568 let damageModifier : 'immune' | 'resistant' | 'vulnerable' | 'normal' = 'normal' ;
532569
570+ // Calculate base damage from number or string
571+ let baseDamageVal = 0 ;
572+ let damageBreakdownStr = '' ;
573+
574+ if ( typeof damage === 'string' ) {
575+ const dmgResult = this . rng . rollDamageDetailed ( damage ) ;
576+ baseDamageVal = dmgResult . total ;
577+ damageBreakdownStr = ` (${ dmgResult . rolls . join ( '+' ) } ${ dmgResult . modifier >= 0 ? '+' + dmgResult . modifier : dmgResult . modifier } )` ;
578+ } else {
579+ baseDamageVal = damage ;
580+ }
581+
533582 if ( attackRoll . isHit ) {
534- const baseDamage = attackRoll . isCrit ? damage * 2 : damage ;
583+ // Critical Hit: Double the dice (approx. double the value for now if passing number)
584+ // If string was passed, we ideally double the DICE, but for now double the total is consistent with current impl.
585+ // TODO: Implement proper crit rules (double dice) later using rollDamageDetailed
586+ const finalBaseDamage = attackRoll . isCrit ? baseDamageVal * 2 : baseDamageVal ;
587+
535588 // HIGH-002: Apply resistance/vulnerability/immunity
536- const modResult = this . calculateDamageWithModifiers ( baseDamage , damageType , target ) ;
589+ const modResult = this . calculateDamageWithModifiers ( finalBaseDamage , damageType , target ) ;
537590 damageDealt = modResult . finalDamage ;
538591 damageModifier = modResult . modifier ;
539592 target . hp = Math . max ( 0 , target . hp - damageDealt ) ;
@@ -566,7 +619,7 @@ export class CombatEngine {
566619 modStr = ' [Vulnerable - Doubled!]' ;
567620 }
568621
569- breakdown += `\n\n💥 Damage: ${ damageDealt } ${ typeStr } ${ attackRoll . isCrit ? ' (crit)' : '' } ${ modStr } \n` ;
622+ breakdown += `\n\n💥 Damage: ${ damageDealt } ${ typeStr } ${ damageBreakdownStr } ${ attackRoll . isCrit ? ' (crit)' : '' } ${ modStr } \n` ;
570623 breakdown += ` ${ target . name } : ${ hpBefore } → ${ target . hp } /${ target . maxHp } HP` ;
571624 if ( defeated ) {
572625 breakdown += ` [DEFEATED]` ;
@@ -1033,27 +1086,43 @@ export class CombatEngine {
10331086
10341087 /**
10351088 * Enhanced nextTurn with condition processing and legendary action reset
1089+ * Now auto-skips dead participants (HP <= 0)
10361090 */
10371091 nextTurnWithConditions ( ) : CombatParticipant | null {
10381092 if ( ! this . state ) return null ;
10391093
10401094 // Process end-of-turn conditions for current participant (if not LAIR)
10411095 const currentParticipant = this . getCurrentParticipant ( ) ;
1042- if ( currentParticipant ) {
1096+ if ( currentParticipant && currentParticipant . hp > 0 ) {
10431097 this . processEndOfTurnConditions ( currentParticipant ) ;
10441098 }
10451099
1046- // Advance turn
1047- this . state . currentTurnIndex ++ ;
1100+ // Advance turn, automatically skipping dead participants
1101+ let iterations = 0 ;
1102+ const maxIterations = this . state . turnOrder . length + 1 ; // Safety limit
1103+ let newParticipant : CombatParticipant | null = null ;
10481104
1049- if ( this . state . currentTurnIndex >= this . state . turnOrder . length ) {
1050- this . state . currentTurnIndex = 0 ;
1051- this . state . round ++ ;
1052- }
1105+ do {
1106+ // Advance turn index
1107+ this . state . currentTurnIndex ++ ;
1108+
1109+ if ( this . state . currentTurnIndex >= this . state . turnOrder . length ) {
1110+ this . state . currentTurnIndex = 0 ;
1111+ this . state . round ++ ;
1112+ }
1113+
1114+ newParticipant = this . getCurrentParticipant ( ) ;
1115+ iterations ++ ;
1116+
1117+ // Exit if we found a living participant or exhausted all options
1118+ } while (
1119+ newParticipant &&
1120+ newParticipant . hp <= 0 &&
1121+ iterations < maxIterations
1122+ ) ;
10531123
1054- // Process start-of-turn conditions for new current participant (if not LAIR)
1055- const newParticipant = this . getCurrentParticipant ( ) ;
1056- if ( newParticipant ) {
1124+ // Process start-of-turn conditions for new current participant (if alive)
1125+ if ( newParticipant && newParticipant . hp > 0 ) {
10571126 this . processStartOfTurnConditions ( newParticipant ) ;
10581127 }
10591128
0 commit comments