"""Personality and skill system for agents in the Village Simulation. Each agent has unique personality traits that affect their behavior: - How much they value wealth vs. survival resources - What activities they prefer (hunting, gathering, trading) - How willing they are to take risks - How much they hoard vs. trade Agents also develop skills over time based on their actions: - Skills improve with practice - Better skills = better outcomes This creates emergent professions: - Hunters: High hunting skill, prefer meat production - Gatherers: High gathering skill, prefer berries/water/wood - Traders: High trading skill, focus on buy low / sell high arbitrage """ import random from dataclasses import dataclass, field from enum import Enum from typing import Optional class ProfessionType(Enum): """Emergent profession types based on behavior patterns.""" HUNTER = "hunter" GATHERER = "gatherer" WOODCUTTER = "woodcutter" TRADER = "trader" GENERALIST = "generalist" @dataclass class PersonalityTraits: """Unique personality traits that affect agent behavior. These are set at birth and don't change during the agent's life. They create natural diversity in the population. """ # How much the agent values accumulating wealth (0.1 = minimal, 0.9 = greedy) wealth_desire: float = 0.3 # How much the agent hoards resources vs trades them (0.1 = trades freely, 0.9 = hoards) hoarding_rate: float = 0.5 # Willingness to take risks (0.1 = very cautious, 0.9 = risk-taker) # Affects: hunting vs gathering preference, price decisions risk_tolerance: float = 0.5 # Sensitivity to good/bad deals (0.5 = not picky, 1.5 = very price conscious) price_sensitivity: float = 1.0 # Activity biases - how much the agent prefers each activity # Higher values = more likely to choose this activity # These create "profession tendencies" hunt_preference: float = 1.0 # Preference for hunting gather_preference: float = 1.0 # Preference for gathering woodcut_preference: float = 1.0 # Preference for wood trade_preference: float = 1.0 # Preference for trading/market # How social/market-oriented the agent is # High = frequent market visits, buys more from others # Low = self-sufficient, prefers to produce own resources market_affinity: float = 0.5 def to_dict(self) -> dict: return { "wealth_desire": round(self.wealth_desire, 2), "hoarding_rate": round(self.hoarding_rate, 2), "risk_tolerance": round(self.risk_tolerance, 2), "price_sensitivity": round(self.price_sensitivity, 2), "hunt_preference": round(self.hunt_preference, 2), "gather_preference": round(self.gather_preference, 2), "woodcut_preference": round(self.woodcut_preference, 2), "trade_preference": round(self.trade_preference, 2), "market_affinity": round(self.market_affinity, 2), } @dataclass class Skills: """Skills that improve with practice. Each skill affects the outcome of related actions. Skills increase slowly through practice (use it or lose it). """ # Combat/hunting skill - affects hunt success rate hunting: float = 1.0 # Foraging skill - affects gather output quantity gathering: float = 1.0 # Woodcutting skill - affects wood output woodcutting: float = 1.0 # Trading skill - affects prices (buy lower, sell higher) trading: float = 1.0 # Crafting skill - affects craft quality/success crafting: float = 1.0 # Skill improvement rate per action IMPROVEMENT_RATE: float = 0.02 # Skill decay rate per turn (use it or lose it, gentle decay) DECAY_RATE: float = 0.001 # Maximum skill level MAX_SKILL: float = 2.0 # Minimum skill level MIN_SKILL: float = 0.5 def improve(self, skill_name: str, amount: Optional[float] = None, learning_modifier: float = 1.0) -> None: """Improve a skill through practice. Args: skill_name: Name of the skill to improve amount: Base improvement amount (defaults to IMPROVEMENT_RATE) learning_modifier: Age-based modifier (young learn faster, old learn slower) """ if amount is None: amount = self.IMPROVEMENT_RATE # Apply learning modifier (young agents learn faster) amount = amount * learning_modifier if hasattr(self, skill_name): current = getattr(self, skill_name) new_value = min(self.MAX_SKILL, current + amount) setattr(self, skill_name, new_value) def decay_all(self) -> None: """Apply gentle decay to all skills (use it or lose it).""" for skill_name in ['hunting', 'gathering', 'woodcutting', 'trading', 'crafting']: current = getattr(self, skill_name) new_value = max(self.MIN_SKILL, current - self.DECAY_RATE) setattr(self, skill_name, new_value) def get_primary_skill(self) -> tuple[str, float]: """Get the agent's highest skill and its name.""" skills = { 'hunting': self.hunting, 'gathering': self.gathering, 'woodcutting': self.woodcutting, 'trading': self.trading, 'crafting': self.crafting, } best_skill = max(skills, key=skills.get) return best_skill, skills[best_skill] def to_dict(self) -> dict: return { "hunting": round(self.hunting, 3), "gathering": round(self.gathering, 3), "woodcutting": round(self.woodcutting, 3), "trading": round(self.trading, 3), "crafting": round(self.crafting, 3), } def generate_random_personality(archetype: Optional[str] = None) -> PersonalityTraits: """Generate random personality traits. If archetype is specified, traits will be biased towards that profession: - "hunter": High risk tolerance, high hunt preference - "gatherer": Low risk tolerance, high gather preference - "trader": High wealth desire, high market affinity, high trade preference - "hoarder": High hoarding rate, low market affinity - None: Fully random Returns a PersonalityTraits instance with randomized values. """ # Start with base random values traits = PersonalityTraits( wealth_desire=random.uniform(0.1, 0.9), hoarding_rate=random.uniform(0.2, 0.8), risk_tolerance=random.uniform(0.2, 0.8), price_sensitivity=random.uniform(0.6, 1.4), hunt_preference=random.uniform(0.5, 1.5), gather_preference=random.uniform(0.5, 1.5), woodcut_preference=random.uniform(0.5, 1.5), trade_preference=random.uniform(0.5, 1.5), market_affinity=random.uniform(0.2, 0.8), ) # Apply archetype biases if archetype == "hunter": traits.hunt_preference = random.uniform(1.3, 2.0) traits.risk_tolerance = random.uniform(0.6, 0.9) traits.gather_preference = random.uniform(0.3, 0.7) elif archetype == "gatherer": traits.gather_preference = random.uniform(1.3, 2.0) traits.risk_tolerance = random.uniform(0.2, 0.5) traits.hunt_preference = random.uniform(0.3, 0.7) elif archetype == "trader": traits.trade_preference = random.uniform(1.5, 2.5) traits.market_affinity = random.uniform(0.7, 0.95) traits.wealth_desire = random.uniform(0.6, 0.95) traits.price_sensitivity = random.uniform(1.1, 1.6) traits.hoarding_rate = random.uniform(0.1, 0.4) # Traders sell! # Traders don't hunt/gather much traits.hunt_preference = random.uniform(0.2, 0.5) traits.gather_preference = random.uniform(0.2, 0.5) elif archetype == "hoarder": traits.hoarding_rate = random.uniform(0.7, 0.95) traits.market_affinity = random.uniform(0.1, 0.4) traits.trade_preference = random.uniform(0.3, 0.7) elif archetype == "woodcutter": traits.woodcut_preference = random.uniform(1.3, 2.0) traits.gather_preference = random.uniform(0.5, 0.8) return traits def generate_random_skills(personality: PersonalityTraits, age: Optional[int] = None) -> Skills: """Generate starting skills influenced by personality and age. Agents with strong preferences start with slightly better skills in those areas (natural talent). Older agents start with higher skills (life experience). """ # Base skill level with small random variation base = 1.0 variance = 0.15 # Age bonus: older agents have more experience age_bonus = 0.0 if age is not None: # Young agents (< 25): no bonus # Prime agents (25-45): small bonus # Old agents (> 45): larger bonus (wisdom) if age >= 45: age_bonus = 0.3 + random.uniform(0, 0.2) elif age >= 25: age_bonus = (age - 25) * 0.01 + random.uniform(0, 0.1) skills = Skills( hunting=base + random.uniform(-variance, variance) + (personality.hunt_preference - 1.0) * 0.1 + age_bonus, gathering=base + random.uniform(-variance, variance) + (personality.gather_preference - 1.0) * 0.1 + age_bonus, woodcutting=base + random.uniform(-variance, variance) + (personality.woodcut_preference - 1.0) * 0.1 + age_bonus, trading=base + random.uniform(-variance, variance) + (personality.trade_preference - 1.0) * 0.1 + age_bonus, crafting=base + random.uniform(-variance, variance) + age_bonus, ) # Clamp all skills to valid range skills.hunting = max(skills.MIN_SKILL, min(skills.MAX_SKILL, skills.hunting)) skills.gathering = max(skills.MIN_SKILL, min(skills.MAX_SKILL, skills.gathering)) skills.woodcutting = max(skills.MIN_SKILL, min(skills.MAX_SKILL, skills.woodcutting)) skills.trading = max(skills.MIN_SKILL, min(skills.MAX_SKILL, skills.trading)) skills.crafting = max(skills.MIN_SKILL, min(skills.MAX_SKILL, skills.crafting)) return skills def determine_profession(personality: PersonalityTraits, skills: Skills) -> ProfessionType: """Determine an agent's emergent profession based on traits and skills. This is for display/statistics - it doesn't affect behavior directly. The behavior is determined by the traits and skills themselves. """ # Calculate profession scores scores = { ProfessionType.HUNTER: personality.hunt_preference * skills.hunting * 1.2, ProfessionType.GATHERER: personality.gather_preference * skills.gathering, ProfessionType.WOODCUTTER: personality.woodcut_preference * skills.woodcutting, ProfessionType.TRADER: personality.trade_preference * skills.trading * personality.market_affinity * 1.5, } # Find the best match best_profession = max(scores, key=scores.get) best_score = scores[best_profession] # If no clear winner (all scores similar), they're a generalist second_best = sorted(scores.values(), reverse=True)[1] if best_score < second_best * 1.2: return ProfessionType.GENERALIST return best_profession def get_action_skill_modifier(skill_value: float) -> float: """Convert skill value to action modifier. Skill 0.5 = 0.75x effectiveness Skill 1.0 = 1.0x effectiveness Skill 1.5 = 1.25x effectiveness Skill 2.0 = 1.5x effectiveness This creates meaningful but not overpowering differences. """ # Linear scaling: (skill - 1.0) * 0.5 + 1.0 # So skill 0.5 -> 0.75, skill 1.0 -> 1.0, skill 2.0 -> 1.5 return max(0.5, min(1.5, 0.5 * skill_value + 0.5)) def get_trade_price_modifier(skill_value: float, is_buying: bool) -> float: """Get price modifier for trading based on skill. Higher trading skill = better deals: - When buying: lower prices (modifier < 1) - When selling: higher prices (modifier > 1) Skill 1.0 = no modifier Skill 2.0 = 15% better deals """ modifier = (skill_value - 1.0) * 0.15 if is_buying: return max(0.85, 1.0 - modifier) # Lower is better for buying else: return min(1.15, 1.0 + modifier) # Higher is better for selling