Skip to main content
Glama
engine.tsβ€’50.1 kB
import { CombatRNG, CheckResult } from './rng.js'; import { Condition, ConditionType, DurationType, Ability, CONDITION_EFFECTS } from './conditions.js'; import { SizeCategory, GridBounds } from '../../schema/encounter.js'; /** * Character interface for combat participants * * D&D 5e legendary creature properties: * - legendaryActions: Total actions available (usually 3) * - legendaryActionsRemaining: Actions left this round (resets at start of their turn) * - legendaryResistances: Total resistances (usually 3/day) * - legendaryResistancesRemaining: Resistances left (does NOT reset between rounds) * - hasLairActions: Whether this creature can use lair actions on initiative 20 * * Spatial combat properties (Phase 1-4): * - position: Current grid position * - movementSpeed: Base speed in feet (default 30) * - movementRemaining: Remaining movement this turn * - size: Creature size category (affects footprint) */ export interface CombatParticipant { id: string; name: string; initiativeBonus: number; initiative?: number; // Rolled initiative value (set when encounter starts) isEnemy?: boolean; // Whether this is an enemy (for turn automation) hp: number; maxHp: number; conditions: Condition[]; position?: { x: number; y: number; z?: number }; // CRIT-003: Spatial position // Phase 4: Movement economy movementSpeed?: number; // Base speed in feet (default 30) movementRemaining?: number; // Remaining movement this turn (in feet) size?: SizeCategory; // Creature size for footprint calculation hasDashed?: boolean; // Whether dash action was used this turn // HIGH-002: Damage modifiers resistances?: string[]; // Damage types that deal half damage vulnerabilities?: string[]; // Damage types that deal double damage immunities?: string[]; // Damage types that deal no damage // HIGH-003: Opportunity attack tracking reactionUsed?: boolean; // Whether reaction has been used this round hasDisengaged?: boolean; // Whether creature took disengage action this turn // ACTION ECONOMY actionUsed?: boolean; // Has used main Action this turn bonusActionUsed?: boolean; // Has used Bonus Action this turn spellsCast?: { // Track spells cast this turn for Bonus Action Rule action?: number; // Level of spell cast as Action bonus?: number; // Level of spell cast as Bonus Action reaction?: number; // Level of spell cast as Reaction }; // LEGENDARY CREATURE SUPPORT legendaryActions?: number; // Total legendary actions per round (usually 3) legendaryActionsRemaining?: number; // Remaining legendary actions this round legendaryResistances?: number; // Total legendary resistances (usually 3/day) legendaryResistancesRemaining?: number; // Remaining legendary resistances hasLairActions?: boolean; // Can use lair actions on initiative 20 abilityScores?: { strength: number; dexterity: number; constitution: number; intelligence: number; wisdom: number; charisma: number; }; // MED-003: Death Saving Throw tracking deathSaveSuccesses?: number; // 0-3, 3 = stabilized deathSaveFailures?: number; // 0-3, 3 = dead isStabilized?: boolean; // Unconscious but won't die isDead?: boolean; // Permanently defeated } /** * Combat state tracking */ export interface CombatState { participants: CombatParticipant[]; turnOrder: string[]; // IDs in initiative order (may include 'LAIR' for lair actions) currentTurnIndex: number; round: number; terrain?: { // CRIT-003: Terrain configuration obstacles: string[]; // "x,y" format blocking tiles difficultTerrain?: string[]; water?: string[]; // Water terrain (streams, rivers) }; props?: Array<{ // Improvised props/objects (trees, ladders, buildings, etc.) id: string; position: string; // "x,y" format label: string; // Free-text label propType: 'structure' | 'cover' | 'climbable' | 'hazard' | 'interactive' | 'decoration'; heightFeet?: number; cover?: 'none' | 'half' | 'three_quarter' | 'full'; climbable?: boolean; climbDC?: number; breakable?: boolean; hp?: number; currentHp?: number; description?: string; }>; gridBounds?: GridBounds; // Phase 2: Spatial boundary validation (BUG-001 fix) hasLairActions?: boolean; // Whether any participant has lair actions lairOwnerId?: string; // ID of the creature that owns the lair } /** * Result of a combat action with full transparency */ export interface CombatActionResult { type: 'attack' | 'heal' | 'damage' | 'save'; actor: { id: string; name: string }; target: { id: string; name: string; hpBefore: number; hpAfter: number; maxHp: number }; // Attack specifics (if type === 'attack') attackRoll?: CheckResult; damage?: number; damageRolls?: number[]; // Individual damage dice // Heal specifics (if type === 'heal') healAmount?: number; // Status success: boolean; defeated: boolean; message: string; detailedBreakdown: string; } /** * Result of a legendary action use */ export interface LegendaryActionResult { success: boolean; remaining: number; error?: string; } /** * Result of a legendary resistance use */ export interface LegendaryResistanceResult { success: boolean; remaining: number; error?: string; } /** * MED-003: Result of a death saving throw */ export interface DeathSaveResult { roll: number; // d20 result isNat20: boolean; // Regain 1 HP isNat1: boolean; // Counts as 2 failures success: boolean; // 10+ = success successes: number; // Total successes (0-3) failures: number; // Total failures (0-3) isStabilized: boolean; // 3 successes isDead: boolean; // 3 failures regainedHp: boolean; // Nat 20 - character is conscious again } export interface EventEmitter { publish(topic: string, payload: any): void; } /** * Combat Engine for managing RPG combat encounters * Handles initiative, turn order, and combat flow * * Now supports D&D 5e legendary creatures: * - Legendary Actions (usable at end of other creatures' turns) * - Legendary Resistances (auto-succeed failed saves) * - Lair Actions (trigger on initiative count 20) */ export class CombatEngine { private rng: CombatRNG; private state: CombatState | null = null; private emitter?: EventEmitter; constructor(seed: string, emitter?: EventEmitter) { this.rng = new CombatRNG(seed); this.emitter = emitter; } /** * Start a new combat encounter * Rolls initiative for all participants and establishes turn order * * If any participant has hasLairActions=true, adds 'LAIR' to turn order at initiative 20 */ startEncounter(participants: CombatParticipant[]): CombatState { // Roll initiative for each participant and store the value const participantsWithInitiative = participants.map(p => { const rolledInitiative = this.rng.d20(p.initiativeBonus); return { ...p, initiative: rolledInitiative, // Auto-detect isEnemy if not explicitly set isEnemy: p.isEnemy ?? this.detectIsEnemy(p.id, p.name), // Initialize legendary actions remaining to max if applicable legendaryActionsRemaining: p.legendaryActions ?? p.legendaryActionsRemaining, legendaryResistancesRemaining: p.legendaryResistances ?? p.legendaryResistancesRemaining, // Initialize resources movementRemaining: p.movementSpeed ?? 30, actionUsed: false, bonusActionUsed: false, spellsCast: {}, reactionUsed: false, hasDashed: false, hasDisengaged: false }; }); // Check if any participant has lair actions const lairOwner = participantsWithInitiative.find(p => p.hasLairActions); const hasLairActions = !!lairOwner; // Sort by initiative (highest first), use ID as tiebreaker for determinism participantsWithInitiative.sort((a, b) => { if (b.initiative !== a.initiative) { return b.initiative - a.initiative; } return a.id.localeCompare(b.id); }); // Build turn order let turnOrder = participantsWithInitiative.map(r => r.id); // If there's a lair owner, insert 'LAIR' at initiative 20 if (hasLairActions) { // Find the right position for initiative 20 // LAIR goes after all creatures with initiative > 20, before those with initiative <= 20 const lairIndex = participantsWithInitiative.findIndex(p => (p.initiative ?? 0) <= 20); if (lairIndex === -1) { // All initiatives are above 20, add at end turnOrder.push('LAIR'); } else { // Insert LAIR at the correct position turnOrder.splice(lairIndex, 0, 'LAIR'); } } this.state = { participants: participantsWithInitiative, turnOrder, currentTurnIndex: 0, round: 1, hasLairActions, lairOwnerId: lairOwner?.id }; this.emitter?.publish('combat', { type: 'encounter_started', state: this.state }); return this.state; } /** * Auto-detect if a participant is an enemy based on ID/name patterns */ private detectIsEnemy(id: string, name: string): boolean { const idLower = id.toLowerCase(); const nameLower = name.toLowerCase(); // Common enemy patterns const enemyPatterns = [ 'goblin', 'orc', 'wolf', 'bandit', 'skeleton', 'zombie', 'dragon', 'troll', 'ogre', 'kobold', 'gnoll', 'demon', 'devil', 'undead', 'enemy', 'monster', 'creature', 'beast', 'spider', 'rat', 'bat', 'slime', 'ghost', 'wraith', 'dracolich', 'lich', 'vampire', 'golem', 'elemental' ]; // Check if ID or name contains enemy patterns for (const pattern of enemyPatterns) { if (idLower.includes(pattern) || nameLower.includes(pattern)) { return true; } } // Common player/ally patterns (not enemies) const allyPatterns = [ 'hero', 'player', 'pc', 'ally', 'companion', 'npc-friendly' ]; for (const pattern of allyPatterns) { if (idLower.includes(pattern) || nameLower.includes(pattern)) { return false; } } // Default: assume it's an enemy if not clearly a player return !idLower.startsWith('player') && !idLower.startsWith('hero'); } /** * Get the current state */ getState(): CombatState | null { return this.state; } /** * Load an existing combat state */ loadState(state: CombatState): void { this.state = state; } /** * Get the participant whose turn it currently is * Returns null if it's LAIR's turn */ getCurrentParticipant(): CombatParticipant | null { if (!this.state) return null; const currentId = this.state.turnOrder[this.state.currentTurnIndex]; // LAIR is a special entry, not a participant if (currentId === 'LAIR') return null; return this.state.participants.find(p => p.id === currentId) || null; } /** * Check if it's currently the LAIR's turn (initiative 20) */ isLairActionPending(): boolean { if (!this.state) return false; return this.state.turnOrder[this.state.currentTurnIndex] === 'LAIR'; } /** * Check if a legendary creature can use a legendary action * Rules: Can only use at the end of another creature's turn, not their own */ canUseLegendaryAction(participantId: string): boolean { if (!this.state) return false; const participant = this.state.participants.find(p => p.id === participantId); if (!participant) return false; // Must have legendary actions if (!participant.legendaryActions || participant.legendaryActions <= 0) return false; // Must have remaining uses if (!participant.legendaryActionsRemaining || participant.legendaryActionsRemaining <= 0) return false; // Cannot use on their own turn const currentId = this.state.turnOrder[this.state.currentTurnIndex]; if (currentId === participantId) return false; // Cannot use if it's the LAIR's turn (no creature to follow) if (currentId === 'LAIR') return false; return true; } /** * Use a legendary action * @param participantId - ID of the legendary creature * @param cost - How many legendary actions this use costs (default 1) * @returns Result with success status and remaining actions */ useLegendaryAction(participantId: string, cost: number = 1): LegendaryActionResult { if (!this.state) { return { success: false, remaining: 0, error: 'No active combat' }; } const participant = this.state.participants.find(p => p.id === participantId); if (!participant) { return { success: false, remaining: 0, error: 'Participant not found' }; } if (!this.canUseLegendaryAction(participantId)) { return { success: false, remaining: participant.legendaryActionsRemaining ?? 0, error: 'Cannot use legendary action (own turn, no actions, or none remaining)' }; } const remaining = participant.legendaryActionsRemaining ?? 0; if (remaining < cost) { return { success: false, remaining, error: `Not enough legendary actions (need ${cost}, have ${remaining})` }; } participant.legendaryActionsRemaining = remaining - cost; this.emitter?.publish('combat', { type: 'legendary_action_used', participantId, cost, remaining: participant.legendaryActionsRemaining }); return { success: true, remaining: participant.legendaryActionsRemaining }; } /** * Use a legendary resistance to automatically succeed on a failed save * Unlike legendary actions, these do NOT reset each round */ useLegendaryResistance(participantId: string): LegendaryResistanceResult { if (!this.state) { return { success: false, remaining: 0, error: 'No active combat' }; } const participant = this.state.participants.find(p => p.id === participantId); if (!participant) { return { success: false, remaining: 0, error: 'Participant not found' }; } // Must have legendary resistances if (!participant.legendaryResistances || participant.legendaryResistances <= 0) { return { success: false, remaining: 0, error: 'No legendary resistances' }; } const remaining = participant.legendaryResistancesRemaining ?? 0; if (remaining <= 0) { return { success: false, remaining: 0, error: 'No legendary resistances remaining' }; } participant.legendaryResistancesRemaining = remaining - 1; this.emitter?.publish('combat', { type: 'legendary_resistance_used', participantId, remaining: participant.legendaryResistancesRemaining }); return { success: true, remaining: participant.legendaryResistancesRemaining }; } /** * Reset legendary actions for a participant (called at start of their turn) */ private resetLegendaryActions(participant: CombatParticipant): void { if (participant.legendaryActions && participant.legendaryActions > 0) { participant.legendaryActionsRemaining = participant.legendaryActions; } } /** * Advance to the next turn * Returns the participant whose turn it now is */ nextTurn(): CombatParticipant | null { if (!this.state) return null; this.state.currentTurnIndex++; // If we've gone through everyone, start a new round if (this.state.currentTurnIndex >= this.state.turnOrder.length) { this.state.currentTurnIndex = 0; this.state.round++; } return this.getCurrentParticipant(); } /** * HIGH-002: Calculate damage after applying resistance/vulnerability/immunity */ private calculateDamageWithModifiers( baseDamage: number, damageType: string | undefined, target: CombatParticipant ): { finalDamage: number; modifier: 'immune' | 'resistant' | 'vulnerable' | 'normal' } { if (!damageType) { return { finalDamage: baseDamage, modifier: 'normal' }; } const typeLC = damageType.toLowerCase(); // Check immunity first (takes precedence) if (target.immunities?.some(i => i.toLowerCase() === typeLC)) { return { finalDamage: 0, modifier: 'immune' }; } // Check resistance if (target.resistances?.some(r => r.toLowerCase() === typeLC)) { return { finalDamage: Math.floor(baseDamage / 2), modifier: 'resistant' }; } // Check vulnerability if (target.vulnerabilities?.some(v => v.toLowerCase() === typeLC)) { return { finalDamage: baseDamage * 2, modifier: 'vulnerable' }; } return { finalDamage: baseDamage, modifier: 'normal' }; } /** * Execute an attack with full transparency * Returns detailed breakdown of what happened */ executeAttack( actorId: string, targetId: string, attackBonus: number, dc: number, damage: number, damageType?: string // HIGH-002: Optional damage type for resistance calculation ): CombatActionResult { if (!this.state) throw new Error('No active combat'); const actor = this.state.participants.find(p => p.id === actorId); const target = this.state.participants.find(p => p.id === targetId); if (!actor) throw new Error(`Actor ${actorId} not found`); if (!target) throw new Error(`Target ${targetId} not found`); const hpBefore = target.hp; // Roll with full transparency const attackRoll = this.rng.checkDegreeDetailed(attackBonus, dc); let damageDealt = 0; let damageModifier: 'immune' | 'resistant' | 'vulnerable' | 'normal' = 'normal'; if (attackRoll.isHit) { const baseDamage = attackRoll.isCrit ? damage * 2 : damage; // HIGH-002: Apply resistance/vulnerability/immunity const modResult = this.calculateDamageWithModifiers(baseDamage, damageType, target); damageDealt = modResult.finalDamage; damageModifier = modResult.modifier; target.hp = Math.max(0, target.hp - damageDealt); } const defeated = target.hp <= 0; // Build detailed breakdown let breakdown = `🎲 Attack Roll: d20(${attackRoll.roll}) + ${attackBonus} = ${attackRoll.total} vs AC ${dc}\n`; if (attackRoll.isNat20) { breakdown += ` ⭐ NATURAL 20!\n`; } else if (attackRoll.isNat1) { breakdown += ` πŸ’€ NATURAL 1!\n`; } breakdown += ` ${attackRoll.isHit ? 'βœ… HIT' : '❌ MISS'}`; if (attackRoll.isHit) { breakdown += attackRoll.isCrit ? ' (CRITICAL!)' : ''; // HIGH-002: Show damage type and modifier const typeStr = damageType ? ` ${damageType}` : ''; let modStr = ''; if (damageModifier === 'immune') { modStr = ' [IMMUNE - No damage!]'; } else if (damageModifier === 'resistant') { modStr = ' [Resistant - Halved!]'; } else if (damageModifier === 'vulnerable') { modStr = ' [Vulnerable - Doubled!]'; } breakdown += `\n\nπŸ’₯ Damage: ${damageDealt}${typeStr}${attackRoll.isCrit ? ' (crit)' : ''}${modStr}\n`; breakdown += ` ${target.name}: ${hpBefore} β†’ ${target.hp}/${target.maxHp} HP`; if (defeated) { breakdown += ` [DEFEATED]`; } } // Build simple message let message = ''; if (attackRoll.isHit) { message = `${attackRoll.isCrit ? 'CRITICAL ' : ''}HIT! ${actor.name} deals ${damageDealt} damage to ${target.name}`; if (defeated) message += ' [DEFEATED]'; } else { message = `MISS! ${actor.name}'s attack misses ${target.name}`; } this.emitter?.publish('combat', { type: 'attack_executed', result: { actor: actor.name, target: target.name, roll: attackRoll.roll, total: attackRoll.total, dc, hit: attackRoll.isHit, crit: attackRoll.isCrit, damage: damageDealt, targetHp: target.hp } }); return { type: 'attack', actor: { id: actor.id, name: actor.name }, target: { id: target.id, name: target.name, hpBefore, hpAfter: target.hp, maxHp: target.maxHp }, attackRoll, damage: damageDealt, success: attackRoll.isHit, defeated, message, detailedBreakdown: breakdown }; } /** * Execute a heal action */ executeHeal(actorId: string, targetId: string, amount: number): CombatActionResult { if (!this.state) throw new Error('No active combat'); const actor = this.state.participants.find(p => p.id === actorId); const target = this.state.participants.find(p => p.id === targetId); if (!actor) throw new Error(`Actor ${actorId} not found`); if (!target) throw new Error(`Target ${targetId} not found`); const hpBefore = target.hp; const actualHeal = Math.min(amount, target.maxHp - target.hp); target.hp = Math.min(target.maxHp, target.hp + amount); const breakdown = `πŸ’š Heal: ${amount} HP\n` + ` ${target.name}: ${hpBefore} β†’ ${target.hp}/${target.maxHp} HP\n` + (actualHeal < amount ? ` (${amount - actualHeal} HP wasted - at max)` : ''); const message = `${actor.name} heals ${target.name} for ${actualHeal} HP`; this.emitter?.publish('combat', { type: 'heal_executed', result: { actor: actor.name, target: target.name, amount: actualHeal, targetHp: target.hp } }); return { type: 'heal', actor: { id: actor.id, name: actor.name }, target: { id: target.id, name: target.name, hpBefore, hpAfter: target.hp, maxHp: target.maxHp }, healAmount: actualHeal, success: true, defeated: false, message, detailedBreakdown: breakdown }; } /** * Pathfinder 2e: Make a check and return degree of success */ makeCheck( modifier: number, dc: number ): 'critical-failure' | 'failure' | 'success' | 'critical-success' { return this.rng.checkDegree(modifier, dc); } /** * Make a detailed check exposing all dice mechanics */ makeCheckDetailed(modifier: number, dc: number): CheckResult { return this.rng.checkDegreeDetailed(modifier, dc); } /** * Apply damage to a participant */ applyDamage(participantId: string, damage: number): void { if (!this.state) return; const participant = this.state.participants.find(p => p.id === participantId); if (participant) { participant.hp = Math.max(0, participant.hp - damage); this.emitter?.publish('combat', { type: 'damage_applied', participantId, amount: damage, newHp: participant.hp }); } } /** * Heal a participant * MED-003: Also resets death saves if healing from 0 HP */ heal(participantId: string, amount: number): void { if (!this.state) return; const participant = this.state.participants.find(p => p.id === participantId); if (participant) { const wasAtZero = participant.hp === 0; participant.hp = Math.min(participant.maxHp, participant.hp + amount); // MED-003: Reset death saves when healed from 0 HP if (wasAtZero && participant.hp > 0) { participant.deathSaveSuccesses = 0; participant.deathSaveFailures = 0; participant.isStabilized = false; } this.emitter?.publish('combat', { type: 'healed', participantId, amount, newHp: participant.hp }); } } /** * MED-003: Roll a death saving throw for a participant at 0 HP * D&D 5e Rules: * - Roll d20 * - 10+ = success * - 9 or less = failure * - Natural 20 = regain 1 HP (conscious again) * - Natural 1 = counts as 2 failures * - 3 successes = stabilized (unconscious but won't die) * - 3 failures = dead */ rollDeathSave(participantId: string): DeathSaveResult | null { if (!this.state) return null; const participant = this.state.participants.find(p => p.id === participantId); if (!participant) return null; // Can only roll death saves at 0 HP if (participant.hp > 0) { return null; } // Already dead - can't roll if (participant.isDead) { return null; } // Already stabilized - no need to roll if (participant.isStabilized) { return null; } // Initialize death save counters if needed if (participant.deathSaveSuccesses === undefined) { participant.deathSaveSuccesses = 0; } if (participant.deathSaveFailures === undefined) { participant.deathSaveFailures = 0; } // Roll the d20 const roll = Math.floor(Math.random() * 20) + 1; const isNat20 = roll === 20; const isNat1 = roll === 1; const success = roll >= 10; // Apply results if (isNat20) { // Natural 20: regain 1 HP, reset death saves participant.hp = 1; participant.deathSaveSuccesses = 0; participant.deathSaveFailures = 0; participant.isStabilized = false; } else if (isNat1) { // Natural 1: counts as 2 failures participant.deathSaveFailures = Math.min(3, participant.deathSaveFailures + 2); } else if (success) { participant.deathSaveSuccesses = Math.min(3, participant.deathSaveSuccesses + 1); } else { participant.deathSaveFailures = Math.min(3, participant.deathSaveFailures + 1); } // Check for stabilization or death if (participant.deathSaveSuccesses >= 3) { participant.isStabilized = true; } if (participant.deathSaveFailures >= 3) { participant.isDead = true; } const result: DeathSaveResult = { roll, isNat20, isNat1, success, successes: participant.deathSaveSuccesses, failures: participant.deathSaveFailures, isStabilized: participant.isStabilized ?? false, isDead: participant.isDead ?? false, regainedHp: isNat20 }; this.emitter?.publish('combat', { type: 'death_save', participantId, result }); return result; } /** * MED-003: Apply damage at 0 HP (causes automatic death save failures) * D&D 5e Rules: Taking damage at 0 HP = 1 failure (crit = 2 failures) */ applyDamageAtZeroHp(participantId: string, isCritical: boolean = false): void { if (!this.state) return; const participant = this.state.participants.find(p => p.id === participantId); if (!participant || participant.hp > 0 || participant.isDead) return; // Initialize if needed if (participant.deathSaveFailures === undefined) { participant.deathSaveFailures = 0; } // Critical hits cause 2 failures, normal hits cause 1 const failures = isCritical ? 2 : 1; participant.deathSaveFailures = Math.min(3, participant.deathSaveFailures + failures); if (participant.deathSaveFailures >= 3) { participant.isDead = true; } // No longer stabilized if taking damage participant.isStabilized = false; this.emitter?.publish('combat', { type: 'death_save_failure', participantId, failures, total: participant.deathSaveFailures, isDead: participant.isDead }); } /** * Check if a participant is still conscious (hp > 0) */ isConscious(participantId: string): boolean { if (!this.state) return false; const participant = this.state.participants.find(p => p.id === participantId); return participant ? participant.hp > 0 : false; } /** * Get count of conscious participants */ getConsciousCount(): number { if (!this.state) return 0; return this.state.participants.filter(p => p.hp > 0).length; } /** * Apply a condition to a participant */ applyCondition(participantId: string, condition: Omit<Condition, 'id'>): Condition { if (!this.state) throw new Error('No active combat'); const participant = this.state.participants.find(p => p.id === participantId); if (!participant) throw new Error(`Participant ${participantId} not found`); // Generate unique ID for condition instance const fullCondition: Condition = { ...condition, id: `${participantId}-${condition.type}-${Date.now()}-${Math.random()}` }; participant.conditions.push(fullCondition); return fullCondition; } /** * Remove a specific condition instance by ID */ removeCondition(participantId: string, conditionId: string): boolean { if (!this.state) return false; const participant = this.state.participants.find(p => p.id === participantId); if (!participant) return false; const initialLength = participant.conditions.length; participant.conditions = participant.conditions.filter(c => c.id !== conditionId); return participant.conditions.length < initialLength; } /** * Remove all conditions of a specific type from a participant */ removeConditionsByType(participantId: string, type: ConditionType): number { if (!this.state) return 0; const participant = this.state.participants.find(p => p.id === participantId); if (!participant) return 0; const initialLength = participant.conditions.length; participant.conditions = participant.conditions.filter(c => c.type !== type); return initialLength - participant.conditions.length; } /** * Check if a participant has a specific condition type */ hasCondition(participantId: string, type: ConditionType): boolean { if (!this.state) return false; const participant = this.state.participants.find(p => p.id === participantId); return participant ? participant.conditions.some(c => c.type === type) : false; } /** * Get all conditions on a participant */ getConditions(participantId: string): Condition[] { if (!this.state) return []; const participant = this.state.participants.find(p => p.id === participantId); return participant ? [...participant.conditions] : []; } /** * HIGH-003: Reset reaction and disengage status at start of turn */ private resetTurnResources(participant: CombatParticipant): void { participant.reactionUsed = false; participant.hasDisengaged = false; participant.hasDashed = false; participant.actionUsed = false; participant.bonusActionUsed = false; participant.spellsCast = {}; participant.movementRemaining = participant.movementSpeed ?? 30; } /** * Process start-of-turn condition effects */ private processStartOfTurnConditions(participant: CombatParticipant): void { // HIGH-003: Reset reaction at start of turn this.resetTurnResources(participant); // LEGENDARY: Reset legendary actions at start of legendary creature's turn this.resetLegendaryActions(participant); for (const condition of [...participant.conditions]) { // Process ongoing effects if (condition.ongoingEffects) { for (const effect of condition.ongoingEffects) { if (effect.trigger === 'start_of_turn') { if (effect.type === 'damage' && effect.amount) { this.applyDamage(participant.id, effect.amount); } else if (effect.type === 'healing' && effect.amount) { this.heal(participant.id, effect.amount); } else if (effect.type === 'damage' && effect.dice) { const damage = this.rng.roll(effect.dice); this.applyDamage(participant.id, damage); } } } } // Handle duration for START_OF_TURN conditions if (condition.durationType === DurationType.START_OF_TURN) { this.removeCondition(participant.id, condition.id); } else if (condition.durationType === DurationType.ROUNDS && condition.duration !== undefined) { // Decrement round-based durations at start of turn condition.duration--; if (condition.duration <= 0) { this.removeCondition(participant.id, condition.id); } } } } /** * Process end-of-turn condition effects */ private processEndOfTurnConditions(participant: CombatParticipant): void { for (const condition of [...participant.conditions]) { // Process ongoing effects if (condition.ongoingEffects) { for (const effect of condition.ongoingEffects) { if (effect.trigger === 'end_of_turn') { if (effect.type === 'damage' && effect.amount) { this.applyDamage(participant.id, effect.amount); } else if (effect.type === 'healing' && effect.amount) { this.heal(participant.id, effect.amount); } else if (effect.type === 'damage' && effect.dice) { const damage = this.rng.roll(effect.dice); this.applyDamage(participant.id, damage); } } } } // Handle duration for END_OF_TURN conditions if (condition.durationType === DurationType.END_OF_TURN) { this.removeCondition(participant.id, condition.id); } // Handle save-ends conditions if (condition.durationType === DurationType.SAVE_ENDS && condition.saveDC && condition.saveAbility) { const saveBonus = this.getSaveBonus(participant, condition.saveAbility); const degree = this.rng.checkDegree(saveBonus, condition.saveDC); if (degree === 'success' || degree === 'critical-success') { this.removeCondition(participant.id, condition.id); } } } } /** * Get saving throw bonus for a participant */ private getSaveBonus(participant: CombatParticipant, ability: Ability): number { if (!participant.abilityScores) return 0; const score = participant.abilityScores[ability]; // D&D 5e modifier calculation: (score - 10) / 2 return Math.floor((score - 10) / 2); } /** * Enhanced nextTurn with condition processing and legendary action reset */ nextTurnWithConditions(): CombatParticipant | null { if (!this.state) return null; // Process end-of-turn conditions for current participant (if not LAIR) const currentParticipant = this.getCurrentParticipant(); if (currentParticipant) { this.processEndOfTurnConditions(currentParticipant); } // Advance turn this.state.currentTurnIndex++; if (this.state.currentTurnIndex >= this.state.turnOrder.length) { this.state.currentTurnIndex = 0; this.state.round++; } // Process start-of-turn conditions for new current participant (if not LAIR) const newParticipant = this.getCurrentParticipant(); if (newParticipant) { this.processStartOfTurnConditions(newParticipant); } this.emitter?.publish('combat', { type: 'turn_changed', round: this.state.round, activeParticipantId: newParticipant?.id, isLairAction: this.isLairActionPending() }); return newParticipant; } /** * Check if a participant can take actions (not incapacitated) */ canTakeActions(participantId: string): boolean { if (!this.state) return false; const participant = this.state.participants.find(p => p.id === participantId); if (!participant || participant.hp <= 0) return false; // Check for incapacitating conditions return !participant.conditions.some(c => { const effects = CONDITION_EFFECTS[c.type]; return effects.canTakeActions === false; }); } /** * Check if a participant can take reactions */ canTakeReactions(participantId: string): boolean { if (!this.state) return false; const participant = this.state.participants.find(p => p.id === participantId); if (!participant || participant.hp <= 0) return false; return !participant.conditions.some(c => { const effects = CONDITION_EFFECTS[c.type]; return effects.canTakeReactions === false; }); } /** * Validate Action Economy rules * Handles: Action/Bonus Action availability and "Bonus Action Spell" rule */ validateActionEconomy( participantId: string, actionType: 'action' | 'bonus' | 'reaction', spellLevel?: number ): { valid: boolean; error?: string } { if (!this.state) return { valid: false, error: 'No active combat' }; const participant = this.state.participants.find(p => p.id === participantId); if (!participant) return { valid: false, error: 'Participant not found' }; // 1. Check strict incapacitation if (!this.canTakeActions(participantId)) { return { valid: false, error: 'Participant is incapacitated' }; } // 2. Check Action availability if (actionType === 'action') { if (participant.actionUsed) { return { valid: false, error: 'Action already used this turn' }; } // Bonus Action Spell Rule: If bonus spell cast, Action can only be Cantrip (level 0) if (spellLevel !== undefined && spellLevel > 0) { if (participant.spellsCast?.bonus !== undefined) { return { valid: false, error: 'Cannot cast leveled spell as Action after casting Bonus Action spell (only Cantrips allowed)' }; } } } else if (actionType === 'bonus') { if (participant.bonusActionUsed) { return { valid: false, error: 'Bonus Action already used this turn' }; } // Bonus Action Spell Rule: If casting spell as BA, no leveled Action spell allowed if (spellLevel !== undefined) { // If we already cast a leveled action spell, we cannot cast a BA spell if (participant.spellsCast?.action !== undefined && participant.spellsCast.action > 0) { return { valid: false, error: 'Cannot cast Bonus Action spell if leveled spell was cast as Action' }; } } } else if (actionType === 'reaction') { if (!this.canTakeReactions(participantId)) { return { valid: false, error: 'Cannot take reactions (incapacitated or condition)' }; } if (participant.reactionUsed) { return { valid: false, error: 'Reaction already used this round' }; } } return { valid: true }; } /** * Commit an action to the economy tracking */ commitAction( participantId: string, actionType: 'action' | 'bonus' | 'reaction', spellLevel?: number ): void { if (!this.state) return; const participant = this.state.participants.find(p => p.id === participantId); if (!participant) return; if (!participant.spellsCast) participant.spellsCast = {}; if (actionType === 'action') { participant.actionUsed = true; if (spellLevel !== undefined) participant.spellsCast.action = spellLevel; } else if (actionType === 'bonus') { participant.bonusActionUsed = true; if (spellLevel !== undefined) participant.spellsCast.bonus = spellLevel; } else if (actionType === 'reaction') { participant.reactionUsed = true; if (spellLevel !== undefined) participant.spellsCast.reaction = spellLevel; } } /** * HIGH-003: Check if two positions are adjacent (within 1 tile - 8-directional) */ isAdjacent(pos1: { x: number; y: number }, pos2: { x: number; y: number }): boolean { const dx = Math.abs(pos1.x - pos2.x); const dy = Math.abs(pos1.y - pos2.y); return dx <= 1 && dy <= 1 && !(dx === 0 && dy === 0); } /** * HIGH-003: Get adjacent enemies that could make opportunity attacks * @param moverId - The creature that is moving * @param fromPos - Starting position * @param toPos - Target position * @returns Array of participants who can make opportunity attacks */ getOpportunityAttackers( moverId: string, fromPos: { x: number; y: number }, toPos: { x: number; y: number } ): CombatParticipant[] { if (!this.state) return []; const mover = this.state.participants.find(p => p.id === moverId); if (!mover) return []; // If mover has disengaged, no opportunity attacks are provoked if (mover.hasDisengaged) return []; const attackers: CombatParticipant[] = []; for (const p of this.state.participants) { // Skip self if (p.id === moverId) continue; // Skip defeated participants if (p.hp <= 0) continue; // Skip same faction (allies don't attack each other) if (p.isEnemy === mover.isEnemy) continue; // Skip if reaction already used if (p.reactionUsed) continue; // Skip if no position if (!p.position) continue; // Check if creature was adjacent to mover at start and is no longer adjacent at end const wasAdjacent = this.isAdjacent(fromPos, p.position); const stillAdjacent = this.isAdjacent(toPos, p.position); // Opportunity attack triggers when leaving threatened square (was adjacent, now not) if (wasAdjacent && !stillAdjacent) { attackers.push(p); } } return attackers; } /** * HIGH-003: Execute an opportunity attack * Uses simplified attack: d20 + attacker's initiative bonus vs target's initiative + 10 * Damage is fixed at 1d6 + 2 for simplicity */ executeOpportunityAttack( attackerId: string, targetId: string ): CombatActionResult { if (!this.state) throw new Error('No active combat'); const attacker = this.state.participants.find(p => p.id === attackerId); const target = this.state.participants.find(p => p.id === targetId); if (!attacker) throw new Error(`Attacker ${attackerId} not found`); if (!target) throw new Error(`Target ${targetId} not found`); // Mark reaction as used attacker.reactionUsed = true; // Simple attack calculation: use initiative bonus as attack modifier // AC approximation: 10 + initiative bonus (simple heuristic) const attackBonus = attacker.initiativeBonus + 2; // Add a small bonus const targetAC = 10 + (target.initiativeBonus > 0 ? Math.floor(target.initiativeBonus / 2) : 0); // Fixed damage for opportunity attacks: 1d6 + 2 const baseDamage = this.rng.roll('1d6') + 2; const hpBefore = target.hp; const attackRoll = this.rng.checkDegreeDetailed(attackBonus, targetAC); let damageDealt = 0; if (attackRoll.isHit) { damageDealt = attackRoll.isCrit ? baseDamage * 2 : baseDamage; target.hp = Math.max(0, target.hp - damageDealt); } const defeated = target.hp <= 0; // Build detailed breakdown let breakdown = `⚑ OPPORTUNITY ATTACK by ${attacker.name}!\n`; breakdown += `🎲 Attack Roll: d20(${attackRoll.roll}) + ${attackBonus} = ${attackRoll.total} vs AC ${targetAC}\n`; if (attackRoll.isNat20) { breakdown += ` ⭐ NATURAL 20!\n`; } else if (attackRoll.isNat1) { breakdown += ` πŸ’€ NATURAL 1!\n`; } breakdown += ` ${attackRoll.isHit ? 'βœ… HIT' : '❌ MISS'}`; if (attackRoll.isHit) { breakdown += attackRoll.isCrit ? ' (CRITICAL!)' : ''; breakdown += `\n\nπŸ’₯ Damage: ${damageDealt}${attackRoll.isCrit ? ' (crit)' : ''}\n`; breakdown += ` ${target.name}: ${hpBefore} β†’ ${target.hp}/${target.maxHp} HP`; if (defeated) { breakdown += ` [DEFEATED]`; } } const message = attackRoll.isHit ? `OPPORTUNITY ATTACK HIT! ${attacker.name} strikes ${target.name} for ${damageDealt} damage` : `OPPORTUNITY ATTACK MISS! ${attacker.name}'s attack misses ${target.name}`; this.emitter?.publish('combat', { type: 'opportunity_attack', result: { attacker: attacker.name, target: target.name, roll: attackRoll.roll, total: attackRoll.total, ac: targetAC, hit: attackRoll.isHit, crit: attackRoll.isCrit, damage: damageDealt, targetHp: target.hp } }); return { type: 'attack', actor: { id: attacker.id, name: attacker.name }, target: { id: target.id, name: target.name, hpBefore, hpAfter: target.hp, maxHp: target.maxHp }, attackRoll, damage: damageDealt, success: attackRoll.isHit, defeated, message, detailedBreakdown: breakdown }; } /** * HIGH-003: Mark a participant as having taken the disengage action */ disengage(participantId: string): void { if (!this.state) return; const participant = this.state.participants.find(p => p.id === participantId); if (participant) { participant.hasDisengaged = true; } } /** * Check if attacks against a participant have advantage */ attacksAgainstHaveAdvantage(participantId: string): boolean { if (!this.state) return false; const participant = this.state.participants.find(p => p.id === participantId); if (!participant) return false; return participant.conditions.some(c => { const effects = CONDITION_EFFECTS[c.type]; return effects.attacksAgainstAdvantage === true; }); } /** * Check if a participant's attacks have disadvantage */ attacksHaveDisadvantage(participantId: string): boolean { if (!this.state) return false; const participant = this.state.participants.find(p => p.id === participantId); if (!participant) return false; return participant.conditions.some(c => { const effects = CONDITION_EFFECTS[c.type]; return effects.attackDisadvantage === true; }); } }

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/Mnehmos/rpg-mcp'

If you have feedback or need assistance with the MCP directory API, please join our Discord server