diff --git a/README.md b/README.md index b20a00d..b165d6a 100644 --- a/README.md +++ b/README.md @@ -9,14 +9,14 @@ This project simulates a village economy with autonomous AI agents. Each agent h ### Features - **Agent-based simulation**: Multiple AI agents with different professions +- **GOAP AI system**: Goal-Oriented Action Planning for intelligent agent behavior - **Vital stats system**: Energy, Hunger, Thirst, and Heat with passive decay - **Market economy**: Order book system for trading resources - **Day/Night cycle**: 10 day steps + 1 night step per day -- **Maslow-priority AI**: Agents prioritize survival over economic activities -- **Real-time visualization**: Pygame frontend showing agents and their states +- **Real-time visualization**: Web-based frontend showing agents and their states - **Agent movement**: Agents visually move to different locations based on their actions - **Action indicators**: Visual feedback showing what each agent is doing -- **Settings panel**: Adjust simulation parameters with sliders +- **GOAP Debug Panel**: View agent planning and decision-making in real-time - **Detailed logging**: All simulation steps are logged for analysis ## Architecture @@ -28,11 +28,13 @@ villsim/ │ ├── config.py # Centralized configuration │ ├── api/ # REST API endpoints │ ├── core/ # Game logic (engine, world, market, AI, logger) +│ │ └── goap/ # GOAP AI system (planner, actions, goals) │ └── domain/ # Data models (agent, resources, actions) -├── frontend/ # Pygame visualizer -│ ├── main.py # Entry point -│ ├── client.py # HTTP client -│ └── renderer/ # Drawing components (map, agents, UI, settings) +├── web_frontend/ # Web-based visualizer +│ ├── index.html # Main application +│ ├── goap_debug.html # GOAP debugging view +│ └── src/ # JavaScript modules (scenes, API client) +├── tools/ # Analysis and optimization scripts ├── logs/ # Simulation log files (created on run) ├── docs/design/ # Design documents ├── requirements.txt @@ -79,40 +81,25 @@ The server will start at `http://localhost:8000`. You can access: - API docs: `http://localhost:8000/docs` - Health check: `http://localhost:8000/health` -### Start the Frontend Visualizer +### Start the Web Frontend -Open another terminal and run: +Open the web frontend by opening `web_frontend/index.html` in a web browser, or serve it with a local HTTP server: ```bash -python -m frontend.main +cd web_frontend +python -m http.server 8080 ``` -A Pygame window will open showing the simulation. +Then navigate to `http://localhost:8080` in your browser. ## Controls -| Key | Action | -|-----|--------| -| `SPACE` | Advance one turn (manual mode) | -| `R` | Reset simulation | -| `M` | Toggle between MANUAL and AUTO mode | -| `S` | Open/close settings panel | -| `ESC` | Close settings or quit | +The web frontend provides buttons for: +- **Step**: Advance one turn (manual mode) +- **Auto/Manual**: Toggle between automatic and manual mode +- **Reset**: Reset simulation -Hover over agents to see detailed information. - -## Settings Panel - -Press `S` to open the settings panel where you can adjust: - -- **Agent Stats**: Max values and decay rates for energy, hunger, thirst, heat -- **World Settings**: Grid size, initial agent count, day length -- **Action Costs**: Energy costs for hunting, gathering, etc. -- **Resource Effects**: How much stats are restored by consuming resources -- **Market Settings**: Price adjustment timing and rates -- **Simulation Speed**: Auto-step interval - -Changes require clicking "Apply & Restart" to take effect. +Click on agents to see detailed information. Use the GOAP debug panel (`goap_debug.html`) to inspect agent planning. ## Logging @@ -189,12 +176,14 @@ Action indicators above agents show: - Movement animation when traveling - Dotted line to destination -### AI Priority System +### AI System (GOAP) -1. **Critical needs** (stat < 20%): Consume, buy, or gather resources -2. **Energy management**: Rest if too tired -3. **Economic activity**: Sell excess inventory, buy needed materials -4. **Routine work**: Perform profession-specific tasks +The simulation uses Goal-Oriented Action Planning (GOAP) for intelligent agent behavior: + +1. **Goals**: Agents have weighted goals (Survive, Maintain Heat, Build Wealth, etc.) +2. **Actions**: Agents can perform actions with preconditions and effects +3. **Planning**: A* search finds optimal action sequences to satisfy goals +4. **Personality**: Each agent has unique traits affecting goal weights and decisions ## Development @@ -202,9 +191,10 @@ Action indicators above agents show: - **Config** (`backend/config.py`): Centralized configuration with dataclasses - **Domain Layer** (`backend/domain/`): Pure data models -- **Core Layer** (`backend/core/`): Game logic, AI, market, logging +- **Core Layer** (`backend/core/`): Game logic, market, logging +- **GOAP AI** (`backend/core/goap/`): Goal-oriented action planning system - **API Layer** (`backend/api/`): FastAPI routes and schemas -- **Frontend** (`frontend/`): Pygame visualization client +- **Web Frontend** (`web_frontend/`): Browser-based visualization ### Analyzing Logs @@ -227,7 +217,7 @@ with open("logs/sim_20260118_123456.jsonl") as f: - Agent reproduction - Skill progression - Persistent save/load -- Web-based frontend alternative +- Unity frontend integration ## License diff --git a/backend/config.py b/backend/config.py index 183d1f1..665f2ae 100644 --- a/backend/config.py +++ b/backend/config.py @@ -133,14 +133,7 @@ class EconomyConfig: @dataclass class AIConfig: - """Configuration for AI decision-making system. - - Controls whether to use GOAP (Goal-Oriented Action Planning) or - the legacy priority-based system. - """ - # Use GOAP-based AI (True) or legacy priority-based AI (False) - use_goap: bool = True - + """Configuration for AI decision-making system (GOAP-based).""" # Maximum A* iterations for GOAP planner goap_max_iterations: int = 50 diff --git a/backend/core/__init__.py b/backend/core/__init__.py index dda0b83..5004c00 100644 --- a/backend/core/__init__.py +++ b/backend/core/__init__.py @@ -3,7 +3,6 @@ from .world import World, TimeOfDay from .market import Order, OrderBook from .engine import GameEngine, SimulationMode -from .ai import AgentAI from .logger import SimulationLogger, get_simulation_logger __all__ = [ @@ -13,7 +12,6 @@ __all__ = [ "OrderBook", "GameEngine", "SimulationMode", - "AgentAI", "SimulationLogger", "get_simulation_logger", ] diff --git a/backend/core/ai.py b/backend/core/ai.py index 72aa712..d0deadd 100644 --- a/backend/core/ai.py +++ b/backend/core/ai.py @@ -1,8 +1,7 @@ """AI decision system for agents in the Village Simulation. -This module provides two AI systems: -1. GOAP (Goal-Oriented Action Planning) - The default, modern approach -2. Legacy priority-based system - Kept for comparison/fallback +This module provides shared AI classes and utilities used by the GOAP +(Goal-Oriented Action Planning) system. GOAP Benefits: - Agents plan multi-step sequences to achieve goals @@ -18,14 +17,12 @@ Major features: - Personality affects: risk tolerance, hoarding, market participation """ -import random from dataclasses import dataclass, field 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 @@ -131,1099 +128,15 @@ def reset_ai_config_cache(): _cached_economy_config = None -class AgentAI: - """AI decision maker with personality-driven economy behavior. - - Core philosophy: Each agent has a unique strategy based on personality. - - 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 - - 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 - LOW_THRESHOLD = 0.45 # 45% - proactive action trigger - COMFORT_THRESHOLD = 0.60 # 60% - aim for comfort - - # Energy thresholds - 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 (modified by personality.hoarding_rate) - BASE_WATER_STOCK = 2 - BASE_FOOD_STOCK = 3 - BASE_WOOD_STOCK = 2 - - # Heat thresholds - HEAT_PROACTIVE_THRESHOLD = 0.50 - - # Base economy settings (loaded from config, modified by personality) - # These are default fallbacks; actual values come from config - MAX_PRICE_MARKUP = 2.0 # Maximum markup over base price - MIN_PRICE_DISCOUNT = 0.5 # Minimum discount (50% of base price) - - def __init__(self, agent: Agent, market: "OrderBook", step_in_day: int = 1, day_steps: int = 10, current_turn: int = 0): - self.agent = agent - self.market = market - self.step_in_day = step_in_day - 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 - - # Personality-adjusted values - # Wealth desire from personality (0.1 to 0.9) - self.WEALTH_DESIRE = self.p.wealth_desire - - # Load economy config - economy = _get_economy_config() - - # Energy to money ratio (how much 1 energy is worth in coins) - self.ENERGY_TO_MONEY_RATIO = getattr(economy, 'energy_to_money_ratio', 150) if economy else 150 - - # Minimum price floor - self.MIN_PRICE = getattr(economy, 'min_price', 100) if economy else 100 - - # Buy efficiency threshold adjusted by price sensitivity - # High sensitivity = only buy very good deals - 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', 5000) if economy else 5000 - 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: - """Check if it's getting close to night (last 2 day steps).""" - return self.step_in_day >= self.day_steps - 1 - - @property - def is_late_day(self) -> bool: - """Check if it's past midday (preparation time).""" - return self.step_in_day >= self.day_steps // 2 - - def _get_resource_fair_value(self, resource_type: ResourceType) -> int: - """Calculate the 'fair value' of a resource based on energy cost to produce. - - This is the theoretical minimum price an agent should sell for, - and the maximum they should pay before just gathering themselves. - """ - energy_cost = get_energy_cost(resource_type) - return max(self.MIN_PRICE, int(energy_cost * self.ENERGY_TO_MONEY_RATIO)) - - def _is_good_buy(self, resource_type: ResourceType, price: int) -> bool: - """Check if a market price is a good deal (cheaper than gathering).""" - fair_value = self._get_resource_fair_value(resource_type) - return price <= fair_value * self.BUY_EFFICIENCY_THRESHOLD - - def _is_wealthy(self) -> bool: - """Check if agent has comfortable wealth.""" - return self.agent.money >= self.MIN_WEALTH_TARGET - - def decide(self) -> AIDecision: - """Make a decision based on survival, personality, and economic goals. - - 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() - if decision: - return decision - - # Priority 2: Proactive survival (prevent problems before they happen) - decision = self._check_proactive_needs() - if decision: - return decision - - # 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 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 6: Craft clothes if we have hide - decision = self._check_clothes_crafting() - if decision: - return decision - - # Priority 7: Energy management - decision = self._check_energy() - if decision: - return decision - - # 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 - - # 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.""" - stats = self.agent.stats - - # Check thirst first (depletes fastest and kills quickly) - if stats.thirst < stats.MAX_THIRST * self.CRITICAL_THRESHOLD: - return self._address_thirst(critical=True) - - # Check hunger - if stats.hunger < stats.MAX_HUNGER * self.CRITICAL_THRESHOLD: - return self._address_hunger(critical=True) - - # Check heat - critical level - if stats.heat < stats.MAX_HEAT * self.CRITICAL_THRESHOLD: - return self._address_heat(critical=True) - - return None - - def _check_proactive_needs(self) -> Optional[AIDecision]: - """Proactively address needs before they become critical. - - IMPORTANT CHANGE: Now considers buying as a first option, not last! - """ - stats = self.agent.stats - - # Proactive thirst management - if stats.thirst < stats.MAX_THIRST * self.LOW_THRESHOLD: - return self._address_thirst(critical=False) - - # Proactive hunger management - if stats.hunger < stats.MAX_HUNGER * self.LOW_THRESHOLD: - return self._address_hunger(critical=False) - - # Proactive heat management - if stats.heat < stats.MAX_HEAT * self.HEAT_PROACTIVE_THRESHOLD: - decision = self._address_heat(critical=False) - if decision: - return decision - - return None - - def _check_price_adjustments(self) -> Optional[AIDecision]: - """Check if we should adjust prices on our market orders. - - Smart pricing strategy: - - If order is stale (not selling), lower price - - If demand is high (scarcity), raise price - - Respond to market signals - """ - my_orders = self.market.get_orders_by_seller(self.agent.id) - if not my_orders: - return None - - for order in my_orders: - resource_type = order.resource_type - signal = self.market.get_market_signal(resource_type) - current_price = order.price_per_unit - fair_value = self._get_resource_fair_value(resource_type) - - # If demand is high and we've waited, consider raising price - if signal == "sell" and order.can_raise_price(self.current_turn, min_turns=3): - # Scarcity - raise price (but not too high) - new_price = min( - int(current_price * 1.25), # 25% increase - int(fair_value * self.MAX_PRICE_MARKUP) - ) - if new_price > current_price: - return AIDecision( - action=ActionType.TRADE, - target_resource=resource_type, - adjust_order_id=order.id, - new_price=new_price, - reason=f"Scarcity: raising {resource_type.value} price to {new_price}c", - ) - - # If order is getting stale (sitting too long), lower price - if order.turns_without_sale >= 5: - # Calculate competitive price - lowest_order = self.market.get_cheapest_order(resource_type) - if lowest_order and lowest_order.id != order.id: - # Price just below the cheapest - new_price = max( - lowest_order.price_per_unit - 1, - int(fair_value * self.MIN_PRICE_DISCOUNT) - ) - else: - # We're the only seller - slight discount to attract buyers - new_price = max( - int(current_price * 0.85), - int(fair_value * self.MIN_PRICE_DISCOUNT) - ) - - if new_price < current_price: - return AIDecision( - action=ActionType.TRADE, - target_resource=resource_type, - adjust_order_id=order.id, - new_price=new_price, - reason=f"Stale order: lowering {resource_type.value} price to {new_price}c", - ) - - return None - - def _check_market_opportunities(self) -> Optional[AIDecision]: - """Look for good buying opportunities on the market. - - KEY INSIGHT: If market price < energy cost to gather, ALWAYS BUY! - This is the core of smart trading behavior. - - Buying is smart because: - - Trade costs only 1 energy - - Gathering costs 4-8 energy - - If price is low, we're getting resources for less than production cost - """ - # Don't shop if we're low on money and not wealthy - if self.agent.money < 10: - return None - - # Resources we might want to buy - shopping_list = [] - - # Check each resource type for good deals - for resource_type in [ResourceType.WATER, ResourceType.BERRIES, ResourceType.MEAT, ResourceType.WOOD]: - order = self.market.get_cheapest_order(resource_type) - 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 - - # Calculate if this is a good deal - fair_value = self._get_resource_fair_value(resource_type) - is_good_deal = self._is_good_buy(resource_type, order.price_per_unit) - - # Calculate our current need for this resource - current_stock = self.agent.get_resource_count(resource_type) - need_score = 0 - - if resource_type == ResourceType.WATER: - need_score = max(0, self.MIN_WATER_STOCK - current_stock) * 3 - elif resource_type in [ResourceType.BERRIES, ResourceType.MEAT]: - food_stock = (self.agent.get_resource_count(ResourceType.BERRIES) + - self.agent.get_resource_count(ResourceType.MEAT)) - need_score = max(0, self.MIN_FOOD_STOCK - food_stock) * 2 - elif resource_type == ResourceType.WOOD: - need_score = max(0, self.MIN_WOOD_STOCK - current_stock) * 1 - - # Score this opportunity - if is_good_deal: - # Good deal - definitely consider buying - 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(): - # Not a great deal, but we need it and have money - total_score = need_score * 0.5 - shopping_list.append((resource_type, order, total_score)) - - if not shopping_list: - return None - - # Sort by score and pick the best opportunity - shopping_list.sort(key=lambda x: x[2], reverse=True) - resource_type, order, score = shopping_list[0] - - # Only act if the opportunity is worth it - if score < 1: - return None - - # Calculate how much to buy - 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 - - if want_quantity <= 0: - return None - - return AIDecision( - action=ActionType.TRADE, - target_resource=resource_type, - order_id=order.id, - quantity=want_quantity, - price=order.price_per_unit, - reason=f"Good deal: buying {resource_type.value} @ {order.price_per_unit}c (fair value: {self._get_resource_fair_value(resource_type)}c)", - ) - - def _check_clothes_crafting(self) -> Optional[AIDecision]: - """Check if we should craft clothes for heat efficiency.""" - # Only craft if we don't already have clothes and have hide - if self.agent.has_clothes(): - return None - - # Need hide to craft - if not self.agent.has_resource(ResourceType.HIDE): - return None - - # Need energy to craft - weave_config = ACTION_CONFIG[ActionType.WEAVE] - if not self.agent.stats.can_work(abs(weave_config.energy_cost)): - return None - - # Only craft if we're not in survival mode - stats = self.agent.stats - if (stats.thirst < stats.MAX_THIRST * self.LOW_THRESHOLD or - stats.hunger < stats.MAX_HUNGER * self.LOW_THRESHOLD): - return None - - return AIDecision( - action=ActionType.WEAVE, - target_resource=ResourceType.CLOTHES, - reason="Crafting clothes for heat protection", - ) - - def _address_thirst(self, critical: bool = False) -> AIDecision: - """Address thirst - water is the primary solution. - - NEW PRIORITY: Try buying FIRST if it's efficient! - Trading uses only 1 energy vs 3 for getting water. - """ - prefix = "Critical" if critical else "Low" - - # Step 1: Consume water from inventory (best - immediate, free) - if self.agent.has_resource(ResourceType.WATER): - return AIDecision( - action=ActionType.CONSUME, - target_resource=ResourceType.WATER, - reason=f"{prefix} thirst: consuming water", - ) - - # Step 2: Check if buying is more efficient than gathering - # Trade = 1 energy, Get water = 3 energy. If price is reasonable, BUY! - water_order = self.market.get_cheapest_order(ResourceType.WATER) - if water_order and water_order.seller_id != self.agent.id: - if self.agent.money >= water_order.price_per_unit: - fair_value = self._get_resource_fair_value(ResourceType.WATER) - - # Buy if: good deal OR critical situation OR we're wealthy - should_buy = ( - self._is_good_buy(ResourceType.WATER, water_order.price_per_unit) or - critical or - (self._is_wealthy() and water_order.price_per_unit <= fair_value * 1.5) - ) - - if should_buy: - return AIDecision( - action=ActionType.TRADE, - target_resource=ResourceType.WATER, - order_id=water_order.id, - quantity=1, - price=water_order.price_per_unit, - reason=f"{prefix} thirst: buying water @ {water_order.price_per_unit}c", - ) - - # Step 3: Get water ourselves - water_config = ACTION_CONFIG[ActionType.GET_WATER] - if self.agent.stats.can_work(abs(water_config.energy_cost)): - return AIDecision( - action=ActionType.GET_WATER, - target_resource=ResourceType.WATER, - reason=f"{prefix} thirst: getting water from river", - ) - - # Step 4: Emergency - consume berries (gives +3 thirst) - if self.agent.has_resource(ResourceType.BERRIES): - return AIDecision( - action=ActionType.CONSUME, - target_resource=ResourceType.BERRIES, - reason=f"{prefix} thirst: consuming berries (emergency)", - ) - - # No energy to get water - rest - return AIDecision( - action=ActionType.REST, - reason=f"{prefix} thirst: too tired, resting", - ) - - def _address_hunger(self, critical: bool = False) -> AIDecision: - """Address hunger - meat is best, berries are backup. - - NEW PRIORITY: Consider buying FIRST if market has good prices! - Trading = 1 energy vs 4-7 for gathering/hunting. - """ - prefix = "Critical" if critical else "Low" - - # Step 1: Consume meat from inventory (best for hunger - +40) - if self.agent.has_resource(ResourceType.MEAT): - return AIDecision( - action=ActionType.CONSUME, - target_resource=ResourceType.MEAT, - reason=f"{prefix} hunger: consuming meat", - ) - - # Step 2: Consume berries if we have them (+10 hunger) - if self.agent.has_resource(ResourceType.BERRIES): - return AIDecision( - action=ActionType.CONSUME, - target_resource=ResourceType.BERRIES, - reason=f"{prefix} hunger: consuming berries", - ) - - # Step 3: Check if buying food is more efficient than gathering - for resource_type in [ResourceType.MEAT, ResourceType.BERRIES]: - 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: - fair_value = self._get_resource_fair_value(resource_type) - - # Buy if: good deal OR critical OR wealthy - should_buy = ( - self._is_good_buy(resource_type, order.price_per_unit) or - critical or - (self._is_wealthy() and order.price_per_unit <= fair_value * 1.5) - ) - - if should_buy: - return AIDecision( - action=ActionType.TRADE, - target_resource=resource_type, - order_id=order.id, - quantity=1, - price=order.price_per_unit, - reason=f"{prefix} hunger: buying {resource_type.value} @ {order.price_per_unit}c", - ) - - # Step 4: Gather berries ourselves (easy, 100% success) - gather_config = ACTION_CONFIG[ActionType.GATHER] - if self.agent.stats.can_work(abs(gather_config.energy_cost)): - return AIDecision( - action=ActionType.GATHER, - target_resource=ResourceType.BERRIES, - reason=f"{prefix} hunger: gathering berries", - ) - - # No energy - rest - return AIDecision( - action=ActionType.REST, - reason=f"{prefix} hunger: too tired, resting", - ) - - def _address_heat(self, critical: bool = False) -> Optional[AIDecision]: - """Address heat by building fire or getting wood. - - NOW: Always considers buying wood if it's a good deal! - Trade = 1 energy vs 8 energy for chopping. - """ - prefix = "Critical" if critical else "Low" - - # Step 1: Build fire if we have wood - if self.agent.has_resource(ResourceType.WOOD): - fire_config = ACTION_CONFIG[ActionType.BUILD_FIRE] - if self.agent.stats.can_work(abs(fire_config.energy_cost)): - return AIDecision( - action=ActionType.BUILD_FIRE, - target_resource=ResourceType.WOOD, - reason=f"{prefix} heat: building fire", - ) - - # Step 2: Buy wood if available and efficient - cheapest = self.market.get_cheapest_order(ResourceType.WOOD) - if cheapest and cheapest.seller_id != self.agent.id and self.agent.money >= cheapest.price_per_unit: - fair_value = self._get_resource_fair_value(ResourceType.WOOD) - - # Buy if: good deal OR critical OR wealthy - should_buy = ( - self._is_good_buy(ResourceType.WOOD, cheapest.price_per_unit) or - critical or - (self._is_wealthy() and cheapest.price_per_unit <= fair_value * 1.5) - ) - - if should_buy: - return AIDecision( - action=ActionType.TRADE, - target_resource=ResourceType.WOOD, - order_id=cheapest.id, - quantity=1, - price=cheapest.price_per_unit, - reason=f"{prefix} heat: buying wood @ {cheapest.price_per_unit}c", - ) - - # Step 3: Chop wood ourselves - chop_config = ACTION_CONFIG[ActionType.CHOP_WOOD] - if self.agent.stats.can_work(abs(chop_config.energy_cost)): - return AIDecision( - action=ActionType.CHOP_WOOD, - target_resource=ResourceType.WOOD, - reason=f"{prefix} heat: chopping wood for fire", - ) - - # If not critical, return None to let other priorities take over - if not critical: - return None - - return AIDecision( - action=ActionType.REST, - reason=f"{prefix} heat: too tired to get wood, resting", - ) - - def _check_energy(self) -> Optional[AIDecision]: - """Check if energy management is needed. - - Improved logic: Don't rest at 13-14 energy just to rest. - Instead, rest only if we truly can't do essential work. - """ - stats = self.agent.stats - - # Only rest if energy is very low - if stats.energy < self.LOW_ENERGY_THRESHOLD: - return AIDecision( - action=ActionType.REST, - reason=f"Energy critically low ({stats.energy}), must rest", - ) - - # If it's evening and energy is moderate, rest to prepare for night - if self.is_evening and stats.energy < self.REST_ENERGY_THRESHOLD: - # Only if we have enough supplies - has_supplies = ( - self.agent.get_resource_count(ResourceType.WATER) >= 1 and - (self.agent.get_resource_count(ResourceType.MEAT) >= 1 or - self.agent.get_resource_count(ResourceType.BERRIES) >= 2) - ) - if has_supplies: - return AIDecision( - action=ActionType.REST, - reason=f"Evening: resting to prepare for night", - ) - - return None - - def _check_economic(self) -> Optional[AIDecision]: - """Economic activities: selling, wealth building, market participation. - - NEW PHILOSOPHY: Actively participate in the market! - - Sell excess resources to build wealth - - Price based on supply/demand, not just to clear inventory - - Wealth = safety = survival - """ - # Proactive selling - not just when inventory is full - # If we have excess and market is favorable, sell! - decision = self._try_proactive_sell() - if decision: - return decision - - # If inventory is getting full, must sell - if self.agent.inventory_space() <= 2: - decision = self._try_to_sell(urgent=True) - if decision: - return decision - - return None - - def _try_proactive_sell(self) -> Optional[AIDecision]: - """Proactively sell when market conditions are good. - - 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 - - # 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, - ResourceType.WOOD: 2, - 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 - - for resource in self.agent.inventory: - if resource.type == ResourceType.CLOTHES: - continue # Don't sell clothes - - min_keep = survival_minimums.get(resource.type, 1) - excess = resource.quantity - min_keep - - if excess <= 0: - continue - - # Check market conditions - 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 * sell_modifier) - score = 3 + excess - elif signal == "hold": # Normal market - price = int(fair_value * sell_modifier) - score = 1 + excess * 0.5 - else: # Surplus - price competitively - # Find cheapest competitor - cheapest = self.market.get_cheapest_order(resource.type) - if cheapest and cheapest.seller_id != self.agent.id: - price = max(self.MIN_PRICE, cheapest.price_per_unit - 1) - else: - 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 - - if best_opportunity and best_score >= 1: - resource_type, quantity, price = best_opportunity - return AIDecision( - action=ActionType.TRADE, - target_resource=resource_type, - quantity=quantity, - price=price, - reason=f"Selling {resource_type.value} @ {price}c", - ) - - return None - - def _try_to_sell(self, urgent: bool = False) -> Optional[AIDecision]: - """Sell excess resources, keeping enough for survival.""" - survival_minimums = { - ResourceType.WATER: 2 if urgent else 3, - ResourceType.MEAT: 1 if urgent else 2, - ResourceType.BERRIES: 2 if urgent else 3, - ResourceType.WOOD: 1 if urgent else 2, - ResourceType.HIDE: 0, - } - - for resource in self.agent.inventory: - if resource.type == ResourceType.CLOTHES: - continue - - min_keep = survival_minimums.get(resource.type, 1) - if resource.quantity > min_keep: - quantity_to_sell = resource.quantity - min_keep - price = self._calculate_sell_price(resource.type) - reason = "Urgent: clearing inventory" if urgent else f"Selling excess {resource.type.value}" - return AIDecision( - action=ActionType.TRADE, - target_resource=resource.type, - quantity=quantity_to_sell, - price=price, - reason=reason, - ) - return None - - def _calculate_sell_price(self, resource_type: ResourceType) -> int: - """Calculate sell price based on fair value and market conditions.""" - fair_value = self._get_resource_fair_value(resource_type) - - # Get market suggestion - suggested = self.market.get_suggested_price(resource_type, fair_value) - - # Check competition - cheapest = self.market.get_cheapest_order(resource_type) - if cheapest and cheapest.seller_id != self.agent.id: - # Don't price higher than cheapest competitor unless scarcity - signal = self.market.get_market_signal(resource_type) - if signal != "sell": - # Match or undercut - suggested = min(suggested, cheapest.price_per_unit) - - return max(self.MIN_PRICE, suggested) - - def _do_survival_work(self) -> AIDecision: - """Perform work based on survival needs AND personality preferences. - - 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 - - # Count current resources - water_count = self.agent.get_resource_count(ResourceType.WATER) - meat_count = self.agent.get_resource_count(ResourceType.MEAT) - berry_count = self.agent.get_resource_count(ResourceType.BERRIES) - wood_count = self.agent.get_resource_count(ResourceType.WOOD) - food_count = meat_count + berry_count - - # Urgency calculations - heat_urgency = 1 - (stats.heat / stats.MAX_HEAT) - - # 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 (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] - if self.agent.stats.can_work(abs(config.energy_cost)): - return AIDecision( - action=gather_action, - target_resource=resource_type, - reason=f"{reason} (gathering)", - ) - return None - - # Priority: Stock up on water if low - if water_count < self.MIN_WATER_STOCK: - decision = get_resource_decision( - ResourceType.WATER, - ActionType.GET_WATER, - f"Stocking water ({water_count} < {self.MIN_WATER_STOCK})" - ) - if decision: - 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, - ActionType.CHOP_WOOD, - f"Stocking wood ({wood_count} < {self.MIN_WOOD_STOCK})" - ) - if decision: - return decision - - # Priority: Stock up on food if low - if food_count < self.MIN_FOOD_STOCK: - hunt_config = ACTION_CONFIG[ActionType.HUNT] - - # 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 - - # 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, - f"Hunting for food ({food_count} < {self.MIN_FOOD_STOCK})" - ) - if decision: - return decision - - # Otherwise try berries - decision = get_resource_decision( - ResourceType.BERRIES, - ActionType.GATHER, - f"Stocking food ({food_count} < {self.MIN_FOOD_STOCK})" - ) - if decision: - return decision - - # Evening preparation - if self.is_late_day: - if water_count < self.MIN_WATER_STOCK + 1: - decision = get_resource_decision(ResourceType.WATER, ActionType.GET_WATER, "Evening: stocking water") - if decision: - return decision - if food_count < self.MIN_FOOD_STOCK + 1: - decision = get_resource_decision(ResourceType.BERRIES, ActionType.GATHER, "Evening: stocking food") - if decision: - return decision - if wood_count < self.MIN_WOOD_STOCK + 1: - decision = get_resource_decision(ResourceType.WOOD, ActionType.CHOP_WOOD, "Evening: stocking wood") - if decision: - return decision - - # 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.0)) - - if food_count < self.MIN_FOOD_STOCK + 2: - # 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: - 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.0 * self.p.woodcut_preference)) - - if not needs: - # We have good reserves, maybe sell excess or rest - if self.agent.inventory_space() <= 4: - decision = self._try_proactive_sell() - if decision: - return decision - - # 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="Default: gathering (personality)", - ) - - # 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) - r = random.random() * total_weight - cumulative = 0 - for resource, action, reason, weight in needs: - cumulative += weight - if r <= cumulative: - return AIDecision( - action=action, - target_resource=resource, - reason=reason, - ) - - # Fallback - resource, action, reason, _ = needs[0] - return AIDecision( - action=action, - target_resource=resource, - reason=reason, - ) - - def get_ai_decision( agent: Agent, market: "OrderBook", step_in_day: int = 1, day_steps: int = 10, current_turn: int = 0, - use_goap: bool = True, is_night: bool = False, ) -> AIDecision: - """Get an AI decision for an agent. - - By default, uses the GOAP (Goal-Oriented Action Planning) system. - Set use_goap=False to use the legacy priority-based system. + """Get an AI decision for an agent using GOAP (Goal-Oriented Action Planning). Args: agent: The agent to make a decision for @@ -1231,38 +144,17 @@ def get_ai_decision( step_in_day: Current step within the day day_steps: Total steps per day current_turn: Current simulation turn - use_goap: Whether to use GOAP (default True) or legacy system is_night: Whether it's currently night time Returns: AIDecision with the chosen action and parameters """ - if use_goap: - from backend.core.goap.goap_ai import get_goap_decision - return get_goap_decision( - agent=agent, - market=market, - step_in_day=step_in_day, - day_steps=day_steps, - current_turn=current_turn, - is_night=is_night, - ) - else: - # Legacy priority-based system - ai = AgentAI(agent, market, step_in_day, day_steps, current_turn) - return ai.decide() - - -def get_legacy_ai_decision( - agent: Agent, - market: "OrderBook", - step_in_day: int = 1, - day_steps: int = 10, - current_turn: int = 0, -) -> AIDecision: - """Get an AI decision using the legacy priority-based system. - - This is kept for comparison and testing purposes. - """ - ai = AgentAI(agent, market, step_in_day, day_steps, current_turn) - return ai.decide() + from backend.core.goap.goap_ai import get_goap_decision + return get_goap_decision( + agent=agent, + market=market, + step_in_day=step_in_day, + day_steps=day_steps, + current_turn=current_turn, + is_night=is_night, + ) diff --git a/backend/core/engine.py b/backend/core/engine.py index 9642a8a..6877ae2 100644 --- a/backend/core/engine.py +++ b/backend/core/engine.py @@ -176,9 +176,6 @@ class GameEngine: money=agent.money, ) - # Get AI config to determine which system to use - ai_config = get_config().ai - # GOAP AI handles night time automatically decision = get_ai_decision( agent, @@ -186,7 +183,6 @@ class GameEngine: step_in_day=self.world.step_in_day, day_steps=self.world.config.day_steps, current_turn=current_turn, - use_goap=ai_config.use_goap, is_night=self.world.is_night(), ) diff --git a/backend/core/goap/goap_ai.py b/backend/core/goap/goap_ai.py index 281d0f6..c73658a 100644 --- a/backend/core/goap/goap_ai.py +++ b/backend/core/goap/goap_ai.py @@ -1,7 +1,7 @@ """GOAP-based AI decision system for agents. -This module provides the main interface for GOAP-based decision making. -It replaces the priority-based AgentAI with a goal-oriented planner. +This module provides the main interface for GOAP-based decision making +using Goal-Oriented Action Planning. """ from dataclasses import dataclass, field diff --git a/backend/main.py b/backend/main.py index 61b2b07..e61045a 100644 --- a/backend/main.py +++ b/backend/main.py @@ -4,6 +4,7 @@ import os import uvicorn from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware +from fastapi.responses import RedirectResponse from fastapi.staticfiles import StaticFiles from backend.api.routes import router @@ -48,14 +49,8 @@ async def startup_event(): @app.get("/", tags=["root"]) def root(): - """Root endpoint with API information.""" - return { - "name": "Village Simulation API", - "version": "1.0.0", - "docs": "/docs", - "web_frontend": "/web/", - "status": "running", - } + """Root endpoint - redirect to web frontend.""" + return RedirectResponse(url="/web/") @app.get("/health", tags=["health"]) @@ -69,12 +64,19 @@ def health_check(): } -# ============== Web Frontend Static Files ============== +# ============== Web Frontend ============== -# Mount static files for web frontend -# Access at http://localhost:8000/web/ +@app.get("/web", include_in_schema=False) +def redirect_to_web_frontend(): + """Redirect /web to /web/ for static file serving.""" + return RedirectResponse(url="/web/") + + +# Mount static files for web frontend (access at http://localhost:8000/web/) if os.path.exists(WEB_FRONTEND_PATH): app.mount("/web", StaticFiles(directory=WEB_FRONTEND_PATH, html=True), name="web_frontend") +else: + print(f"Warning: Web frontend not found at {WEB_FRONTEND_PATH}") def main(): diff --git a/config.json b/config.json index 0da74f7..f313cfa 100644 --- a/config.json +++ b/config.json @@ -7,7 +7,6 @@ "stats_update_interval": 10 }, "ai": { - "use_goap": false, "goap_max_iterations": 30, "goap_max_plan_depth": 2, "reactive_fallback": true diff --git a/config_goap_optimized.json b/config_goap_optimized.json index 89e4950..152d8cb 100644 --- a/config_goap_optimized.json +++ b/config_goap_optimized.json @@ -1,6 +1,5 @@ { "ai": { - "use_goap": true, "goap_max_iterations": 50, "goap_max_plan_depth": 3, "reactive_fallback": true diff --git a/docs/design/simple-architecture.md b/docs/design/simple-architecture.md index 2c03b5e..18d26c1 100644 --- a/docs/design/simple-architecture.md +++ b/docs/design/simple-architecture.md @@ -5,10 +5,10 @@ This document outlines the architecture for the Village Simulation based on [Vil ## 1. System Overview The system consists of two distinct applications communicating via HTTP (REST API): -1. **Backend (Server)**: Responsible for the entire simulation state, economic logic, AI decision-making, and turn management. -2. **Frontend (Client)**: A "dumb" terminal using **Pygame** that queries the current state to render it and sends user commands (if any) to the server. +1. **Backend (Server)**: Responsible for the entire simulation state, economic logic, AI decision-making (GOAP-based), and turn management. +2. **Frontend (Client)**: A web-based frontend (HTML/JavaScript) that queries the current state to render it and sends user commands to the server. -This separation allows replacing the Pygame frontend with Web (React/Vue) or Unity in the future without changing the backend logic. +This separation allows replacing the web frontend with other technologies (React/Vue, Unity, etc.) without changing the backend logic. --- @@ -54,21 +54,24 @@ backend/ --- -## 3. Frontend Architecture (Pygame) +## 3. Frontend Architecture (Web) The frontend acts as a **Visualizer**. It does not calculate simulation logic. ### 3.1. Structure ```text -frontend/ -├── main.py # Pygame Game Loop -├── client.py # Network Client (requests lib) -├── assets/ # Sprites/Fonts -└── renderer/ # Drawing Logic - ├── map_renderer.py # Draws the grid/terrain - ├── agent_renderer.py # Draws agents and their status bars - └── ui_renderer.py # Draws text info (Market prices, Day/Night) +web_frontend/ +├── index.html # Main HTML page +├── goap_debug.html # GOAP debugging view +├── styles.css # Styling +└── src/ + ├── main.js # Application entry point + ├── api.js # Network client (fetch API) + ├── constants.js # Configuration constants + └── scenes/ # Game scenes (Phaser.js) + ├── BootScene.js # Loading scene + └── GameScene.js # Main game visualization ``` ### 3.2. Flow @@ -77,12 +80,11 @@ frontend/ * Call `GET http://localhost:8000/state`. * Receive JSON: `{"turn": 5, "time_of_day": "day", "agents": [...], "market": [...]}`. 2. **Update Step**: - * Parse JSON into local simplified objects. + * Parse JSON into JavaScript objects. 3. **Draw Step**: - * Clear screen. + * Update Phaser.js game scene. * Render Agents at their coordinates. * Render UI overlays (e.g., "Day 1, Step 5", "Total Coins: 500"). - * `pygame.display.flip()`. --- @@ -97,12 +99,10 @@ Since the simulation involves AI agents acting autonomously, the Frontend is pri * Frontend updates the screen. ### 4.1. The "God Mode" Problem -To test the simulation efficiently, the Server will expose a **Simulation Controller**: -* **Manual Mode**: The server waits for a `POST /next_step` call to advance. The User presses `SPACE` in Pygame -> Pygame sends request -> Server updates -> Pygame fetches new state. +To test the simulation efficiently, the Server exposes a **Simulation Controller**: +* **Manual Mode**: The server waits for a `POST /next_step` call to advance. The User clicks the advance button in the web frontend -> Frontend sends request -> Server updates -> Frontend fetches new state. * **Auto Mode**: Server runs a background thread updating every N seconds. Frontend just polls. -*Recommended for MVP: Manual Mode (Spacebar to advance turn).* - --- ## 5. Technology Stack @@ -110,12 +110,13 @@ To test the simulation efficiently, the Server will expose a **Simulation Contro * **Language**: Python 3.11+ * **Backend Framework**: FastAPI (for speed and auto-generated docs). * **Data Validation**: Pydantic. -* **Frontend**: Pygame Community Edition (pygame-ce). -* **Communication**: HTTP (Requests/Uvicorn). +* **AI System**: GOAP (Goal-Oriented Action Planning). +* **Frontend**: HTML/JavaScript with Phaser.js for rendering. +* **Communication**: HTTP (Fetch API/Uvicorn). ## 6. Future Extensibility (Why this architecture?) -* **Switch to Web**: Replace `frontend/` folder with a React app. The React app simply calls the same `GET /state` endpoint. +* **Switch to React/Vue**: Replace `web_frontend/` folder with a React app. The React app simply calls the same `GET /state` endpoint. * **Switch to Unity**: Unity `UnityWebRequest` calls `GET /state`. * **Database**: Currently state is in-memory (`core/engine.py`). Easy to swap for SQLite/Postgres later by adding a `repository` layer in Backend. diff --git a/frontend/__init__.py b/frontend/__init__.py deleted file mode 100644 index 7444daa..0000000 --- a/frontend/__init__.py +++ /dev/null @@ -1,2 +0,0 @@ -"""Frontend package for Village Simulation visualization.""" - diff --git a/frontend/client.py b/frontend/client.py deleted file mode 100644 index 2177820..0000000 --- a/frontend/client.py +++ /dev/null @@ -1,180 +0,0 @@ -"""HTTP client for communicating with the Village Simulation backend.""" - -import time -from dataclasses import dataclass -from typing import Optional, Any - -import requests -from requests.exceptions import RequestException - - -@dataclass -class SimulationState: - """Parsed simulation state from the API.""" - turn: int - day: int - step_in_day: int - time_of_day: str - world_width: int - world_height: int - agents: list[dict] - market_orders: list[dict] - market_prices: dict - statistics: dict - mode: str - is_running: bool - recent_logs: list[dict] - - @classmethod - def from_api_response(cls, data: dict) -> "SimulationState": - """Create from API response data.""" - return cls( - turn=data.get("turn", 0), - day=data.get("day", 1), - step_in_day=data.get("step_in_day", 0), - time_of_day=data.get("time_of_day", "day"), - world_width=data.get("world_size", {}).get("width", 20), - world_height=data.get("world_size", {}).get("height", 20), - agents=data.get("agents", []), - market_orders=data.get("market", {}).get("orders", []), - market_prices=data.get("market", {}).get("prices", {}), - statistics=data.get("statistics", {}), - mode=data.get("mode", "manual"), - is_running=data.get("is_running", False), - recent_logs=data.get("recent_logs", []), - ) - - def get_living_agents(self) -> list[dict]: - """Get only living agents.""" - return [a for a in self.agents if a.get("is_alive", False)] - - -class SimulationClient: - """HTTP client for the Village Simulation backend.""" - - def __init__(self, base_url: str = "http://localhost:8000"): - self.base_url = base_url.rstrip("/") - self.api_url = f"{self.base_url}/api" - self.session = requests.Session() - self.last_state: Optional[SimulationState] = None - self.connected = False - self._retry_count = 0 - self._max_retries = 3 - - def _request( - self, - method: str, - endpoint: str, - json: Optional[dict] = None, - timeout: float = 5.0, - ) -> Optional[dict]: - """Make an HTTP request to the API.""" - url = f"{self.api_url}{endpoint}" - - try: - response = self.session.request( - method=method, - url=url, - json=json, - timeout=timeout, - ) - response.raise_for_status() - self.connected = True - self._retry_count = 0 - return response.json() - except RequestException as e: - self._retry_count += 1 - if self._retry_count >= self._max_retries: - self.connected = False - return None - - def check_connection(self) -> bool: - """Check if the backend is reachable.""" - try: - response = self.session.get( - f"{self.base_url}/health", - timeout=2.0, - ) - self.connected = response.status_code == 200 - return self.connected - except RequestException: - self.connected = False - return False - - def get_state(self) -> Optional[SimulationState]: - """Fetch the current simulation state.""" - data = self._request("GET", "/state") - if data: - self.last_state = SimulationState.from_api_response(data) - return self.last_state - return self.last_state # Return cached state if request failed - - def advance_turn(self) -> bool: - """Advance the simulation by one step.""" - result = self._request("POST", "/control/next_step") - return result is not None and result.get("success", False) - - def set_mode(self, mode: str) -> bool: - """Set the simulation mode ('manual' or 'auto').""" - result = self._request("POST", "/control/mode", json={"mode": mode}) - return result is not None and result.get("success", False) - - def initialize( - self, - num_agents: int = 8, - world_width: int = 20, - world_height: int = 20, - ) -> bool: - """Initialize or reset the simulation.""" - result = self._request("POST", "/control/initialize", json={ - "num_agents": num_agents, - "world_width": world_width, - "world_height": world_height, - }) - return result is not None and result.get("success", False) - - def get_status(self) -> Optional[dict]: - """Get simulation status.""" - return self._request("GET", "/control/status") - - def get_agents(self) -> Optional[list[dict]]: - """Get all agents.""" - result = self._request("GET", "/agents") - if result: - return result.get("agents", []) - return None - - def get_market_orders(self) -> Optional[list[dict]]: - """Get all market orders.""" - result = self._request("GET", "/market/orders") - if result: - return result.get("orders", []) - return None - - def get_market_prices(self) -> Optional[dict]: - """Get market prices.""" - return self._request("GET", "/market/prices") - - def wait_for_connection(self, timeout: float = 30.0) -> bool: - """Wait for backend connection with timeout.""" - start = time.time() - while time.time() - start < timeout: - if self.check_connection(): - return True - time.sleep(0.5) - return False - - def get_config(self) -> Optional[dict]: - """Get current simulation configuration.""" - return self._request("GET", "/config") - - def update_config(self, config_data: dict) -> bool: - """Update simulation configuration.""" - result = self._request("POST", "/config", json=config_data) - return result is not None and result.get("success", False) - - def reset_config(self) -> bool: - """Reset configuration to defaults.""" - result = self._request("POST", "/config/reset") - return result is not None and result.get("success", False) - diff --git a/frontend/main.py b/frontend/main.py deleted file mode 100644 index 62a1742..0000000 --- a/frontend/main.py +++ /dev/null @@ -1,324 +0,0 @@ -"""Main Pygame application for the Village Simulation frontend.""" - -import sys -import pygame - -from frontend.client import SimulationClient, SimulationState -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 = 1200 -WINDOW_HEIGHT = 800 -WINDOW_TITLE = "Village Economy Simulation" -FPS = 30 - -# Layout configuration -TOP_PANEL_HEIGHT = 50 -RIGHT_PANEL_WIDTH = 200 - - -class VillageSimulationApp: - """Main application class for the Village Simulation frontend.""" - - def __init__(self, server_url: str = "http://localhost:8000"): - # Initialize Pygame - pygame.init() - pygame.font.init() - - # Create window - self.screen = pygame.display.set_mode((WINDOW_WIDTH, WINDOW_HEIGHT)) - pygame.display.set_caption(WINDOW_TITLE) - - # Clock for FPS control - self.clock = pygame.time.Clock() - - # Fonts - self.font = pygame.font.Font(None, 24) - - # Network client - self.client = SimulationClient(server_url) - - # Calculate map area - self.map_rect = pygame.Rect( - 0, - TOP_PANEL_HEIGHT, - WINDOW_WIDTH - RIGHT_PANEL_WIDTH, - WINDOW_HEIGHT - TOP_PANEL_HEIGHT, - ) - - # Initialize renderers - self.map_renderer = MapRenderer(self.screen, self.map_rect) - 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 - self.poll_interval = 100 # Poll every 100ms for smoother updates - - # Setup settings callbacks - self._setup_settings_callbacks() - - def _setup_settings_callbacks(self) -> None: - """Set up callbacks for the settings panel.""" - # Override the apply and reset callbacks - original_apply = self.settings_renderer._apply_config - original_reset = self.settings_renderer._reset_config - - def apply_config(): - config = self.settings_renderer.get_config() - if self.client.update_config(config): - # Restart simulation with new config - if self.client.initialize(): - self.state = self.client.get_state() - self.settings_renderer.status_message = "Config applied & simulation restarted!" - self.settings_renderer.status_color = (80, 180, 100) - else: - self.settings_renderer.status_message = "Config saved but restart failed" - self.settings_renderer.status_color = (200, 160, 80) - else: - self.settings_renderer.status_message = "Failed to apply config" - self.settings_renderer.status_color = (200, 80, 80) - - def reset_config(): - if self.client.reset_config(): - # Reload config from server - config = self.client.get_config() - if config: - self.settings_renderer.set_config(config) - self.settings_renderer.status_message = "Config reset to defaults" - self.settings_renderer.status_color = (200, 160, 80) - else: - self.settings_renderer.status_message = "Failed to reset config" - self.settings_renderer.status_color = (200, 80, 80) - - self.settings_renderer._apply_config = apply_config - self.settings_renderer._reset_config = reset_config - - def _load_config(self) -> None: - """Load configuration from server into settings panel.""" - config = self.client.get_config() - if config: - self.settings_renderer.set_config(config) - - def handle_events(self) -> None: - """Handle Pygame events.""" - for event in pygame.event.get(): - 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 - - if event.type == pygame.KEYDOWN: - self._handle_keydown(event) - - elif event.type == pygame.MOUSEMOTION: - self._handle_mouse_motion(event) - - def _handle_keydown(self, event: pygame.event.Event) -> None: - """Handle keyboard input.""" - if event.key == pygame.K_ESCAPE: - 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 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 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 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.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.""" - if not self.state or self.settings_renderer.visible: - self.hovered_agent = None - return - - mouse_pos = event.pos - self.hovered_agent = None - - # Check if mouse is in map area - if not self.map_rect.collidepoint(mouse_pos): - return - - # Check each agent - for agent in self.state.agents: - if not agent.get("is_alive", False): - continue - - pos = agent.get("position", {"x": 0, "y": 0}) - screen_x, screen_y = self.map_renderer.grid_to_screen(pos["x"], pos["y"]) - - # Check if mouse is near agent - dx = mouse_pos[0] - screen_x - dy = mouse_pos[1] - screen_y - distance = (dx * dx + dy * dy) ** 0.5 - - cell_w, cell_h = self.map_renderer.get_cell_size() - agent_radius = min(cell_w, cell_h) / 2 - - if distance < agent_radius + 5: - self.hovered_agent = agent - break - - def update(self) -> None: - """Update game state by polling the server.""" - current_time = pygame.time.get_ticks() - - # Check if we need to poll - if current_time - self.last_poll_time >= self.poll_interval: - self.last_poll_time = current_time - - if not self.client.connected: - self.client.check_connection() - - if self.client.connected: - new_state = self.client.get_state() - if new_state: - # Update map dimensions if changed - if ( - new_state.world_width != self.map_renderer.world_width or - new_state.world_height != self.map_renderer.world_height - ): - self.map_renderer.update_dimensions( - new_state.world_width, - 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.""" - # Clear screen - self.screen.fill((30, 35, 45)) - - if self.state: - # Draw map - self.map_renderer.draw(self.state) - - # Draw agents - self.agent_renderer.draw(self.state) - - # Draw UI - self.ui_renderer.draw(self.state) - - # Draw agent tooltip if hovering - if self.hovered_agent and not self.settings_renderer.visible: - mouse_pos = pygame.mouse.get_pos() - self.agent_renderer.draw_agent_tooltip(self.hovered_agent, mouse_pos) - - # Draw connection status overlay if disconnected - if not self.client.connected: - self.ui_renderer.draw_connection_status(self.client.connected) - - # Draw settings panel if visible - self.settings_renderer.draw() - - # 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 - pygame.display.flip() - - def run(self) -> None: - """Main game loop.""" - print("Starting Village Simulation Frontend...") - print("Connecting to backend at http://localhost:8000...") - - # Try to connect initially - if not self.client.check_connection(): - print("Backend not available. Will retry in the main loop.") - else: - print("Connected!") - self.state = self.client.get_state() - - print("\nControls:") - print(" SPACE - Advance turn") - print(" R - Reset simulation") - print(" M - Toggle auto/manual mode") - print(" S - Open settings") - print(" G - Open statistics & graphs") - print(" ESC - Close panel / Quit") - print() - - while self.running: - self.handle_events() - self.update() - self.draw() - self.clock.tick(FPS) - - pygame.quit() - - -def main(): - """Entry point for the frontend application.""" - # Get server URL from command line if provided - server_url = "http://localhost:8000" - if len(sys.argv) > 1: - server_url = sys.argv[1] - - app = VillageSimulationApp(server_url) - app.run() - - -if __name__ == "__main__": - main() diff --git a/frontend/renderer/__init__.py b/frontend/renderer/__init__.py deleted file mode 100644 index 49cf007..0000000 --- a/frontend/renderer/__init__.py +++ /dev/null @@ -1,9 +0,0 @@ -"""Renderer components for the Village Simulation frontend.""" - -from .map_renderer import MapRenderer -from .agent_renderer import AgentRenderer -from .ui_renderer import UIRenderer -from .settings_renderer import SettingsRenderer - -__all__ = ["MapRenderer", "AgentRenderer", "UIRenderer", "SettingsRenderer"] - diff --git a/frontend/renderer/agent_renderer.py b/frontend/renderer/agent_renderer.py deleted file mode 100644 index e8d5cfa..0000000 --- a/frontend/renderer/agent_renderer.py +++ /dev/null @@ -1,430 +0,0 @@ -"""Agent renderer for the Village Simulation.""" - -import math -import pygame -from typing import TYPE_CHECKING - -if TYPE_CHECKING: - from frontend.client import SimulationState - from frontend.renderer.map_renderer import MapRenderer - - -# Profession colors (villager is the default now) -PROFESSION_COLORS = { - "villager": (100, 140, 180), # Blue-gray for generic villager - "hunter": (180, 80, 80), # Red - "gatherer": (80, 160, 80), # Green - "woodcutter": (139, 90, 43), # Brown - "crafter": (160, 120, 200), # Purple -} - -# Corpse color -CORPSE_COLOR = (60, 60, 60) # Dark gray - -# Status bar colors -BAR_COLORS = { - "energy": (255, 220, 80), # Yellow - "hunger": (220, 140, 80), # Orange - "thirst": (80, 160, 220), # Blue - "heat": (220, 80, 80), # Red -} - -# Action icons/symbols -ACTION_SYMBOLS = { - "hunt": "🏹", - "gather": "🍇", - "chop_wood": "🪓", - "get_water": "💧", - "weave": "🧵", - "build_fire": "🔥", - "trade": "💰", - "rest": "💤", - "sleep": "😴", - "consume": "🍖", - "dead": "💀", -} - -# Fallback ASCII symbols for systems without emoji support -ACTION_LETTERS = { - "hunt": "H", - "gather": "G", - "chop_wood": "W", - "get_water": "~", - "weave": "C", - "build_fire": "F", - "trade": "$", - "rest": "R", - "sleep": "Z", - "consume": "E", - "dead": "X", -} - - -class AgentRenderer: - """Renders agents on the map with movement and action indicators.""" - - def __init__( - self, - screen: pygame.Surface, - map_renderer: "MapRenderer", - font: pygame.font.Font, - ): - self.screen = screen - self.map_renderer = map_renderer - self.font = font - self.small_font = pygame.font.Font(None, 16) - self.action_font = pygame.font.Font(None, 20) - - # Animation state - self.animation_tick = 0 - - def _get_agent_color(self, agent: dict) -> tuple[int, int, int]: - """Get the color for an agent based on state.""" - # Corpses are dark gray - if agent.get("is_corpse", False) or not agent.get("is_alive", True): - return CORPSE_COLOR - - profession = agent.get("profession", "villager") - base_color = PROFESSION_COLORS.get(profession, (100, 140, 180)) - - if not agent.get("can_act", True): - # Slightly dimmed for exhausted agents - return tuple(int(c * 0.7) for c in base_color) - - return base_color - - def _draw_status_bar( - self, - x: int, - y: int, - width: int, - height: int, - value: int, - max_value: int, - color: tuple[int, int, int], - ) -> None: - """Draw a single status bar.""" - # Background - pygame.draw.rect(self.screen, (40, 40, 40), (x, y, width, height)) - - # Fill - fill_width = int((value / max_value) * width) if max_value > 0 else 0 - if fill_width > 0: - pygame.draw.rect(self.screen, color, (x, y, fill_width, height)) - - # Border - pygame.draw.rect(self.screen, (80, 80, 80), (x, y, width, height), 1) - - def _draw_status_bars(self, agent: dict, center_x: int, center_y: int, size: int) -> None: - """Draw status bars below the agent.""" - stats = agent.get("stats", {}) - - bar_width = size + 10 - bar_height = 3 - bar_spacing = 4 - start_y = center_y + size // 2 + 4 - - bars = [ - ("energy", stats.get("energy", 0), stats.get("max_energy", 100)), - ("hunger", stats.get("hunger", 0), stats.get("max_hunger", 100)), - ("thirst", stats.get("thirst", 0), stats.get("max_thirst", 50)), - ("heat", stats.get("heat", 0), stats.get("max_heat", 100)), - ] - - for i, (stat_name, value, max_value) in enumerate(bars): - bar_y = start_y + i * bar_spacing - self._draw_status_bar( - center_x - bar_width // 2, - bar_y, - bar_width, - bar_height, - value, - max_value, - BAR_COLORS[stat_name], - ) - - def _draw_action_indicator( - self, - agent: dict, - center_x: int, - center_y: int, - agent_size: int, - ) -> None: - """Draw action indicator above the agent.""" - current_action = agent.get("current_action", {}) - action_type = current_action.get("action_type", "") - is_moving = current_action.get("is_moving", False) - message = current_action.get("message", "") - - if not action_type: - return - - # Get action symbol - symbol = ACTION_LETTERS.get(action_type, "?") - - # Draw action bubble above agent - bubble_y = center_y - agent_size // 2 - 20 - - # Animate if moving - if is_moving: - # Bouncing animation - offset = int(3 * math.sin(self.animation_tick * 0.3)) - bubble_y += offset - - # Draw bubble background - bubble_width = 22 - bubble_height = 18 - bubble_rect = pygame.Rect( - center_x - bubble_width // 2, - bubble_y - bubble_height // 2, - bubble_width, - bubble_height, - ) - - # Color based on action success/failure - if "Failed" in message: - bg_color = (120, 60, 60) - border_color = (180, 80, 80) - elif is_moving: - bg_color = (60, 80, 120) - border_color = (100, 140, 200) - else: - bg_color = (50, 70, 50) - border_color = (80, 140, 80) - - pygame.draw.rect(self.screen, bg_color, bubble_rect, border_radius=4) - pygame.draw.rect(self.screen, border_color, bubble_rect, 1, border_radius=4) - - # Draw action letter - text = self.action_font.render(symbol, True, (255, 255, 255)) - text_rect = text.get_rect(center=(center_x, bubble_y)) - self.screen.blit(text, text_rect) - - # Draw movement trail if moving - if is_moving: - target_pos = current_action.get("target_position") - if target_pos: - target_x, target_y = self.map_renderer.grid_to_screen( - target_pos.get("x", 0), - target_pos.get("y", 0), - ) - # Draw dotted line to target - self._draw_dotted_line( - (center_x, center_y), - (target_x, target_y), - (100, 100, 100), - 4, - ) - - def _draw_dotted_line( - self, - start: tuple[int, int], - end: tuple[int, int], - color: tuple[int, int, int], - dot_spacing: int = 5, - ) -> None: - """Draw a dotted line between two points.""" - dx = end[0] - start[0] - dy = end[1] - start[1] - distance = max(1, int((dx ** 2 + dy ** 2) ** 0.5)) - - for i in range(0, distance, dot_spacing * 2): - t = i / distance - x = int(start[0] + dx * t) - y = int(start[1] + dy * t) - pygame.draw.circle(self.screen, color, (x, y), 1) - - def _draw_last_action_result( - self, - agent: dict, - center_x: int, - center_y: int, - agent_size: int, - ) -> None: - """Draw the last action result as floating text.""" - result = agent.get("last_action_result", "") - if not result: - return - - # Truncate long messages - if len(result) > 25: - result = result[:22] + "..." - - # Draw text below status bars - text_y = center_y + agent_size // 2 + 22 - - text = self.small_font.render(result, True, (180, 180, 180)) - text_rect = text.get_rect(center=(center_x, text_y)) - - # Background for readability - bg_rect = text_rect.inflate(4, 2) - pygame.draw.rect(self.screen, (30, 30, 40, 180), bg_rect) - - self.screen.blit(text, text_rect) - - def draw(self, state: "SimulationState") -> None: - """Draw all agents (including corpses for one turn).""" - self.animation_tick += 1 - - cell_w, cell_h = self.map_renderer.get_cell_size() - agent_size = min(cell_w, cell_h) - 8 - agent_size = max(10, min(agent_size, 30)) # Clamp size - - for agent in state.agents: - is_corpse = agent.get("is_corpse", False) - is_alive = agent.get("is_alive", True) - - # Get screen position from agent's current position - pos = agent.get("position", {"x": 0, "y": 0}) - screen_x, screen_y = self.map_renderer.grid_to_screen(pos["x"], pos["y"]) - - if is_corpse: - # Draw corpse with death indicator - self._draw_corpse(agent, screen_x, screen_y, agent_size) - continue - - if not is_alive: - continue - - # Draw movement trail/line to target first (behind agent) - self._draw_action_indicator(agent, screen_x, screen_y, agent_size) - - # Draw agent circle - color = self._get_agent_color(agent) - pygame.draw.circle(self.screen, color, (screen_x, screen_y), agent_size // 2) - - # Draw border - animated if moving - current_action = agent.get("current_action", {}) - is_moving = current_action.get("is_moving", False) - - if is_moving: - # Pulsing border when moving - pulse = int(127 + 127 * math.sin(self.animation_tick * 0.2)) - border_color = (pulse, pulse, 255) - elif agent.get("can_act"): - border_color = (255, 255, 255) - else: - border_color = (100, 100, 100) - - pygame.draw.circle(self.screen, border_color, (screen_x, screen_y), agent_size // 2, 2) - - # Draw money indicator (small coin icon) - money = agent.get("money", 0) - if money > 0: - coin_x = screen_x + agent_size // 2 - 4 - coin_y = screen_y - agent_size // 2 - 4 - pygame.draw.circle(self.screen, (255, 215, 0), (coin_x, coin_y), 4) - pygame.draw.circle(self.screen, (200, 160, 0), (coin_x, coin_y), 4, 1) - - # Draw "V" for villager - text = self.small_font.render("V", True, (255, 255, 255)) - text_rect = text.get_rect(center=(screen_x, screen_y)) - self.screen.blit(text, text_rect) - - # Draw status bars - self._draw_status_bars(agent, screen_x, screen_y, agent_size) - - # Draw last action result - self._draw_last_action_result(agent, screen_x, screen_y, agent_size) - - def _draw_corpse( - self, - agent: dict, - center_x: int, - center_y: int, - agent_size: int, - ) -> None: - """Draw a corpse with death reason displayed.""" - # Draw corpse circle (dark gray) - pygame.draw.circle(self.screen, CORPSE_COLOR, (center_x, center_y), agent_size // 2) - - # Draw red X border - pygame.draw.circle(self.screen, (150, 50, 50), (center_x, center_y), agent_size // 2, 2) - - # Draw skull symbol - text = self.action_font.render("X", True, (180, 80, 80)) - text_rect = text.get_rect(center=(center_x, center_y)) - self.screen.blit(text, text_rect) - - # Draw death reason above corpse - death_reason = agent.get("death_reason", "unknown") - name = agent.get("name", "Unknown") - - # Death indicator bubble - bubble_y = center_y - agent_size // 2 - 20 - bubble_text = f"💀 {death_reason}" - - text = self.small_font.render(bubble_text, True, (255, 100, 100)) - text_rect = text.get_rect(center=(center_x, bubble_y)) - - # Background for readability - bg_rect = text_rect.inflate(8, 4) - pygame.draw.rect(self.screen, (40, 20, 20), bg_rect, border_radius=3) - pygame.draw.rect(self.screen, (120, 50, 50), bg_rect, 1, border_radius=3) - - self.screen.blit(text, text_rect) - - # Draw name below - name_y = center_y + agent_size // 2 + 8 - name_text = self.small_font.render(name, True, (150, 150, 150)) - name_rect = name_text.get_rect(center=(center_x, name_y)) - self.screen.blit(name_text, name_rect) - - def draw_agent_tooltip(self, agent: dict, mouse_pos: tuple[int, int]) -> None: - """Draw a tooltip for an agent when hovered.""" - # Build tooltip text - lines = [ - agent.get("name", "Unknown"), - f"Profession: {agent.get('profession', '?').capitalize()}", - f"Money: {agent.get('money', 0)} coins", - "", - ] - - # Current action - current_action = agent.get("current_action", {}) - action_type = current_action.get("action_type", "") - if action_type: - action_msg = current_action.get("message", action_type) - lines.append(f"Action: {action_msg[:40]}") - if current_action.get("is_moving"): - lines.append(" (moving to location)") - lines.append("") - - lines.append("Stats:") - stats = agent.get("stats", {}) - lines.append(f" Energy: {stats.get('energy', 0)}/{stats.get('max_energy', 100)}") - lines.append(f" Hunger: {stats.get('hunger', 0)}/{stats.get('max_hunger', 100)}") - lines.append(f" Thirst: {stats.get('thirst', 0)}/{stats.get('max_thirst', 50)}") - lines.append(f" Heat: {stats.get('heat', 0)}/{stats.get('max_heat', 100)}") - - inventory = agent.get("inventory", []) - if inventory: - lines.append("") - lines.append("Inventory:") - for item in inventory[:5]: - lines.append(f" {item.get('type', '?')}: {item.get('quantity', 0)}") - - # Last action result - last_result = agent.get("last_action_result", "") - if last_result: - lines.append("") - lines.append(f"Last: {last_result[:35]}") - - # Calculate tooltip size - line_height = 16 - max_width = max(self.small_font.size(line)[0] for line in lines) + 20 - height = len(lines) * line_height + 10 - - # Position tooltip near mouse but not off screen - x = min(mouse_pos[0] + 15, self.screen.get_width() - max_width - 5) - y = min(mouse_pos[1] + 15, self.screen.get_height() - height - 5) - - # Draw background - tooltip_rect = pygame.Rect(x, y, max_width, height) - pygame.draw.rect(self.screen, (30, 30, 40), tooltip_rect) - pygame.draw.rect(self.screen, (100, 100, 120), tooltip_rect, 1) - - # Draw text - for i, line in enumerate(lines): - text = self.small_font.render(line, True, (220, 220, 220)) - self.screen.blit(text, (x + 10, y + 5 + i * line_height)) diff --git a/frontend/renderer/map_renderer.py b/frontend/renderer/map_renderer.py deleted file mode 100644 index 6ed2a2d..0000000 --- a/frontend/renderer/map_renderer.py +++ /dev/null @@ -1,146 +0,0 @@ -"""Map renderer for the Village Simulation.""" - -import pygame -from typing import TYPE_CHECKING - -if TYPE_CHECKING: - from frontend.client import SimulationState - - -# Color palette -class Colors: - # Background colors - DAY_BG = (180, 200, 160) # Soft green for day - NIGHT_BG = (40, 45, 60) # Dark blue for night - GRID_LINE = (120, 140, 110) # Subtle grid lines - GRID_LINE_NIGHT = (60, 65, 80) - - # Terrain features (for visual variety) - GRASS_LIGHT = (160, 190, 140) - GRASS_DARK = (140, 170, 120) - WATER_SPOT = (100, 140, 180) - - -class MapRenderer: - """Renders the map/terrain background.""" - - def __init__( - self, - screen: pygame.Surface, - map_rect: pygame.Rect, - world_width: int = 20, - world_height: int = 20, - ): - self.screen = screen - self.map_rect = map_rect - self.world_width = world_width - self.world_height = world_height - self._cell_width = map_rect.width / world_width - self._cell_height = map_rect.height / world_height - - # Pre-generate some terrain variation - self._terrain_cache = self._generate_terrain() - - def _generate_terrain(self) -> list[list[int]]: - """Generate simple terrain variation (0 = light, 1 = dark, 2 = water).""" - import random - terrain = [] - for y in range(self.world_height): - row = [] - for x in range(self.world_width): - # Simple pattern: mostly grass with occasional water spots - if random.random() < 0.05: - row.append(2) # Water spot - elif (x + y) % 3 == 0: - row.append(1) # Dark grass - else: - row.append(0) # Light grass - terrain.append(row) - return terrain - - def update_dimensions(self, world_width: int, world_height: int) -> None: - """Update world dimensions and recalculate cell sizes.""" - if world_width != self.world_width or world_height != self.world_height: - self.world_width = world_width - self.world_height = world_height - self._cell_width = self.map_rect.width / world_width - self._cell_height = self.map_rect.height / world_height - self._terrain_cache = self._generate_terrain() - - def grid_to_screen(self, grid_x: int, grid_y: int) -> tuple[int, int]: - """Convert grid coordinates to screen coordinates (center of cell).""" - screen_x = self.map_rect.left + (grid_x + 0.5) * self._cell_width - screen_y = self.map_rect.top + (grid_y + 0.5) * self._cell_height - return int(screen_x), int(screen_y) - - def get_cell_size(self) -> tuple[int, int]: - """Get the size of a single cell.""" - return int(self._cell_width), int(self._cell_height) - - def draw(self, state: "SimulationState") -> None: - """Draw the map background.""" - is_night = state.time_of_day == "night" - - # Fill background - bg_color = Colors.NIGHT_BG if is_night else Colors.DAY_BG - pygame.draw.rect(self.screen, bg_color, self.map_rect) - - # Draw terrain cells - for y in range(self.world_height): - for x in range(self.world_width): - cell_rect = pygame.Rect( - self.map_rect.left + x * self._cell_width, - self.map_rect.top + y * self._cell_height, - self._cell_width + 1, # +1 to avoid gaps - self._cell_height + 1, - ) - - terrain_type = self._terrain_cache[y][x] - - if is_night: - # Darker colors at night - if terrain_type == 2: - color = (60, 80, 110) - elif terrain_type == 1: - color = (35, 40, 55) - else: - color = (45, 50, 65) - else: - if terrain_type == 2: - color = Colors.WATER_SPOT - elif terrain_type == 1: - color = Colors.GRASS_DARK - else: - color = Colors.GRASS_LIGHT - - pygame.draw.rect(self.screen, color, cell_rect) - - # Draw grid lines - grid_color = Colors.GRID_LINE_NIGHT if is_night else Colors.GRID_LINE - - # Vertical lines - for x in range(self.world_width + 1): - start_x = self.map_rect.left + x * self._cell_width - pygame.draw.line( - self.screen, - grid_color, - (start_x, self.map_rect.top), - (start_x, self.map_rect.bottom), - 1, - ) - - # Horizontal lines - for y in range(self.world_height + 1): - start_y = self.map_rect.top + y * self._cell_height - pygame.draw.line( - self.screen, - grid_color, - (self.map_rect.left, start_y), - (self.map_rect.right, start_y), - 1, - ) - - # Draw border - border_color = (80, 90, 70) if not is_night else (80, 85, 100) - pygame.draw.rect(self.screen, border_color, self.map_rect, 2) - diff --git a/frontend/renderer/settings_renderer.py b/frontend/renderer/settings_renderer.py deleted file mode 100644 index 00add4b..0000000 --- a/frontend/renderer/settings_renderer.py +++ /dev/null @@ -1,448 +0,0 @@ -"""Settings UI renderer with sliders for the Village Simulation.""" - -import pygame -from dataclasses import dataclass -from typing import Optional, Callable, Any - - -class Colors: - """Color palette for settings UI.""" - BG = (25, 28, 35) - PANEL_BG = (35, 40, 50) - PANEL_BORDER = (70, 80, 95) - TEXT_PRIMARY = (230, 230, 235) - TEXT_SECONDARY = (160, 165, 175) - TEXT_HIGHLIGHT = (100, 180, 255) - SLIDER_BG = (50, 55, 65) - SLIDER_FILL = (80, 140, 200) - SLIDER_HANDLE = (220, 220, 230) - BUTTON_BG = (60, 100, 160) - BUTTON_HOVER = (80, 120, 180) - BUTTON_TEXT = (255, 255, 255) - SUCCESS = (80, 180, 100) - WARNING = (200, 160, 80) - - -@dataclass -class SliderConfig: - """Configuration for a slider widget.""" - name: str - key: str # Dot-separated path like "agent_stats.max_energy" - min_val: float - max_val: float - step: float = 1.0 - is_int: bool = True - description: str = "" - - -# Define all configurable parameters with sliders -SLIDER_CONFIGS = [ - # Agent Stats Section - SliderConfig("Max Energy", "agent_stats.max_energy", 50, 200, 10, True, "Maximum energy capacity"), - SliderConfig("Max Hunger", "agent_stats.max_hunger", 50, 200, 10, True, "Maximum hunger capacity"), - SliderConfig("Max Thirst", "agent_stats.max_thirst", 25, 100, 5, True, "Maximum thirst capacity"), - SliderConfig("Max Heat", "agent_stats.max_heat", 50, 200, 10, True, "Maximum heat capacity"), - SliderConfig("Energy Decay", "agent_stats.energy_decay", 1, 10, 1, True, "Energy lost per turn"), - SliderConfig("Hunger Decay", "agent_stats.hunger_decay", 1, 10, 1, True, "Hunger lost per turn"), - SliderConfig("Thirst Decay", "agent_stats.thirst_decay", 1, 10, 1, True, "Thirst lost per turn"), - SliderConfig("Heat Decay", "agent_stats.heat_decay", 1, 10, 1, True, "Heat lost per turn"), - SliderConfig("Critical %", "agent_stats.critical_threshold", 0.1, 0.5, 0.05, False, "Threshold for survival mode"), - - # World Section - SliderConfig("World Width", "world.width", 10, 50, 5, True, "World grid width"), - SliderConfig("World Height", "world.height", 10, 50, 5, True, "World grid height"), - SliderConfig("Initial Agents", "world.initial_agents", 2, 20, 1, True, "Starting agent count"), - SliderConfig("Day Steps", "world.day_steps", 5, 20, 1, True, "Steps per day"), - SliderConfig("Inventory Slots", "world.inventory_slots", 5, 20, 1, True, "Agent inventory size"), - SliderConfig("Starting Money", "world.starting_money", 50, 500, 50, True, "Initial coins per agent"), - - # Actions Section - SliderConfig("Hunt Energy Cost", "actions.hunt_energy", -30, -5, 5, True, "Energy spent hunting"), - SliderConfig("Gather Energy Cost", "actions.gather_energy", -20, -1, 1, True, "Energy spent gathering"), - SliderConfig("Hunt Success %", "actions.hunt_success", 0.3, 1.0, 0.1, False, "Hunting success chance"), - SliderConfig("Sleep Restore", "actions.sleep_energy", 30, 100, 10, True, "Energy restored by sleep"), - SliderConfig("Rest Restore", "actions.rest_energy", 5, 30, 5, True, "Energy restored by rest"), - - # Resources Section - SliderConfig("Meat Decay", "resources.meat_decay", 2, 20, 1, True, "Turns until meat spoils"), - SliderConfig("Berries Decay", "resources.berries_decay", 10, 50, 5, True, "Turns until berries spoil"), - SliderConfig("Meat Hunger +", "resources.meat_hunger", 10, 60, 5, True, "Hunger restored by meat"), - SliderConfig("Water Thirst +", "resources.water_thirst", 20, 60, 5, True, "Thirst restored by water"), - - # Market Section - SliderConfig("Discount Turns", "market.turns_before_discount", 1, 10, 1, True, "Turns before price drop"), - SliderConfig("Discount Rate %", "market.discount_rate", 0.05, 0.30, 0.05, False, "Price reduction per period"), - - # Simulation Section - SliderConfig("Auto Step (s)", "auto_step_interval", 0.2, 3.0, 0.2, False, "Seconds between auto steps"), -] - - -class Slider: - """A slider widget for adjusting numeric values.""" - - def __init__( - self, - rect: pygame.Rect, - config: SliderConfig, - font: pygame.font.Font, - small_font: pygame.font.Font, - ): - self.rect = rect - self.config = config - self.font = font - self.small_font = small_font - self.value = config.min_val - self.dragging = False - self.hovered = False - - def set_value(self, value: float) -> None: - """Set the slider value.""" - self.value = max(self.config.min_val, min(self.config.max_val, value)) - if self.config.is_int: - self.value = int(round(self.value)) - - def get_value(self) -> Any: - """Get the current value.""" - return int(self.value) if self.config.is_int else round(self.value, 2) - - def handle_event(self, event: pygame.event.Event) -> bool: - """Handle input events. Returns True if value changed.""" - if event.type == pygame.MOUSEBUTTONDOWN: - if self._slider_area().collidepoint(event.pos): - self.dragging = True - return self._update_from_mouse(event.pos[0]) - - elif event.type == pygame.MOUSEBUTTONUP: - self.dragging = False - - elif event.type == pygame.MOUSEMOTION: - self.hovered = self.rect.collidepoint(event.pos) - if self.dragging: - return self._update_from_mouse(event.pos[0]) - - return False - - def _slider_area(self) -> pygame.Rect: - """Get the actual slider track area.""" - return pygame.Rect( - self.rect.x + 120, # Leave space for label - self.rect.y + 15, - self.rect.width - 180, # Leave space for value display - 20, - ) - - def _update_from_mouse(self, mouse_x: int) -> bool: - """Update value based on mouse position.""" - slider_area = self._slider_area() - - # Calculate position as 0-1 - rel_x = mouse_x - slider_area.x - ratio = max(0, min(1, rel_x / slider_area.width)) - - # Calculate value - range_val = self.config.max_val - self.config.min_val - new_value = self.config.min_val + ratio * range_val - - # Apply step - if self.config.step > 0: - new_value = round(new_value / self.config.step) * self.config.step - - old_value = self.value - self.set_value(new_value) - return abs(old_value - self.value) > 0.001 - - def draw(self, screen: pygame.Surface) -> None: - """Draw the slider.""" - # Background - if self.hovered: - pygame.draw.rect(screen, (45, 50, 60), self.rect) - - # Label - label = self.small_font.render(self.config.name, True, Colors.TEXT_PRIMARY) - screen.blit(label, (self.rect.x + 5, self.rect.y + 5)) - - # Slider track - slider_area = self._slider_area() - pygame.draw.rect(screen, Colors.SLIDER_BG, slider_area, border_radius=3) - - # Slider fill - ratio = (self.value - self.config.min_val) / (self.config.max_val - self.config.min_val) - fill_width = int(ratio * slider_area.width) - fill_rect = pygame.Rect(slider_area.x, slider_area.y, fill_width, slider_area.height) - pygame.draw.rect(screen, Colors.SLIDER_FILL, fill_rect, border_radius=3) - - # Handle - handle_x = slider_area.x + fill_width - handle_rect = pygame.Rect(handle_x - 4, slider_area.y - 2, 8, slider_area.height + 4) - pygame.draw.rect(screen, Colors.SLIDER_HANDLE, handle_rect, border_radius=2) - - # Value display - value_str = str(self.get_value()) - value_text = self.small_font.render(value_str, True, Colors.TEXT_HIGHLIGHT) - value_x = self.rect.right - 50 - screen.blit(value_text, (value_x, self.rect.y + 5)) - - # Description on hover - if self.hovered and self.config.description: - desc = self.small_font.render(self.config.description, True, Colors.TEXT_SECONDARY) - screen.blit(desc, (self.rect.x + 5, self.rect.y + 25)) - - -class Button: - """A simple button widget.""" - - def __init__( - self, - rect: pygame.Rect, - text: str, - font: pygame.font.Font, - callback: Optional[Callable] = None, - color: tuple = Colors.BUTTON_BG, - ): - self.rect = rect - self.text = text - self.font = font - self.callback = callback - self.color = color - self.hovered = False - - def handle_event(self, event: pygame.event.Event) -> bool: - """Handle input events. Returns True if clicked.""" - if event.type == pygame.MOUSEMOTION: - self.hovered = self.rect.collidepoint(event.pos) - - elif event.type == pygame.MOUSEBUTTONDOWN: - if self.rect.collidepoint(event.pos): - if self.callback: - self.callback() - return True - - return False - - def draw(self, screen: pygame.Surface) -> None: - """Draw the button.""" - color = Colors.BUTTON_HOVER if self.hovered else self.color - pygame.draw.rect(screen, color, self.rect, border_radius=5) - pygame.draw.rect(screen, Colors.PANEL_BORDER, self.rect, 1, border_radius=5) - - text = self.font.render(self.text, True, Colors.BUTTON_TEXT) - text_rect = text.get_rect(center=self.rect.center) - screen.blit(text, text_rect) - - -class SettingsRenderer: - """Renders the settings UI panel with sliders.""" - - def __init__(self, screen: pygame.Surface): - self.screen = screen - self.font = pygame.font.Font(None, 24) - self.small_font = pygame.font.Font(None, 18) - self.title_font = pygame.font.Font(None, 32) - - self.visible = False - self.scroll_offset = 0 - self.max_scroll = 0 - - # Create sliders - self.sliders: list[Slider] = [] - self.buttons: list[Button] = [] - self.config_data: dict = {} - - self._create_widgets() - self.status_message = "" - self.status_color = Colors.TEXT_SECONDARY - - def _create_widgets(self) -> None: - """Create slider widgets.""" - panel_width = 400 - slider_height = 45 - start_y = 80 - - panel_x = (self.screen.get_width() - panel_width) // 2 - - for i, config in enumerate(SLIDER_CONFIGS): - rect = pygame.Rect( - panel_x + 10, - start_y + i * slider_height, - panel_width - 20, - slider_height, - ) - slider = Slider(rect, config, self.font, self.small_font) - self.sliders.append(slider) - - # Calculate max scroll - total_height = len(SLIDER_CONFIGS) * slider_height + 150 - visible_height = self.screen.get_height() - 150 - self.max_scroll = max(0, total_height - visible_height) - - # Create buttons at the bottom - button_y = self.screen.get_height() - 60 - button_width = 100 - button_height = 35 - - buttons_data = [ - ("Apply & Restart", self._apply_config, Colors.SUCCESS), - ("Reset Defaults", self._reset_config, Colors.WARNING), - ("Close", self.toggle, Colors.PANEL_BORDER), - ] - - total_button_width = len(buttons_data) * button_width + (len(buttons_data) - 1) * 10 - start_x = (self.screen.get_width() - total_button_width) // 2 - - for i, (text, callback, color) in enumerate(buttons_data): - rect = pygame.Rect( - start_x + i * (button_width + 10), - button_y, - button_width, - button_height, - ) - self.buttons.append(Button(rect, text, self.small_font, callback, color)) - - def toggle(self) -> None: - """Toggle settings visibility.""" - self.visible = not self.visible - if self.visible: - self.scroll_offset = 0 - - def set_config(self, config_data: dict) -> None: - """Set slider values from config data.""" - self.config_data = config_data - - for slider in self.sliders: - value = self._get_nested_value(config_data, slider.config.key) - if value is not None: - slider.set_value(value) - - def get_config(self) -> dict: - """Get current config from slider values.""" - result = {} - - for slider in self.sliders: - self._set_nested_value(result, slider.config.key, slider.get_value()) - - return result - - def _get_nested_value(self, data: dict, key: str) -> Any: - """Get a value from nested dict using dot notation.""" - parts = key.split(".") - current = data - for part in parts: - if isinstance(current, dict) and part in current: - current = current[part] - else: - return None - return current - - def _set_nested_value(self, data: dict, key: str, value: Any) -> None: - """Set a value in nested dict using dot notation.""" - parts = key.split(".") - current = data - for part in parts[:-1]: - if part not in current: - current[part] = {} - current = current[part] - current[parts[-1]] = value - - def _apply_config(self) -> None: - """Apply configuration callback (to be set externally).""" - self.status_message = "Config applied - restart to see changes" - self.status_color = Colors.SUCCESS - - def _reset_config(self) -> None: - """Reset configuration callback (to be set externally).""" - self.status_message = "Config reset to defaults" - self.status_color = Colors.WARNING - - def handle_event(self, event: pygame.event.Event) -> bool: - """Handle input events. Returns True if event was consumed.""" - if not self.visible: - return False - - # Handle scrolling - if event.type == pygame.MOUSEWHEEL: - self.scroll_offset -= event.y * 30 - self.scroll_offset = max(0, min(self.max_scroll, self.scroll_offset)) - return True - - # Handle sliders - for slider in self.sliders: - # Adjust slider position for scroll - original_y = slider.rect.y - slider.rect.y -= self.scroll_offset - - if slider.handle_event(event): - slider.rect.y = original_y - return True - - slider.rect.y = original_y - - # Handle buttons - for button in self.buttons: - if button.handle_event(event): - return True - - # Consume all clicks when settings are visible - if event.type == pygame.MOUSEBUTTONDOWN: - return True - - return False - - def draw(self) -> None: - """Draw the settings panel.""" - if not self.visible: - return - - # Dim background - overlay = pygame.Surface(self.screen.get_size(), pygame.SRCALPHA) - overlay.fill((0, 0, 0, 200)) - self.screen.blit(overlay, (0, 0)) - - # Panel background - panel_width = 420 - panel_height = self.screen.get_height() - 40 - panel_x = (self.screen.get_width() - panel_width) // 2 - panel_rect = pygame.Rect(panel_x, 20, panel_width, panel_height) - pygame.draw.rect(self.screen, Colors.PANEL_BG, panel_rect, border_radius=10) - pygame.draw.rect(self.screen, Colors.PANEL_BORDER, panel_rect, 2, border_radius=10) - - # Title - title = self.title_font.render("Simulation Settings", True, Colors.TEXT_PRIMARY) - title_rect = title.get_rect(centerx=self.screen.get_width() // 2, y=35) - self.screen.blit(title, title_rect) - - # Create clipping region for scrollable area - clip_rect = pygame.Rect(panel_x, 70, panel_width, panel_height - 130) - - # Draw sliders with scroll offset - for slider in self.sliders: - # Adjust position for scroll - adjusted_rect = slider.rect.copy() - adjusted_rect.y -= self.scroll_offset - - # Only draw if visible - if clip_rect.colliderect(adjusted_rect): - # Temporarily move slider for drawing - original_y = slider.rect.y - slider.rect.y = adjusted_rect.y - slider.draw(self.screen) - slider.rect.y = original_y - - # Draw scroll indicator - if self.max_scroll > 0: - scroll_ratio = self.scroll_offset / self.max_scroll - scroll_height = max(30, int((clip_rect.height / (clip_rect.height + self.max_scroll)) * clip_rect.height)) - scroll_y = clip_rect.y + int(scroll_ratio * (clip_rect.height - scroll_height)) - scroll_rect = pygame.Rect(panel_rect.right - 8, scroll_y, 4, scroll_height) - pygame.draw.rect(self.screen, Colors.SLIDER_FILL, scroll_rect, border_radius=2) - - # Draw buttons - for button in self.buttons: - button.draw(self.screen) - - # Status message - if self.status_message: - status = self.small_font.render(self.status_message, True, self.status_color) - status_rect = status.get_rect(centerx=self.screen.get_width() // 2, y=self.screen.get_height() - 90) - self.screen.blit(status, status_rect) - diff --git a/frontend/renderer/stats_renderer.py b/frontend/renderer/stats_renderer.py deleted file mode 100644 index 9459936..0000000 --- a/frontend/renderer/stats_renderer.py +++ /dev/null @@ -1,770 +0,0 @@ -"""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/frontend/renderer/ui_renderer.py b/frontend/renderer/ui_renderer.py deleted file mode 100644 index cfdc2b1..0000000 --- a/frontend/renderer/ui_renderer.py +++ /dev/null @@ -1,239 +0,0 @@ -"""UI renderer for the Village Simulation.""" - -import pygame -from typing import TYPE_CHECKING, Optional - -if TYPE_CHECKING: - from frontend.client import SimulationState - - -class Colors: - # UI colors - PANEL_BG = (35, 40, 50) - PANEL_BORDER = (70, 80, 95) - TEXT_PRIMARY = (230, 230, 235) - TEXT_SECONDARY = (160, 165, 175) - TEXT_HIGHLIGHT = (100, 180, 255) - TEXT_WARNING = (255, 180, 80) - TEXT_DANGER = (255, 100, 100) - - # Day/Night indicator - DAY_COLOR = (255, 220, 100) - NIGHT_COLOR = (100, 120, 180) - - -class UIRenderer: - """Renders UI elements (HUD, panels, text info).""" - - def __init__(self, screen: pygame.Surface, font: pygame.font.Font): - self.screen = screen - self.font = font - self.small_font = pygame.font.Font(None, 20) - self.title_font = pygame.font.Font(None, 28) - - # Panel dimensions - self.top_panel_height = 50 - self.right_panel_width = 200 - - def _draw_panel(self, rect: pygame.Rect, title: Optional[str] = None) -> None: - """Draw a panel background.""" - pygame.draw.rect(self.screen, Colors.PANEL_BG, rect) - pygame.draw.rect(self.screen, Colors.PANEL_BORDER, rect, 1) - - if title: - title_text = self.small_font.render(title, True, Colors.TEXT_SECONDARY) - self.screen.blit(title_text, (rect.x + 8, rect.y + 4)) - - def draw_top_bar(self, state: "SimulationState") -> None: - """Draw the top information bar.""" - rect = pygame.Rect(0, 0, self.screen.get_width(), self.top_panel_height) - pygame.draw.rect(self.screen, Colors.PANEL_BG, rect) - pygame.draw.line( - self.screen, - Colors.PANEL_BORDER, - (0, self.top_panel_height), - (self.screen.get_width(), self.top_panel_height), - ) - - # Day/Night and Turn info - is_night = state.time_of_day == "night" - time_color = Colors.NIGHT_COLOR if is_night else Colors.DAY_COLOR - time_text = "NIGHT" if is_night else "DAY" - - # Draw time indicator circle - pygame.draw.circle(self.screen, time_color, (25, 25), 12) - pygame.draw.circle(self.screen, Colors.PANEL_BORDER, (25, 25), 12, 1) - - # Time/day text - info_text = f"{time_text} | Day {state.day}, Step {state.step_in_day} | Turn {state.turn}" - text = self.font.render(info_text, True, Colors.TEXT_PRIMARY) - self.screen.blit(text, (50, 15)) - - # Mode indicator - mode_color = Colors.TEXT_HIGHLIGHT if state.mode == "auto" else Colors.TEXT_SECONDARY - mode_text = f"Mode: {state.mode.upper()}" - text = self.small_font.render(mode_text, True, mode_color) - self.screen.blit(text, (self.screen.get_width() - 120, 8)) - - # Running indicator - if state.is_running: - status_text = "RUNNING" - status_color = (100, 200, 100) - else: - status_text = "STOPPED" - status_color = Colors.TEXT_DANGER - - text = self.small_font.render(status_text, True, status_color) - self.screen.blit(text, (self.screen.get_width() - 120, 28)) - - def draw_right_panel(self, state: "SimulationState") -> None: - """Draw the right information panel.""" - panel_x = self.screen.get_width() - self.right_panel_width - rect = pygame.Rect( - panel_x, - self.top_panel_height, - self.right_panel_width, - self.screen.get_height() - self.top_panel_height, - ) - pygame.draw.rect(self.screen, Colors.PANEL_BG, rect) - pygame.draw.line( - self.screen, - Colors.PANEL_BORDER, - (panel_x, self.top_panel_height), - (panel_x, self.screen.get_height()), - ) - - y = self.top_panel_height + 10 - - # Statistics section - y = self._draw_statistics_section(state, panel_x + 10, y) - - # Market section - y = self._draw_market_section(state, panel_x + 10, y + 20) - - # Controls help section - self._draw_controls_help(panel_x + 10, self.screen.get_height() - 100) - - def _draw_statistics_section(self, state: "SimulationState", x: int, y: int) -> int: - """Draw the statistics section.""" - # Title - title = self.title_font.render("Statistics", True, Colors.TEXT_PRIMARY) - self.screen.blit(title, (x, y)) - y += 30 - - stats = state.statistics - living = len(state.get_living_agents()) - - # Population - pop_color = Colors.TEXT_PRIMARY if living > 2 else Colors.TEXT_DANGER - text = self.small_font.render(f"Population: {living}", True, pop_color) - self.screen.blit(text, (x, y)) - y += 18 - - # Deaths - deaths = stats.get("total_agents_died", 0) - if deaths > 0: - text = self.small_font.render(f"Deaths: {deaths}", True, Colors.TEXT_WARNING) - self.screen.blit(text, (x, y)) - y += 18 - - # Total money - total_money = stats.get("total_money_in_circulation", 0) - text = self.small_font.render(f"Total Coins: {total_money}", True, Colors.TEXT_SECONDARY) - self.screen.blit(text, (x, y)) - y += 18 - - # Professions - professions = stats.get("professions", {}) - if professions: - y += 5 - text = self.small_font.render("Professions:", True, Colors.TEXT_SECONDARY) - self.screen.blit(text, (x, y)) - y += 16 - - for prof, count in professions.items(): - text = self.small_font.render(f" {prof}: {count}", True, Colors.TEXT_SECONDARY) - self.screen.blit(text, (x, y)) - y += 14 - - return y - - def _draw_market_section(self, state: "SimulationState", x: int, y: int) -> int: - """Draw the market section.""" - # Title - title = self.title_font.render("Market", True, Colors.TEXT_PRIMARY) - self.screen.blit(title, (x, y)) - y += 30 - - # Order count - order_count = len(state.market_orders) - text = self.small_font.render(f"Active Orders: {order_count}", True, Colors.TEXT_SECONDARY) - self.screen.blit(text, (x, y)) - y += 20 - - # Price summary for each resource with available stock - prices = state.market_prices - for resource, data in prices.items(): - if data.get("total_available", 0) > 0: - price = data.get("lowest_price", "?") - qty = data.get("total_available", 0) - text = self.small_font.render( - f"{resource}: {qty}x @ {price}c", - True, - Colors.TEXT_SECONDARY, - ) - self.screen.blit(text, (x, y)) - y += 16 - - return y - - def _draw_controls_help(self, x: int, y: int) -> None: - """Draw controls help at bottom of panel.""" - pygame.draw.line( - self.screen, - Colors.PANEL_BORDER, - (x - 5, y - 10), - (self.screen.get_width() - 5, y - 10), - ) - - title = self.small_font.render("Controls", True, Colors.TEXT_PRIMARY) - self.screen.blit(title, (x, y)) - y += 20 - - controls = [ - "SPACE - Next Turn", - "R - Reset Simulation", - "M - Toggle Mode", - "S - Settings", - "ESC - Quit", - ] - - for control in controls: - text = self.small_font.render(control, True, Colors.TEXT_SECONDARY) - self.screen.blit(text, (x, y)) - y += 16 - - def draw_connection_status(self, connected: bool) -> None: - """Draw connection status overlay when disconnected.""" - if connected: - return - - # Semi-transparent overlay - overlay = pygame.Surface(self.screen.get_size(), pygame.SRCALPHA) - overlay.fill((0, 0, 0, 180)) - self.screen.blit(overlay, (0, 0)) - - # Connection message - text = self.title_font.render("Connecting to server...", True, Colors.TEXT_WARNING) - text_rect = text.get_rect(center=(self.screen.get_width() // 2, self.screen.get_height() // 2)) - self.screen.blit(text, text_rect) - - hint = self.small_font.render("Make sure the backend is running on localhost:8000", True, Colors.TEXT_SECONDARY) - hint_rect = hint.get_rect(center=(self.screen.get_width() // 2, self.screen.get_height() // 2 + 30)) - self.screen.blit(hint, hint_rect) - - def draw(self, state: "SimulationState") -> None: - """Draw all UI elements.""" - self.draw_top_bar(state) - self.draw_right_panel(state) - diff --git a/requirements.txt b/requirements.txt index 46b0afb..ff5b927 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,8 +5,7 @@ fastapi>=0.104.0 uvicorn[standard]>=0.24.0 pydantic>=2.5.0 -# Frontend -pygame-ce>=2.4.0 +# HTTP client (for web frontend communication) requests>=2.31.0 # Tools (balance sheet export/import)