villsim/backend/domain/personality.py
2026-01-19 22:55:26 +03:00

320 lines
12 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, 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