Spaces:
Build error
Build error
| """ | |
| 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") | |