dnd-rag-g / tests /test_combat_simulation.py
alexchilton's picture
feat: Add epic D&D combat simulator
d5a131e
"""
Combat Simulation Test
Simulates a full D&D combat encounter between:
- Half-Elf Fighter/Magic-User/Cleric (multiclass)
- Dark Elf (Drow) Rogue
Features:
- Random stat generation
- Random level generation
- Spell casting with slot tracking
- Weapon attacks with damage rolls
- Initiative and turn order
- Combat until death
"""
import random
import sys
from pathlib import Path
# Add project to path
project_root = Path(__file__).parent.parent
sys.path.insert(0, str(project_root))
from dnd_rag_system.systems.game_state import (
CharacterState, SpellSlots, CombatState, Condition
)
def roll_dice(num_dice: int, die_size: int, modifier: int = 0) -> int:
"""Roll dice (e.g., 2d6+3)."""
total = sum(random.randint(1, die_size) for _ in range(num_dice))
return total + modifier
def roll_stat() -> int:
"""Roll a D&D ability score (3d6, keep best 3 of 4d6)."""
rolls = [random.randint(1, 6) for _ in range(4)]
rolls.sort(reverse=True)
return sum(rolls[:3])
def ability_modifier(score: int) -> int:
"""Calculate ability modifier from score."""
return (score - 10) // 2
def create_half_elf_multiclass() -> CharacterState:
"""Create a random half-elf Fighter/Magic-User/Cleric."""
# Random stats
str_score = roll_stat()
dex_score = roll_stat()
con_score = roll_stat()
int_score = roll_stat()
wis_score = roll_stat()
cha_score = roll_stat()
# Half-elf racial bonuses (+2 CHA, +1 to two others)
cha_score += 2
str_score += 1
wis_score += 1
# Random level (3-8)
level = random.randint(3, 8)
# Multiclass: Fighter 2/Cleric 2/Wizard remaining
fighter_levels = 2
cleric_levels = 2
wizard_levels = max(1, level - 4)
# Calculate HP (d10 for fighter, d8 for cleric, d6 for wizard)
con_mod = ability_modifier(con_score)
hp = 10 + con_mod # Fighter 1st level (max)
hp += roll_dice(1, 10, con_mod) # Fighter 2nd
hp += roll_dice(1, 8, con_mod) # Cleric 1st
hp += roll_dice(1, 8, con_mod) # Cleric 2nd
for _ in range(wizard_levels):
hp += roll_dice(1, 6, con_mod)
# AC (chainmail + DEX or leather + DEX)
ac = 16 + min(2, ability_modifier(dex_score)) # Chainmail
# Spell slots (Cleric 2 + Wizard levels)
caster_level_cleric = cleric_levels
caster_level_wizard = wizard_levels
# Simplified spell slots (level 2 cleric + wizard levels)
spell_slots = SpellSlots(
level_1=3, # Combined from both
level_2=2 if (caster_level_cleric + caster_level_wizard) >= 3 else 0,
level_3=2 if (caster_level_cleric + caster_level_wizard) >= 5 else 0,
)
char = CharacterState(
character_name="Aelindra Silverstaff",
max_hp=hp,
level=level
)
# Store stats in inventory as metadata (for combat calculations)
char.inventory["_stats"] = {
"STR": str_score,
"DEX": dex_score,
"CON": con_score,
"INT": int_score,
"WIS": wis_score,
"CHA": cha_score
}
char.inventory["_ac"] = ac
char.inventory["_weapon"] = "Longsword"
char.inventory["_weapon_damage"] = "1d8"
char.spell_slots = spell_slots
char.hit_dice_max = level
char.hit_dice_current = level
# Spells known
char.inventory["_spells"] = [
"Cure Wounds", # Cleric 1st
"Sacred Flame", # Cleric cantrip
"Bless", # Cleric 1st (concentration)
"Magic Missile", # Wizard 1st
"Shield", # Wizard 1st (reaction)
"Burning Hands", # Wizard 1st
]
return char
def create_drow_rogue() -> CharacterState:
"""Create a random Drow (Dark Elf) Rogue."""
# Random stats (Drow get +2 DEX, +1 CHA)
str_score = roll_stat()
dex_score = roll_stat() + 2 # Drow racial
con_score = roll_stat()
int_score = roll_stat()
wis_score = roll_stat()
cha_score = roll_stat() + 1 # Drow racial
# Random level (3-8)
level = random.randint(3, 8)
# Calculate HP (d8 for rogue)
con_mod = ability_modifier(con_score)
hp = 8 + con_mod # 1st level max
for _ in range(level - 1):
hp += roll_dice(1, 8, con_mod)
# AC (leather armor + DEX)
ac = 11 + ability_modifier(dex_score)
# Drow innate spellcasting
spell_slots = SpellSlots(
level_1=2, # Drow magic
level_2=1 if level >= 5 else 0
)
char = CharacterState(
character_name="Vhaeraun Nightstalker",
max_hp=hp,
level=level
)
# Store stats
char.inventory["_stats"] = {
"STR": str_score,
"DEX": dex_score,
"CON": con_score,
"INT": int_score,
"WIS": wis_score,
"CHA": cha_score
}
char.inventory["_ac"] = ac
char.inventory["_weapon"] = "Rapier"
char.inventory["_weapon_damage"] = "1d8"
char.spell_slots = spell_slots
char.hit_dice_max = level
char.hit_dice_current = level
# Drow spells
char.inventory["_spells"] = [
"Dancing Lights", # Drow cantrip
"Faerie Fire", # Drow 1st level
"Darkness", # Drow 2nd level
]
# Sneak attack damage
sneak_attack_dice = (level + 1) // 2
char.inventory["_sneak_attack"] = f"{sneak_attack_dice}d6"
return char
def print_character_sheet(char: CharacterState):
"""Print character information."""
stats = char.inventory.get("_stats", {})
ac = char.inventory.get("_ac", 10)
weapon = char.inventory.get("_weapon", "Unarmed")
spells = char.inventory.get("_spells", [])
print(f"\n{'='*60}")
print(f" {char.character_name} - Level {char.level}")
print(f"{'='*60}")
print(f"HP: {char.current_hp}/{char.max_hp} | AC: {ac}")
print(f"Weapon: {weapon}")
if stats:
print(f"STR: {stats['STR']} ({ability_modifier(stats['STR']):+d}) | "
f"DEX: {stats['DEX']} ({ability_modifier(stats['DEX']):+d}) | "
f"CON: {stats['CON']} ({ability_modifier(stats['CON']):+d})")
print(f"INT: {stats['INT']} ({ability_modifier(stats['INT']):+d}) | "
f"WIS: {stats['WIS']} ({ability_modifier(stats['WIS']):+d}) | "
f"CHA: {stats['CHA']} ({ability_modifier(stats['CHA']):+d})")
if spells:
print(f"Spells: {', '.join(spells)}")
slots = char.spell_slots.get_available()
if slots:
slot_str = ", ".join([f"L{lvl}: {curr}/{max_}" for lvl, (curr, max_) in slots.items()])
print(f"Spell Slots: {slot_str}")
print(f"{'='*60}\n")
def make_attack(attacker: CharacterState, defender: CharacterState) -> dict:
"""Make a weapon attack."""
attacker_stats = attacker.inventory.get("_stats", {})
defender_ac = defender.inventory.get("_ac", 10)
weapon = attacker.inventory.get("_weapon", "Unarmed")
weapon_damage = attacker.inventory.get("_weapon_damage", "1d4")
# Attack roll (d20 + STR or DEX modifier + proficiency)
str_mod = ability_modifier(attacker_stats.get("STR", 10))
dex_mod = ability_modifier(attacker_stats.get("DEX", 10))
prof_bonus = (attacker.level - 1) // 4 + 2 # Proficiency bonus
# Use DEX for finesse weapons (rapier), STR for others
attack_mod = dex_mod if "Rapier" in weapon else str_mod
attack_roll = roll_dice(1, 20, attack_mod + prof_bonus)
natural_roll = attack_roll - attack_mod - prof_bonus
# Critical hit on natural 20, critical miss on natural 1
critical = natural_roll == 20
miss = natural_roll == 1
if miss or (attack_roll < defender_ac and not critical):
return {
"hit": False,
"critical": False,
"attack_roll": attack_roll,
"damage": 0,
"message": f"⚔️ {attacker.character_name} attacks with {weapon} (rolled {attack_roll} vs AC {defender_ac}) - MISS!"
}
# Hit! Roll damage
dice_parts = weapon_damage.split('d')
num_dice = int(dice_parts[0])
die_size = int(dice_parts[1])
damage_mod = attack_mod
if critical:
# Critical hit: double damage dice
damage = roll_dice(num_dice * 2, die_size, damage_mod)
message = f"⚔️ {attacker.character_name} attacks with {weapon} (rolled {attack_roll} vs AC {defender_ac}) - CRITICAL HIT! {damage} damage!"
else:
damage = roll_dice(num_dice, die_size, damage_mod)
message = f"⚔️ {attacker.character_name} attacks with {weapon} (rolled {attack_roll} vs AC {defender_ac}) - HIT for {damage} damage!"
# Check for sneak attack (Rogue)
if "_sneak_attack" in attacker.inventory and random.random() > 0.5:
sneak_dice = attacker.inventory["_sneak_attack"]
dice_parts = sneak_dice.split('d')
sneak_damage = roll_dice(int(dice_parts[0]), int(dice_parts[1]))
damage += sneak_damage
message += f" (+ {sneak_damage} sneak attack!)"
return {
"hit": True,
"critical": critical,
"attack_roll": attack_roll,
"damage": damage,
"message": message
}
def cast_offensive_spell(caster: CharacterState, target: CharacterState) -> dict:
"""Cast an offensive spell."""
spells = caster.inventory.get("_spells", [])
caster_stats = caster.inventory.get("_stats", {})
target_ac = target.inventory.get("_ac", 10)
# Choose a spell to cast
offensive_spells = []
if "Magic Missile" in spells and caster.spell_slots.has_slot(1):
offensive_spells.append(("Magic Missile", 1, "auto-hit"))
if "Burning Hands" in spells and caster.spell_slots.has_slot(1):
offensive_spells.append(("Burning Hands", 1, "save"))
if "Sacred Flame" in spells: # Cantrip
offensive_spells.append(("Sacred Flame", 0, "save"))
if "Faerie Fire" in spells and caster.spell_slots.has_slot(1):
offensive_spells.append(("Faerie Fire", 1, "support"))
if not offensive_spells:
return {"cast": False, "message": "No offensive spells available"}
spell_name, spell_level, spell_type = random.choice(offensive_spells)
# Cast the spell
result = caster.cast_spell(spell_level, spell_name)
if not result["success"]:
return {"cast": False, "message": f"Failed to cast {spell_name} - no spell slots"}
# Calculate damage
damage = 0
message = f"✨ {caster.character_name} casts {spell_name}!"
if spell_name == "Magic Missile":
# 3 missiles, 1d4+1 each
missiles = 3
damage = sum(roll_dice(1, 4, 1) for _ in range(missiles))
message += f" {missiles} missiles strike for {damage} force damage (auto-hit)!"
elif spell_name == "Burning Hands":
# 3d6 fire damage, DEX save for half
damage = roll_dice(3, 6)
# Simplified: 50% chance to save
if random.random() > 0.5:
damage //= 2
message += f" Flames erupt! {target.character_name} saves for {damage} fire damage (halved)!"
else:
message += f" Flames erupt! {damage} fire damage!"
elif spell_name == "Sacred Flame":
# Cantrip: 1d8 radiant, DEX save
if random.random() > 0.5:
damage = 0
message += f" Radiant light descends, but {target.character_name} dodges!"
else:
damage = roll_dice(1, 8)
message += f" Radiant light strikes for {damage} radiant damage!"
elif spell_name == "Faerie Fire":
# Support spell - grants advantage
message += f" {target.character_name} is outlined in magical light!"
# Don't apply damage, just a visual effect for this simulation
damage = 0
return {
"cast": True,
"spell": spell_name,
"damage": damage,
"message": message
}
def take_turn(attacker: CharacterState, defender: CharacterState, round_num: int):
"""Take a combat turn."""
print(f"\n--- {attacker.character_name}'s Turn ---")
# 40% chance to cast spell if available, 60% to attack
if random.random() < 0.4:
spell_result = cast_offensive_spell(attacker, defender)
if spell_result.get("cast"):
print(spell_result["message"])
if spell_result.get("damage", 0) > 0:
damage_result = defender.take_damage(spell_result["damage"])
print(f"💥 {defender.character_name} takes {damage_result['damage_taken']} damage! ({defender.current_hp}/{defender.max_hp} HP remaining)")
if damage_result.get("unconscious"):
print(f"💀 {defender.character_name} falls unconscious!")
return
# Spell failed, fall through to attack
# Make weapon attack
attack_result = make_attack(attacker, defender)
print(attack_result["message"])
if attack_result["hit"]:
damage_result = defender.take_damage(attack_result["damage"])
print(f"💥 {defender.character_name} takes {damage_result['damage_taken']} damage! ({defender.current_hp}/{defender.max_hp} HP remaining)")
if damage_result.get("unconscious"):
print(f"💀 {defender.character_name} falls unconscious!")
def run_combat():
"""Run a full combat simulation."""
print("\n" + "="*60)
print(" 🎲 D&D COMBAT SIMULATOR 🎲")
print("="*60)
print("\n⚡ Generating Characters...\n")
# Create characters
hero = create_half_elf_multiclass()
villain = create_drow_rogue()
# Show character sheets
print_character_sheet(hero)
print_character_sheet(villain)
# Roll initiative
hero_stats = hero.inventory.get("_stats", {})
villain_stats = villain.inventory.get("_stats", {})
hero_init = roll_dice(1, 20, ability_modifier(hero_stats.get("DEX", 10)))
villain_init = roll_dice(1, 20, ability_modifier(villain_stats.get("DEX", 10)))
print(f"\n🎲 Initiative Rolls:")
print(f" {hero.character_name}: {hero_init}")
print(f" {villain.character_name}: {villain_init}")
# Create combat state
combat = CombatState()
combat.start_combat({
hero.character_name: hero_init,
villain.character_name: villain_init
})
print(f"\n⚔️ COMBAT BEGINS! ⚔️")
print(f"Turn Order: ", end="")
for name, init in combat.initiative_order:
print(f"{name} ({init})", end=" ")
print("\n")
# Combat loop
while hero.is_conscious() and villain.is_conscious():
current_name = combat.get_current_turn()
print(f"\n{'='*60}")
print(f" ROUND {combat.round_number} - {current_name}'s Turn")
print(f"{'='*60}")
if current_name == hero.character_name:
take_turn(hero, villain, combat.round_number)
else:
take_turn(villain, hero, combat.round_number)
combat.next_turn()
# Safety: max 20 rounds
if combat.round_number > 20:
print("\n⏰ Combat timeout after 20 rounds!")
break
# Combat ended
print("\n" + "="*60)
print(" ⚔️ COMBAT ENDED ⚔️")
print("="*60)
if hero.is_conscious() and not villain.is_conscious():
print(f"\n🏆 VICTORY! {hero.character_name} defeats {villain.character_name}!")
print(f" {hero.character_name} has {hero.current_hp}/{hero.max_hp} HP remaining")
elif villain.is_conscious() and not hero.is_conscious():
print(f"\n💀 DEFEAT! {villain.character_name} defeats {hero.character_name}!")
print(f" {villain.character_name} has {villain.current_hp}/{villain.max_hp} HP remaining")
else:
print(f"\n⚖️ DRAW! Both combatants still standing after {combat.round_number} rounds!")
# Final stats
print(f"\n📊 Combat Statistics:")
print(f" Rounds: {combat.round_number}")
print(f" {hero.character_name}: {hero.current_hp}/{hero.max_hp} HP")
print(f" {villain.character_name}: {villain.current_hp}/{villain.max_hp} HP")
# Spell slots used
hero_slots = hero.spell_slots.get_available()
villain_slots = villain.spell_slots.get_available()
if hero_slots:
print(f"\n {hero.character_name} Spell Slots:")
for lvl, (curr, max_) in hero_slots.items():
used = max_ - curr
if max_ > 0:
print(f" Level {lvl}: {used}/{max_} used")
if villain_slots:
print(f"\n {villain.character_name} Spell Slots:")
for lvl, (curr, max_) in villain_slots.items():
used = max_ - curr
if max_ > 0:
print(f" Level {lvl}: {used}/{max_} used")
print("\n" + "="*60 + "\n")
if __name__ == "__main__":
# Seed for reproducibility (remove for true randomness)
# random.seed(42)
print("\n🎮 Starting Combat Simulation...\n")
run_combat()
print("\n✅ Combat simulation complete!")
print("\n💡 Run again for a new random battle!\n")