[upd] Add birth and age-based death and modifiers

This commit is contained in:
Снесарев Максим 2026-01-19 22:55:26 +03:00
parent 25bd13e001
commit d190e3efe5
9 changed files with 1163 additions and 195 deletions

View File

@ -55,9 +55,31 @@ class AgentResponse(BaseModel):
inventory: list[ResourceSchema]
money: int
is_alive: bool
is_corpse: bool = False
can_act: bool
current_action: AgentActionSchema
last_action_result: str
death_turn: int = -1
death_reason: str = ""
# Age system
age: int = 25
max_age: int = 70
age_category: str = "prime"
birth_day: int = 0
generation: int = 0
parent_ids: list[str] = []
children_count: int = 0
# Age modifiers
skill_modifier: float = 1.0
energy_cost_modifier: float = 1.0
learning_modifier: float = 1.0
# Personality and skills
personality: dict = {}
skills: dict = {}
actions_performed: dict = {}
total_trades: int = 0
total_money_earned: int = 0
action_history: list = []
# ============== Market Schemas ==============
@ -119,6 +141,7 @@ class StatisticsSchema(BaseModel):
living_agents: int
total_agents_spawned: int
total_agents_died: int
total_births: int = 0
total_money_in_circulation: int
professions: dict[str, int]
# Wealth inequality metrics
@ -127,6 +150,16 @@ class StatisticsSchema(BaseModel):
richest_agent: int = 0
poorest_agent: int = 0
gini_coefficient: float = 0.0
# Age demographics
age_distribution: dict[str, int] = {}
avg_age: float = 0.0
oldest_agent: int = 0
youngest_agent: int = 0
generations: dict[int, int] = {}
# Death statistics
deaths_by_cause: dict[str, int] = {}
# Village storage
village_storage: dict[str, int] = {}
class ActionLogSchema(BaseModel):
@ -142,10 +175,12 @@ class TurnLogSchema(BaseModel):
turn: int
agent_actions: list[ActionLogSchema]
deaths: list[str]
births: list[str] = []
trades: list[dict]
resources_produced: dict[str, int] = {}
resources_consumed: dict[str, int] = {}
resources_spoiled: dict[str, int] = {}
day_events: dict = {}
class ResourceStatsSchema(BaseModel):

View File

@ -151,6 +151,105 @@ class AIConfig:
reactive_fallback: bool = True
@dataclass
class AgeConfig:
"""Configuration for the age and lifecycle system.
Age affects skills, energy costs, and creates birth/death cycles.
Age is measured in "years" where 1 year = 1 simulation day.
Population is controlled by economy:
- Birth rate scales with village prosperity (food availability)
- Parents transfer wealth to children at birth and death
"""
# Starting age range for initial agents
min_start_age: int = 18
max_start_age: int = 35
# Age category thresholds
young_age_threshold: int = 25 # Below this = young
prime_age_start: int = 25 # Prime age begins
prime_age_end: int = 50 # Prime age ends
old_age_threshold: int = 50 # Above this = old
# Lifespan
base_max_age: int = 75 # Base maximum age
max_age_variance: int = 10 # ± variance for max age
age_per_day: int = 1 # How many "years" per sim day
# Birth mechanics - economy controlled
birth_cooldown_days: int = 20 # Days after birth before can birth again
min_birth_age: int = 20 # Minimum age to give birth
max_birth_age: int = 45 # Maximum age to give birth
birth_base_chance: float = 0.02 # Base chance of birth per day
birth_prosperity_multiplier: float = 3.0 # Max multiplier based on food abundance
birth_food_requirement: int = 60 # Min hunger to attempt birth
birth_energy_requirement: int = 25 # Min energy to attempt birth
# Wealth transfer
birth_wealth_transfer: float = 0.25 # Parent gives 25% wealth to child at birth
inheritance_enabled: bool = True # Children inherit from dead parents
child_start_age: int = 18 # Age children start at (adult)
# Age modifiers for YOUNG agents (learning phase)
young_skill_multiplier: float = 0.8 # Skills are 80% effective
young_learning_multiplier: float = 1.4 # Learn 40% faster
young_energy_cost_multiplier: float = 0.85 # 15% less energy cost
# Age modifiers for PRIME agents (peak performance)
prime_skill_multiplier: float = 1.0
prime_learning_multiplier: float = 1.0
prime_energy_cost_multiplier: float = 1.0
# Age modifiers for OLD agents (wisdom but frailty)
old_skill_multiplier: float = 1.15 # Skills 15% more effective (wisdom)
old_learning_multiplier: float = 0.6 # Learn 40% slower
old_energy_cost_multiplier: float = 1.2 # 20% more energy cost
old_max_energy_multiplier: float = 0.75 # 25% less max energy
old_decay_multiplier: float = 1.15 # 15% faster stat decay
@dataclass
class StorageConfig:
"""Configuration for resource storage limits.
Limits the total resources that can exist in the village economy.
"""
# Village-wide storage limits per resource type
village_meat_limit: int = 100
village_berries_limit: int = 150
village_water_limit: int = 200
village_wood_limit: int = 200
village_hide_limit: int = 80
village_clothes_limit: int = 50
# Market limits
market_order_limit_per_agent: int = 5 # Max active orders per agent
market_total_order_limit: int = 500 # Max total market orders
@dataclass
class SinksConfig:
"""Configuration for resource sinks (ways resources leave the economy).
These create pressure to keep producing resources rather than hoarding.
"""
# Daily decay of village storage (percentage)
daily_village_decay_rate: float = 0.02 # 2% of stored resources decay daily
# Money tax (redistributed or removed)
daily_tax_rate: float = 0.01 # 1% wealth tax per day
# Random events
random_event_chance: float = 0.05 # 5% chance of event per day
fire_event_resource_loss: float = 0.1 # 10% resources lost in fire
theft_event_money_loss: float = 0.05 # 5% money stolen
# Maintenance costs
clothes_maintenance_per_day: int = 1 # Clothes degrade 1 durability/day
fire_wood_cost_per_night: int = 1 # Wood consumed to stay warm at night
@dataclass
class SimulationConfig:
"""Master configuration containing all sub-configs."""
@ -161,6 +260,9 @@ class SimulationConfig:
market: MarketConfig = field(default_factory=MarketConfig)
economy: EconomyConfig = field(default_factory=EconomyConfig)
ai: AIConfig = field(default_factory=AIConfig)
age: AgeConfig = field(default_factory=AgeConfig)
storage: StorageConfig = field(default_factory=StorageConfig)
sinks: SinksConfig = field(default_factory=SinksConfig)
# Simulation control
auto_step_interval: float = 1.0 # Seconds between auto steps
@ -175,6 +277,9 @@ class SimulationConfig:
"world": asdict(self.world),
"market": asdict(self.market),
"economy": asdict(self.economy),
"age": asdict(self.age),
"storage": asdict(self.storage),
"sinks": asdict(self.sinks),
"auto_step_interval": self.auto_step_interval,
}
@ -189,6 +294,9 @@ class SimulationConfig:
world=WorldConfig(**data.get("world", {})),
market=MarketConfig(**data.get("market", {})),
economy=EconomyConfig(**data.get("economy", {})),
age=AgeConfig(**data.get("age", {})),
storage=StorageConfig(**data.get("storage", {})),
sinks=SinksConfig(**data.get("sinks", {})),
auto_step_interval=data.get("auto_step_interval", 1.0),
)

View File

