diff --git a/backend/api/schemas.py b/backend/api/schemas.py index da1681e..772032d 100644 --- a/backend/api/schemas.py +++ b/backend/api/schemas.py @@ -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): diff --git a/backend/config.py b/backend/config.py index ee4e02c..2c4442a 100644 --- a/backend/config.py +++ b/backend/config.py @@ -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), ) diff --git a/backend/core/engine.py b/backend/core/engine.py index d50711a..24285c4 100644 --- a/backend/core/engine.py +++ b/backend/core/engine.py @@ -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, diff --git a/backend/core/logger.py b/backend/core/logger.py index 18587d3..30b1249 100644 --- a/backend/core/logger.py +++ b/backend/core/logger.py @@ -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() diff --git a/backend/core/world.py b/backend/core/world.py index e8211a2..da2191f 100644 --- a/backend/core/world.py +++ b/backend/core/world.py @@ -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) diff --git a/backend/domain/agent.py b/backend/domain/agent.py index 9cbdc6b..ef1333d 100644 --- a/backend/domain/agent.py +++ b/backend/domain/agent.py @@ -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(), diff --git a/backend/domain/personality.py b/backend/domain/personality.py index af31ef3..1eb2c29 100644 --- a/backend/domain/personality.py +++ b/backend/domain/personality.py @@ -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: diff --git a/config.json b/config.json index e2d124b..a9bfc09 100644 --- a/config.json +++ b/config.json @@ -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 -} \ No newline at end of file +} diff --git a/config_goap_optimized.json b/config_goap_optimized.json index 1a00e63..89e4950 100644 --- a/config_goap_optimized.json +++ b/config_goap_optimized.json @@ -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 -} \ No newline at end of file +}