villsim/backend/domain/personality.py

298 lines
11 KiB
Python

"""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) -> None:
"""Improve a skill through practice."""
if amount is None:
amount = self.IMPROVEMENT_RATE
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) -> Skills:
"""Generate starting skills influenced by personality.
Agents with strong preferences start with slightly better skills
in those areas (natural talent).
"""
# Base skill level with small random variation
base = 1.0
variance = 0.15
skills = Skills(
hunting=base + random.uniform(-variance, variance) + (personality.hunt_preference - 1.0) * 0.1,
gathering=base + random.uniform(-variance, variance) + (personality.gather_preference - 1.0) * 0.1,
woodcutting=base + random.uniform(-variance, variance) + (personality.woodcut_preference - 1.0) * 0.1,
trading=base + random.uniform(-variance, variance) + (personality.trade_preference - 1.0) * 0.1,
crafting=base + random.uniform(-variance, variance),
)
# 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