@ -30,21 +30,26 @@ class TurnLog:
turn: int
agent_actions: list[dict] = field(default_factory=list)
deaths: list[str] = field(default_factory=list)
births: list[str] = field(default_factory=list)
trades: list[dict] = field(default_factory=list)
# Resource tracking for this turn
resources_produced: dict = field(default_factory=dict)
resources_consumed: dict = field(default_factory=dict)
resources_spoiled: dict = field(default_factory=dict)
# New day events
day_events: dict = field(default_factory=dict)
def to_dict(self) -> dict:
return {
"turn": self.turn,
"agent_actions": self.agent_actions,
"deaths": self.deaths,
"births": self.births,
"trades": self.trades,
"resources_produced": self.resources_produced,
"resources_consumed": self.resources_consumed,
"resources_spoiled": self.resources_spoiled,
"day_events": self.day_events,
}
@ -281,10 +286,15 @@ class GameEngine:
# End turn logging
self.logger.end_turn()
# 8. Advance time
self.world.advance_time()
# 8. Advance time (returns True if new day started)
new_day = self.world.advance_time()
# 9. Check win/lose conditions (count only truly living agents, not corpses)
# 9. Process new day events (aging, births, sinks)
if new_day:
day_events = self._process_new_day(turn_log)
turn_log.day_events = day_events
# 10. Check win/lose conditions (count only truly living agents, not corpses)
if len(self.world.get_living_agents()) == 0:
self.is_running = False
self.logger.close()
@ -293,16 +303,32 @@ class GameEngine:
return turn_log
def _mark_dead_agents(self, current_turn: int) -> list[Agent]:
"""Mark agents who just died as corpses. Returns list of newly dead agents."""
"""Mark agents who just died as corpses. Returns list of newly dead agents.
Also processes inheritance - distributing wealth to children.
"""
newly_dead = []
for agent in self.world.agents:
if not agent.is_alive() and not agent.is_corpse():
# Agent just died this turn
cause = agent.stats.get_critical_stat() or "unknown"
# Determine cause of death
if agent.is_too_old():
cause = "age"
else:
cause = agent.stats.get_critical_stat() or "unknown"
# Process inheritance BEFORE marking dead (while inventory still accessible)
inheritance = self.world.process_inheritance(agent)
if inheritance.get("beneficiaries"):
self.logger.log_event("inheritance", inheritance)
agent.mark_dead(current_turn, cause)
# Clear their action to show death state
agent.current_action.action_type = "dead"
agent.current_action.message = f"Died: {cause}"
# Record death statistics
self.world.record_death(agent, cause)
newly_dead.append(agent)
return newly_dead
@ -316,10 +342,148 @@ class GameEngine:
for agent in to_remove:
self.world.agents.remove(agent)
self.world.total_agents_died += 1
# Note: death was already recorded in _mark_dead_agents
return to_remove
def _process_new_day(self, turn_log: TurnLog) -> dict:
"""Process all new-day events: aging, births, resource sinks.
Called when a new simulation day starts.
"""
events = {
"day": self.world.current_day,
"births": [],
"age_deaths": [],
"taxes_collected": 0,
"storage_decay": {},
"random_events": [],
}
sinks_config = get_config().sinks
age_config = get_config().age
# 1. Age all living agents
for agent in self.world.get_living_agents():
agent.age_one_day()
# 2. Check for age-related deaths (after aging)
current_turn = self.world.current_turn
for agent in self.world.agents:
if not agent.is_corpse() and agent.is_too_old() and not agent.is_alive():
# Will be caught by _mark_dead_agents in the next turn
pass
# 3. Process potential births
for agent in list(self.world.get_living_agents()): # Copy list since we modify it
if agent.can_give_birth(self.world.current_day):
child = self.world.spawn_child(agent)
if child:
birth_info = {
"parent_id": agent.id,
"parent_name": agent.name,
"child_id": child.id,
"child_name": child.name,
}
events["births"].append(birth_info)
turn_log.births.append(child.name)
self.logger.log_event("birth", birth_info)
# 4. Apply daily money tax (wealth redistribution/removal)
if sinks_config.daily_tax_rate > 0:
total_taxes = 0
for agent in self.world.get_living_agents():
tax = int(agent.money * sinks_config.daily_tax_rate)
if tax > 0:
agent.money -= tax
total_taxes += tax
events["taxes_collected"] = total_taxes
# 5. Apply village storage decay (resources spoil over time)
if sinks_config.daily_village_decay_rate > 0:
decay_rate = sinks_config.daily_village_decay_rate
for agent in self.world.get_living_agents():
for resource in agent.inventory[:]: # Copy list to allow modification
# Random chance for each resource to decay
if random.random() < decay_rate:
decay_amount = max(1, int(resource.quantity * decay_rate))
resource.quantity -= decay_amount
res_type = resource.type.value
events["storage_decay"][res_type] = events["storage_decay"].get(res_type, 0) + decay_amount
# Track as spoiled
turn_log.resources_spoiled[res_type] = turn_log.resources_spoiled.get(res_type, 0) + decay_amount
self.resource_stats["spoiled"][res_type] = self.resource_stats["spoiled"].get(res_type, 0) + decay_amount
if resource.quantity <= 0:
agent.inventory.remove(resource)
# 6. Random events (fires, theft, etc.)
if random.random() < sinks_config.random_event_chance:
event = self._generate_random_event(events, turn_log)
if event:
events["random_events"].append(event)
return events
def _generate_random_event(self, events: dict, turn_log: TurnLog) -> Optional[dict]:
"""Generate a random village event (disaster, theft, etc.)."""
sinks_config = get_config().sinks
living_agents = self.world.get_living_agents()
if not living_agents:
return None
event_types = ["fire", "theft", "blessing"]
event_type = random.choice(event_types)
event_info = {"type": event_type, "affected": []}
if event_type == "fire":
# Fire destroys some resources from random agents
num_affected = max(1, len(living_agents) // 5) # 20% of agents affected
affected_agents = random.sample(living_agents, min(num_affected, len(living_agents)))
for agent in affected_agents:
for resource in agent.inventory[:]:
loss = int(resource.quantity * sinks_config.fire_event_resource_loss)
if loss > 0:
resource.quantity -= loss
res_type = resource.type.value
turn_log.resources_spoiled[res_type] = turn_log.resources_spoiled.get(res_type, 0) + loss
if resource.quantity <= 0:
agent.inventory.remove(resource)
event_info["affected"].append(agent.name)
elif event_type == "theft":
# Some money is stolen from wealthy agents
wealthy_agents = [a for a in living_agents if a.money > 1000]
if wealthy_agents:
victim = random.choice(wealthy_agents)
stolen = int(victim.money * sinks_config.theft_event_money_loss)
victim.money -= stolen
event_info["affected"].append(victim.name)
event_info["amount_stolen"] = stolen
elif event_type == "blessing":
# Good harvest - some agents get bonus resources
lucky_agent = random.choice(living_agents)
from backend.domain.resources import Resource, ResourceType
bonus_type = random.choice([ResourceType.BERRIES, ResourceType.WOOD])
bonus = Resource(type=bonus_type, quantity=random.randint(2, 5), created_turn=self.world.current_turn)
lucky_agent.add_to_inventory(bonus)
event_info["affected"].append(lucky_agent.name)
event_info["bonus"] = f"+{bonus.quantity} {bonus_type.value}"
if event_info["affected"]:
self.logger.log_event("random_event", event_info)
return event_info
return None
def _execute_action(self, agent: Agent, decision: AIDecision) -> Optional[ActionResult]:
"""Execute an action for an agent."""
action = decision.action
@ -398,9 +562,17 @@ class GameEngine:
- Gathering skill affects gather output
- Woodcutting skill affects wood output
- Skills improve with use
Age modifies:
- Energy costs (young use less, old use more)
- Skill effectiveness (young less effective, old more effective "wisdom")
- Learning rate (young learn faster, old learn slower)
"""
# Check energy
energy_cost = abs(config.energy_cost)
# Calculate age-modified energy cost
base_energy_cost = abs(config.energy_cost)
energy_cost_modifier = agent.get_energy_cost_modifier()
energy_cost = max(1, int(base_energy_cost * energy_cost_modifier))
if not agent.spend_energy(energy_cost):
return ActionResult(
action_type=action,
@ -428,16 +600,20 @@ class GameEngine:
# Get relevant skill for this action
skill_name = self._get_skill_for_action(action)
skill_value = getattr(agent.skills, skill_name, 1.0) if skill_name else 1.0
skill_modifier = get_action_skill_modifier(skill_value)
# Check success chance (modified by skill)
# Apply age-based skill modifier (young less effective, old more effective)
age_skill_modifier = agent.get_skill_modifier()
skill_modifier = get_action_skill_modifier(skill_value) * age_skill_modifier
# Check success chance (modified by skill and age)
# Higher skill = higher effective success chance
effective_success_chance = min(0.98, config.success_chance * skill_modifier)
if random.random() > effective_success_chance:
# Record action attempt (skill still improves on failure, just less)
agent.record_action(action.value)
if skill_name:
agent.skills.improve(skill_name, 0.005) # Small improvement on failure
learning_modifier = agent.get_learning_modifier()
agent.skills.improve(skill_name, 0.005, learning_modifier) # Small improvement on failure
return ActionResult(
action_type=action,
success=False,
@ -445,14 +621,21 @@ class GameEngine:
message="Action failed",
)
# Generate output (modified by skill for quantity)
# Generate output (modified by skill and age for quantity)
resources_gained = []
if config.output_resource:
# Check storage limit before producing
res_type = config.output_resource.value
storage_available = self.world.get_storage_available(res_type)
# Skill affects output quantity
base_quantity = random.randint(config.min_output, config.max_output)
quantity = max(config.min_output, int(base_quantity * skill_modifier))
# Limit by storage
quantity = min(quantity, storage_available)
if quantity > 0:
resource = Resource(
type=config.output_resource,
@ -467,10 +650,15 @@ class GameEngine:
created_turn=self.world.current_turn,
))
# Secondary output (e.g., hide from hunting) - also affected by skill
# Secondary output (e.g., hide from hunting) - also affected by skill and storage
if config.secondary_output:
res_type = config.secondary_output.value
storage_available = self.world.get_storage_available(res_type)
base_quantity = random.randint(config.secondary_min, config.secondary_max)
quantity = max(0, int(base_quantity * skill_modifier))
quantity = min(quantity, storage_available)
if quantity > 0:
resource = Resource(
type=config.secondary_output,
@ -485,10 +673,11 @@ class GameEngine:
created_turn=self.world.current_turn,
))
# Record action and improve skill
# Record action and improve skill (modified by age learning rate)
agent.record_action(action.value)
if skill_name:
agent.skills.improve(skill_name, 0.015) # Skill improves with successful use
learning_modifier = agent.get_learning_modifier()
agent.skills.improve(skill_name, 0.015, learning_modifier) # Skill improves with successful use
# Build success message with details
gained_str = ", ".join(f"+{r.quantity} {r.type.value}" for r in resources_gained)
@ -569,13 +758,15 @@ class GameEngine:
if seller:
seller.money += result.total_paid
seller.record_trade(result.total_paid)
seller.skills.improve("trading", 0.02) # Seller skill improves
seller.skills.improve("trading", 0.02, seller.get_learning_modifier()) # Seller skill improves
agent.spend_energy(abs(config.energy_cost))
# Age-modified energy cost for trading
energy_cost = max(1, int(abs(config.energy_cost) * agent.get_energy_cost_modifier()))
agent.spend_energy(energy_cost)
# Record buyer's trade and improve skill
# Record buyer's trade and improve skill (with age learning modifier)
agent.record_action("trade")
agent.skills.improve("trading", 0.01) # Buyer skill improves less
agent.skills.improve("trading", 0.01, agent.get_learning_modifier()) # Buyer skill improves less
return ActionResult(
action_type=ActionType.TRADE,

View File

@ -39,7 +39,7 @@ class TurnLogEntry:
trades: list = field(default_factory=list)
deaths: list = field(default_factory=list)
statistics: dict = field(default_factory=dict)
def to_dict(self) -> dict:
"""Convert to dictionary for JSON serialization."""
return {
@ -59,24 +59,24 @@ class TurnLogEntry:
class SimulationLogger:
"""Logger that dumps detailed simulation data to files."""
def __init__(self, log_dir: str = "logs"):
self.log_dir = Path(log_dir)
self.log_dir.mkdir(exist_ok=True)
# Create session-specific log file
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
self.session_file = self.log_dir / f"sim_{timestamp}.jsonl"
self.summary_file = self.log_dir / f"sim_{timestamp}_summary.txt"
# File handles
self._json_file: Optional[TextIO] = None
self._summary_file: Optional[TextIO] = None
# Also set up standard Python logging
self.logger = logging.getLogger("simulation")
self.logger.setLevel(logging.DEBUG)
# File handler for detailed logs
file_handler = logging.FileHandler(self.log_dir / f"sim_{timestamp}.log")
file_handler.setLevel(logging.DEBUG)
@ -84,7 +84,7 @@ class SimulationLogger:
"%(asctime)s | %(levelname)s | %(message)s"
))
self.logger.addHandler(file_handler)
# Console handler for important events
console_handler = logging.StreamHandler()
console_handler.setLevel(logging.INFO)
@ -92,25 +92,25 @@ class SimulationLogger:
"%(asctime)s | %(message)s", datefmt="%H:%M:%S"
))
self.logger.addHandler(console_handler)
self._entries: list[TurnLogEntry] = []
self._current_entry: Optional[TurnLogEntry] = None
def start_session(self, config: dict) -> None:
"""Start a new logging session."""
self._json_file = open(self.session_file, "w")
self._summary_file = open(self.summary_file, "w")
# Write config as first line
self._json_file.write(json.dumps({"type": "config", "data": config}) + "\n")
self._json_file.flush()
self._summary_file.write(f"Simulation Session Started: {datetime.now()}\n")
self._summary_file.write("=" * 60 + "\n\n")
self._summary_file.flush()
self.logger.info(f"Logging session started: {self.session_file}")
def start_turn(self, turn: int, day: int, step_in_day: int, time_of_day: str) -> None:
"""Start logging a new turn."""
self._current_entry = TurnLogEntry(
@ -121,7 +121,7 @@ class SimulationLogger:
timestamp=datetime.now().isoformat(),
)
self.logger.debug(f"Turn {turn} started (Day {day}, Step {step_in_day}, {time_of_day})")
def log_agent_before(
self,
agent_id: str,
@ -135,7 +135,7 @@ class SimulationLogger:
"""Log agent state before action."""
if self._current_entry is None:
return
# Create placeholder entry
entry = AgentLogEntry(
agent_id=agent_id,
@ -152,12 +152,12 @@ class SimulationLogger:
money_after=money,
)
self._current_entry.agent_entries.append(entry)
def log_agent_decision(self, agent_id: str, decision: dict) -> None:
"""Log agent's AI decision."""
if self._current_entry is None:
return
for entry in self._current_entry.agent_entries:
if entry.agent_id == agent_id:
entry.decision = decision.copy()
@ -166,7 +166,7 @@ class SimulationLogger:
f"- {decision.get('reason', '')}"
)
break
def log_agent_after(
self,
agent_id: str,
@ -179,7 +179,7 @@ class SimulationLogger:
"""Log agent state after action."""
if self._current_entry is None:
return
for entry in self._current_entry.agent_entries:
if entry.agent_id == agent_id:
entry.stats_after = stats.copy()
@ -188,55 +188,71 @@ class SimulationLogger:
entry.position = position.copy()
entry.action_result = action_result.copy()
break
def log_market_state(self, orders_before: list, orders_after: list) -> None:
"""Log market state."""
if self._current_entry is None:
return
self._current_entry.market_orders_before = orders_before
self._current_entry.market_orders_after = orders_after
def log_trade(self, trade: dict) -> None:
"""Log a trade transaction."""
if self._current_entry is None:
return
self._current_entry.trades.append(trade)
self.logger.debug(f" Trade: {trade.get('message', 'Unknown trade')}")
def log_death(self, agent_name: str, cause: str) -> None:
"""Log an agent death."""
if self._current_entry is None:
return
self._current_entry.deaths.append({"name": agent_name, "cause": cause})
self.logger.info(f" DEATH: {agent_name} died from {cause}")
def log_event(self, event_type: str, event_data: dict) -> None:
"""Log a general event (births, random events, etc.)."""
if self._current_entry is None:
return
if event_type == "birth":
self.logger.info(
f" BIRTH: {event_data.get('child_name', '?')} born to {event_data.get('parent_name', '?')}"
)
elif event_type == "random_event":
self.logger.info(
f" EVENT: {event_data.get('type', '?')} affecting {event_data.get('affected', [])}"
)
else:
self.logger.debug(f" Event [{event_type}]: {event_data}")
def log_statistics(self, stats: dict) -> None:
"""Log end-of-turn statistics."""
if self._current_entry is None:
return
self._current_entry.statistics = stats.copy()
def end_turn(self) -> None:
"""Finish logging the current turn and write to file."""
if self._current_entry is None:
return
self._entries.append(self._current_entry)
# Write to JSON lines file
if self._json_file:
self._json_file.write(
json.dumps({"type": "turn", "data": self._current_entry.to_dict()}) + "\n"
)
self._json_file.flush()
# Write summary
if self._summary_file:
entry = self._current_entry
self._summary_file.write(
f"Turn {entry.turn} | Day {entry.day} Step {entry.step_in_day} ({entry.time_of_day})\n"
)
for agent in entry.agent_entries:
action = agent.decision.get("action", "?")
result = "" if agent.action_result.get("success", False) else ""
@ -247,17 +263,17 @@ class SimulationLogger:
f"T:{agent.stats_after.get('thirst', '?')} "
f"${agent.money_after}\n"
)
if entry.deaths:
for death in entry.deaths:
self._summary_file.write(f" 💀 {death['name']} died: {death['cause']}\n")
self._summary_file.write("\n")
self._summary_file.flush()
self.logger.debug(f"Turn {self._current_entry.turn} completed")
self._current_entry = None
def close(self) -> None:
"""Close log files."""
if self._json_file:
@ -268,7 +284,7 @@ class SimulationLogger:
self._summary_file.close()
self._summary_file = None
self.logger.info("Logging session closed")
def get_entries(self) -> list[TurnLogEntry]:
"""Get all logged entries."""
return self._entries.copy()

