diff --git a/.cursor/rules/00-python-env.mdc b/.cursor/rules/00-python-env.mdc
new file mode 100644
index 0000000..2c2c477
--- /dev/null
+++ b/.cursor/rules/00-python-env.mdc
@@ -0,0 +1,29 @@
+---
+description: "Ensure Python is always run via the project's .venv virtual environment"
+globs: ["**/*.py", "**/*"]
+alwaysApply: true
+---
+# Python Environment Rule
+
+## Context
+- Applies to all Python files and commands in the VillSim project.
+
+## Requirements
+1. **Always use the virtual environment** located at `.venv/` for running Python scripts.
+2. Activate with: `source .venv/bin/activate` (Unix/macOS) before running any Python commands.
+3. When running Python files, use: `python` (after activation) or `.venv/bin/python` directly.
+4. Install dependencies with: `.venv/bin/pip install -r requirements.txt`.
+5. Never use system Python - all scripts, tests, and tasks must use the `.venv` interpreter.
+
+## Examples
+
+
+**Valid:**
+- `source .venv/bin/activate && python backend/main.py`
+- `.venv/bin/python tools/run_headless_analysis.py`
+- `.venv/bin/pip install -r requirements.txt`
+
+**Invalid:**
+- `python backend/main.py` (using system Python)
+- `pip install package` (using system pip)
+
diff --git a/.cursor/rules/01-config-management.mdc b/.cursor/rules/01-config-management.mdc
new file mode 100644
index 0000000..bfd9b4d
--- /dev/null
+++ b/.cursor/rules/01-config-management.mdc
@@ -0,0 +1,54 @@
+---
+description: "Always use config.json as the single source of truth for all simulation parameters"
+globs: ["**/*.py", "config.json"]
+alwaysApply: true
+---
+# Configuration Management Rule
+
+## Context
+- Applies across all code that references configuration or simulation parameters.
+- The `config.json` file is the **single source of truth** for:
+ - Agent stats (max_energy, max_hunger, decay rates, thresholds)
+ - Resource properties (decay rates, hunger/thirst/energy values)
+ - Action costs and success rates
+ - World parameters (dimensions, agent count, day/night cycles)
+ - Market settings (discounts, price multipliers)
+ - Economy parameters (ratios, thresholds, markup limits)
+ - UI settings (auto_step_interval)
+
+## Requirements
+1. **Never hardcode values** that exist or should exist in `config.json`.
+2. When adding new features, add corresponding config entries to `config.json`.
+3. Load config values at runtime using the existing config mechanism:
+ ```python
+ from backend.config import Config
+ config = Config()
+ # Access via: config.agent_stats, config.resources, config.actions, etc.
+ ```
+4. Keep `config.json` synchronized with any code changes.
+5. Always check `config.json` first before adding magic numbers to code.
+
+## Examples
+
+
+**Valid:**
+```python
+from backend.config import Config
+config = Config()
+max_energy = config.agent_stats["max_energy"]
+hunt_success = config.actions["hunt_success"]
+```
+
+**Invalid:**
+```python
+max_energy = 50 # Hardcoded instead of using config
+hunt_success = 0.70 # Magic number
+```
+
+
+## Project Structure Reference
+- Backend code: `backend/` (API, core simulation, domain models)
+- Frontend code: `frontend/` (client, renderers)
+- Tools/utilities: `tools/` (analysis scripts, config converters)
+- Documentation: `docs/design/`
+- Logs output: `logs/`
diff --git a/backend/api/routes.py b/backend/api/routes.py
index 7d6c0c6..b193444 100644
--- a/backend/api/routes.py
+++ b/backend/api/routes.py
@@ -89,20 +89,34 @@ def get_market_prices():
"/control/initialize",
response_model=ControlResponse,
summary="Initialize simulation",
- description="Initialize or reset the simulation with the specified parameters.",
+ description="Initialize or reset the simulation. Uses config.json values if no params provided.",
)
-def initialize_simulation(request: InitializeRequest):
- """Initialize or reset the simulation."""
+def initialize_simulation(request: InitializeRequest = None):
+ """Initialize or reset the simulation.
+
+ If request is provided with specific values, use those.
+ Otherwise, use values from config.json.
+ """
engine = get_engine()
- config = WorldConfig(
- width=request.world_width,
- height=request.world_height,
- initial_agents=request.num_agents,
- )
- engine.reset(config)
+
+ if request and (request.num_agents != 8 or request.world_width != 20 or request.world_height != 20):
+ # Custom values provided - use them
+ from backend.core.world import WorldConfig
+ config = WorldConfig(
+ width=request.world_width,
+ height=request.world_height,
+ initial_agents=request.num_agents,
+ )
+ engine.reset(config)
+ num_agents = request.num_agents
+ else:
+ # Use values from config.json
+ engine.reset() # This now loads from config.json automatically
+ num_agents = engine.world.config.initial_agents
+
return ControlResponse(
success=True,
- message=f"Simulation initialized with {request.num_agents} agents",
+ message=f"Simulation initialized with {num_agents} agents",
turn=engine.world.current_turn,
mode=engine.mode.value,
)
diff --git a/backend/core/ai.py b/backend/core/ai.py
index 3474ba7..e714b48 100644
--- a/backend/core/ai.py
+++ b/backend/core/ai.py
@@ -1,14 +1,16 @@
"""AI decision system for agents in the Village Simulation.
-Major rework to create market-driven economy:
-- Agents understand that BUYING saves energy (trading is smart!)
-- Wealth accumulation as a goal (money = safety buffer)
-- Dynamic pricing based on supply/demand signals
-- Proactive trading - buy low, sell high
-- Market participation is now central to survival strategy
+Major rework to create diverse, personality-driven economy:
+- Each agent has unique personality traits affecting all decisions
+- Emergent professions: Hunters, Gatherers, Traders, Generalists
+- Class inequality through varied strategies and skills
+- Traders focus on arbitrage (buy low, sell high)
+- Personality affects: risk tolerance, hoarding, market participation
-Key insight: An agent with money can survive without working.
-The market is not a last resort - it's the optimal strategy when prices are good.
+Key insight: Different personalities lead to different strategies.
+Traders don't gather - they profit from others' labor.
+Hunters take risks for bigger rewards.
+Gatherers play it safe.
"""
import random
@@ -18,6 +20,7 @@ from typing import Optional, TYPE_CHECKING
from backend.domain.agent import Agent
from backend.domain.action import ActionType, ACTION_CONFIG
from backend.domain.resources import ResourceType
+from backend.domain.personality import get_trade_price_modifier
if TYPE_CHECKING:
from backend.core.market import OrderBook
@@ -108,23 +111,23 @@ def _get_economy_config():
class AgentAI:
- """AI decision maker with market-driven economy behavior.
+ """AI decision maker with personality-driven economy behavior.
- Core philosophy: Trading is SMART, not a last resort.
+ Core philosophy: Each agent has a unique strategy based on personality.
- The agent now understands:
- 1. Buying is often more efficient than gathering (saves energy!)
- 2. Money is power - wealth means safety and flexibility
- 3. Selling at good prices builds wealth
- 4. Adjusting prices responds to supply/demand
- 5. The market is a tool for survival, not just emergency trades
+ Personality effects:
+ 1. wealth_desire: How aggressively to accumulate money
+ 2. hoarding_rate: How much to keep vs. sell on market
+ 3. risk_tolerance: Hunt (risky, high reward) vs. gather (safe)
+ 4. market_affinity: How often to engage with market
+ 5. trade_preference: High = trader profession (arbitrage focus)
+ 6. price_sensitivity: How picky about deals
- Economic behaviors:
- - Calculate "fair value" of resources based on energy cost
- - Buy when market price < energy cost to gather
- - Sell when market price > production cost
- - Adjust prices based on market conditions (supply/demand)
- - Accumulate wealth as a safety buffer
+ Emergent professions:
+ - Traders: High trade_preference + market_affinity = buy low, sell high
+ - Hunters: High hunt_preference + risk_tolerance = meat production
+ - Gatherers: High gather_preference, low risk = safe resource collection
+ - Generalists: Balanced approach to all activities
"""
# Thresholds for stat management
@@ -135,19 +138,16 @@ class AgentAI:
REST_ENERGY_THRESHOLD = 18 # Rest when below this if no urgent needs
WORK_ENERGY_MINIMUM = 20 # Prefer to have this much for work
- # Resource stockpile targets
- MIN_WATER_STOCK = 3
- MIN_FOOD_STOCK = 4
- MIN_WOOD_STOCK = 3
+ # Resource stockpile targets (modified by personality.hoarding_rate)
+ BASE_WATER_STOCK = 2
+ BASE_FOOD_STOCK = 3
+ BASE_WOOD_STOCK = 2
# Heat thresholds
HEAT_PROACTIVE_THRESHOLD = 0.50
- # ECONOMY SETTINGS - These make agents trade more
+ # Base economy settings (modified by personality)
ENERGY_TO_MONEY_RATIO = 1.5 # 1 energy = ~1.5 coins in perceived value
- WEALTH_DESIRE = 0.3 # How much agents want to accumulate wealth (0-1)
- BUY_EFFICIENCY_THRESHOLD = 0.7 # Buy if market price < 70% of gather cost
- MIN_WEALTH_TARGET = 50 # Agents want at least this much money
MAX_PRICE_MARKUP = 2.0 # Maximum markup over base price
MIN_PRICE_DISCOUNT = 0.5 # Minimum discount (50% of base price)
@@ -158,18 +158,38 @@ class AgentAI:
self.day_steps = day_steps
self.current_turn = current_turn
+ # Personality shortcuts
+ self.p = agent.personality # Convenience shortcut
+ self.skills = agent.skills
+
# Load thresholds from config
config = _get_ai_config()
self.CRITICAL_THRESHOLD = config.critical_threshold
self.LOW_ENERGY_THRESHOLD = config.low_energy_threshold
- # Try to load economy config
+ # Personality-adjusted values
+ # Wealth desire from personality (0.1 to 0.9)
+ self.WEALTH_DESIRE = self.p.wealth_desire
+
+ # Buy efficiency threshold adjusted by price sensitivity
+ # High sensitivity = only buy very good deals
economy = _get_economy_config()
- if economy:
- self.ENERGY_TO_MONEY_RATIO = getattr(economy, 'energy_to_money_ratio', self.ENERGY_TO_MONEY_RATIO)
- self.WEALTH_DESIRE = getattr(economy, 'wealth_desire', self.WEALTH_DESIRE)
- self.BUY_EFFICIENCY_THRESHOLD = getattr(economy, 'buy_efficiency_threshold', self.BUY_EFFICIENCY_THRESHOLD)
- self.MIN_WEALTH_TARGET = getattr(economy, 'min_wealth_target', self.MIN_WEALTH_TARGET)
+ base_threshold = getattr(economy, 'buy_efficiency_threshold', 0.7) if economy else 0.7
+ self.BUY_EFFICIENCY_THRESHOLD = base_threshold / self.p.price_sensitivity
+
+ # Wealth target scaled by wealth desire
+ base_target = getattr(economy, 'min_wealth_target', 50) if economy else 50
+ self.MIN_WEALTH_TARGET = int(base_target * (0.5 + self.p.wealth_desire))
+
+ # Resource stockpile targets modified by hoarding rate
+ # High hoarders keep more in reserve
+ hoarding_mult = 0.5 + self.p.hoarding_rate # 0.6 to 1.4
+ self.MIN_WATER_STOCK = max(1, int(self.BASE_WATER_STOCK * hoarding_mult))
+ self.MIN_FOOD_STOCK = max(2, int(self.BASE_FOOD_STOCK * hoarding_mult))
+ self.MIN_WOOD_STOCK = max(1, int(self.BASE_WOOD_STOCK * hoarding_mult))
+
+ # Trader mode: agents with high trade preference become market-focused
+ self.is_trader = self.p.trade_preference > 1.3 and self.p.market_affinity > 0.5
@property
def is_evening(self) -> bool:
@@ -200,12 +220,13 @@ class AgentAI:
return self.agent.money >= self.MIN_WEALTH_TARGET
def decide(self) -> AIDecision:
- """Make a decision based on survival AND economic optimization.
+ """Make a decision based on survival, personality, and economic goals.
- Key insight: Trading is often BETTER than gathering because:
- 1. Trade uses only 1 energy vs 4-8 for gathering
- 2. If market price < energy cost, buying is pure profit
- 3. Money = stored energy = safety buffer
+ Decision flow varies by personality:
+ - Traders prioritize market operations (arbitrage)
+ - Hunters prefer hunting when possible
+ - Gatherers stick to safe resource collection
+ - All agents prioritize survival when needed
"""
# Priority 1: Critical survival needs (immediate danger)
decision = self._check_critical_needs()
@@ -217,33 +238,155 @@ class AgentAI:
if decision:
return decision
- # Priority 3: Price adjustment - respond to market conditions
+ # Priority 3: Trader-specific behavior (high trade_preference agents)
+ # Traders focus on market operations when survival is secured
+ if self.is_trader:
+ decision = self._do_trader_behavior()
+ if decision:
+ return decision
+
+ # Priority 4: Price adjustment - respond to market conditions
decision = self._check_price_adjustments()
if decision:
return decision
- # Priority 4: Smart shopping - buy good deals on the market!
- decision = self._check_market_opportunities()
- if decision:
- return decision
+ # Priority 5: Smart shopping - buy good deals on the market!
+ # Frequency affected by market_affinity
+ if random.random() < self.p.market_affinity:
+ decision = self._check_market_opportunities()
+ if decision:
+ return decision
- # Priority 5: Craft clothes if we have hide
+ # Priority 6: Craft clothes if we have hide
decision = self._check_clothes_crafting()
if decision:
return decision
- # Priority 6: Energy management
+ # Priority 7: Energy management
decision = self._check_energy()
if decision:
return decision
- # Priority 7: Economic activities (sell excess, build wealth)
- decision = self._check_economic()
+ # Priority 8: Economic activities (sell excess, build wealth)
+ # Frequency affected by hoarding_rate (low hoarding = sell more)
+ if random.random() > self.p.hoarding_rate * 0.5:
+ decision = self._check_economic()
+ if decision:
+ return decision
+
+ # Priority 9: Routine survival work (gather resources we need)
+ return self._do_survival_work()
+
+ def _do_trader_behavior(self) -> Optional[AIDecision]:
+ """Trader-specific behavior: focus on arbitrage and market operations.
+
+ Traders don't gather much - they profit from buying low and selling high.
+ Core trader strategy:
+ 1. Look for arbitrage opportunities (price differences)
+ 2. Buy underpriced goods
+ 3. Sell at markup
+ 4. Build wealth through trading margins
+ """
+ # Traders need money to operate
+ if self.agent.money < 20:
+ # Low on capital - need to do some work or sell inventory
+ decision = self._try_to_sell(urgent=True)
+ if decision:
+ return decision
+ # If nothing to sell, do some quick gathering
+ return None # Fall through to normal behavior
+
+ # Look for arbitrage opportunities
+ # Find resources being sold below fair value
+ best_deal = None
+ best_margin = 0
+
+ for resource_type in [ResourceType.MEAT, ResourceType.BERRIES, ResourceType.WATER, ResourceType.WOOD]:
+ order = self.market.get_cheapest_order(resource_type)
+ if not order or order.seller_id == self.agent.id:
+ continue
+
+ fair_value = self._get_resource_fair_value(resource_type)
+
+ # Check if we can profit by buying and reselling
+ buy_price = order.price_per_unit
+ # Apply trading skill to get better prices
+ sell_modifier = get_trade_price_modifier(self.skills.trading, is_buying=False)
+ potential_sell_price = int(fair_value * sell_modifier * 1.1) # 10% markup target
+
+ margin = potential_sell_price - buy_price
+ if margin > best_margin and self.agent.money >= buy_price:
+ best_margin = margin
+ best_deal = (resource_type, order, buy_price, potential_sell_price)
+
+ # Execute arbitrage if profitable
+ if best_deal and best_margin >= 2: # At least 2 coins profit
+ resource_type, order, buy_price, sell_price = best_deal
+ safe_price = max(1, buy_price) # Prevent division by zero
+ quantity = min(3, self.agent.inventory_space(), order.quantity,
+ self.agent.money // safe_price)
+
+ if quantity > 0:
+ return AIDecision(
+ action=ActionType.TRADE,
+ target_resource=resource_type,
+ order_id=order.id,
+ quantity=quantity,
+ price=buy_price,
+ reason=f"Trader: buying {resource_type.value} @ {buy_price}c (resell @ {sell_price}c)",
+ )
+
+ # If holding inventory, try to sell at markup
+ decision = self._try_trader_sell()
if decision:
return decision
- # Priority 8: Routine survival work (gather resources we need)
- return self._do_survival_work()
+ # Adjust prices on existing orders
+ decision = self._check_price_adjustments()
+ if decision:
+ return decision
+
+ return None
+
+ def _try_trader_sell(self) -> Optional[AIDecision]:
+ """Trader sells inventory at markup prices."""
+ for resource in self.agent.inventory:
+ if resource.type == ResourceType.CLOTHES:
+ continue
+
+ # Traders sell everything except minimal survival reserves
+ if resource.quantity <= 1:
+ continue
+
+ fair_value = self._get_resource_fair_value(resource.type)
+
+ # Apply trading skill for better sell prices
+ sell_modifier = get_trade_price_modifier(self.skills.trading, is_buying=False)
+
+ # Check market conditions
+ signal = self.market.get_market_signal(resource.type)
+ if signal == "sell": # Scarcity - high markup
+ price = int(fair_value * sell_modifier * 1.4)
+ else:
+ price = int(fair_value * sell_modifier * 1.15)
+
+ # Don't undercut ourselves if we have active orders
+ my_orders = [o for o in self.market.get_orders_by_seller(self.agent.id)
+ if o.resource_type == resource.type]
+ if my_orders:
+ continue # Wait for existing order to sell
+
+ quantity = resource.quantity - 1 # Keep 1 for emergencies
+
+ return AIDecision(
+ action=ActionType.TRADE,
+ target_resource=resource.type,
+ quantity=quantity,
+ price=price,
+ reason=f"Trader: selling {resource.type.value} @ {price}c (markup)",
+ )
+
+ return None
def _check_critical_needs(self) -> Optional[AIDecision]:
"""Check if any vital stat is critical and act accordingly."""
@@ -372,6 +515,10 @@ class AgentAI:
if not order or order.seller_id == self.agent.id:
continue
+ # Skip invalid orders (price <= 0)
+ if order.price_per_unit <= 0:
+ continue
+
if self.agent.money < order.price_per_unit:
continue
@@ -395,7 +542,8 @@ class AgentAI:
# Score this opportunity
if is_good_deal:
# Good deal - definitely consider buying
- efficiency_score = fair_value / order.price_per_unit # How much we're saving
+ price = max(1, order.price_per_unit) # Prevent division by zero
+ efficiency_score = fair_value / price # How much we're saving
total_score = need_score + efficiency_score * 2
shopping_list.append((resource_type, order, total_score))
elif need_score > 0 and self._is_wealthy():
@@ -415,7 +563,8 @@ class AgentAI:
return None
# Calculate how much to buy
- can_afford = self.agent.money // order.price_per_unit
+ price = max(1, order.price_per_unit) # Prevent division by zero
+ can_afford = self.agent.money // price
space = self.agent.inventory_space()
want_quantity = min(2, can_afford, space, order.quantity) # Buy up to 2 at a time
@@ -697,16 +846,21 @@ class AgentAI:
def _try_proactive_sell(self) -> Optional[AIDecision]:
"""Proactively sell when market conditions are good.
- Sell when:
- - Market signal says 'sell' (scarcity)
- - We have more than minimum stock
- - We could use more money (not wealthy)
+ Affected by personality:
+ - hoarding_rate: High hoarders keep more, sell less
+ - wealth_desire: High wealth desire = more aggressive selling
+ - trade_preference: High traders sell more frequently
"""
if self._is_wealthy() and self.agent.inventory_space() > 3:
# Already rich and have space, no rush to sell
return None
- survival_minimums = {
+ # Hoarders are reluctant to sell
+ if random.random() < self.p.hoarding_rate * 0.7:
+ return None
+
+ # Survival minimums scaled by hoarding rate
+ base_min = {
ResourceType.WATER: 2,
ResourceType.MEAT: 1,
ResourceType.BERRIES: 2,
@@ -714,6 +868,10 @@ class AgentAI:
ResourceType.HIDE: 0,
}
+ # High hoarders keep more
+ hoarding_mult = 0.5 + self.p.hoarding_rate
+ survival_minimums = {k: int(v * hoarding_mult) for k, v in base_min.items()}
+
# Look for profitable sales
best_opportunity = None
best_score = 0
@@ -732,12 +890,15 @@ class AgentAI:
signal = self.market.get_market_signal(resource.type)
fair_value = self._get_resource_fair_value(resource.type)
+ # Apply trading skill for better sell prices
+ sell_modifier = get_trade_price_modifier(self.skills.trading, is_buying=False)
+
# Calculate optimal price
if signal == "sell": # Scarcity - we can charge more
- price = int(fair_value * 1.3) # 30% markup
+ price = int(fair_value * 1.3 * sell_modifier)
score = 3 + excess
elif signal == "hold": # Normal market
- price = fair_value
+ price = int(fair_value * sell_modifier)
score = 1 + excess * 0.5
else: # Surplus - price competitively
# Find cheapest competitor
@@ -745,9 +906,12 @@ class AgentAI:
if cheapest and cheapest.seller_id != self.agent.id:
price = max(1, cheapest.price_per_unit - 1)
else:
- price = int(fair_value * 0.8)
+ price = int(fair_value * 0.8 * sell_modifier)
score = 0.5 # Not a great time to sell
+ # Wealth desire increases sell motivation
+ score *= (0.7 + self.p.wealth_desire * 0.6)
+
if score > best_score:
best_score = score
best_opportunity = (resource.type, min(excess, 3), price) # Sell up to 3 at a time
@@ -759,7 +923,7 @@ class AgentAI:
target_resource=resource_type,
quantity=quantity,
price=price,
- reason=f"Market opportunity: selling {resource_type.value} @ {price}c",
+ reason=f"Selling {resource_type.value} @ {price}c",
)
return None
@@ -811,11 +975,13 @@ class AgentAI:
return max(1, suggested)
def _do_survival_work(self) -> AIDecision:
- """Perform work based on what we need most for survival.
+ """Perform work based on survival needs AND personality preferences.
- KEY CHANGE: Always consider buying as an alternative!
- If there's a good deal on the market, BUY instead of gathering.
- This is the core economic behavior we want.
+ Personality effects:
+ - hunt_preference: Likelihood of choosing to hunt
+ - gather_preference: Likelihood of choosing to gather
+ - risk_tolerance: Affects hunt vs gather choice
+ - market_affinity: Likelihood of buying vs gathering
"""
stats = self.agent.stats
@@ -832,18 +998,23 @@ class AgentAI:
# Helper to decide: buy or gather?
def get_resource_decision(resource_type: ResourceType, gather_action: ActionType, reason: str) -> AIDecision:
"""Decide whether to buy or gather a resource."""
- # Check if buying is efficient
- order = self.market.get_cheapest_order(resource_type)
- if order and order.seller_id != self.agent.id and self.agent.money >= order.price_per_unit:
- if self._is_good_buy(resource_type, order.price_per_unit):
- return AIDecision(
- action=ActionType.TRADE,
- target_resource=resource_type,
- order_id=order.id,
- quantity=1,
- price=order.price_per_unit,
- reason=f"{reason} (buying @ {order.price_per_unit}c - good deal!)",
- )
+ # Check if buying is efficient (affected by market_affinity)
+ if random.random() < self.p.market_affinity:
+ order = self.market.get_cheapest_order(resource_type)
+ if order and order.seller_id != self.agent.id and self.agent.money >= order.price_per_unit:
+ # Apply trading skill for better buy prices
+ buy_modifier = get_trade_price_modifier(self.skills.trading, is_buying=True)
+ effective_price = order.price_per_unit # Skill affects perceived value
+
+ if self._is_good_buy(resource_type, effective_price):
+ return AIDecision(
+ action=ActionType.TRADE,
+ target_resource=resource_type,
+ order_id=order.id,
+ quantity=1,
+ price=order.price_per_unit,
+ reason=f"{reason} (buying @ {order.price_per_unit}c)",
+ )
# Gather it ourselves
config = ACTION_CONFIG[gather_action]
@@ -866,6 +1037,7 @@ class AgentAI:
return decision
# Priority: Stock up on wood if low (for heat)
+ # Affected by woodcut_preference
if wood_count < self.MIN_WOOD_STOCK and heat_urgency > 0.3:
decision = get_resource_decision(
ResourceType.WOOD,
@@ -877,18 +1049,28 @@ class AgentAI:
# Priority: Stock up on food if low
if food_count < self.MIN_FOOD_STOCK:
- # Decide between hunting and gathering based on conditions
- # Meat is more valuable (more hunger restored), but hunting costs more energy
hunt_config = ACTION_CONFIG[ActionType.HUNT]
- gather_config = ACTION_CONFIG[ActionType.GATHER]
- # Prefer hunting if:
- # - We have enough energy for hunt
- # - AND (we have no meat OR random chance favors hunting for diversity)
+ # Personality-driven choice between hunting and gathering
+ # risk_tolerance and hunt_preference affect this choice
can_hunt = stats.energy >= abs(hunt_config.energy_cost) + 5
- prefer_hunt = (meat_count == 0) or (can_hunt and random.random() < 0.4) # 40% hunt chance
- if prefer_hunt and can_hunt:
+ # Calculate hunt probability based on personality
+ # High risk_tolerance + high hunt_preference = more hunting
+ hunt_score = self.p.hunt_preference * self.p.risk_tolerance
+ gather_score = self.p.gather_preference * (1.5 - self.p.risk_tolerance)
+
+ # Normalize to probability
+ total = hunt_score + gather_score
+ hunt_prob = hunt_score / total if total > 0 else 0.3
+
+ # Also prefer hunting if we have no meat
+ if meat_count == 0:
+ hunt_prob = min(0.8, hunt_prob + 0.3)
+
+ prefer_hunt = can_hunt and random.random() < hunt_prob
+
+ if prefer_hunt:
decision = get_resource_decision(
ResourceType.MEAT,
ActionType.HUNT,
@@ -921,22 +1103,26 @@ class AgentAI:
if decision:
return decision
- # Default: varied work based on need (with buy checks)
+ # Default: varied work based on need AND personality preferences
needs = []
if water_count < self.MIN_WATER_STOCK + 2:
- needs.append((ResourceType.WATER, ActionType.GET_WATER, "Getting water", 2))
+ needs.append((ResourceType.WATER, ActionType.GET_WATER, "Getting water", 2.0))
if food_count < self.MIN_FOOD_STOCK + 2:
- # Both berries and hunting are valid options
- needs.append((ResourceType.BERRIES, ActionType.GATHER, "Gathering berries", 2))
- # Add hunting with good weight if we have energy
+ # Weight by personality preferences
+ needs.append((ResourceType.BERRIES, ActionType.GATHER, "Gathering berries",
+ 2.0 * self.p.gather_preference))
+
+ # Add hunting weighted by personality
hunt_config = ACTION_CONFIG[ActionType.HUNT]
if stats.energy >= abs(hunt_config.energy_cost) + 3:
- needs.append((ResourceType.MEAT, ActionType.HUNT, "Hunting for meat", 2)) # Same weight as berries
+ hunt_weight = 2.0 * self.p.hunt_preference * self.p.risk_tolerance
+ needs.append((ResourceType.MEAT, ActionType.HUNT, "Hunting for meat", hunt_weight))
if wood_count < self.MIN_WOOD_STOCK + 2:
- needs.append((ResourceType.WOOD, ActionType.CHOP_WOOD, "Chopping wood", 1))
+ needs.append((ResourceType.WOOD, ActionType.CHOP_WOOD, "Chopping wood",
+ 1.0 * self.p.woodcut_preference))
if not needs:
# We have good reserves, maybe sell excess or rest
@@ -945,26 +1131,34 @@ class AgentAI:
if decision:
return decision
- # Default: maintain food supply
+ # Default activity based on personality
+ # High hunt_preference = hunt, else gather
+ if self.p.hunt_preference > self.p.gather_preference and stats.energy >= 10:
+ return AIDecision(
+ action=ActionType.HUNT,
+ target_resource=ResourceType.MEAT,
+ reason="Default: hunting (personality)",
+ )
return AIDecision(
action=ActionType.GATHER,
target_resource=ResourceType.BERRIES,
- reason="Maintaining supplies",
+ reason="Default: gathering (personality)",
)
- # For each need, check if we can buy cheaply
- for resource_type, action, reason, weight in needs:
- order = self.market.get_cheapest_order(resource_type)
- if order and order.seller_id != self.agent.id and self.agent.money >= order.price_per_unit:
- if self._is_good_buy(resource_type, order.price_per_unit):
- return AIDecision(
- action=ActionType.TRADE,
- target_resource=resource_type,
- order_id=order.id,
- quantity=1,
- price=order.price_per_unit,
- reason=f"{reason} (buying cheap!)",
- )
+ # For each need, check if we can buy cheaply (market_affinity affects this)
+ if random.random() < self.p.market_affinity:
+ for resource_type, action, reason, weight in needs:
+ order = self.market.get_cheapest_order(resource_type)
+ if order and order.seller_id != self.agent.id and self.agent.money >= order.price_per_unit:
+ if self._is_good_buy(resource_type, order.price_per_unit):
+ return AIDecision(
+ action=ActionType.TRADE,
+ target_resource=resource_type,
+ order_id=order.id,
+ quantity=1,
+ price=order.price_per_unit,
+ reason=f"{reason} (buying cheap!)",
+ )
# Weighted random selection for gathering
total_weight = sum(weight for _, _, _, weight in needs)
diff --git a/backend/core/engine.py b/backend/core/engine.py
index 10e9e6c..8aeefa6 100644
--- a/backend/core/engine.py
+++ b/backend/core/engine.py
@@ -10,6 +10,7 @@ from typing import Optional
from backend.domain.agent import Agent
from backend.domain.action import ActionType, ActionResult, ACTION_CONFIG
from backend.domain.resources import Resource, ResourceType
+from backend.domain.personality import get_action_skill_modifier
from backend.core.world import World, WorldConfig, TimeOfDay
from backend.core.market import OrderBook
from backend.core.ai import get_ai_decision, AIDecision
@@ -59,7 +60,8 @@ class GameEngine:
self.market = OrderBook()
self.mode = SimulationMode.MANUAL
self.is_running = False
- self.auto_step_interval = 1.0 # seconds
+ # Load auto_step_interval from config
+ self.auto_step_interval = get_config().auto_step_interval
self._auto_thread: Optional[threading.Thread] = None
self._stop_event = threading.Event()
self.turn_logs: list[TurnLog] = []
@@ -86,9 +88,16 @@ class GameEngine:
self.world.initialize()
self.is_running = True
- def initialize(self, num_agents: int = 8) -> None:
- """Initialize the simulation with agents."""
- self.world.config.initial_agents = num_agents
+ def initialize(self, num_agents: Optional[int] = None) -> None:
+ """Initialize the simulation with agents.
+
+ Args:
+ num_agents: Number of agents to spawn. If None, uses config.json value.
+ """
+ if num_agents is not None:
+ self.world.config.initial_agents = num_agents
+ # Otherwise use the value already loaded from config.json
+
self.world.initialize()
# Start logging session
@@ -323,7 +332,14 @@ class GameEngine:
return ActionResult(action_type=action, success=False, message="Unknown action")
def _execute_work(self, agent: Agent, action: ActionType, config) -> ActionResult:
- """Execute a work action (hunting, gathering, etc.)."""
+ """Execute a work action (hunting, gathering, etc.).
+
+ Skills now affect outcomes:
+ - Hunting skill affects hunt success rate
+ - Gathering skill affects gather output
+ - Woodcutting skill affects wood output
+ - Skills improve with use
+ """
# Check energy
energy_cost = abs(config.energy_cost)
if not agent.spend_energy(energy_cost):
@@ -344,8 +360,19 @@ class GameEngine:
)
agent.remove_from_inventory(config.requires_resource, config.requires_quantity)
- # Check success chance
- if random.random() > config.success_chance:
+ # 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)
+ # 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
return ActionResult(
action_type=action,
success=False,
@@ -353,11 +380,14 @@ class GameEngine:
message="Action failed",
)
- # Generate output
+ # Generate output (modified by skill for quantity)
resources_gained = []
if config.output_resource:
- quantity = random.randint(config.min_output, config.max_output)
+ # Skill affects output quantity
+ base_quantity = random.randint(config.min_output, config.max_output)
+ quantity = max(config.min_output, int(base_quantity * skill_modifier))
+
if quantity > 0:
resource = Resource(
type=config.output_resource,
@@ -372,9 +402,10 @@ class GameEngine:
created_turn=self.world.current_turn,
))
- # Secondary output (e.g., hide from hunting)
+ # Secondary output (e.g., hide from hunting) - also affected by skill
if config.secondary_output:
- quantity = random.randint(config.secondary_min, config.secondary_max)
+ base_quantity = random.randint(config.secondary_min, config.secondary_max)
+ quantity = max(0, int(base_quantity * skill_modifier))
if quantity > 0:
resource = Resource(
type=config.secondary_output,
@@ -389,6 +420,11 @@ class GameEngine:
created_turn=self.world.current_turn,
))
+ # Record action and improve skill
+ agent.record_action(action.value)
+ if skill_name:
+ agent.skills.improve(skill_name, 0.015) # Skill improves with successful use
+
# Build success message with details
gained_str = ", ".join(f"+{r.quantity} {r.type.value}" for r in resources_gained)
message = f"{action.value}: {gained_str}" if gained_str else f"{action.value} (nothing gained)"
@@ -401,8 +437,21 @@ class GameEngine:
message=message,
)
+ def _get_skill_for_action(self, action: ActionType) -> Optional[str]:
+ """Get the skill name that affects a given action."""
+ skill_map = {
+ ActionType.HUNT: "hunting",
+ ActionType.GATHER: "gathering",
+ ActionType.CHOP_WOOD: "woodcutting",
+ ActionType.WEAVE: "crafting",
+ }
+ return skill_map.get(action)
+
def _execute_trade(self, agent: Agent, decision: AIDecision) -> ActionResult:
- """Execute a trade action (buy, sell, or price adjustment). Supports multi-item trades."""
+ """Execute a trade action (buy, sell, or price adjustment). Supports multi-item trades.
+
+ Trading skill improves with successful trades and affects prices slightly.
+ """
config = ACTION_CONFIG[ActionType.TRADE]
# Handle price adjustments (no energy cost)
@@ -445,13 +494,19 @@ class GameEngine:
)
agent.add_to_inventory(resource)
- # Add money to seller
+ # Add money to seller and record their trade
seller = self.world.get_agent(result.seller_id)
if seller:
seller.money += result.total_paid
+ seller.record_trade(result.total_paid)
+ seller.skills.improve("trading", 0.02) # Seller skill improves
agent.spend_energy(abs(config.energy_cost))
+ # Record buyer's trade and improve skill
+ agent.record_action("trade")
+ agent.skills.improve("trading", 0.01) # Buyer skill improves less
+
return ActionResult(
action_type=ActionType.TRADE,
success=True,
@@ -467,7 +522,7 @@ class GameEngine:
)
elif decision.target_resource and decision.quantity > 0:
- # Selling to market
+ # Selling to market (listing)
if agent.has_resource(decision.target_resource, decision.quantity):
agent.remove_from_inventory(decision.target_resource, decision.quantity)
@@ -480,6 +535,7 @@ class GameEngine:
)
agent.spend_energy(abs(config.energy_cost))
+ agent.record_action("trade") # Track listing action
return ActionResult(
action_type=ActionType.TRADE,
diff --git a/backend/core/market.py b/backend/core/market.py
index e640b39..a95d25a 100644
--- a/backend/core/market.py
+++ b/backend/core/market.py
@@ -118,6 +118,12 @@ class TradeResult:
}
+def _get_market_config():
+ """Load market configuration from config.json."""
+ from backend.config import get_config
+ return get_config().market
+
+
@dataclass
class OrderBook:
"""Central market order book with supply/demand tracking.
@@ -127,14 +133,16 @@ class OrderBook:
- Calculate supply/demand scores
- Suggest prices based on market conditions
- Allow sellers to adjust prices dynamically
+
+ Configuration is loaded from config.json.
"""
orders: list[Order] = field(default_factory=list)
trade_history: list[TradeResult] = field(default_factory=list)
price_history: dict[ResourceType, PriceHistory] = field(default_factory=dict)
- # Configuration
- TURNS_BEFORE_DISCOUNT: int = 3
- DISCOUNT_RATE: float = 0.15 # 15% discount after waiting
+ # Configuration - defaults loaded from config.json in __post_init__
+ TURNS_BEFORE_DISCOUNT: int = 15
+ DISCOUNT_RATE: float = 0.12
# Supply/demand thresholds
LOW_SUPPLY_THRESHOLD: int = 3 # Less than this = scarcity
@@ -142,7 +150,15 @@ class OrderBook:
DEMAND_DECAY: float = 0.95 # How fast demand score decays per turn
def __post_init__(self):
- """Initialize price history for all resource types."""
+ """Initialize price history and load config values."""
+ # Load market config from config.json
+ try:
+ cfg = _get_market_config()
+ self.TURNS_BEFORE_DISCOUNT = cfg.turns_before_discount
+ self.DISCOUNT_RATE = cfg.discount_rate
+ except Exception:
+ pass # Use defaults if config not available
+
if not self.price_history:
for resource_type in ResourceType:
self.price_history[resource_type] = PriceHistory()
diff --git a/backend/core/world.py b/backend/core/world.py
index a421f4f..e8211a2 100644
--- a/backend/core/world.py
+++ b/backend/core/world.py
@@ -1,4 +1,9 @@
-"""World container for the Village Simulation."""
+"""World container for the Village Simulation.
+
+The world spawns diverse agents with varied personality traits,
+skills, and starting conditions to create emergent professions
+and class inequality.
+"""
import random
from dataclasses import dataclass, field
@@ -6,6 +11,10 @@ from enum import Enum
from typing import Optional
from backend.domain.agent import Agent, Position, Profession
+from backend.domain.personality import (
+ PersonalityTraits, Skills,
+ generate_random_personality, generate_random_skills
+)
class TimeOfDay(Enum):
@@ -14,20 +23,42 @@ class TimeOfDay(Enum):
NIGHT = "night"
+def _get_world_config_from_file():
+ """Load world configuration from config.json."""
+ from backend.config import get_config
+ return get_config().world
+
+
@dataclass
class WorldConfig:
- """Configuration for the world."""
- width: int = 20
- height: int = 20
- initial_agents: int = 8
+ """Configuration for the world.
+
+ Default values are loaded from config.json via create_world_config().
+ These hardcoded defaults are only fallbacks.
+ """
+ width: int = 25
+ height: int = 25
+ initial_agents: int = 25
day_steps: int = 10
night_steps: int = 1
+def create_world_config() -> WorldConfig:
+ """Factory function to create WorldConfig from config.json."""
+ cfg = _get_world_config_from_file()
+ return WorldConfig(
+ width=cfg.width,
+ height=cfg.height,
+ initial_agents=cfg.initial_agents,
+ day_steps=cfg.day_steps,
+ night_steps=cfg.night_steps,
+ )
+
+
@dataclass
class World:
"""Container for all entities in the simulation."""
- config: WorldConfig = field(default_factory=WorldConfig)
+ config: WorldConfig = field(default_factory=create_world_config)
agents: list[Agent] = field(default_factory=list)
current_turn: int = 0
current_day: int = 1
@@ -43,22 +74,48 @@ class World:
name: Optional[str] = None,
profession: Optional[Profession] = None,
position: Optional[Position] = None,
+ archetype: Optional[str] = None,
+ starting_money: Optional[int] = None,
) -> Agent:
- """Spawn a new agent in the world."""
- # All agents are now generic villagers - profession is not used for decisions
- if profession is None:
- profession = Profession.VILLAGER
+ """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)
+ """
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
+ personality = generate_random_personality(archetype)
+ skills = generate_random_skills(personality)
+
+ # 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
+ money_multiplier = random.uniform(0.3, 2.0)
+ # Traders start with more money (their capital)
+ 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,
+ profession=Profession.VILLAGER, # Will be updated based on personality
position=position,
+ personality=personality,
+ skills=skills,
+ money=starting_money,
)
self.agents.append(agent)
@@ -106,15 +163,35 @@ class World:
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."""
+ """Get current world statistics including wealth distribution."""
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
+ # Calculate wealth inequality metrics
+ if living:
+ moneys = sorted([a.money for a in living])
+ avg_money = total_money / len(living)
+ 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:
+ sum_of_diffs = sum(abs(m1 - m2) for m1 in moneys for m2 in moneys)
+ gini = sum_of_diffs / (2 * n * total_money)
+ else:
+ gini = 0
+ else:
+ avg_money = median_money = richest = poorest = gini = 0
+
return {
"current_turn": self.current_turn,
"current_day": self.current_day,
@@ -125,6 +202,12 @@ class World:
"total_agents_died": self.total_agents_died,
"total_money_in_circulation": total_money,
"professions": profession_counts,
+ # Wealth inequality metrics
+ "avg_money": round(avg_money, 1),
+ "median_money": median_money,
+ "richest_agent": richest,
+ "poorest_agent": poorest,
+ "gini_coefficient": round(gini, 3),
}
def get_state_snapshot(self) -> dict:
@@ -140,7 +223,32 @@ class World:
}
def initialize(self) -> None:
- """Initialize the world with starting agents."""
- for _ in range(self.config.initial_agents):
- self.spawn_agent()
+ """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)
+ - Some traders (market-focused wealth builders)
+ - Some generalists (balanced approach)
+ """
+ n = self.config.initial_agents
+
+ # Distribute archetypes for diversity
+ # ~15% hunters, ~15% gatherers, ~15% traders, ~10% woodcutters, 45% random
+ archetypes = (
+ ["hunter"] * max(1, n // 7) +
+ ["gatherer"] * max(1, n // 7) +
+ ["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 1c02b3f..3b54ba6 100644
--- a/backend/domain/agent.py
+++ b/backend/domain/agent.py
@@ -1,6 +1,8 @@
"""Agent model for the Village Simulation.
Agent stats are loaded dynamically from the global config.
+Each agent now has unique personality traits and skills that create
+emergent professions and behavioral diversity.
"""
import math
@@ -11,6 +13,10 @@ from typing import Optional
from uuid import uuid4
from .resources import Resource, ResourceType, RESOURCE_EFFECTS
+from .personality import (
+ PersonalityTraits, Skills, ProfessionType,
+ determine_profession
+)
def _get_agent_stats_config():
@@ -20,11 +26,12 @@ def _get_agent_stats_config():
class Profession(Enum):
- """Agent professions - kept for backwards compatibility but no longer used."""
+ """Agent professions - now derived from personality and skills."""
VILLAGER = "villager"
HUNTER = "hunter"
GATHERER = "gatherer"
WOODCUTTER = "woodcutter"
+ TRADER = "trader"
CRAFTER = "crafter"
@@ -208,15 +215,21 @@ class Agent:
"""An agent in the village simulation.
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.
"""
id: str = field(default_factory=lambda: str(uuid4())[:8])
name: str = ""
- profession: Profession = Profession.VILLAGER # No longer used for decision making
+ profession: Profession = Profession.VILLAGER # Now derived from personality/skills
position: Position = field(default_factory=Position)
stats: AgentStats = field(default_factory=create_agent_stats)
inventory: list[Resource] = field(default_factory=list)
money: int = field(default=-1) # -1 signals to use config value
+ # Personality and skills - create agent diversity
+ personality: PersonalityTraits = field(default_factory=PersonalityTraits)
+ skills: Skills = field(default_factory=Skills)
+
# Movement and action tracking
home_position: Position = field(default_factory=Position)
current_action: AgentAction = field(default_factory=AgentAction)
@@ -226,6 +239,13 @@ class Agent:
death_turn: int = -1 # Turn when agent died, -1 if alive
death_reason: str = "" # Cause of death
+ # Statistics tracking for profession determination
+ actions_performed: dict = field(default_factory=lambda: {
+ "hunt": 0, "gather": 0, "chop_wood": 0, "trade": 0, "craft": 0
+ })
+ total_trades_completed: int = 0
+ total_money_earned: int = 0
+
# Configuration - loaded from config
INVENTORY_SLOTS: int = field(default=-1) # -1 signals to use config value
MOVE_SPEED: float = 0.8 # Grid cells per turn
@@ -242,6 +262,32 @@ class Agent:
self.money = config.starting_money
if self.INVENTORY_SLOTS == -1:
self.INVENTORY_SLOTS = config.inventory_slots
+
+ # Update profession based on personality and skills
+ self._update_profession()
+
+ def _update_profession(self) -> None:
+ """Update profession based on personality and skills."""
+ prof_type = determine_profession(self.personality, self.skills)
+ profession_map = {
+ ProfessionType.HUNTER: Profession.HUNTER,
+ ProfessionType.GATHERER: Profession.GATHERER,
+ ProfessionType.WOODCUTTER: Profession.WOODCUTTER,
+ ProfessionType.TRADER: Profession.TRADER,
+ ProfessionType.GENERALIST: Profession.VILLAGER,
+ }
+ self.profession = profession_map.get(prof_type, Profession.VILLAGER)
+
+ def record_action(self, action_type: str) -> None:
+ """Record an action for profession tracking."""
+ if action_type in self.actions_performed:
+ self.actions_performed[action_type] += 1
+
+ def record_trade(self, money_earned: int) -> None:
+ """Record a completed trade for statistics."""
+ self.total_trades_completed += 1
+ if money_earned > 0:
+ self.total_money_earned += money_earned
def is_alive(self) -> bool:
"""Check if the agent is still alive."""
@@ -440,6 +486,9 @@ class Agent:
def to_dict(self) -> dict:
"""Convert to dictionary for API serialization."""
+ # Update profession before serializing
+ self._update_profession()
+
return {
"id": self.id,
"name": self.name,
@@ -456,4 +505,10 @@ class Agent:
"last_action_result": self.last_action_result,
"death_turn": self.death_turn,
"death_reason": self.death_reason,
+ # New fields for agent diversity
+ "personality": self.personality.to_dict(),
+ "skills": self.skills.to_dict(),
+ "actions_performed": self.actions_performed.copy(),
+ "total_trades": self.total_trades_completed,
+ "total_money_earned": self.total_money_earned,
}
diff --git a/backend/domain/personality.py b/backend/domain/personality.py
new file mode 100644
index 0000000..af31ef3
--- /dev/null
+++ b/backend/domain/personality.py
@@ -0,0 +1,297 @@
+"""Personality and skill system for agents in the Village Simulation.
+
+Each agent has unique personality traits that affect their behavior:
+- How much they value wealth vs. survival resources
+- What activities they prefer (hunting, gathering, trading)
+- How willing they are to take risks
+- How much they hoard vs. trade
+
+Agents also develop skills over time based on their actions:
+- Skills improve with practice
+- Better skills = better outcomes
+
+This creates emergent professions:
+- Hunters: High hunting skill, prefer meat production
+- Gatherers: High gathering skill, prefer berries/water/wood
+- Traders: High trading skill, focus on buy low / sell high arbitrage
+"""
+
+import random
+from dataclasses import dataclass, field
+from enum import Enum
+from typing import Optional
+
+
+class ProfessionType(Enum):
+ """Emergent profession types based on behavior patterns."""
+ HUNTER = "hunter"
+ GATHERER = "gatherer"
+ WOODCUTTER = "woodcutter"
+ TRADER = "trader"
+ GENERALIST = "generalist"
+
+
+@dataclass
+class PersonalityTraits:
+ """Unique personality traits that affect agent behavior.
+
+ These are set at birth and don't change during the agent's life.
+ They create natural diversity in the population.
+ """
+ # How much the agent values accumulating wealth (0.1 = minimal, 0.9 = greedy)
+ wealth_desire: float = 0.3
+
+ # How much the agent hoards resources vs trades them (0.1 = trades freely, 0.9 = hoards)
+ hoarding_rate: float = 0.5
+
+ # Willingness to take risks (0.1 = very cautious, 0.9 = risk-taker)
+ # Affects: hunting vs gathering preference, price decisions
+ risk_tolerance: float = 0.5
+
+ # Sensitivity to good/bad deals (0.5 = not picky, 1.5 = very price conscious)
+ price_sensitivity: float = 1.0
+
+ # Activity biases - how much the agent prefers each activity
+ # Higher values = more likely to choose this activity
+ # These create "profession tendencies"
+ hunt_preference: float = 1.0 # Preference for hunting
+ gather_preference: float = 1.0 # Preference for gathering
+ woodcut_preference: float = 1.0 # Preference for wood
+ trade_preference: float = 1.0 # Preference for trading/market
+
+ # How social/market-oriented the agent is
+ # High = frequent market visits, buys more from others
+ # Low = self-sufficient, prefers to produce own resources
+ market_affinity: float = 0.5
+
+ def to_dict(self) -> dict:
+ return {
+ "wealth_desire": round(self.wealth_desire, 2),
+ "hoarding_rate": round(self.hoarding_rate, 2),
+ "risk_tolerance": round(self.risk_tolerance, 2),
+ "price_sensitivity": round(self.price_sensitivity, 2),
+ "hunt_preference": round(self.hunt_preference, 2),
+ "gather_preference": round(self.gather_preference, 2),
+ "woodcut_preference": round(self.woodcut_preference, 2),
+ "trade_preference": round(self.trade_preference, 2),
+ "market_affinity": round(self.market_affinity, 2),
+ }
+
+
+@dataclass
+class Skills:
+ """Skills that improve with practice.
+
+ Each skill affects the outcome of related actions.
+ Skills increase slowly through practice (use it or lose it).
+ """
+ # Combat/hunting skill - affects hunt success rate
+ hunting: float = 1.0
+
+ # Foraging skill - affects gather output quantity
+ gathering: float = 1.0
+
+ # Woodcutting skill - affects wood output
+ woodcutting: float = 1.0
+
+ # Trading skill - affects prices (buy lower, sell higher)
+ trading: float = 1.0
+
+ # Crafting skill - affects craft quality/success
+ crafting: float = 1.0
+
+ # Skill improvement rate per action
+ IMPROVEMENT_RATE: float = 0.02
+
+ # Skill decay rate per turn (use it or lose it, gentle decay)
+ DECAY_RATE: float = 0.001
+
+ # Maximum skill level
+ MAX_SKILL: float = 2.0
+
+ # Minimum skill level
+ MIN_SKILL: float = 0.5
+
+ def improve(self, skill_name: str, amount: Optional[float] = None) -> None:
+ """Improve a skill through practice."""
+ if amount is None:
+ amount = self.IMPROVEMENT_RATE
+
+ if hasattr(self, skill_name):
+ current = getattr(self, skill_name)
+ new_value = min(self.MAX_SKILL, current + amount)
+ setattr(self, skill_name, new_value)
+
+ def decay_all(self) -> None:
+ """Apply gentle decay to all skills (use it or lose it)."""
+ for skill_name in ['hunting', 'gathering', 'woodcutting', 'trading', 'crafting']:
+ current = getattr(self, skill_name)
+ new_value = max(self.MIN_SKILL, current - self.DECAY_RATE)
+ setattr(self, skill_name, new_value)
+
+ def get_primary_skill(self) -> tuple[str, float]:
+ """Get the agent's highest skill and its name."""
+ skills = {
+ 'hunting': self.hunting,
+ 'gathering': self.gathering,
+ 'woodcutting': self.woodcutting,
+ 'trading': self.trading,
+ 'crafting': self.crafting,
+ }
+ best_skill = max(skills, key=skills.get)
+ return best_skill, skills[best_skill]
+
+ def to_dict(self) -> dict:
+ return {
+ "hunting": round(self.hunting, 3),
+ "gathering": round(self.gathering, 3),
+ "woodcutting": round(self.woodcutting, 3),
+ "trading": round(self.trading, 3),
+ "crafting": round(self.crafting, 3),
+ }
+
+
+def generate_random_personality(archetype: Optional[str] = None) -> PersonalityTraits:
+ """Generate random personality traits.
+
+ If archetype is specified, traits will be biased towards that profession:
+ - "hunter": High risk tolerance, high hunt preference
+ - "gatherer": Low risk tolerance, high gather preference
+ - "trader": High wealth desire, high market affinity, high trade preference
+ - "hoarder": High hoarding rate, low market affinity
+ - None: Fully random
+
+ Returns a PersonalityTraits instance with randomized values.
+ """
+ # Start with base random values
+ traits = PersonalityTraits(
+ wealth_desire=random.uniform(0.1, 0.9),
+ hoarding_rate=random.uniform(0.2, 0.8),
+ risk_tolerance=random.uniform(0.2, 0.8),
+ price_sensitivity=random.uniform(0.6, 1.4),
+ hunt_preference=random.uniform(0.5, 1.5),
+ gather_preference=random.uniform(0.5, 1.5),
+ woodcut_preference=random.uniform(0.5, 1.5),
+ trade_preference=random.uniform(0.5, 1.5),
+ market_affinity=random.uniform(0.2, 0.8),
+ )
+
+ # Apply archetype biases
+ if archetype == "hunter":
+ traits.hunt_preference = random.uniform(1.3, 2.0)
+ traits.risk_tolerance = random.uniform(0.6, 0.9)
+ traits.gather_preference = random.uniform(0.3, 0.7)
+
+ elif archetype == "gatherer":
+ traits.gather_preference = random.uniform(1.3, 2.0)
+ traits.risk_tolerance = random.uniform(0.2, 0.5)
+ traits.hunt_preference = random.uniform(0.3, 0.7)
+
+ elif archetype == "trader":
+ traits.trade_preference = random.uniform(1.5, 2.5)
+ traits.market_affinity = random.uniform(0.7, 0.95)
+ traits.wealth_desire = random.uniform(0.6, 0.95)
+ traits.price_sensitivity = random.uniform(1.1, 1.6)
+ traits.hoarding_rate = random.uniform(0.1, 0.4) # Traders sell!
+ # Traders don't hunt/gather much
+ traits.hunt_preference = random.uniform(0.2, 0.5)
+ traits.gather_preference = random.uniform(0.2, 0.5)
+
+ elif archetype == "hoarder":
+ traits.hoarding_rate = random.uniform(0.7, 0.95)
+ traits.market_affinity = random.uniform(0.1, 0.4)
+ traits.trade_preference = random.uniform(0.3, 0.7)
+
+ elif archetype == "woodcutter":
+ traits.woodcut_preference = random.uniform(1.3, 2.0)
+ traits.gather_preference = random.uniform(0.5, 0.8)
+
+ return traits
+
+
+def generate_random_skills(personality: PersonalityTraits) -> Skills:
+ """Generate starting skills influenced by personality.
+
+ Agents with strong preferences start with slightly better skills
+ in those areas (natural talent).
+ """
+ # Base skill level with small random variation
+ base = 1.0
+ variance = 0.15
+
+ skills = Skills(
+ hunting=base + random.uniform(-variance, variance) + (personality.hunt_preference - 1.0) * 0.1,
+ gathering=base + random.uniform(-variance, variance) + (personality.gather_preference - 1.0) * 0.1,
+ woodcutting=base + random.uniform(-variance, variance) + (personality.woodcut_preference - 1.0) * 0.1,
+ trading=base + random.uniform(-variance, variance) + (personality.trade_preference - 1.0) * 0.1,
+ crafting=base + random.uniform(-variance, variance),
+ )
+
+ # Clamp all skills to valid range
+ skills.hunting = max(skills.MIN_SKILL, min(skills.MAX_SKILL, skills.hunting))
+ skills.gathering = max(skills.MIN_SKILL, min(skills.MAX_SKILL, skills.gathering))
+ skills.woodcutting = max(skills.MIN_SKILL, min(skills.MAX_SKILL, skills.woodcutting))
+ skills.trading = max(skills.MIN_SKILL, min(skills.MAX_SKILL, skills.trading))
+ skills.crafting = max(skills.MIN_SKILL, min(skills.MAX_SKILL, skills.crafting))
+
+ return skills
+
+
+def determine_profession(personality: PersonalityTraits, skills: Skills) -> ProfessionType:
+ """Determine an agent's emergent profession based on traits and skills.
+
+ This is for display/statistics - it doesn't affect behavior directly.
+ The behavior is determined by the traits and skills themselves.
+ """
+ # Calculate profession scores
+ scores = {
+ ProfessionType.HUNTER: personality.hunt_preference * skills.hunting * 1.2,
+ ProfessionType.GATHERER: personality.gather_preference * skills.gathering,
+ ProfessionType.WOODCUTTER: personality.woodcut_preference * skills.woodcutting,
+ ProfessionType.TRADER: personality.trade_preference * skills.trading * personality.market_affinity * 1.5,
+ }
+
+ # Find the best match
+ best_profession = max(scores, key=scores.get)
+ best_score = scores[best_profession]
+
+ # If no clear winner (all scores similar), they're a generalist
+ second_best = sorted(scores.values(), reverse=True)[1]
+ if best_score < second_best * 1.2:
+ return ProfessionType.GENERALIST
+
+ return best_profession
+
+
+def get_action_skill_modifier(skill_value: float) -> float:
+ """Convert skill value to action modifier.
+
+ Skill 0.5 = 0.75x effectiveness
+ Skill 1.0 = 1.0x effectiveness
+ Skill 1.5 = 1.25x effectiveness
+ Skill 2.0 = 1.5x effectiveness
+
+ This creates meaningful but not overpowering differences.
+ """
+ # Linear scaling: (skill - 1.0) * 0.5 + 1.0
+ # So skill 0.5 -> 0.75, skill 1.0 -> 1.0, skill 2.0 -> 1.5
+ return max(0.5, min(1.5, 0.5 * skill_value + 0.5))
+
+
+def get_trade_price_modifier(skill_value: float, is_buying: bool) -> float:
+ """Get price modifier for trading based on skill.
+
+ Higher trading skill = better deals:
+ - When buying: lower prices (modifier < 1)
+ - When selling: higher prices (modifier > 1)
+
+ Skill 1.0 = no modifier
+ Skill 2.0 = 15% better deals
+ """
+ modifier = (skill_value - 1.0) * 0.15
+
+ if is_buying:
+ return max(0.85, 1.0 - modifier) # Lower is better for buying
+ else:
+ return min(1.15, 1.0 + modifier) # Higher is better for selling
+
diff --git a/backend/main.py b/backend/main.py
index 8a7aa4c..6626a31 100644
--- a/backend/main.py
+++ b/backend/main.py
@@ -31,10 +31,14 @@ app.include_router(router, prefix="/api", tags=["simulation"])
@app.on_event("startup")
async def startup_event():
- """Initialize the simulation on startup."""
+ """Initialize the simulation on startup with config.json values."""
+ from backend.config import get_config
+
+ config = get_config()
engine = get_engine()
- engine.initialize(num_agents=8)
- print("Village Simulation initialized with 8 agents")
+ # Use reset() which automatically loads config values
+ engine.reset()
+ print(f"Village Simulation initialized with {config.world.initial_agents} agents")
@app.get("/", tags=["root"])
diff --git a/config.json b/config.json
index 2352645..7cbff9c 100644
--- a/config.json
+++ b/config.json
@@ -5,69 +5,69 @@
"max_thirst": 100,
"max_heat": 100,
"start_energy": 50,
- "start_hunger": 64,
- "start_thirst": 69,
+ "start_hunger": 70,
+ "start_thirst": 75,
"start_heat": 100,
"energy_decay": 1,
- "hunger_decay": 1,
- "thirst_decay": 2,
+ "hunger_decay": 2,
+ "thirst_decay": 3,
"heat_decay": 3,
"critical_threshold": 0.25,
- "low_energy_threshold": 15
+ "low_energy_threshold": 12
},
"resources": {
- "meat_decay": 8,
- "berries_decay": 4,
- "clothes_decay": 15,
- "meat_hunger": 34,
- "meat_energy": 15,
- "berries_hunger": 8,
- "berries_thirst": 3,
- "water_thirst": 56,
- "fire_heat": 15
+ "meat_decay": 10,
+ "berries_decay": 6,
+ "clothes_decay": 20,
+ "meat_hunger": 35,
+ "meat_energy": 12,
+ "berries_hunger": 10,
+ "berries_thirst": 4,
+ "water_thirst": 50,
+ "fire_heat": 20
},
"actions": {
- "sleep_energy": 60,
- "rest_energy": 15,
- "hunt_energy": -8,
+ "sleep_energy": 55,
+ "rest_energy": 12,
+ "hunt_energy": -7,
"gather_energy": -3,
- "chop_wood_energy": -8,
- "get_water_energy": -3,
- "weave_energy": -8,
- "build_fire_energy": -5,
+ "chop_wood_energy": -6,
+ "get_water_energy": -2,
+ "weave_energy": -6,
+ "build_fire_energy": -4,
"trade_energy": -1,
- "hunt_success": 0.79,
- "chop_wood_success": 0.95,
- "hunt_meat_min": 1,
- "hunt_meat_max": 4,
+ "hunt_success": 0.70,
+ "chop_wood_success": 0.90,
+ "hunt_meat_min": 2,
+ "hunt_meat_max": 5,
"hunt_hide_min": 0,
- "hunt_hide_max": 1,
- "gather_min": 1,
- "gather_max": 3,
+ "hunt_hide_max": 2,
+ "gather_min": 2,
+ "gather_max": 4,
"chop_wood_min": 1,
- "chop_wood_max": 2
+ "chop_wood_max": 3
},
"world": {
- "width": 20,
- "height": 20,
- "initial_agents": 10,
+ "width": 25,
+ "height": 25,
+ "initial_agents": 25,
"day_steps": 10,
"night_steps": 1,
- "inventory_slots": 10,
- "starting_money": 100
+ "inventory_slots": 12,
+ "starting_money": 80
},
"market": {
- "turns_before_discount": 20,
- "discount_rate": 0.15,
- "base_price_multiplier": 1.25
+ "turns_before_discount": 15,
+ "discount_rate": 0.12,
+ "base_price_multiplier": 1.3
},
"economy": {
- "energy_to_money_ratio": 1.46,
- "wealth_desire": 0.23,
- "buy_efficiency_threshold": 0.89,
- "min_wealth_target": 63,
+ "energy_to_money_ratio": 1.5,
+ "wealth_desire": 0.35,
+ "buy_efficiency_threshold": 0.75,
+ "min_wealth_target": 50,
"max_price_markup": 2.5,
- "min_price_discount": 0.3
+ "min_price_discount": 0.4
},
- "auto_step_interval": 0.2
+ "auto_step_interval": 0.15
}
\ No newline at end of file
diff --git a/frontend/main.py b/frontend/main.py
index 40f368c..62a1742 100644
--- a/frontend/main.py
+++ b/frontend/main.py
@@ -8,11 +8,12 @@ from frontend.renderer.map_renderer import MapRenderer
from frontend.renderer.agent_renderer import AgentRenderer
from frontend.renderer.ui_renderer import UIRenderer
from frontend.renderer.settings_renderer import SettingsRenderer
+from frontend.renderer.stats_renderer import StatsRenderer
# Window configuration
-WINDOW_WIDTH = 1000
-WINDOW_HEIGHT = 700
+WINDOW_WIDTH = 1200
+WINDOW_HEIGHT = 800
WINDOW_TITLE = "Village Economy Simulation"
FPS = 30
@@ -55,11 +56,13 @@ class VillageSimulationApp:
self.agent_renderer = AgentRenderer(self.screen, self.map_renderer, self.font)
self.ui_renderer = UIRenderer(self.screen, self.font)
self.settings_renderer = SettingsRenderer(self.screen)
+ self.stats_renderer = StatsRenderer(self.screen)
# State
self.state: SimulationState | None = None
self.running = True
self.hovered_agent: dict | None = None
+ self._last_turn: int = -1 # Track turn changes for stats update
# Polling interval (ms)
self.last_poll_time = 0
@@ -116,6 +119,10 @@ class VillageSimulationApp:
if event.type == pygame.QUIT:
self.running = False
+ # Let stats panel handle events first if visible
+ if self.stats_renderer.handle_event(event):
+ continue
+
# Let settings panel handle events first if visible
if self.settings_renderer.handle_event(event):
continue
@@ -129,36 +136,46 @@ class VillageSimulationApp:
def _handle_keydown(self, event: pygame.event.Event) -> None:
"""Handle keyboard input."""
if event.key == pygame.K_ESCAPE:
- if self.settings_renderer.visible:
+ if self.stats_renderer.visible:
+ self.stats_renderer.toggle()
+ elif self.settings_renderer.visible:
self.settings_renderer.toggle()
else:
self.running = False
elif event.key == pygame.K_SPACE:
# Advance one turn
- if self.client.connected and not self.settings_renderer.visible:
+ if self.client.connected and not self.settings_renderer.visible and not self.stats_renderer.visible:
if self.client.advance_turn():
# Immediately fetch new state
self.state = self.client.get_state()
elif event.key == pygame.K_r:
# Reset simulation
- if self.client.connected and not self.settings_renderer.visible:
+ if self.client.connected and not self.settings_renderer.visible and not self.stats_renderer.visible:
if self.client.initialize():
self.state = self.client.get_state()
+ self.stats_renderer.clear_history()
+ self._last_turn = -1
elif event.key == pygame.K_m:
# Toggle mode
- if self.client.connected and self.state and not self.settings_renderer.visible:
+ if self.client.connected and self.state and not self.settings_renderer.visible and not self.stats_renderer.visible:
new_mode = "auto" if self.state.mode == "manual" else "manual"
if self.client.set_mode(new_mode):
self.state = self.client.get_state()
+ elif event.key == pygame.K_g:
+ # Toggle statistics/graphs panel
+ if not self.settings_renderer.visible:
+ self.stats_renderer.toggle()
+
elif event.key == pygame.K_s:
# Toggle settings panel
- if not self.settings_renderer.visible:
- self._load_config()
- self.settings_renderer.toggle()
+ if not self.stats_renderer.visible:
+ if not self.settings_renderer.visible:
+ self._load_config()
+ self.settings_renderer.toggle()
def _handle_mouse_motion(self, event: pygame.event.Event) -> None:
"""Handle mouse motion for agent hover detection."""
@@ -217,6 +234,11 @@ class VillageSimulationApp:
new_state.world_height,
)
self.state = new_state
+
+ # Update stats history when turn changes
+ if new_state.turn != self._last_turn:
+ self.stats_renderer.update_history(new_state)
+ self._last_turn = new_state.turn
def draw(self) -> None:
"""Draw all elements."""
@@ -245,9 +267,13 @@ class VillageSimulationApp:
# Draw settings panel if visible
self.settings_renderer.draw()
- # Draw settings hint
- if not self.settings_renderer.visible:
- hint = pygame.font.Font(None, 18).render("Press S for Settings", True, (100, 100, 120))
+ # Draw stats panel if visible
+ self.stats_renderer.draw(self.state)
+
+ # Draw hints at bottom
+ if not self.settings_renderer.visible and not self.stats_renderer.visible:
+ hint_font = pygame.font.Font(None, 18)
+ hint = hint_font.render("S: Settings | G: Statistics & Graphs", True, (100, 100, 120))
self.screen.blit(hint, (5, self.screen.get_height() - 20))
# Update display
@@ -270,7 +296,8 @@ class VillageSimulationApp:
print(" R - Reset simulation")
print(" M - Toggle auto/manual mode")
print(" S - Open settings")
- print(" ESC - Close settings / Quit")
+ print(" G - Open statistics & graphs")
+ print(" ESC - Close panel / Quit")
print()
while self.running:
diff --git a/frontend/renderer/stats_renderer.py b/frontend/renderer/stats_renderer.py
new file mode 100644
index 0000000..7a2d5ea
--- /dev/null
+++ b/frontend/renderer/stats_renderer.py
@@ -0,0 +1,770 @@
+"""Real-time statistics and charts renderer for the Village Simulation.
+
+Uses matplotlib to render charts to pygame surfaces for a seamless visualization experience.
+"""
+
+import io
+from dataclasses import dataclass, field
+from collections import deque
+from typing import TYPE_CHECKING, Optional
+
+import pygame
+import matplotlib
+matplotlib.use('Agg') # Use non-interactive backend for pygame integration
+import matplotlib.pyplot as plt
+import matplotlib.ticker as mticker
+from matplotlib.figure import Figure
+import numpy as np
+
+if TYPE_CHECKING:
+ from frontend.client import SimulationState
+
+
+# Color scheme - dark cyberpunk inspired
+class ChartColors:
+ """Color palette for charts - dark theme with neon accents."""
+ BG = '#1a1d26'
+ PANEL = '#252a38'
+ GRID = '#2f3545'
+ TEXT = '#e0e0e8'
+ TEXT_DIM = '#7a7e8c'
+
+ # Neon accents for data series
+ CYAN = '#00d4ff'
+ MAGENTA = '#ff0099'
+ LIME = '#39ff14'
+ ORANGE = '#ff6600'
+ PURPLE = '#9d4edd'
+ YELLOW = '#ffcc00'
+ TEAL = '#00ffa3'
+ PINK = '#ff1493'
+
+ # Series colors for different resources/categories
+ SERIES = [CYAN, MAGENTA, LIME, ORANGE, PURPLE, YELLOW, TEAL, PINK]
+
+
+class UIColors:
+ """Color palette for pygame UI elements."""
+ BG = (26, 29, 38)
+ PANEL_BG = (37, 42, 56)
+ PANEL_BORDER = (70, 80, 100)
+ TEXT_PRIMARY = (224, 224, 232)
+ TEXT_SECONDARY = (122, 126, 140)
+ TEXT_HIGHLIGHT = (0, 212, 255)
+ TAB_ACTIVE = (0, 212, 255)
+ TAB_INACTIVE = (55, 60, 75)
+ TAB_HOVER = (75, 85, 110)
+
+
+@dataclass
+class HistoryData:
+ """Stores historical simulation data for charting."""
+ max_history: int = 200
+
+ # Time series data
+ turns: deque = field(default_factory=lambda: deque(maxlen=200))
+ population: deque = field(default_factory=lambda: deque(maxlen=200))
+ deaths_cumulative: deque = field(default_factory=lambda: deque(maxlen=200))
+
+ # Money/Wealth data
+ total_money: deque = field(default_factory=lambda: deque(maxlen=200))
+ avg_wealth: deque = field(default_factory=lambda: deque(maxlen=200))
+ gini_coefficient: deque = field(default_factory=lambda: deque(maxlen=200))
+
+ # Price history per resource
+ prices: dict = field(default_factory=dict) # resource -> deque of prices
+
+ # Trade statistics
+ trade_volume: deque = field(default_factory=lambda: deque(maxlen=200))
+
+ # Profession counts over time
+ professions: dict = field(default_factory=dict) # profession -> deque of counts
+
+ def clear(self) -> None:
+ """Clear all history data."""
+ self.turns.clear()
+ self.population.clear()
+ self.deaths_cumulative.clear()
+ self.total_money.clear()
+ self.avg_wealth.clear()
+ self.gini_coefficient.clear()
+ self.prices.clear()
+ self.trade_volume.clear()
+ self.professions.clear()
+
+ def update(self, state: "SimulationState") -> None:
+ """Update history with new state data."""
+ turn = state.turn
+
+ # Avoid duplicate entries for the same turn
+ if self.turns and self.turns[-1] == turn:
+ return
+
+ self.turns.append(turn)
+
+ # Population
+ living = len([a for a in state.agents if a.get("is_alive", False)])
+ self.population.append(living)
+ self.deaths_cumulative.append(state.statistics.get("total_agents_died", 0))
+
+ # Wealth data
+ stats = state.statistics
+ self.total_money.append(stats.get("total_money_in_circulation", 0))
+ self.avg_wealth.append(stats.get("avg_money", 0))
+ self.gini_coefficient.append(stats.get("gini_coefficient", 0))
+
+ # Price history from market
+ for resource, data in state.market_prices.items():
+ if resource not in self.prices:
+ self.prices[resource] = deque(maxlen=self.max_history)
+
+ # Track lowest price (current market rate)
+ lowest = data.get("lowest_price")
+ avg = data.get("avg_sale_price")
+ # Use lowest price if available, else avg sale price
+ price = lowest if lowest is not None else avg
+ self.prices[resource].append(price)
+
+ # Trade volume (from recent trades in market orders)
+ trades = len(state.market_orders) # Active orders as proxy
+ self.trade_volume.append(trades)
+
+ # Profession distribution
+ professions = stats.get("professions", {})
+ for prof, count in professions.items():
+ if prof not in self.professions:
+ self.professions[prof] = deque(maxlen=self.max_history)
+ self.professions[prof].append(count)
+
+ # Pad missing professions with 0
+ for prof in self.professions:
+ if prof not in professions:
+ self.professions[prof].append(0)
+
+
+class ChartRenderer:
+ """Renders matplotlib charts to pygame surfaces."""
+
+ def __init__(self, width: int, height: int):
+ self.width = width
+ self.height = height
+ self.dpi = 100
+
+ # Configure matplotlib style
+ plt.style.use('dark_background')
+ plt.rcParams.update({
+ 'figure.facecolor': ChartColors.BG,
+ 'axes.facecolor': ChartColors.PANEL,
+ 'axes.edgecolor': ChartColors.GRID,
+ 'axes.labelcolor': ChartColors.TEXT,
+ 'text.color': ChartColors.TEXT,
+ 'xtick.color': ChartColors.TEXT_DIM,
+ 'ytick.color': ChartColors.TEXT_DIM,
+ 'grid.color': ChartColors.GRID,
+ 'grid.alpha': 0.3,
+ 'legend.facecolor': ChartColors.PANEL,
+ 'legend.edgecolor': ChartColors.GRID,
+ 'font.size': 9,
+ 'axes.titlesize': 11,
+ 'axes.titleweight': 'bold',
+ })
+
+ def _fig_to_surface(self, fig: Figure) -> pygame.Surface:
+ """Convert a matplotlib figure to a pygame surface."""
+ buf = io.BytesIO()
+ fig.savefig(buf, format='png', dpi=self.dpi,
+ facecolor=ChartColors.BG, edgecolor='none',
+ bbox_inches='tight', pad_inches=0.1)
+ buf.seek(0)
+
+ surface = pygame.image.load(buf, 'png')
+ buf.close()
+ plt.close(fig)
+
+ return surface
+
+ def render_price_history(self, history: HistoryData, width: int, height: int) -> pygame.Surface:
+ """Render price history chart for all resources."""
+ fig, ax = plt.subplots(figsize=(width/self.dpi, height/self.dpi), dpi=self.dpi)
+
+ turns = list(history.turns) if history.turns else [0]
+
+ has_data = False
+ for i, (resource, prices) in enumerate(history.prices.items()):
+ if prices and any(p is not None for p in prices):
+ color = ChartColors.SERIES[i % len(ChartColors.SERIES)]
+ # Filter out None values
+ valid_prices = [p if p is not None else 0 for p in prices]
+ # Align with turns
+ min_len = min(len(turns), len(valid_prices))
+ ax.plot(list(turns)[-min_len:], valid_prices[-min_len:],
+ color=color, linewidth=1.5, label=resource.capitalize(), alpha=0.9)
+ has_data = True
+
+ ax.set_title('Market Prices', color=ChartColors.CYAN)
+ ax.set_xlabel('Turn')
+ ax.set_ylabel('Price (coins)')
+ ax.grid(True, alpha=0.2)
+
+ if has_data:
+ ax.legend(loc='upper left', fontsize=8, framealpha=0.8)
+
+ ax.set_ylim(bottom=0)
+ ax.yaxis.set_major_locator(mticker.MaxNLocator(integer=True))
+
+ fig.tight_layout()
+ return self._fig_to_surface(fig)
+
+ def render_population(self, history: HistoryData, width: int, height: int) -> pygame.Surface:
+ """Render population over time chart."""
+ fig, ax = plt.subplots(figsize=(width/self.dpi, height/self.dpi), dpi=self.dpi)
+
+ turns = list(history.turns) if history.turns else [0]
+ population = list(history.population) if history.population else [0]
+ deaths = list(history.deaths_cumulative) if history.deaths_cumulative else [0]
+
+ min_len = min(len(turns), len(population))
+
+ # Population line
+ ax.fill_between(turns[-min_len:], population[-min_len:],
+ alpha=0.3, color=ChartColors.CYAN)
+ ax.plot(turns[-min_len:], population[-min_len:],
+ color=ChartColors.CYAN, linewidth=2, label='Living')
+
+ # Deaths line
+ if deaths:
+ ax.plot(turns[-min_len:], deaths[-min_len:],
+ color=ChartColors.MAGENTA, linewidth=1.5, linestyle='--',
+ label='Total Deaths', alpha=0.8)
+
+ ax.set_title('Population Over Time', color=ChartColors.LIME)
+ ax.set_xlabel('Turn')
+ ax.set_ylabel('Count')
+ ax.grid(True, alpha=0.2)
+ ax.legend(loc='upper right', fontsize=8)
+ ax.set_ylim(bottom=0)
+ ax.yaxis.set_major_locator(mticker.MaxNLocator(integer=True))
+
+ fig.tight_layout()
+ return self._fig_to_surface(fig)
+
+ def render_wealth_distribution(self, state: "SimulationState", width: int, height: int) -> pygame.Surface:
+ """Render current wealth distribution as a bar chart."""
+ fig, ax = plt.subplots(figsize=(width/self.dpi, height/self.dpi), dpi=self.dpi)
+
+ # Get agent wealth data
+ agents = [a for a in state.agents if a.get("is_alive", False)]
+ if not agents:
+ ax.text(0.5, 0.5, 'No living agents', ha='center', va='center',
+ color=ChartColors.TEXT_DIM, fontsize=12)
+ ax.set_title('Wealth Distribution', color=ChartColors.ORANGE)
+ fig.tight_layout()
+ return self._fig_to_surface(fig)
+
+ # Sort by wealth
+ agents_sorted = sorted(agents, key=lambda a: a.get("money", 0), reverse=True)
+ names = [a.get("name", "?")[:8] for a in agents_sorted]
+ wealth = [a.get("money", 0) for a in agents_sorted]
+
+ # Create gradient colors based on wealth ranking
+ colors = []
+ for i in range(len(agents_sorted)):
+ ratio = i / max(1, len(agents_sorted) - 1)
+ # Gradient from cyan (rich) to magenta (poor)
+ r = int(0 + ratio * 255)
+ g = int(212 - ratio * 212)
+ b = int(255 - ratio * 102)
+ colors.append(f'#{r:02x}{g:02x}{b:02x}')
+
+ bars = ax.barh(range(len(agents_sorted)), wealth, color=colors, alpha=0.85)
+ ax.set_yticks(range(len(agents_sorted)))
+ ax.set_yticklabels(names, fontsize=7)
+ ax.invert_yaxis() # Rich at top
+
+ # Add value labels
+ for bar, val in zip(bars, wealth):
+ ax.text(bar.get_width() + 1, bar.get_y() + bar.get_height()/2,
+ f'{val}', va='center', fontsize=7, color=ChartColors.TEXT_DIM)
+
+ ax.set_title('Wealth Distribution', color=ChartColors.ORANGE)
+ ax.set_xlabel('Coins')
+ ax.grid(True, alpha=0.2, axis='x')
+
+ fig.tight_layout()
+ return self._fig_to_surface(fig)
+
+ def render_wealth_over_time(self, history: HistoryData, width: int, height: int) -> pygame.Surface:
+ """Render wealth metrics over time (total money, avg, gini)."""
+ fig, (ax1, ax2) = plt.subplots(2, 1, figsize=(width/self.dpi, height/self.dpi),
+ dpi=self.dpi, height_ratios=[2, 1])
+
+ turns = list(history.turns) if history.turns else [0]
+ total = list(history.total_money) if history.total_money else [0]
+ avg = list(history.avg_wealth) if history.avg_wealth else [0]
+ gini = list(history.gini_coefficient) if history.gini_coefficient else [0]
+
+ min_len = min(len(turns), len(total), len(avg))
+
+ # Total and average wealth
+ ax1.plot(turns[-min_len:], total[-min_len:],
+ color=ChartColors.CYAN, linewidth=2, label='Total Money')
+ ax1.fill_between(turns[-min_len:], total[-min_len:],
+ alpha=0.2, color=ChartColors.CYAN)
+
+ ax1_twin = ax1.twinx()
+ ax1_twin.plot(turns[-min_len:], avg[-min_len:],
+ color=ChartColors.LIME, linewidth=1.5, linestyle='--', label='Avg Wealth')
+ ax1_twin.set_ylabel('Avg Wealth', color=ChartColors.LIME)
+ ax1_twin.tick_params(axis='y', labelcolor=ChartColors.LIME)
+
+ ax1.set_title('Money in Circulation', color=ChartColors.YELLOW)
+ ax1.set_ylabel('Total Money', color=ChartColors.CYAN)
+ ax1.tick_params(axis='y', labelcolor=ChartColors.CYAN)
+ ax1.grid(True, alpha=0.2)
+ ax1.set_ylim(bottom=0)
+
+ # Gini coefficient (inequality)
+ min_len_gini = min(len(turns), len(gini))
+ ax2.fill_between(turns[-min_len_gini:], gini[-min_len_gini:],
+ alpha=0.4, color=ChartColors.MAGENTA)
+ ax2.plot(turns[-min_len_gini:], gini[-min_len_gini:],
+ color=ChartColors.MAGENTA, linewidth=1.5)
+ ax2.set_xlabel('Turn')
+ ax2.set_ylabel('Gini')
+ ax2.set_title('Inequality Index', color=ChartColors.MAGENTA, fontsize=9)
+ ax2.set_ylim(0, 1)
+ ax2.grid(True, alpha=0.2)
+
+ # Add reference lines for gini
+ ax2.axhline(y=0.4, color=ChartColors.YELLOW, linestyle=':', alpha=0.5, linewidth=1)
+ ax2.text(turns[-1] if turns else 0, 0.42, 'Moderate', fontsize=7,
+ color=ChartColors.YELLOW, alpha=0.7)
+
+ fig.tight_layout()
+ return self._fig_to_surface(fig)
+
+ def render_professions(self, state: "SimulationState", history: HistoryData,
+ width: int, height: int) -> pygame.Surface:
+ """Render profession distribution as pie chart and area chart."""
+ fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(width/self.dpi, height/self.dpi), dpi=self.dpi)
+
+ # Current profession pie chart
+ professions = state.statistics.get("professions", {})
+ if professions:
+ labels = list(professions.keys())
+ sizes = list(professions.values())
+ colors = [ChartColors.SERIES[i % len(ChartColors.SERIES)] for i in range(len(labels))]
+
+ wedges, texts, autotexts = ax1.pie(
+ sizes, labels=labels, colors=colors, autopct='%1.0f%%',
+ startangle=90, pctdistance=0.75,
+ textprops={'fontsize': 8, 'color': ChartColors.TEXT}
+ )
+ for autotext in autotexts:
+ autotext.set_color(ChartColors.BG)
+ autotext.set_fontweight('bold')
+ ax1.set_title('Current Distribution', color=ChartColors.PURPLE, fontsize=10)
+ else:
+ ax1.text(0.5, 0.5, 'No data', ha='center', va='center', color=ChartColors.TEXT_DIM)
+ ax1.set_title('Current Distribution', color=ChartColors.PURPLE)
+
+ # Profession history as stacked area
+ turns = list(history.turns) if history.turns else [0]
+ if history.professions and turns:
+ profs_list = list(history.professions.keys())
+ data = []
+ for prof in profs_list:
+ prof_data = list(history.professions[prof])
+ # Pad to match turns length
+ while len(prof_data) < len(turns):
+ prof_data.insert(0, 0)
+ data.append(prof_data[-len(turns):])
+
+ colors = [ChartColors.SERIES[i % len(ChartColors.SERIES)] for i in range(len(profs_list))]
+ ax2.stackplot(turns, *data, labels=profs_list, colors=colors, alpha=0.8)
+ ax2.legend(loc='upper left', fontsize=7, framealpha=0.8)
+ ax2.set_xlabel('Turn')
+ ax2.set_ylabel('Count')
+
+ ax2.set_title('Over Time', color=ChartColors.PURPLE, fontsize=10)
+ ax2.grid(True, alpha=0.2)
+
+ fig.tight_layout()
+ return self._fig_to_surface(fig)
+
+ def render_market_activity(self, state: "SimulationState", history: HistoryData,
+ width: int, height: int) -> pygame.Surface:
+ """Render market activity - orders by resource, supply/demand."""
+ fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(width/self.dpi, height/self.dpi), dpi=self.dpi)
+
+ # Current market orders by resource type
+ prices = state.market_prices
+ resources = []
+ quantities = []
+ colors = []
+
+ for i, (resource, data) in enumerate(prices.items()):
+ qty = data.get("total_available", 0)
+ if qty > 0:
+ resources.append(resource.capitalize())
+ quantities.append(qty)
+ colors.append(ChartColors.SERIES[i % len(ChartColors.SERIES)])
+
+ if resources:
+ bars = ax1.bar(resources, quantities, color=colors, alpha=0.85)
+ ax1.set_ylabel('Available')
+ for bar, val in zip(bars, quantities):
+ ax1.text(bar.get_x() + bar.get_width()/2, bar.get_height() + 0.3,
+ str(val), ha='center', fontsize=8, color=ChartColors.TEXT)
+ else:
+ ax1.text(0.5, 0.5, 'No orders', ha='center', va='center', color=ChartColors.TEXT_DIM)
+
+ ax1.set_title('Market Supply', color=ChartColors.TEAL, fontsize=10)
+ ax1.tick_params(axis='x', rotation=45, labelsize=7)
+ ax1.grid(True, alpha=0.2, axis='y')
+
+ # Supply/Demand scores
+ resources_sd = []
+ supply_scores = []
+ demand_scores = []
+
+ for resource, data in prices.items():
+ resources_sd.append(resource[:6])
+ supply_scores.append(data.get("supply_score", 0.5))
+ demand_scores.append(data.get("demand_score", 0.5))
+
+ if resources_sd:
+ x = np.arange(len(resources_sd))
+ width_bar = 0.35
+
+ ax2.bar(x - width_bar/2, supply_scores, width_bar, label='Supply',
+ color=ChartColors.CYAN, alpha=0.8)
+ ax2.bar(x + width_bar/2, demand_scores, width_bar, label='Demand',
+ color=ChartColors.MAGENTA, alpha=0.8)
+
+ ax2.set_xticks(x)
+ ax2.set_xticklabels(resources_sd, fontsize=7, rotation=45)
+ ax2.set_ylabel('Score')
+ ax2.legend(fontsize=7)
+ ax2.set_ylim(0, 1.2)
+
+ ax2.set_title('Supply/Demand', color=ChartColors.TEAL, fontsize=10)
+ ax2.grid(True, alpha=0.2, axis='y')
+
+ fig.tight_layout()
+ return self._fig_to_surface(fig)
+
+ def render_agent_stats(self, state: "SimulationState", width: int, height: int) -> pygame.Surface:
+ """Render aggregate agent statistics - energy, hunger, thirst distributions."""
+ fig, axes = plt.subplots(2, 2, figsize=(width/self.dpi, height/self.dpi), dpi=self.dpi)
+
+ agents = [a for a in state.agents if a.get("is_alive", False)]
+
+ if not agents:
+ for ax in axes.flat:
+ ax.text(0.5, 0.5, 'No agents', ha='center', va='center', color=ChartColors.TEXT_DIM)
+ fig.suptitle('Agent Statistics', color=ChartColors.CYAN)
+ fig.tight_layout()
+ return self._fig_to_surface(fig)
+
+ # Extract stats
+ energies = [a.get("stats", {}).get("energy", 0) for a in agents]
+ hungers = [a.get("stats", {}).get("hunger", 0) for a in agents]
+ thirsts = [a.get("stats", {}).get("thirst", 0) for a in agents]
+ heats = [a.get("stats", {}).get("heat", 0) for a in agents]
+
+ max_energy = agents[0].get("stats", {}).get("max_energy", 100)
+ max_hunger = agents[0].get("stats", {}).get("max_hunger", 100)
+ max_thirst = agents[0].get("stats", {}).get("max_thirst", 100)
+ max_heat = agents[0].get("stats", {}).get("max_heat", 100)
+
+ stats_data = [
+ (energies, max_energy, 'Energy', ChartColors.LIME),
+ (hungers, max_hunger, 'Hunger', ChartColors.ORANGE),
+ (thirsts, max_thirst, 'Thirst', ChartColors.CYAN),
+ (heats, max_heat, 'Heat', ChartColors.MAGENTA),
+ ]
+
+ for ax, (values, max_val, name, color) in zip(axes.flat, stats_data):
+ # Histogram
+ bins = np.linspace(0, max_val, 11)
+ ax.hist(values, bins=bins, color=color, alpha=0.7, edgecolor=ChartColors.PANEL)
+
+ # Mean line
+ mean_val = np.mean(values)
+ ax.axvline(x=mean_val, color=ChartColors.TEXT, linestyle='--',
+ linewidth=1.5, label=f'Avg: {mean_val:.0f}')
+
+ # Critical threshold
+ critical = max_val * 0.25
+ ax.axvline(x=critical, color=ChartColors.MAGENTA, linestyle=':',
+ linewidth=1, alpha=0.7)
+
+ ax.set_title(name, color=color, fontsize=9)
+ ax.set_xlim(0, max_val)
+ ax.legend(fontsize=7, loc='upper right')
+ ax.grid(True, alpha=0.2)
+
+ fig.suptitle('Agent Statistics Distribution', color=ChartColors.CYAN, fontsize=11)
+ fig.tight_layout()
+ return self._fig_to_surface(fig)
+
+
+class StatsRenderer:
+ """Main statistics panel with tabs and charts."""
+
+ TABS = [
+ ("Prices", "price_history"),
+ ("Wealth", "wealth"),
+ ("Population", "population"),
+ ("Professions", "professions"),
+ ("Market", "market"),
+ ("Agent Stats", "agent_stats"),
+ ]
+
+ def __init__(self, screen: pygame.Surface):
+ self.screen = screen
+ self.visible = False
+
+ self.font = pygame.font.Font(None, 24)
+ self.small_font = pygame.font.Font(None, 18)
+ self.title_font = pygame.font.Font(None, 32)
+
+ self.current_tab = 0
+ self.tab_hovered = -1
+
+ # History data
+ self.history = HistoryData()
+
+ # Chart renderer
+ self.chart_renderer: Optional[ChartRenderer] = None
+
+ # Cached chart surfaces
+ self._chart_cache: dict[str, pygame.Surface] = {}
+ self._cache_turn: int = -1
+
+ # Layout
+ self._calculate_layout()
+
+ def _calculate_layout(self) -> None:
+ """Calculate panel layout based on screen size."""
+ screen_w, screen_h = self.screen.get_size()
+
+ # Panel takes most of the screen with some margin
+ margin = 30
+ self.panel_rect = pygame.Rect(
+ margin, margin,
+ screen_w - margin * 2,
+ screen_h - margin * 2
+ )
+
+ # Tab bar
+ self.tab_height = 40
+ self.tab_rect = pygame.Rect(
+ self.panel_rect.x,
+ self.panel_rect.y,
+ self.panel_rect.width,
+ self.tab_height
+ )
+
+ # Chart area
+ self.chart_rect = pygame.Rect(
+ self.panel_rect.x + 10,
+ self.panel_rect.y + self.tab_height + 10,
+ self.panel_rect.width - 20,
+ self.panel_rect.height - self.tab_height - 20
+ )
+
+ # Initialize chart renderer with chart area size
+ self.chart_renderer = ChartRenderer(
+ self.chart_rect.width,
+ self.chart_rect.height
+ )
+
+ # Calculate tab widths
+ self.tab_width = self.panel_rect.width // len(self.TABS)
+
+ def toggle(self) -> None:
+ """Toggle visibility of the stats panel."""
+ self.visible = not self.visible
+ if self.visible:
+ self._invalidate_cache()
+
+ def update_history(self, state: "SimulationState") -> None:
+ """Update history data with new state."""
+ if state:
+ self.history.update(state)
+
+ def clear_history(self) -> None:
+ """Clear all history data (e.g., on simulation reset)."""
+ self.history.clear()
+ self._invalidate_cache()
+
+ def _invalidate_cache(self) -> None:
+ """Invalidate chart cache to force re-render."""
+ self._chart_cache.clear()
+ self._cache_turn = -1
+
+ def handle_event(self, event: pygame.event.Event) -> bool:
+ """Handle input events. Returns True if event was consumed."""
+ if not self.visible:
+ return False
+
+ if event.type == pygame.MOUSEMOTION:
+ self._handle_mouse_motion(event.pos)
+ return True
+
+ elif event.type == pygame.MOUSEBUTTONDOWN:
+ if self._handle_click(event.pos):
+ return True
+ # Consume clicks when visible
+ return True
+
+ elif event.type == pygame.KEYDOWN:
+ if event.key == pygame.K_ESCAPE:
+ self.toggle()
+ return True
+ elif event.key == pygame.K_LEFT:
+ self.current_tab = (self.current_tab - 1) % len(self.TABS)
+ self._invalidate_cache()
+ return True
+ elif event.key == pygame.K_RIGHT:
+ self.current_tab = (self.current_tab + 1) % len(self.TABS)
+ self._invalidate_cache()
+ return True
+
+ return False
+
+ def _handle_mouse_motion(self, pos: tuple[int, int]) -> None:
+ """Handle mouse motion for tab hover effects."""
+ self.tab_hovered = -1
+
+ if self.tab_rect.collidepoint(pos):
+ rel_x = pos[0] - self.tab_rect.x
+ tab_idx = rel_x // self.tab_width
+ if 0 <= tab_idx < len(self.TABS):
+ self.tab_hovered = tab_idx
+
+ def _handle_click(self, pos: tuple[int, int]) -> bool:
+ """Handle mouse click. Returns True if click was on a tab."""
+ if self.tab_rect.collidepoint(pos):
+ rel_x = pos[0] - self.tab_rect.x
+ tab_idx = rel_x // self.tab_width
+ if 0 <= tab_idx < len(self.TABS) and tab_idx != self.current_tab:
+ self.current_tab = tab_idx
+ self._invalidate_cache()
+ return True
+ return False
+
+ def _render_chart(self, state: "SimulationState") -> pygame.Surface:
+ """Render the current tab's chart."""
+ tab_name, tab_key = self.TABS[self.current_tab]
+
+ # Check cache
+ current_turn = state.turn if state else 0
+ if tab_key in self._chart_cache and self._cache_turn == current_turn:
+ return self._chart_cache[tab_key]
+
+ # Render chart based on current tab
+ width = self.chart_rect.width
+ height = self.chart_rect.height
+
+ if tab_key == "price_history":
+ surface = self.chart_renderer.render_price_history(self.history, width, height)
+ elif tab_key == "wealth":
+ # Split into two charts
+ half_height = height // 2
+ dist_surface = self.chart_renderer.render_wealth_distribution(state, width, half_height)
+ time_surface = self.chart_renderer.render_wealth_over_time(self.history, width, half_height)
+
+ surface = pygame.Surface((width, height))
+ surface.fill(UIColors.BG)
+ surface.blit(dist_surface, (0, 0))
+ surface.blit(time_surface, (0, half_height))
+ elif tab_key == "population":
+ surface = self.chart_renderer.render_population(self.history, width, height)
+ elif tab_key == "professions":
+ surface = self.chart_renderer.render_professions(state, self.history, width, height)
+ elif tab_key == "market":
+ surface = self.chart_renderer.render_market_activity(state, self.history, width, height)
+ elif tab_key == "agent_stats":
+ surface = self.chart_renderer.render_agent_stats(state, width, height)
+ else:
+ # Fallback empty surface
+ surface = pygame.Surface((width, height))
+ surface.fill(UIColors.BG)
+
+ # Cache the result
+ self._chart_cache[tab_key] = surface
+ self._cache_turn = current_turn
+
+ return surface
+
+ def draw(self, state: "SimulationState") -> None:
+ """Draw the statistics panel."""
+ if not self.visible:
+ return
+
+ # Dim background
+ overlay = pygame.Surface(self.screen.get_size(), pygame.SRCALPHA)
+ overlay.fill((0, 0, 0, 220))
+ self.screen.blit(overlay, (0, 0))
+
+ # Panel background
+ pygame.draw.rect(self.screen, UIColors.PANEL_BG, self.panel_rect, border_radius=12)
+ pygame.draw.rect(self.screen, UIColors.PANEL_BORDER, self.panel_rect, 2, border_radius=12)
+
+ # Draw tabs
+ self._draw_tabs()
+
+ # Draw chart
+ if state:
+ chart_surface = self._render_chart(state)
+ self.screen.blit(chart_surface, self.chart_rect.topleft)
+
+ # Draw close hint
+ hint = self.small_font.render("Press G or ESC to close | โ โ to switch tabs",
+ True, UIColors.TEXT_SECONDARY)
+ hint_rect = hint.get_rect(centerx=self.panel_rect.centerx,
+ y=self.panel_rect.bottom - 25)
+ self.screen.blit(hint, hint_rect)
+
+ def _draw_tabs(self) -> None:
+ """Draw the tab bar."""
+ for i, (tab_name, _) in enumerate(self.TABS):
+ tab_x = self.tab_rect.x + i * self.tab_width
+ tab_rect = pygame.Rect(tab_x, self.tab_rect.y, self.tab_width, self.tab_height)
+
+ # Tab background
+ if i == self.current_tab:
+ color = UIColors.TAB_ACTIVE
+ elif i == self.tab_hovered:
+ color = UIColors.TAB_HOVER
+ else:
+ color = UIColors.TAB_INACTIVE
+
+ # Draw tab with rounded top corners
+ tab_surface = pygame.Surface((self.tab_width, self.tab_height), pygame.SRCALPHA)
+ pygame.draw.rect(tab_surface, color, (0, 0, self.tab_width, self.tab_height),
+ border_top_left_radius=8, border_top_right_radius=8)
+
+ if i == self.current_tab:
+ # Active tab - solid color
+ tab_surface.set_alpha(255)
+ else:
+ tab_surface.set_alpha(180)
+
+ self.screen.blit(tab_surface, (tab_x, self.tab_rect.y))
+
+ # Tab text
+ text_color = UIColors.BG if i == self.current_tab else UIColors.TEXT_PRIMARY
+ text = self.small_font.render(tab_name, True, text_color)
+ text_rect = text.get_rect(center=tab_rect.center)
+ self.screen.blit(text, text_rect)
+
+ # Tab border
+ if i != self.current_tab:
+ pygame.draw.line(self.screen, UIColors.PANEL_BORDER,
+ (tab_x + self.tab_width - 1, self.tab_rect.y + 5),
+ (tab_x + self.tab_width - 1, self.tab_rect.y + self.tab_height - 5))
+
diff --git a/tools/run_headless_analysis.py b/tools/run_headless_analysis.py
index 0e82294..31a2411 100644
--- a/tools/run_headless_analysis.py
+++ b/tools/run_headless_analysis.py
@@ -63,6 +63,13 @@ class SimulationStats:
# Time tracking
actions_by_time_of_day: dict = field(default_factory=lambda: {"day": defaultdict(int), "night": defaultdict(int)})
+
+ # Profession and wealth tracking (new)
+ professions_over_time: list = field(default_factory=list)
+ gini_over_time: list = field(default_factory=list)
+ richest_agent_money: list = field(default_factory=list)
+ poorest_agent_money: list = field(default_factory=list)
+ final_agent_stats: list = field(default_factory=list) # List of agent dicts at end
def run_headless_simulation(num_steps: int = 1000, num_agents: int = 10) -> tuple[str, SimulationStats]:
@@ -110,16 +117,42 @@ def run_headless_simulation(num_steps: int = 1000, num_agents: int = 10) -> tupl
time_of_day = engine.world.time_of_day.value
# Track living agents
- living = len(engine.world.get_living_agents())
+ living_agents = engine.world.get_living_agents()
+ living = len(living_agents)
stats.living_agents_over_time.append(living)
- # Track money
- total_money = sum(a.money for a in engine.world.agents)
- stats.money_circulation_over_time.append(total_money)
- if living > 0:
+ # Track money and wealth inequality
+ if living_agents:
+ moneys = sorted([a.money for a in living_agents])
+ total_money = sum(moneys)
+ stats.money_circulation_over_time.append(total_money)
stats.avg_agent_money_over_time.append(total_money / living)
+ stats.richest_agent_money.append(moneys[-1])
+ stats.poorest_agent_money.append(moneys[0])
+
+ # Track Gini coefficient
+ n = len(moneys)
+ if n > 1 and total_money > 0:
+ sum_of_diffs = sum(abs(m1 - m2) for m1 in moneys for m2 in moneys)
+ gini = sum_of_diffs / (2 * n * total_money)
+ else:
+ gini = 0
+ stats.gini_over_time.append(gini)
+
+ # Track profession distribution
+ professions = {}
+ for agent in living_agents:
+ agent._update_profession()
+ prof = agent.profession.value
+ professions[prof] = professions.get(prof, 0) + 1
+ stats.professions_over_time.append(professions)
else:
+ stats.money_circulation_over_time.append(0)
stats.avg_agent_money_over_time.append(0)
+ stats.richest_agent_money.append(0)
+ stats.poorest_agent_money.append(0)
+ stats.gini_over_time.append(0)
+ stats.professions_over_time.append({})
# Process agent actions
for action_data in turn_log.agent_actions:
@@ -206,6 +239,25 @@ def run_headless_simulation(num_steps: int = 1000, num_agents: int = 10) -> tupl
print("\n")
+ # Collect final agent statistics
+ for agent in engine.world.get_living_agents():
+ agent._update_profession()
+ stats.final_agent_stats.append({
+ "name": agent.name,
+ "profession": agent.profession.value,
+ "money": agent.money,
+ "trades": agent.total_trades_completed,
+ "money_earned": agent.total_money_earned,
+ "skills": agent.skills.to_dict(),
+ "personality": {
+ "wealth_desire": round(agent.personality.wealth_desire, 2),
+ "trade_preference": round(agent.personality.trade_preference, 2),
+ "hoarding_rate": round(agent.personality.hoarding_rate, 2),
+ "market_affinity": round(agent.personality.market_affinity, 2),
+ },
+ "actions": agent.actions_performed.copy(),
+ })
+
# Close logger
engine.logger.close()
@@ -420,6 +472,69 @@ def generate_text_report(stats: SimulationStats) -> str:
for action, count in sorted(stats.actions_by_time_of_day["night"].items(), key=lambda x: -x[1])[:5]:
lines.append(f" - {action}: {count:,}")
+ # Profession Distribution (new)
+ lines.append("\n\n๐ค PROFESSION DISTRIBUTION")
+ lines.append("-" * 40)
+ if stats.professions_over_time:
+ final_profs = stats.professions_over_time[-1]
+ if final_profs:
+ total_agents = sum(final_profs.values())
+ lines.append(f" {'Profession':<15} {'Count':>8} {'Percentage':>12}")
+ lines.append(f" {'-'*15} {'-'*8} {'-'*12}")
+ for prof, count in sorted(final_profs.items(), key=lambda x: -x[1]):
+ pct = count / total_agents * 100 if total_agents > 0 else 0
+ lines.append(f" {prof:<15} {count:>8} {pct:>10.1f}%")
+ else:
+ lines.append(" No agents remaining")
+ else:
+ lines.append(" No profession data")
+
+ # Wealth Inequality (new)
+ lines.append("\n\n๐ WEALTH INEQUALITY")
+ lines.append("-" * 40)
+ if stats.gini_over_time:
+ final_gini = stats.gini_over_time[-1]
+ avg_gini = sum(stats.gini_over_time) / len(stats.gini_over_time)
+ max_gini = max(stats.gini_over_time)
+
+ lines.append(f" Final Gini Coefficient: {final_gini:.3f}")
+ lines.append(f" Average Gini: {avg_gini:.3f}")
+ lines.append(f" Peak Gini: {max_gini:.3f}")
+ lines.append("")
+ lines.append(f" (0 = perfect equality, 1 = maximum inequality)")
+
+ if stats.richest_agent_money and stats.poorest_agent_money:
+ lines.append("")
+ lines.append(f" Richest agent at end: {stats.richest_agent_money[-1]}ยข")
+ lines.append(f" Poorest agent at end: {stats.poorest_agent_money[-1]}ยข")
+ wealth_ratio = stats.richest_agent_money[-1] / max(1, stats.poorest_agent_money[-1])
+ lines.append(f" Wealth ratio (rich/poor): {wealth_ratio:.1f}x")
+ else:
+ lines.append(" No wealth data")
+
+ # Top Agents by Wealth (new)
+ lines.append("\n\n๐ TOP AGENTS BY WEALTH")
+ lines.append("-" * 40)
+ if stats.final_agent_stats:
+ sorted_agents = sorted(stats.final_agent_stats, key=lambda x: -x["money"])
+ lines.append(f" {'Name':<15} {'Prof':<12} {'Money':>8} {'Trades':>8}")
+ lines.append(f" {'-'*15} {'-'*12} {'-'*8} {'-'*8}")
+ for agent in sorted_agents[:10]:
+ lines.append(f" {agent['name']:<15} {agent['profession']:<12} {agent['money']:>7}ยข {agent['trades']:>8}")
+
+ # Skill leaders
+ lines.append("\n ๐ Highest Skills:")
+ skill_leaders = {}
+ for agent in stats.final_agent_stats:
+ for skill, value in agent["skills"].items():
+ if skill not in skill_leaders or value > skill_leaders[skill][1]:
+ skill_leaders[skill] = (agent["name"], value)
+
+ for skill, (name, value) in sorted(skill_leaders.items()):
+ lines.append(f" {skill}: {name} ({value:.2f})")
+ else:
+ lines.append(" No agent data")
+
lines.append("\n" + "=" * 70)
return "\n".join(lines)