Spaces:
Sleeping
Sleeping
| """ | |
| Character Creator Agent - D&D character generation for D'n'D Campaign Manager | |
| """ | |
| import uuid | |
| from typing import Optional, Dict, Any | |
| from datetime import datetime | |
| from src.models.character import ( | |
| Character, CharacterStats, CharacterBackground, | |
| DnDRace, DnDClass, Alignment, HIT_DICE_BY_CLASS | |
| ) | |
| from src.utils.ai_client import get_ai_client | |
| from src.utils.dice import DiceRoller | |
| from src.utils.database import get_database | |
| from src.utils.validators import validate_character | |
| from src.utils.image_generator import get_image_generator | |
| class CharacterAgent: | |
| """Agent for creating D&D characters""" | |
| def __init__(self): | |
| self.ai_client = get_ai_client() | |
| self.dice_roller = DiceRoller() | |
| self.database = get_database() | |
| try: | |
| self.image_generator = get_image_generator() | |
| except Exception as e: | |
| print(f"Warning: Image generator not available: {e}") | |
| self.image_generator = None | |
| def create_character( | |
| self, | |
| name: Optional[str] = None, | |
| race: Optional[DnDRace] = None, | |
| character_class: Optional[DnDClass] = None, | |
| level: int = 1, | |
| background_type: Optional[str] = None, | |
| personality_prompt: Optional[str] = None, | |
| stats_method: str = "standard_array", # "roll", "standard_array", "point_buy" | |
| custom_stats: Optional[Dict[str, int]] = None, | |
| ) -> Character: | |
| """ | |
| Create a complete D&D character | |
| Args: | |
| name: Character name (auto-generated if None) | |
| race: Character race (random if None) | |
| character_class: Character class (random if None) | |
| level: Starting level (1-20) | |
| background_type: Background type | |
| personality_prompt: Prompt to generate personality | |
| stats_method: How to generate ability scores | |
| custom_stats: Pre-set ability scores | |
| Returns: | |
| Complete Character object | |
| """ | |
| # Generate character ID | |
| character_id = str(uuid.uuid4()) | |
| # Generate name if not provided | |
| if not name: | |
| name = self._generate_name(race, character_class) | |
| # Select race and class if not provided | |
| if not race: | |
| race = self._select_random_race() | |
| if not character_class: | |
| character_class = self._select_random_class() | |
| # Generate ability scores | |
| if custom_stats: | |
| stats = CharacterStats(**custom_stats) | |
| else: | |
| stats = self._generate_stats(stats_method) | |
| # Apply racial ability score bonuses (D&D 5e rule) | |
| stats = self._apply_racial_bonuses(stats, race) | |
| # Calculate HP | |
| max_hp = self._calculate_starting_hp(character_class, stats.constitution_modifier, level) | |
| # Generate background and personality | |
| background = self._generate_background( | |
| name, race, character_class, background_type, personality_prompt | |
| ) | |
| # Generate alignment based on personality | |
| alignment = self._determine_alignment(background) | |
| # Get starting equipment | |
| equipment = self._get_starting_equipment(character_class) | |
| # Get class features | |
| features = self._get_class_features(character_class, level) | |
| # Get proficiencies | |
| proficiencies = self._get_proficiencies(character_class, background.background_type) | |
| # Calculate AC (base 10 + dex modifier) | |
| armor_class = 10 + stats.dexterity_modifier | |
| # Create character | |
| character = Character( | |
| id=character_id, | |
| name=name, | |
| race=race, | |
| character_class=character_class, | |
| level=level, | |
| alignment=alignment, | |
| stats=stats, | |
| max_hit_points=max_hp, | |
| current_hit_points=max_hp, | |
| armor_class=armor_class, | |
| background=background, | |
| equipment=equipment, | |
| features=features, | |
| proficiencies=proficiencies, | |
| ) | |
| # Validate character | |
| is_valid, errors = validate_character(character) | |
| if not is_valid: | |
| raise ValueError(f"Character validation failed: {', '.join(errors)}") | |
| # Save to database | |
| self.save_character(character) | |
| return character | |
| def generate_name( | |
| self, | |
| race: Optional[DnDRace] = None, | |
| character_class: Optional[DnDClass] = None, | |
| gender: Optional[str] = None | |
| ) -> str: | |
| """ | |
| PUBLIC method to generate character name using AI | |
| Can be called independently of character creation | |
| Args: | |
| race: Character race (optional) | |
| character_class: Character class (optional) | |
| gender: Character gender (optional) | |
| Returns: | |
| Generated character name | |
| """ | |
| gender_text = f"\nGender: {gender}" if gender and gender != "Not specified" else "" | |
| prompt = f"""Generate a single fantasy character name for a D&D character. | |
| Race: {race.value if race else 'any race'} | |
| Class: {character_class.value if character_class else 'any class'}{gender_text} | |
| Requirements: | |
| - Just the name, nothing else | |
| - Make it sound appropriate for the race{' and gender' if gender_text else ''} | |
| - Make it memorable and fitting for an adventurer | |
| - 2-3 words maximum | |
| Example formats: | |
| - "Thorin Ironforge" (Male Dwarf) | |
| - "Elara Moonwhisper" (Female Elf) | |
| - "Grunk Bonecrusher" (Male Orc) | |
| - "Pip Thornberry" (Any Halfling) | |
| Generate only the name:""" | |
| try: | |
| name = self.ai_client.generate_creative(prompt).strip() | |
| # Clean up any extra text | |
| name = name.split('\n')[0].strip('"\'') | |
| return name | |
| except Exception as e: | |
| # Fallback to simple name generation | |
| import random | |
| prefixes = ["Brave", "Bold", "Swift", "Wise", "Dark", "Bright"] | |
| suffixes = ["blade", "heart", "forge", "walker", "runner", "seeker"] | |
| return f"{random.choice(prefixes)}{random.choice(suffixes)}" | |
| def _generate_name(self, race: Optional[DnDRace], character_class: Optional[DnDClass]) -> str: | |
| """ | |
| PRIVATE method - calls public generate_name | |
| Kept for backward compatibility | |
| """ | |
| return self.generate_name(race, character_class) | |
| def _select_random_race(self) -> DnDRace: | |
| """Select random race""" | |
| import random | |
| return random.choice(list(DnDRace)) | |
| def _select_random_class(self) -> DnDClass: | |
| """Select random class""" | |
| import random | |
| return random.choice(list(DnDClass)) | |
| def _generate_stats(self, method: str) -> CharacterStats: | |
| """Generate ability scores""" | |
| if method == "roll": | |
| # Roll 4d6 drop lowest | |
| stats_dict = self.dice_roller.roll_stats() | |
| return CharacterStats(**stats_dict) | |
| elif method == "standard_array": | |
| # Standard array: 15, 14, 13, 12, 10, 8 | |
| import random | |
| array = [15, 14, 13, 12, 10, 8] | |
| random.shuffle(array) | |
| return CharacterStats( | |
| strength=array[0], | |
| dexterity=array[1], | |
| constitution=array[2], | |
| intelligence=array[3], | |
| wisdom=array[4], | |
| charisma=array[5] | |
| ) | |
| elif method == "point_buy": | |
| # Balanced point buy (27 points) | |
| return CharacterStats( | |
| strength=13, | |
| dexterity=14, | |
| constitution=13, | |
| intelligence=12, | |
| wisdom=10, | |
| charisma=10 | |
| ) | |
| else: | |
| # Default to standard array | |
| return CharacterStats() | |
| def _apply_racial_bonuses(self, stats: CharacterStats, race: DnDRace) -> CharacterStats: | |
| """Apply racial ability score increases per D&D 5e PHB""" | |
| racial_bonuses = { | |
| DnDRace.HUMAN: {"strength": 1, "dexterity": 1, "constitution": 1, | |
| "intelligence": 1, "wisdom": 1, "charisma": 1}, | |
| DnDRace.ELF: {"dexterity": 2}, | |
| DnDRace.DWARF: {"constitution": 2}, | |
| DnDRace.HALFLING: {"dexterity": 2}, | |
| DnDRace.DRAGONBORN: {"strength": 2, "charisma": 1}, | |
| DnDRace.GNOME: {"intelligence": 2}, | |
| DnDRace.HALF_ELF: {"charisma": 2}, # +1 to two highest (auto-assigned) | |
| DnDRace.HALF_ORC: {"strength": 2, "constitution": 1}, | |
| DnDRace.TIEFLING: {"charisma": 2, "intelligence": 1}, | |
| DnDRace.DROW: {"dexterity": 2, "charisma": 1}, | |
| } | |
| bonuses = racial_bonuses.get(race, {}) | |
| stats_dict = stats.model_dump() | |
| # Apply bonuses, capping at 20 per D&D 5e standard rules | |
| for ability, bonus in bonuses.items(): | |
| stats_dict[ability] = min(20, stats_dict[ability] + bonus) | |
| # Half-Elf special case: +1 to two highest abilities after CHA | |
| if race == DnDRace.HALF_ELF: | |
| # Find two highest abilities (excluding charisma which already got +2) | |
| abilities_except_cha = [(k, v) for k, v in stats_dict.items() if k != "charisma"] | |
| abilities_except_cha.sort(key=lambda x: x[1], reverse=True) | |
| # Apply +1 to top two (capped at 20) | |
| for i in range(min(2, len(abilities_except_cha))): | |
| ability_name = abilities_except_cha[i][0] | |
| stats_dict[ability_name] = min(20, stats_dict[ability_name] + 1) | |
| return CharacterStats(**stats_dict) | |
| def _calculate_starting_hp(self, character_class: DnDClass, con_modifier: int, level: int) -> int: | |
| """Calculate starting hit points (D&D 5e rules)""" | |
| hit_die = HIT_DICE_BY_CLASS.get(character_class, 8) | |
| # First level: max hit die + con mod | |
| # Subsequent levels: average of hit die + con mod | |
| first_level_hp = hit_die + con_modifier | |
| subsequent_hp = ((hit_die // 2) + 1 + con_modifier) * (level - 1) | |
| return max(1, first_level_hp + subsequent_hp) | |
| def _generate_background( | |
| self, | |
| name: str, | |
| race: DnDRace, | |
| character_class: DnDClass, | |
| background_type: Optional[str], | |
| personality_prompt: Optional[str] | |
| ) -> CharacterBackground: | |
| """Generate character background and personality using AI""" | |
| system_prompt = """You are a D&D character background generator. | |
| Create compelling, detailed character backgrounds that feel authentic and provide hooks for roleplay. | |
| Be creative but grounded in D&D lore.""" | |
| prompt = f"""Generate a complete character background for: | |
| Name: {name} | |
| Race: {race.value} | |
| Class: {character_class.value} | |
| Background Type: {background_type or 'Adventurer'} | |
| {f'Additional guidance: {personality_prompt}' if personality_prompt else ''} | |
| Generate in this EXACT format: | |
| PERSONALITY TRAITS: | |
| - [trait 1] | |
| - [trait 2] | |
| IDEALS: | |
| [One core ideal that drives them] | |
| BONDS: | |
| [What/who they care about most] | |
| FLAWS: | |
| [A meaningful character flaw] | |
| BACKSTORY: | |
| [2-3 paragraphs of compelling backstory that explains how they became an adventurer] | |
| GOALS: | |
| - [goal 1] | |
| - [goal 2] | |
| Keep it concise but evocative. Focus on what makes this character interesting to play.""" | |
| response = self.ai_client.generate_creative(prompt, system_prompt=system_prompt) | |
| # Parse response | |
| background = self._parse_background_response(response, background_type or "Adventurer") | |
| return background | |
| def _parse_background_response(self, response: str, background_type: str) -> CharacterBackground: | |
| """Parse AI response into CharacterBackground""" | |
| lines = response.strip().split('\n') | |
| traits = [] | |
| ideals = "" | |
| bonds = "" | |
| flaws = "" | |
| backstory = "" | |
| goals = [] | |
| current_section = None | |
| backstory_lines = [] | |
| for line in lines: | |
| line = line.strip() | |
| if not line: | |
| continue | |
| if line.startswith('PERSONALITY TRAITS:'): | |
| current_section = 'traits' | |
| elif line.startswith('IDEALS:'): | |
| current_section = 'ideals' | |
| elif line.startswith('BONDS:'): | |
| current_section = 'bonds' | |
| elif line.startswith('FLAWS:'): | |
| current_section = 'flaws' | |
| elif line.startswith('BACKSTORY:'): | |
| current_section = 'backstory' | |
| elif line.startswith('GOALS:'): | |
| current_section = 'goals' | |
| elif line.startswith('-'): | |
| content = line[1:].strip() | |
| if current_section == 'traits': | |
| traits.append(content) | |
| elif current_section == 'goals': | |
| goals.append(content) | |
| else: | |
| if current_section == 'ideals': | |
| ideals += line + " " | |
| elif current_section == 'bonds': | |
| bonds += line + " " | |
| elif current_section == 'flaws': | |
| flaws += line + " " | |
| elif current_section == 'backstory': | |
| backstory_lines.append(line) | |
| backstory = '\n'.join(backstory_lines).strip() | |
| return CharacterBackground( | |
| background_type=background_type, | |
| personality_traits=traits[:3], # Max 3 traits | |
| ideals=ideals.strip(), | |
| bonds=bonds.strip(), | |
| flaws=flaws.strip(), | |
| backstory=backstory, | |
| goals=goals | |
| ) | |
| def _determine_alignment(self, background: CharacterBackground) -> Alignment: | |
| """Determine alignment based on personality""" | |
| # Simple heuristic based on ideals and traits | |
| ideals_lower = background.ideals.lower() | |
| if 'law' in ideals_lower or 'order' in ideals_lower or 'honor' in ideals_lower: | |
| if 'help' in ideals_lower or 'good' in ideals_lower or 'kind' in ideals_lower: | |
| return Alignment.LAWFUL_GOOD | |
| elif 'evil' in ideals_lower or 'power' in ideals_lower: | |
| return Alignment.LAWFUL_EVIL | |
| else: | |
| return Alignment.LAWFUL_NEUTRAL | |
| elif 'chaos' in ideals_lower or 'freedom' in ideals_lower: | |
| if 'help' in ideals_lower or 'good' in ideals_lower: | |
| return Alignment.CHAOTIC_GOOD | |
| elif 'evil' in ideals_lower or 'selfish' in ideals_lower: | |
| return Alignment.CHAOTIC_EVIL | |
| else: | |
| return Alignment.CHAOTIC_NEUTRAL | |
| else: | |
| if 'help' in ideals_lower or 'good' in ideals_lower: | |
| return Alignment.NEUTRAL_GOOD | |
| elif 'evil' in ideals_lower: | |
| return Alignment.NEUTRAL_EVIL | |
| else: | |
| return Alignment.TRUE_NEUTRAL | |
| def _get_starting_equipment(self, character_class: DnDClass) -> list: | |
| """Get starting equipment for class""" | |
| equipment_by_class = { | |
| DnDClass.FIGHTER: ["Longsword", "Shield", "Chain Mail", "Explorer's Pack"], | |
| DnDClass.WIZARD: ["Spellbook", "Quarterstaff", "Component Pouch", "Scholar's Pack"], | |
| DnDClass.ROGUE: ["Shortbow", "Arrows (20)", "Leather Armor", "Thieves' Tools", "Burglar's Pack"], | |
| DnDClass.CLERIC: ["Mace", "Scale Mail", "Holy Symbol", "Priest's Pack"], | |
| DnDClass.RANGER: ["Longbow", "Arrows (20)", "Leather Armor", "Explorer's Pack"], | |
| DnDClass.PALADIN: ["Longsword", "Shield", "Chain Mail", "Holy Symbol", "Priest's Pack"], | |
| DnDClass.BARD: ["Rapier", "Lute", "Leather Armor", "Entertainer's Pack"], | |
| DnDClass.BARBARIAN: ["Greataxe", "Javelin (4)", "Explorer's Pack"], | |
| DnDClass.DRUID: ["Quarterstaff", "Leather Armor", "Druidic Focus", "Explorer's Pack"], | |
| DnDClass.MONK: ["Shortsword", "Dart (10)", "Explorer's Pack"], | |
| DnDClass.SORCERER: ["Dagger (2)", "Component Pouch", "Dungeoneer's Pack"], | |
| DnDClass.WARLOCK: ["Crossbow", "Bolts (20)", "Leather Armor", "Component Pouch", "Scholar's Pack"], | |
| } | |
| return equipment_by_class.get(character_class, ["Basic Equipment"]) | |
| def _get_class_features(self, character_class: DnDClass, level: int) -> list: | |
| """Get class features for level (D&D 5e PHB)""" | |
| # Features by class and level | |
| features_by_level = { | |
| DnDClass.FIGHTER: { | |
| 1: ["Fighting Style", "Second Wind"], | |
| 2: ["Action Surge (1 use)"], | |
| 3: ["Martial Archetype"], | |
| 4: ["Ability Score Improvement"], | |
| 5: ["Extra Attack (1)"], | |
| 6: ["Ability Score Improvement"], | |
| 7: ["Martial Archetype Feature"], | |
| 8: ["Ability Score Improvement"], | |
| 9: ["Indomitable (1 use)"], | |
| 10: ["Martial Archetype Feature"], | |
| 11: ["Extra Attack (2)"], | |
| 12: ["Ability Score Improvement"], | |
| 13: ["Indomitable (2 uses)"], | |
| 14: ["Ability Score Improvement"], | |
| 15: ["Martial Archetype Feature"], | |
| 16: ["Ability Score Improvement"], | |
| 17: ["Action Surge (2 uses)", "Indomitable (3 uses)"], | |
| 18: ["Martial Archetype Feature"], | |
| 19: ["Ability Score Improvement"], | |
| 20: ["Extra Attack (3)"], | |
| }, | |
| DnDClass.WIZARD: { | |
| 1: ["Spellcasting", "Arcane Recovery"], | |
| 2: ["Arcane Tradition"], | |
| 3: [], | |
| 4: ["Ability Score Improvement"], | |
| 5: [], | |
| 6: ["Arcane Tradition Feature"], | |
| 7: [], | |
| 8: ["Ability Score Improvement"], | |
| 9: [], | |
| 10: ["Arcane Tradition Feature"], | |
| 11: [], | |
| 12: ["Ability Score Improvement"], | |
| 13: [], | |
| 14: ["Arcane Tradition Feature"], | |
| 15: [], | |
| 16: ["Ability Score Improvement"], | |
| 17: [], | |
| 18: ["Spell Mastery"], | |
| 19: ["Ability Score Improvement"], | |
| 20: ["Signature Spells"], | |
| }, | |
| DnDClass.ROGUE: { | |
| 1: ["Expertise", "Sneak Attack (1d6)", "Thieves' Cant"], | |
| 2: ["Cunning Action"], | |
| 3: ["Sneak Attack (2d6)", "Roguish Archetype"], | |
| 4: ["Ability Score Improvement"], | |
| 5: ["Sneak Attack (3d6)", "Uncanny Dodge"], | |
| 6: ["Expertise"], | |
| 7: ["Sneak Attack (4d6)", "Evasion"], | |
| 8: ["Ability Score Improvement"], | |
| 9: ["Sneak Attack (5d6)", "Roguish Archetype Feature"], | |
| 10: ["Ability Score Improvement"], | |
| 11: ["Sneak Attack (6d6)", "Reliable Talent"], | |
| 12: ["Ability Score Improvement"], | |
| 13: ["Sneak Attack (7d6)", "Roguish Archetype Feature"], | |
| 14: ["Blindsense"], | |
| 15: ["Sneak Attack (8d6)", "Slippery Mind"], | |
| 16: ["Ability Score Improvement"], | |
| 17: ["Sneak Attack (9d6)", "Roguish Archetype Feature"], | |
| 18: ["Elusive"], | |
| 19: ["Sneak Attack (10d6)", "Ability Score Improvement"], | |
| 20: ["Stroke of Luck"], | |
| }, | |
| DnDClass.CLERIC: { | |
| 1: ["Spellcasting", "Divine Domain"], | |
| 2: ["Channel Divinity (1/rest)", "Divine Domain Feature"], | |
| 3: [], | |
| 4: ["Ability Score Improvement"], | |
| 5: ["Destroy Undead (CR 1/2)"], | |
| 6: ["Channel Divinity (2/rest)", "Divine Domain Feature"], | |
| 7: [], | |
| 8: ["Ability Score Improvement", "Destroy Undead (CR 1)", "Divine Domain Feature"], | |
| 9: [], | |
| 10: ["Divine Intervention"], | |
| 11: ["Destroy Undead (CR 2)"], | |
| 12: ["Ability Score Improvement"], | |
| 13: [], | |
| 14: ["Destroy Undead (CR 3)"], | |
| 15: [], | |
| 16: ["Ability Score Improvement"], | |
| 17: ["Destroy Undead (CR 4)", "Divine Domain Feature"], | |
| 18: ["Channel Divinity (3/rest)"], | |
| 19: ["Ability Score Improvement"], | |
| 20: ["Divine Intervention Improvement"], | |
| }, | |
| DnDClass.RANGER: { | |
| 1: ["Favored Enemy", "Natural Explorer"], | |
| 2: ["Fighting Style", "Spellcasting"], | |
| 3: ["Ranger Archetype", "Primeval Awareness"], | |
| 4: ["Ability Score Improvement"], | |
| 5: ["Extra Attack"], | |
| 6: ["Favored Enemy Improvement", "Natural Explorer Improvement"], | |
| 7: ["Ranger Archetype Feature"], | |
| 8: ["Ability Score Improvement", "Land's Stride"], | |
| 9: [], | |
| 10: ["Natural Explorer Improvement", "Hide in Plain Sight"], | |
| 11: ["Ranger Archetype Feature"], | |
| 12: ["Ability Score Improvement"], | |
| 13: [], | |
| 14: ["Favored Enemy Improvement", "Vanish"], | |
| 15: ["Ranger Archetype Feature"], | |
| 16: ["Ability Score Improvement"], | |
| 17: [], | |
| 18: ["Feral Senses"], | |
| 19: ["Ability Score Improvement"], | |
| 20: ["Foe Slayer"], | |
| }, | |
| DnDClass.PALADIN: { | |
| 1: ["Divine Sense", "Lay on Hands"], | |
| 2: ["Fighting Style", "Spellcasting", "Divine Smite"], | |
| 3: ["Divine Health", "Sacred Oath"], | |
| 4: ["Ability Score Improvement"], | |
| 5: ["Extra Attack"], | |
| 6: ["Aura of Protection"], | |
| 7: ["Sacred Oath Feature"], | |
| 8: ["Ability Score Improvement"], | |
| 9: [], | |
| 10: ["Aura of Courage"], | |
| 11: ["Improved Divine Smite"], | |
| 12: ["Ability Score Improvement"], | |
| 13: [], | |
| 14: ["Cleansing Touch"], | |
| 15: ["Sacred Oath Feature"], | |
| 16: ["Ability Score Improvement"], | |
| 17: [], | |
| 18: ["Aura Improvements"], | |
| 19: ["Ability Score Improvement"], | |
| 20: ["Sacred Oath Feature"], | |
| }, | |
| DnDClass.BARD: { | |
| 1: ["Spellcasting", "Bardic Inspiration (d6)"], | |
| 2: ["Jack of All Trades", "Song of Rest (d6)"], | |
| 3: ["Bard College", "Expertise"], | |
| 4: ["Ability Score Improvement"], | |
| 5: ["Bardic Inspiration (d8)", "Font of Inspiration"], | |
| 6: ["Countercharm", "Bard College Feature"], | |
| 7: [], | |
| 8: ["Ability Score Improvement"], | |
| 9: ["Song of Rest (d8)"], | |
| 10: ["Bardic Inspiration (d10)", "Expertise", "Magical Secrets"], | |
| 11: [], | |
| 12: ["Ability Score Improvement"], | |
| 13: ["Song of Rest (d10)"], | |
| 14: ["Magical Secrets", "Bard College Feature"], | |
| 15: ["Bardic Inspiration (d12)"], | |
| 16: ["Ability Score Improvement"], | |
| 17: ["Song of Rest (d12)"], | |
| 18: ["Magical Secrets"], | |
| 19: ["Ability Score Improvement"], | |
| 20: ["Superior Inspiration"], | |
| }, | |
| DnDClass.BARBARIAN: { | |
| 1: ["Rage (2/day)", "Unarmored Defense"], | |
| 2: ["Reckless Attack", "Danger Sense"], | |
| 3: ["Primal Path", "Rage (3/day)"], | |
| 4: ["Ability Score Improvement"], | |
| 5: ["Extra Attack", "Fast Movement"], | |
| 6: ["Path Feature", "Rage (4/day)"], | |
| 7: ["Feral Instinct"], | |
| 8: ["Ability Score Improvement"], | |
| 9: ["Brutal Critical (1 die)"], | |
| 10: ["Path Feature", "Rage (5/day)"], | |
| 11: ["Relentless Rage"], | |
| 12: ["Ability Score Improvement", "Rage (6/day)"], | |
| 13: ["Brutal Critical (2 dice)"], | |
| 14: ["Path Feature"], | |
| 15: ["Persistent Rage"], | |
| 16: ["Ability Score Improvement"], | |
| 17: ["Brutal Critical (3 dice)", "Rage (Unlimited)"], | |
| 18: ["Indomitable Might"], | |
| 19: ["Ability Score Improvement"], | |
| 20: ["Primal Champion"], | |
| }, | |
| DnDClass.DRUID: { | |
| 1: ["Druidic", "Spellcasting"], | |
| 2: ["Wild Shape", "Druid Circle"], | |
| 3: [], | |
| 4: ["Wild Shape Improvement", "Ability Score Improvement"], | |
| 5: [], | |
| 6: ["Druid Circle Feature"], | |
| 7: [], | |
| 8: ["Wild Shape Improvement", "Ability Score Improvement"], | |
| 9: [], | |
| 10: ["Druid Circle Feature"], | |
| 11: [], | |
| 12: ["Ability Score Improvement"], | |
| 13: [], | |
| 14: ["Druid Circle Feature"], | |
| 15: [], | |
| 16: ["Ability Score Improvement"], | |
| 17: [], | |
| 18: ["Timeless Body", "Beast Spells"], | |
| 19: ["Ability Score Improvement"], | |
| 20: ["Archdruid"], | |
| }, | |
| DnDClass.MONK: { | |
| 1: ["Unarmored Defense", "Martial Arts (1d4)"], | |
| 2: ["Ki", "Unarmored Movement"], | |
| 3: ["Monastic Tradition", "Deflect Missiles"], | |
| 4: ["Ability Score Improvement", "Slow Fall"], | |
| 5: ["Extra Attack", "Stunning Strike", "Martial Arts (1d6)"], | |
| 6: ["Ki-Empowered Strikes", "Monastic Tradition Feature"], | |
| 7: ["Evasion", "Stillness of Mind"], | |
| 8: ["Ability Score Improvement"], | |
| 9: ["Unarmored Movement Improvement"], | |
| 10: ["Purity of Body"], | |
| 11: ["Monastic Tradition Feature", "Martial Arts (1d8)"], | |
| 12: ["Ability Score Improvement"], | |
| 13: ["Tongue of the Sun and Moon"], | |
| 14: ["Diamond Soul"], | |
| 15: ["Timeless Body"], | |
| 16: ["Ability Score Improvement"], | |
| 17: ["Monastic Tradition Feature", "Martial Arts (1d10)"], | |
| 18: ["Empty Body"], | |
| 19: ["Ability Score Improvement"], | |
| 20: ["Perfect Self"], | |
| }, | |
| DnDClass.SORCERER: { | |
| 1: ["Spellcasting", "Sorcerous Origin"], | |
| 2: ["Font of Magic"], | |
| 3: ["Metamagic (2 options)"], | |
| 4: ["Ability Score Improvement"], | |
| 5: [], | |
| 6: ["Sorcerous Origin Feature"], | |
| 7: [], | |
| 8: ["Ability Score Improvement"], | |
| 9: [], | |
| 10: ["Metamagic (3 options)"], | |
| 11: [], | |
| 12: ["Ability Score Improvement"], | |
| 13: [], | |
| 14: ["Sorcerous Origin Feature"], | |
| 15: [], | |
| 16: ["Ability Score Improvement"], | |
| 17: ["Metamagic (4 options)"], | |
| 18: ["Sorcerous Origin Feature"], | |
| 19: ["Ability Score Improvement"], | |
| 20: ["Sorcerous Restoration"], | |
| }, | |
| DnDClass.WARLOCK: { | |
| 1: ["Otherworldly Patron", "Pact Magic"], | |
| 2: ["Eldritch Invocations (2)"], | |
| 3: ["Pact Boon"], | |
| 4: ["Ability Score Improvement"], | |
| 5: ["Eldritch Invocations (3)"], | |
| 6: ["Otherworldly Patron Feature"], | |
| 7: ["Eldritch Invocations (4)"], | |
| 8: ["Ability Score Improvement"], | |
| 9: ["Eldritch Invocations (5)"], | |
| 10: ["Otherworldly Patron Feature"], | |
| 11: ["Mystic Arcanum (6th level)"], | |
| 12: ["Ability Score Improvement", "Eldritch Invocations (6)"], | |
| 13: ["Mystic Arcanum (7th level)"], | |
| 14: ["Otherworldly Patron Feature"], | |
| 15: ["Mystic Arcanum (8th level)", "Eldritch Invocations (7)"], | |
| 16: ["Ability Score Improvement"], | |
| 17: ["Mystic Arcanum (9th level)"], | |
| 18: ["Eldritch Invocations (8)"], | |
| 19: ["Ability Score Improvement"], | |
| 20: ["Eldritch Master"], | |
| }, | |
| } | |
| # Get features for this class up to the current level | |
| class_features_by_level = features_by_level.get(character_class, {}) | |
| all_features = [] | |
| for lvl in range(1, min(level + 1, 21)): | |
| all_features.extend(class_features_by_level.get(lvl, [])) | |
| return all_features if all_features else ["Class Features"] | |
| def _get_proficiencies(self, character_class: DnDClass, background: str) -> list: | |
| """ | |
| Get proficiencies - includes fixed proficiencies and notes about choices | |
| Players need to make skill choices in D&D 5e | |
| """ | |
| # Saving throw proficiencies (FIXED - no choice) | |
| saves = { | |
| DnDClass.FIGHTER: ["Saving Throws: Strength, Constitution"], | |
| DnDClass.WIZARD: ["Saving Throws: Intelligence, Wisdom"], | |
| DnDClass.ROGUE: ["Saving Throws: Dexterity, Intelligence"], | |
| DnDClass.CLERIC: ["Saving Throws: Wisdom, Charisma"], | |
| DnDClass.RANGER: ["Saving Throws: Strength, Dexterity"], | |
| DnDClass.PALADIN: ["Saving Throws: Wisdom, Charisma"], | |
| DnDClass.BARD: ["Saving Throws: Dexterity, Charisma"], | |
| DnDClass.BARBARIAN: ["Saving Throws: Strength, Constitution"], | |
| DnDClass.DRUID: ["Saving Throws: Intelligence, Wisdom"], | |
| DnDClass.MONK: ["Saving Throws: Strength, Dexterity"], | |
| DnDClass.SORCERER: ["Saving Throws: Constitution, Charisma"], | |
| DnDClass.WARLOCK: ["Saving Throws: Wisdom, Charisma"], | |
| } | |
| # Armor and weapon proficiencies (FIXED - no choice) | |
| armor_weapons = { | |
| DnDClass.FIGHTER: ["All armor", "All shields", "Simple weapons", "Martial weapons"], | |
| DnDClass.WIZARD: ["Weapons: Daggers, Darts, Slings, Quarterstaffs, Light crossbows"], | |
| DnDClass.ROGUE: ["Light armor", "Weapons: Simple weapons, Hand crossbows, Longswords, Rapiers, Shortswords", "Tools: Thieves' tools"], | |
| DnDClass.CLERIC: ["Light armor", "Medium armor", "Shields", "Simple weapons"], | |
| DnDClass.RANGER: ["Light armor", "Medium armor", "Shields", "Simple weapons", "Martial weapons"], | |
| DnDClass.PALADIN: ["All armor", "All shields", "Simple weapons", "Martial weapons"], | |
| DnDClass.BARD: ["Light armor", "Weapons: Simple weapons, Hand crossbows, Longswords, Rapiers, Shortswords", "Tools: Three musical instruments of your choice"], | |
| DnDClass.BARBARIAN: ["Light armor", "Medium armor", "Shields", "Simple weapons", "Martial weapons"], | |
| DnDClass.DRUID: ["Light armor (nonmetal)", "Medium armor (nonmetal)", "Shields (nonmetal)", "Weapons: Clubs, Daggers, Darts, Javelins, Maces, Quarterstaffs, Scimitars, Sickles, Slings, Spears", "Tools: Herbalism kit"], | |
| DnDClass.MONK: ["Weapons: Simple weapons, Shortswords", "Tools: Choose one artisan's tool or musical instrument"], | |
| DnDClass.SORCERER: ["Weapons: Daggers, Darts, Slings, Quarterstaffs, Light crossbows"], | |
| DnDClass.WARLOCK: ["Light armor", "Simple weapons"], | |
| } | |
| # Skill choices (PLAYER MUST CHOOSE - provide guidance) | |
| skill_choices = { | |
| DnDClass.FIGHTER: ["Choose 2 skills from: Acrobatics, Animal Handling, Athletics, History, Insight, Intimidation, Perception, Survival"], | |
| DnDClass.WIZARD: ["Choose 2 skills from: Arcana, History, Insight, Investigation, Medicine, Religion"], | |
| DnDClass.ROGUE: ["Choose 4 skills from: Acrobatics, Athletics, Deception, Insight, Intimidation, Investigation, Perception, Performance, Persuasion, Sleight of Hand, Stealth"], | |
| DnDClass.CLERIC: ["Choose 2 skills from: History, Insight, Medicine, Persuasion, Religion"], | |
| DnDClass.RANGER: ["Choose 3 skills from: Animal Handling, Athletics, Insight, Investigation, Nature, Perception, Stealth, Survival"], | |
| DnDClass.PALADIN: ["Choose 2 skills from: Athletics, Insight, Intimidation, Medicine, Persuasion, Religion"], | |
| DnDClass.BARD: ["Choose any 3 skills"], | |
| DnDClass.BARBARIAN: ["Choose 2 skills from: Animal Handling, Athletics, Intimidation, Nature, Perception, Survival"], | |
| DnDClass.DRUID: ["Choose 2 skills from: Arcana, Animal Handling, Insight, Medicine, Nature, Perception, Religion, Survival"], | |
| DnDClass.MONK: ["Choose 2 skills from: Acrobatics, Athletics, History, Insight, Religion, Stealth"], | |
| DnDClass.SORCERER: ["Choose 2 skills from: Arcana, Deception, Insight, Intimidation, Persuasion, Religion"], | |
| DnDClass.WARLOCK: ["Choose 2 skills from: Arcana, Deception, History, Intimidation, Investigation, Nature, Religion"], | |
| } | |
| # Background provides 2 additional skills (VARIES - typical examples provided) | |
| background_note = f"Background ({background}): Grants 2 additional skill proficiencies (varies by background)" | |
| # Combine all proficiencies | |
| proficiencies = [] | |
| proficiencies.extend(saves.get(character_class, [])) | |
| proficiencies.extend(armor_weapons.get(character_class, [])) | |
| proficiencies.extend(skill_choices.get(character_class, [])) | |
| proficiencies.append(background_note) | |
| return proficiencies | |
| def save_character(self, character: Character): | |
| """Save character to database""" | |
| self.database.save( | |
| entity_id=character.id, | |
| entity_type="character", | |
| data=character.to_dict() | |
| ) | |
| def load_character(self, character_id: str) -> Optional[Character]: | |
| """Load character from database""" | |
| data = self.database.load(character_id, "character") | |
| if data: | |
| return Character(**data) | |
| return None | |
| def list_characters(self) -> list[Character]: | |
| """List all saved characters""" | |
| characters_data = self.database.load_all("character") | |
| return [Character(**data) for data in characters_data] | |
| def delete_character(self, character_id: str): | |
| """Delete character from database""" | |
| self.database.delete(character_id) | |
| def generate_portrait( | |
| self, | |
| character: Character, | |
| style: str = "fantasy art", | |
| quality: str = "standard", | |
| provider: str = "auto" | |
| ) -> tuple[Optional[str], Optional[str]]: | |
| """ | |
| Generate character portrait using DALL-E 3 or HuggingFace | |
| Args: | |
| character: Character to generate portrait for | |
| style: Art style (e.g., "fantasy art", "digital painting", "anime") | |
| quality: Image quality ("standard" or "hd") - OpenAI only | |
| provider: "openai", "huggingface", or "auto" | |
| Returns: | |
| Tuple of (file_path, status_message) | |
| """ | |
| if not self.image_generator: | |
| return None, "β Image generation not available (API key required)" | |
| try: | |
| file_path, image_bytes = self.image_generator.generate_character_portrait( | |
| character=character, | |
| style=style, | |
| quality=quality, | |
| provider=provider | |
| ) | |
| if file_path: | |
| return file_path, f"β Portrait generated successfully!\nSaved to: {file_path}" | |
| else: | |
| return None, "β Failed to generate portrait" | |
| except Exception as e: | |
| return None, f"β Error generating portrait: {str(e)}" | |
| def get_portrait_path(self, character_id: str) -> Optional[str]: | |
| """Get saved portrait path for character""" | |
| if not self.image_generator: | |
| return None | |
| return self.image_generator.get_portrait_path(character_id) | |