View File

@ -3,6 +3,8 @@
The world spawns diverse agents with varied personality traits,
skills, and starting conditions to create emergent professions
and class inequality.
Now includes age-based lifecycle with birth and death by old age.
"""
import random
@ -15,6 +17,7 @@ from backend.domain.personality import (
PersonalityTraits, Skills,
generate_random_personality, generate_random_skills
)
from backend.domain.resources import ResourceType
class TimeOfDay(Enum):
@ -32,7 +35,7 @@ def _get_world_config_from_file():
@dataclass
class WorldConfig:
"""Configuration for the world.
Default values are loaded from config.json via create_world_config().
These hardcoded defaults are only fallbacks.
"""
@ -64,11 +67,26 @@ class World:
current_day: int = 1
step_in_day: int = 0
time_of_day: TimeOfDay = TimeOfDay.DAY
# Statistics
total_agents_spawned: int = 0
total_agents_died: int = 0
total_births: int = 0
total_deaths_by_age: int = 0
total_deaths_by_starvation: int = 0
total_deaths_by_thirst: int = 0
total_deaths_by_cold: int = 0
# Village-wide storage tracking (for storage limits)
village_storage: dict = field(default_factory=lambda: {
"meat": 0,
"berries": 0,
"water": 0,
"wood": 0,
"hide": 0,
"clothes": 0,
})
def spawn_agent(
self,
name: Optional[str] = None,
@ -76,30 +94,43 @@ class World:
position: Optional[Position] = None,
archetype: Optional[str] = None,
starting_money: Optional[int] = None,
age: Optional[int] = None,
generation: int = 0,
parent_ids: Optional[list[str]] = None,
) -> Agent:
"""Spawn a new agent in the world with unique personality.
Args:
name: Agent name (auto-generated if None)
profession: Deprecated, now derived from personality
position: Starting position (random if None)
archetype: Personality archetype ("hunter", "gatherer", "trader", etc.)
starting_money: Starting money (random with inequality if None)
age: Starting age (random within config range if None)
generation: 0 for initial spawn, 1+ for born in simulation
parent_ids: IDs of parent agents (for lineage tracking)
"""
if position is None:
position = Position(
x=random.randint(0, self.config.width - 1),
y=random.randint(0, self.config.height - 1),
)
# Generate unique personality and skills
# Get age config for age calculation
from backend.config import get_config
age_config = get_config().age
# Calculate starting age
if age is None:
age = random.randint(age_config.min_start_age, age_config.max_start_age)
# Generate unique personality and skills (skills influenced by age)
personality = generate_random_personality(archetype)
skills = generate_random_skills(personality)
skills = generate_random_skills(personality, age=age)
# Variable starting money for class inequality
# Some agents start with more, some with less
if starting_money is None:
from backend.config import get_config
base_money = get_config().world.starting_money
# Random multiplier: 0.3x to 2.0x base money
# This creates natural class inequality
@ -108,7 +139,7 @@ class World:
if personality.trade_preference > 1.3:
money_multiplier *= 1.5
starting_money = int(base_money * money_multiplier)
agent = Agent(
name=name or f"Villager_{self.total_agents_spawned + 1}",
profession=Profession.VILLAGER, # Will be updated based on personality
@ -116,19 +147,192 @@ class World:
personality=personality,
skills=skills,
money=starting_money,
age=age,
birth_day=self.current_day,
generation=generation,
parent_ids=parent_ids or [],
)
self.agents.append(agent)
self.total_agents_spawned += 1
return agent
def spawn_child(self, parent: Agent) -> Optional[Agent]:
"""Spawn a new agent as a child of an existing agent.
Birth chance is controlled by village prosperity (food abundance).
Parent transfers wealth to child at birth.
Returns the new agent or None if birth conditions not met.
"""
from backend.config import get_config
age_config = get_config().age
# Check birth eligibility
if not parent.can_give_birth(self.current_day):
return None
# Calculate economy-based birth chance
# More food in village = higher birth rate
# But even in hard times, some births occur (base chance always applies)
prosperity = self.calculate_prosperity()
# Prosperity boosts birth rate: base_chance * (1 + prosperity * multiplier)
# At prosperity=0: birth_chance = base_chance
# At prosperity=1: birth_chance = base_chance * (1 + multiplier)
birth_chance = age_config.birth_base_chance * (1 + prosperity * age_config.birth_prosperity_multiplier)
birth_chance = min(0.20, birth_chance) # Cap at 20%
if random.random() > birth_chance:
return None
# Birth happens! Child spawns near parent
child_pos = Position(
x=parent.position.x + random.uniform(-1, 1),
y=parent.position.y + random.uniform(-1, 1),
)
# Clamp to world bounds
child_pos.x = max(0, min(self.config.width - 1, child_pos.x))
child_pos.y = max(0, min(self.config.height - 1, child_pos.y))
# Child inherits some personality traits from parent with mutation
child_archetype = None # Random, not determined by parent
# Wealth transfer: parent gives portion of their wealth to child
wealth_transfer = age_config.birth_wealth_transfer
child_money = int(parent.money * wealth_transfer)
parent.money -= child_money
# Ensure child has minimum viable wealth
min_child_money = int(get_config().world.starting_money * 0.3)
child_money = max(child_money, min_child_money)
# Child starts at configured age (adult)
child_age = age_config.child_start_age
child = self.spawn_agent(
name=f"Child_{self.total_agents_spawned + 1}",
position=child_pos,
archetype=child_archetype,
starting_money=child_money,
age=child_age,
generation=parent.generation + 1,
parent_ids=[parent.id],
)
# Parent also transfers some food to child
self._transfer_resources_to_child(parent, child)
# Record birth for parent
parent.record_birth(self.current_day, child.id)
self.total_births += 1
return child
def _transfer_resources_to_child(self, parent: Agent, child: Agent) -> None:
"""Transfer some resources from parent to child at birth."""
# Transfer 1 of each food type parent has (if available)
for res_type in [ResourceType.MEAT, ResourceType.BERRIES, ResourceType.WATER]:
if parent.has_resource(res_type, 1):
parent.remove_from_inventory(res_type, 1)
from backend.domain.resources import Resource
child.add_to_inventory(Resource(
type=res_type,
quantity=1,
created_turn=self.current_turn,
))
def calculate_prosperity(self) -> float:
"""Calculate village prosperity (0.0 to 1.0) based on food abundance.
Higher prosperity = more births allowed.
This creates population cycles tied to resource availability.
"""
self.update_village_storage()
from backend.config import get_config
storage_config = get_config().storage
# Calculate how full each food storage is
meat_ratio = self.village_storage.get("meat", 0) / max(1, storage_config.village_meat_limit)
berries_ratio = self.village_storage.get("berries", 0) / max(1, storage_config.village_berries_limit)
water_ratio = self.village_storage.get("water", 0) / max(1, storage_config.village_water_limit)
# Average food abundance (weighted: meat most valuable)
prosperity = (meat_ratio * 0.4 + berries_ratio * 0.3 + water_ratio * 0.3)
return min(1.0, max(0.0, prosperity))
def process_inheritance(self, dead_agent: Agent) -> dict:
"""Process inheritance when an agent dies.
Wealth and resources are distributed to living children.
If no children, wealth is distributed to random villagers (estate tax).
Returns dict with inheritance details.
"""
from backend.config import get_config
age_config = get_config().age
if not age_config.inheritance_enabled:
return {"enabled": False}
inheritance_info = {
"enabled": True,
"deceased": dead_agent.name,
"total_money": dead_agent.money,
"total_resources": sum(r.quantity for r in dead_agent.inventory),
"beneficiaries": [],
}
# Find living children
living_children = []
for child_id in dead_agent.children_ids:
child = self.get_agent(child_id)
if child and child.is_alive():
living_children.append(child)
if living_children:
# Distribute equally among children
money_per_child = dead_agent.money // len(living_children)
for child in living_children:
child.money += money_per_child
inheritance_info["beneficiaries"].append({
"name": child.name,
"money": money_per_child,
})
# Distribute resources (round-robin)
for i, resource in enumerate(dead_agent.inventory):
recipient = living_children[i % len(living_children)]
recipient.add_to_inventory(resource)
else:
# No children - distribute to random villagers (estate tax effect)
living = self.get_living_agents()
if living:
# Give money to poorest villagers
poorest = sorted(living, key=lambda a: a.money)[:3]
if poorest:
money_each = dead_agent.money // len(poorest)
for villager in poorest:
villager.money += money_each
inheritance_info["beneficiaries"].append({
"name": villager.name,
"money": money_each,
"relation": "community"
})
# Clear dead agent's inventory (already distributed or lost)
dead_agent.inventory.clear()
dead_agent.money = 0
return inheritance_info
def get_agent(self, agent_id: str) -> Optional[Agent]:
"""Get an agent by ID."""
for agent in self.agents:
if agent.id == agent_id:
return agent
return None
def remove_dead_agents(self) -> list[Agent]:
"""Remove all dead agents from the world. Returns list of removed agents.
Note: This is now handled by the engine's corpse system for visualization.
@ -136,44 +340,149 @@ class World:
dead_agents = [a for a in self.agents if not a.is_alive() and not a.is_corpse()]
# Don't actually remove here - let the engine handle corpse visualization
return dead_agents
def advance_time(self) -> None:
"""Advance the simulation time by one step."""
def advance_time(self) -> bool:
"""Advance the simulation time by one step.
Returns True if a new day started (for age/birth processing).
"""
self.current_turn += 1
self.step_in_day += 1
total_steps = self.config.day_steps + self.config.night_steps
new_day = False
if self.step_in_day > total_steps:
self.step_in_day = 1
self.current_day += 1
new_day = True
# Determine time of day
if self.step_in_day <= self.config.day_steps:
self.time_of_day = TimeOfDay.DAY
else:
self.time_of_day = TimeOfDay.NIGHT
return new_day
def process_new_day(self) -> dict:
"""Process all new-day events: aging, births, sinks.
Returns a dict with events that happened.
"""
events = {
"aged_agents": [],
"births": [],
"age_deaths": [],
"storage_decay": {},
"taxes_collected": 0,
"random_events": [],
}
# Age all living agents
for agent in self.get_living_agents():
agent.age_one_day()
events["aged_agents"].append(agent.id)
# Check for births (only from living agents after aging)
for agent in self.get_living_agents():
if agent.can_give_birth(self.current_day):
child = self.spawn_child(agent)
if child:
events["births"].append({
"parent_id": agent.id,
"child_id": child.id,
"child_name": child.name,
})
return events
def update_village_storage(self) -> None:
"""Update the village-wide storage tracking."""
# Reset counts
for key in self.village_storage:
self.village_storage[key] = 0
# Count all resources in agent inventories
for agent in self.get_living_agents():
for resource in agent.inventory:
res_type = resource.type.value
if res_type in self.village_storage:
self.village_storage[res_type] += resource.quantity
def get_storage_limit(self, resource_type: str) -> int:
"""Get the storage limit for a resource type."""
from backend.config import get_config
storage_config = get_config().storage
limit_map = {
"meat": storage_config.village_meat_limit,
"berries": storage_config.village_berries_limit,
"water": storage_config.village_water_limit,
"wood": storage_config.village_wood_limit,
"hide": storage_config.village_hide_limit,
"clothes": storage_config.village_clothes_limit,
}
return limit_map.get(resource_type, 999999)
def get_storage_available(self, resource_type: str) -> int:
"""Get how much more of a resource can be stored village-wide."""
self.update_village_storage()
limit = self.get_storage_limit(resource_type)
current = self.village_storage.get(resource_type, 0)
return max(0, limit - current)
def is_storage_full(self, resource_type: str) -> bool:
"""Check if village storage for a resource type is full."""
return self.get_storage_available(resource_type) <= 0
def record_death(self, agent: Agent, reason: str) -> None:
"""Record a death and update statistics."""
self.total_agents_died += 1
if reason == "age":
self.total_deaths_by_age += 1
elif reason == "hunger":
self.total_deaths_by_starvation += 1
elif reason == "thirst":
self.total_deaths_by_thirst += 1
elif reason == "heat":
self.total_deaths_by_cold += 1
def is_night(self) -> bool:
"""Check if it's currently night."""
return self.time_of_day == TimeOfDay.NIGHT
def get_living_agents(self) -> list[Agent]:
"""Get all living agents (excludes corpses)."""
return [a for a in self.agents if a.is_alive() and not a.is_corpse()]
def get_statistics(self) -> dict:
"""Get current world statistics including wealth distribution."""
"""Get current world statistics including wealth distribution and demographics."""
living = self.get_living_agents()
total_money = sum(a.money for a in living)
# Count emergent professions (updated based on current skills)
profession_counts = {}
for agent in living:
agent._update_profession() # Update based on current state
prof = agent.profession.value
profession_counts[prof] = profession_counts.get(prof, 0) + 1
# Age demographics
age_distribution = {"young": 0, "prime": 0, "old": 0}
ages = []
generations = {}
for agent in living:
category = agent.get_age_category()
age_distribution[category] = age_distribution.get(category, 0) + 1
ages.append(agent.age)
gen = agent.generation
generations[gen] = generations.get(gen, 0) + 1
avg_age = sum(ages) / len(ages) if ages else 0
oldest_age = max(ages) if ages else 0
youngest_age = min(ages) if ages else 0
# Calculate wealth inequality metrics
if living:
moneys = sorted([a.money for a in living])
@ -181,7 +490,7 @@ class World:
median_money = moneys[len(moneys) // 2]
richest = moneys[-1] if moneys else 0
poorest = moneys[0] if moneys else 0
# Gini coefficient for inequality (0 = perfect equality, 1 = max inequality)
n = len(moneys)
if n > 1 and total_money > 0:
@ -191,7 +500,10 @@ class World:
gini = 0
else:
avg_money = median_money = richest = poorest = gini = 0
# Update village storage
self.update_village_storage()
return {
"current_turn": self.current_turn,
"current_day": self.current_day,
@ -200,6 +512,7 @@ class World:
"living_agents": len(living),
"total_agents_spawned": self.total_agents_spawned,
"total_agents_died": self.total_agents_died,
"total_births": self.total_births,
"total_money_in_circulation": total_money,
"professions": profession_counts,
# Wealth inequality metrics
@ -208,8 +521,23 @@ class World:
"richest_agent": richest,
"poorest_agent": poorest,
"gini_coefficient": round(gini, 3),
# Age demographics
"age_distribution": age_distribution,
"avg_age": round(avg_age, 1),
"oldest_agent": oldest_age,
"youngest_agent": youngest_age,
"generations": generations,
# Death statistics
"deaths_by_cause": {
"age": self.total_deaths_by_age,
"starvation": self.total_deaths_by_starvation,
"thirst": self.total_deaths_by_thirst,
"cold": self.total_deaths_by_cold,
},
# Village storage
"village_storage": self.village_storage.copy(),
}
def get_state_snapshot(self) -> dict:
"""Get a full snapshot of the world state for API."""
return {
@ -221,10 +549,10 @@ class World:
"agents": [a.to_dict() for a in self.agents],
"statistics": self.get_statistics(),
}
def initialize(self) -> None:
"""Initialize the world with diverse starting agents.
Creates a mix of agent archetypes to seed profession diversity:
- Some hunters (risk-takers who hunt)
- Some gatherers (cautious resource collectors)
@ -232,7 +560,7 @@ class World:
- Some generalists (balanced approach)
"""
n = self.config.initial_agents
# Distribute archetypes for diversity
# ~15% hunters, ~15% gatherers, ~15% traders, ~10% woodcutters, 45% random
archetypes = (
@ -241,14 +569,14 @@ class World:
["trader"] * max(1, n // 7) +
["woodcutter"] * max(1, n // 10)
)
# Fill remaining slots with random (no archetype)
while len(archetypes) < n:
archetypes.append(None)
# Shuffle to randomize positions
random.shuffle(archetypes)
for archetype in archetypes:
self.spawn_agent(archetype=archetype)

View File

@ -25,6 +25,12 @@ def _get_agent_stats_config():
return get_config().agent_stats
def _get_age_config():
"""Get age configuration from global config."""
from backend.config import get_config
return get_config().age
class Profession(Enum):
"""Agent professions - now derived from personality and skills."""
VILLAGER = "villager"
@ -96,14 +102,24 @@ class AgentStats:
# Critical threshold - loaded from config
CRITICAL_THRESHOLD: float = field(default=0.25)
def apply_passive_decay(self, has_clothes: bool = False) -> None:
"""Apply passive stat decay each turn."""
self.energy = max(0, self.energy - self.ENERGY_DECAY)
self.hunger = max(0, self.hunger - self.HUNGER_DECAY)
self.thirst = max(0, self.thirst - self.THIRST_DECAY)
def apply_passive_decay(self, has_clothes: bool = False, decay_modifier: float = 1.0) -> None:
"""Apply passive stat decay each turn.
Args:
has_clothes: Whether agent has clothes (reduces heat decay)
decay_modifier: Age-based modifier (old agents decay faster)
"""
energy_decay = int(self.ENERGY_DECAY * decay_modifier)
hunger_decay = int(self.HUNGER_DECAY * decay_modifier)
thirst_decay = int(self.THIRST_DECAY * decay_modifier)
self.energy = max(0, self.energy - energy_decay)
self.hunger = max(0, self.hunger - hunger_decay)
self.thirst = max(0, self.thirst - thirst_decay)
# Clothes reduce heat loss by 50%
heat_decay = self.HEAT_DECAY // 2 if has_clothes else self.HEAT_DECAY
heat_decay = int(self.HEAT_DECAY * decay_modifier)
heat_decay = heat_decay // 2 if has_clothes else heat_decay
self.heat = max(0, self.heat - heat_decay)
def is_critical(self) -> bool:
@ -217,6 +233,11 @@ class Agent:
Stats, inventory slots, and starting money are loaded from config.json.
Each agent now has unique personality traits and skills that create
emergent behaviors and professions.
Age affects skills, energy costs, and survival:
- Young (< 25): Learning faster, lower skill effectiveness, less energy cost
- Prime (25-45): Peak performance
- Old (> 45): Higher skill effectiveness (wisdom), but higher energy costs
"""
id: str = field(default_factory=lambda: str(uuid4())[:8])
name: str = ""
@ -230,6 +251,15 @@ class Agent:
personality: PersonalityTraits = field(default_factory=PersonalityTraits)
skills: Skills = field(default_factory=Skills)
# Age system - age is in "years" where 1 year = 1 simulation day
age: int = field(default=-1) # -1 signals to use random start age
max_age: int = field(default=-1) # -1 signals to calculate from config
birth_day: int = 0 # Day this agent was born (0 = initial spawn)
last_birth_day: int = -1000 # Last day this agent gave birth (for cooldown)
parent_ids: list[str] = field(default_factory=list) # IDs of parents (for lineage)
children_ids: list[str] = field(default_factory=list) # IDs of children
generation: int = 0 # 0 = initial spawn, 1+ = born in simulation
# Movement and action tracking
home_position: Position = field(default_factory=Position)
current_action: AgentAction = field(default_factory=AgentAction)
@ -267,6 +297,21 @@ class Agent:
if self.INVENTORY_SLOTS == -1:
self.INVENTORY_SLOTS = config.inventory_slots
# Initialize age system
age_config = _get_age_config()
if self.age == -1:
# Random starting age within configured range
self.age = random.randint(age_config.min_start_age, age_config.max_start_age)
if self.max_age == -1:
# Calculate max age with variance
variance = random.randint(-age_config.max_age_variance, age_config.max_age_variance)
self.max_age = age_config.base_max_age + variance
# Apply age-based max energy adjustment for old agents
if self.get_age_category() == "old":
self.stats.MAX_ENERGY = int(self.stats.MAX_ENERGY * age_config.old_max_energy_multiplier)
self.stats.energy = min(self.stats.energy, self.stats.MAX_ENERGY)
# Update profession based on personality and skills
self._update_profession()
@ -282,6 +327,111 @@ class Agent:
}
self.profession = profession_map.get(prof_type, Profession.VILLAGER)
def get_age_category(self) -> str:
"""Get the agent's age category: 'young', 'prime', or 'old'."""
age_config = _get_age_config()
if self.age < age_config.young_age_threshold:
return "young"
elif self.age <= age_config.old_age_threshold:
return "prime"
else:
return "old"
def get_skill_modifier(self) -> float:
"""Get skill effectiveness modifier based on age.
Young agents are less effective but learn faster.
Old agents are more effective (wisdom) but learn slower.
"""
age_config = _get_age_config()
category = self.get_age_category()
if category == "young":
return age_config.young_skill_multiplier
elif category == "prime":
return age_config.prime_skill_multiplier
else:
return age_config.old_skill_multiplier
def get_learning_modifier(self) -> float:
"""Get learning rate modifier based on age."""
age_config = _get_age_config()
category = self.get_age_category()
if category == "young":
return age_config.young_learning_multiplier
elif category == "prime":
return age_config.prime_learning_multiplier
else:
return age_config.old_learning_multiplier
def get_energy_cost_modifier(self) -> float:
"""Get energy cost modifier based on age.
Young agents use less energy.
Old agents use more energy.
"""
age_config = _get_age_config()
category = self.get_age_category()
if category == "young":
return age_config.young_energy_cost_multiplier
elif category == "prime":
return age_config.prime_energy_cost_multiplier
else:
return age_config.old_energy_cost_multiplier
def get_decay_modifier(self) -> float:
"""Get stat decay modifier based on age.
Old agents decay faster (frailer).
"""
age_config = _get_age_config()
if self.get_age_category() == "old":
return age_config.old_decay_multiplier
return 1.0
def age_one_day(self) -> None:
"""Age the agent by one day (called at day transition)."""
age_config = _get_age_config()
self.age += age_config.age_per_day
# Check if agent just became old - reduce max energy
if self.age == age_config.old_age_threshold + 1:
self.stats.MAX_ENERGY = int(self.stats.MAX_ENERGY * age_config.old_max_energy_multiplier)
self.stats.energy = min(self.stats.energy, self.stats.MAX_ENERGY)
def is_too_old(self) -> bool:
"""Check if agent has exceeded their maximum age."""
return self.age >= self.max_age
def can_give_birth(self, current_day: int) -> bool:
"""Check if agent is eligible to give birth."""
age_config = _get_age_config()
# Age check
if self.age < age_config.min_birth_age or self.age > age_config.max_birth_age:
return False
# Cooldown check
days_since_birth = current_day - self.last_birth_day
if days_since_birth < age_config.birth_cooldown_days:
return False
# Resource check
if self.stats.hunger < age_config.birth_food_requirement:
return False
if self.stats.energy < age_config.birth_energy_requirement:
return False
return True
def record_birth(self, current_day: int, child_id: str) -> None:
"""Record that this agent gave birth."""
self.last_birth_day = current_day
self.children_ids.append(child_id)
# Birth is exhausting - reduce stats
self.stats.energy = max(0, self.stats.energy - 20)
self.stats.hunger = max(0, self.stats.hunger - 30)
def record_action(self, action_type: str) -> None:
"""Record an action for profession tracking."""
if action_type in self.actions_performed:
@ -308,11 +458,13 @@ class Agent:
def is_alive(self) -> bool:
"""Check if the agent is still alive."""
return (
self.stats.hunger > 0 and
self.stats.thirst > 0 and
self.stats.heat > 0
)
# Death by needs
if self.stats.hunger <= 0 or self.stats.thirst <= 0 or self.stats.heat <= 0:
return False
# Death by old age
if self.is_too_old():
return False
return True
def is_corpse(self) -> bool:
"""Check if this agent is a corpse (died but still visible)."""
@ -493,8 +645,9 @@ class Agent:
return expired
def apply_passive_decay(self) -> None:
"""Apply passive stat decay for this turn."""
self.stats.apply_passive_decay(has_clothes=self.has_clothes())
"""Apply passive stat decay for this turn, modified by age."""
decay_modifier = self.get_decay_modifier()
self.stats.apply_passive_decay(has_clothes=self.has_clothes(), decay_modifier=decay_modifier)
def mark_dead(self, turn: int, reason: str) -> None:
"""Mark this agent as dead."""
@ -522,6 +675,18 @@ class Agent:
"last_action_result": self.last_action_result,
"death_turn": self.death_turn,
"death_reason": self.death_reason,
# Age system
"age": self.age,
"max_age": self.max_age,
"age_category": self.get_age_category(),
"birth_day": self.birth_day,
"generation": self.generation,
"parent_ids": self.parent_ids.copy(),
"children_count": len(self.children_ids),
# Age modifiers (for UI display)
"skill_modifier": round(self.get_skill_modifier(), 2),
"energy_cost_modifier": round(self.get_energy_cost_modifier(), 2),
"learning_modifier": round(self.get_learning_modifier(), 2),
# New fields for agent diversity
"personality": self.personality.to_dict(),
"skills": self.skills.to_dict(),

View File

@ -34,23 +34,23 @@ class ProfessionType(Enum):
@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"
@ -58,12 +58,12 @@ class PersonalityTraits:
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),
@ -81,54 +81,63 @@ class PersonalityTraits:
@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."""
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 = {
@ -140,7 +149,7 @@ class Skills:
}
best_skill = max(skills, key=skills.get)
return best_skill, skills[best_skill]
def to_dict(self) -> dict:
return {
"hunting": round(self.hunting, 3),
@ -153,14 +162,14 @@ class Skills:
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
- "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
@ -175,18 +184,18 @@ def generate_random_personality(archetype: Optional[str] = None) -> PersonalityT
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)
@ -196,50 +205,63 @@ def generate_random_personality(archetype: Optional[str] = None) -> PersonalityT
# 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.
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,
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),
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.
"""
@ -250,27 +272,27 @@ def determine_profession(personality: PersonalityTraits, skills: Skills) -> Prof
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.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
@ -280,16 +302,16 @@ def get_action_skill_modifier(skill_value: float) -> float:
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:

View File

@ -11,47 +11,98 @@
"max_thirst": 100,
"max_heat": 100,
"start_energy": 50,
"start_hunger": 70,
"start_thirst": 75,
"start_hunger": 80,
"start_thirst": 85,
"start_heat": 100,
"energy_decay": 1,
"hunger_decay": 2,
"thirst_decay": 3,
"heat_decay": 3,
"thirst_decay": 2,
"heat_decay": 2,
"critical_threshold": 0.25,
"low_energy_threshold": 12
},
"age": {
"min_start_age": 18,
"max_start_age": 28,
"young_age_threshold": 25,
"prime_age_start": 25,
"prime_age_end": 50,
"old_age_threshold": 50,
"base_max_age": 75,
"max_age_variance": 8,
"age_per_day": 1,
"birth_cooldown_days": 8,
"min_birth_age": 20,
"max_birth_age": 50,
"birth_base_chance": 0.06,
"birth_prosperity_multiplier": 2.5,
"birth_food_requirement": 40,
"birth_energy_requirement": 15,
"birth_wealth_transfer": 0.15,
"inheritance_enabled": true,
"child_start_age": 18,
"young_skill_multiplier": 0.85,
"young_learning_multiplier": 1.3,
"young_energy_cost_multiplier": 0.9,
"prime_skill_multiplier": 1.0,
"prime_learning_multiplier": 1.0,
"prime_energy_cost_multiplier": 1.0,
"old_skill_multiplier": 1.1,
"old_learning_multiplier": 0.7,
"old_energy_cost_multiplier": 1.15,
"old_max_energy_multiplier": 0.8,
"old_decay_multiplier": 1.1
},
"storage": {
"village_meat_limit": 200,
"village_berries_limit": 300,
"village_water_limit": 400,
"village_wood_limit": 400,
"village_hide_limit": 150,
"village_clothes_limit": 100,
"market_order_limit_per_agent": 5,
"market_total_order_limit": 500
},
"sinks": {
"daily_village_decay_rate": 0.01,
"daily_tax_rate": 0.005,
"random_event_chance": 0.02,
"fire_event_resource_loss": 0.05,
"theft_event_money_loss": 0.03,
"clothes_maintenance_per_day": 1,
"fire_wood_cost_per_night": 1
},
"resources": {
"meat_decay": 10,
"berries_decay": 6,
"clothes_decay": 20,
"meat_decay": 12,
"berries_decay": 8,
"clothes_decay": 30,
"meat_hunger": 45,
"meat_energy": 15,
"berries_hunger": 8,
"berries_thirst": 2,
"berries_hunger": 10,
"berries_thirst": 3,
"water_thirst": 50,
"fire_heat": 20
"fire_heat": 25
},
"actions": {
"sleep_energy": 55,
"rest_energy": 12,
"hunt_energy": -5,
"gather_energy": -4,
"chop_wood_energy": -6,
"gather_energy": -3,
"chop_wood_energy": -5,
"get_water_energy": -2,
"weave_energy": -6,
"build_fire_energy": -4,
"weave_energy": -5,
"build_fire_energy": -3,
"trade_energy": -1,
"hunt_success": 0.85,
"chop_wood_success": 0.9,
"hunt_meat_min": 2,
"hunt_meat_max": 4,
"hunt_meat_max": 5,
"hunt_hide_min": 0,
"hunt_hide_max": 2,
"gather_min": 2,
"gather_max": 4,
"chop_wood_min": 1,
"chop_wood_max": 3
"gather_min": 3,
"gather_max": 5,
"chop_wood_min": 2,
"chop_wood_max": 4
},
"world": {
"width": 25,
@ -59,7 +110,7 @@
"initial_agents": 25,
"day_steps": 10,
"night_steps": 1,
"inventory_slots": 12,
"inventory_slots": 15,
"starting_money": 8000
},
"market": {
@ -77,4 +128,4 @@
"min_price_discount": 0.4
},
"auto_step_interval": 0.15
}
}

View File

@ -11,47 +11,98 @@
"max_thirst": 100,
"max_heat": 100,
"start_energy": 50,
"start_hunger": 70,
"start_thirst": 75,
"start_hunger": 80,
"start_thirst": 85,
"start_heat": 100,
"energy_decay": 1,
"hunger_decay": 2,
"thirst_decay": 3,
"heat_decay": 3,
"thirst_decay": 2,
"heat_decay": 2,
"critical_threshold": 0.25,
"low_energy_threshold": 12
},
"age": {
"min_start_age": 18,
"max_start_age": 28,
"young_age_threshold": 25,
"prime_age_start": 25,
"prime_age_end": 50,
"old_age_threshold": 50,
"base_max_age": 75,
"max_age_variance": 8,
"age_per_day": 1,
"birth_cooldown_days": 8,
"min_birth_age": 20,
"max_birth_age": 50,
"birth_base_chance": 0.06,
"birth_prosperity_multiplier": 2.5,
"birth_food_requirement": 40,
"birth_energy_requirement": 15,
"birth_wealth_transfer": 0.15,
"inheritance_enabled": true,
"child_start_age": 18,
"young_skill_multiplier": 0.85,
"young_learning_multiplier": 1.3,
"young_energy_cost_multiplier": 0.9,
"prime_skill_multiplier": 1.0,
"prime_learning_multiplier": 1.0,
"prime_energy_cost_multiplier": 1.0,
"old_skill_multiplier": 1.1,
"old_learning_multiplier": 0.7,
"old_energy_cost_multiplier": 1.15,
"old_max_energy_multiplier": 0.8,
"old_decay_multiplier": 1.1
},
"storage": {
"village_meat_limit": 200,
"village_berries_limit": 300,
"village_water_limit": 400,
"village_wood_limit": 400,
"village_hide_limit": 150,
"village_clothes_limit": 100,
"market_order_limit_per_agent": 5,
"market_total_order_limit": 500
},
"sinks": {
"daily_village_decay_rate": 0.01,
"daily_tax_rate": 0.005,
"random_event_chance": 0.02,
"fire_event_resource_loss": 0.05,
"theft_event_money_loss": 0.03,
"clothes_maintenance_per_day": 1,
"fire_wood_cost_per_night": 1
},
"resources": {
"meat_decay": 10,
"berries_decay": 6,
"clothes_decay": 20,
"meat_decay": 12,
"berries_decay": 8,
"clothes_decay": 30,
"meat_hunger": 45,
"meat_energy": 15,
"berries_hunger": 8,
"berries_thirst": 2,
"berries_hunger": 10,
"berries_thirst": 3,
"water_thirst": 50,
"fire_heat": 20
"fire_heat": 25
},
"actions": {
"sleep_energy": 55,
"rest_energy": 12,
"hunt_energy": -5,
"gather_energy": -4,
"chop_wood_energy": -6,
"gather_energy": -3,
"chop_wood_energy": -5,
"get_water_energy": -2,
"weave_energy": -6,
"build_fire_energy": -4,
"weave_energy": -5,
"build_fire_energy": -3,
"trade_energy": -1,
"hunt_success": 0.85,
"chop_wood_success": 0.9,
"hunt_meat_min": 2,
"hunt_meat_max": 4,
"hunt_meat_max": 5,
"hunt_hide_min": 0,
"hunt_hide_max": 2,
"gather_min": 2,
"gather_max": 4,
"chop_wood_min": 1,
"chop_wood_max": 3
"gather_min": 3,
"gather_max": 5,
"chop_wood_min": 2,
"chop_wood_max": 4
},
"world": {
"width": 25,
@ -59,7 +110,7 @@
"initial_agents": 25,
"day_steps": 10,
"night_steps": 1,
"inventory_slots": 12,
"inventory_slots": 15,
"starting_money": 80
},
"market": {
@ -69,6 +120,7 @@
},
"economy": {
"energy_to_money_ratio": 1.5,
"min_price": 1,
"wealth_desire": 0.35,
"buy_efficiency_threshold": 0.75,
"min_wealth_target": 50,
@ -76,4 +128,4 @@
"min_price_discount": 0.4
},
"auto_step_interval": 0.15
}
}