diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e165b64 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +.venv +__pycache__ +logs +*.xlsx \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..b20a00d --- /dev/null +++ b/README.md @@ -0,0 +1,234 @@ +# Village Economy Simulation + +A turn-based agent simulation of a village economy where AI agents work, trade, and survive based on prioritized needs. + +## Overview + +This project simulates a village economy with autonomous AI agents. Each agent has vital stats (energy, hunger, thirst, heat), can perform various actions (hunting, gathering, crafting), and trades resources on a central market. + +### Features + +- **Agent-based simulation**: Multiple AI agents with different professions +- **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 +- **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 +- **Detailed logging**: All simulation steps are logged for analysis + +## Architecture + +``` +villsim/ +├── backend/ # FastAPI server +│ ├── main.py # Entry point +│ ├── config.py # Centralized configuration +│ ├── api/ # REST API endpoints +│ ├── core/ # Game logic (engine, world, market, AI, logger) +│ └── domain/ # Data models (agent, resources, actions) +├── frontend/ # Pygame visualizer +│ ├── main.py # Entry point +│ ├── client.py # HTTP client +│ └── renderer/ # Drawing components (map, agents, UI, settings) +├── logs/ # Simulation log files (created on run) +├── docs/design/ # Design documents +├── requirements.txt +└── config.json # Saved configuration (optional) +``` + +## Installation + +### Prerequisites + +- Python 3.11 or higher +- pip + +### Setup + +1. Clone the repository: + ```bash + git clone + cd villsim + ``` + +2. Create a virtual environment (recommended): + ```bash + python -m venv venv + source venv/bin/activate # On Windows: venv\Scripts\activate + ``` + +3. Install dependencies: + ```bash + pip install -r requirements.txt + ``` + +## Running the Simulation + +### Start the Backend Server + +Open a terminal and run: + +```bash +python -m backend.main +``` + +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 + +Open another terminal and run: + +```bash +python -m frontend.main +``` + +A Pygame window will open showing the simulation. + +## 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 | + +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. + +## Logging + +All simulation steps are logged to the `logs/` directory: + +- `sim_YYYYMMDD_HHMMSS.jsonl`: Detailed JSON lines format for programmatic analysis +- `sim_YYYYMMDD_HHMMSS_summary.txt`: Human-readable summary of each turn +- `sim_YYYYMMDD_HHMMSS.log`: Standard Python logging output + +Log files include: +- Every agent's stats before and after each turn +- AI decisions and reasons +- Action results (success/failure, resources gained) +- Market transactions +- Deaths and their causes + +## API Endpoints + +| Endpoint | Method | Description | +|----------|--------|-------------| +| `/api/state` | GET | Full simulation state | +| `/api/control/next_step` | POST | Advance one turn | +| `/api/control/mode` | POST | Set mode (manual/auto) | +| `/api/control/initialize` | POST | Reset simulation | +| `/api/agents` | GET | List all agents | +| `/api/market/orders` | GET | Active market orders | +| `/api/config` | GET | Get current configuration | +| `/api/config` | POST | Update configuration | +| `/api/config/reset` | POST | Reset config to defaults | + +## Simulation Rules + +### Agent Stats + +| Stat | Max | Start | Decay/Turn | +|------|-----|-------|------------| +| Energy | 100 | 80 | -2 | +| Hunger | 100 | 80 | -2 | +| Thirst | 50 | 40 | -3 | +| Heat | 100 | 100 | -2 | + +- Agents die if Hunger, Thirst, or Heat reaches 0 +- Energy at 0 prevents actions but doesn't kill +- Clothes reduce heat decay by 50% + +### Resources + +| Resource | Source | Effect | Decay | +|----------|--------|--------|-------| +| Meat | Hunting | Hunger +30, Energy +5 | 5 turns | +| Berries | Gathering | Hunger +5, Thirst +2 | 20 turns | +| Water | Water source | Thirst +40 | ∞ | +| Wood | Chopping | Fuel for fire | ∞ | +| Hide | Hunting | Craft material | ∞ | +| Clothes | Weaving | Reduces heat loss | 50 turns | + +### Professions + +- **Hunter** (H): Hunts for meat and hide - moves to forest area +- **Gatherer** (G): Collects berries - moves to bushes area +- **Woodcutter** (W): Chops wood - moves to forest area +- **Crafter** (C): Weaves clothes from hide - works in village + +### Agent Movement + +Agents visually move across the map based on their actions: +- **River** (left): Water gathering +- **Bushes** (center-left): Berry gathering +- **Village** (center): Crafting, trading, resting +- **Forest** (right): Hunting, wood chopping + +Action indicators above agents show: +- Current action letter (H=Hunt, G=Gather, etc.) +- Movement animation when traveling +- Dotted line to destination + +### AI Priority System + +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 + +## Development + +### Project Structure + +- **Config** (`backend/config.py`): Centralized configuration with dataclasses +- **Domain Layer** (`backend/domain/`): Pure data models +- **Core Layer** (`backend/core/`): Game logic, AI, market, logging +- **API Layer** (`backend/api/`): FastAPI routes and schemas +- **Frontend** (`frontend/`): Pygame visualization client + +### Analyzing Logs + +The JSON lines log files can be analyzed with Python: + +```python +import json + +with open("logs/sim_20260118_123456.jsonl") as f: + for line in f: + entry = json.loads(line) + if entry["type"] == "turn": + turn_data = entry["data"] + print(f"Turn {turn_data['turn']}: {len(turn_data['agent_entries'])} agents") +``` + +### Future Improvements + +- Social interactions (gifting, cooperation) +- Agent reproduction +- Skill progression +- Persistent save/load +- Web-based frontend alternative + +## License + +MIT License diff --git a/backend/__init__.py b/backend/__init__.py new file mode 100644 index 0000000..14875f2 --- /dev/null +++ b/backend/__init__.py @@ -0,0 +1,2 @@ +"""Backend package for the Village Simulation.""" + diff --git a/backend/api/__init__.py b/backend/api/__init__.py new file mode 100644 index 0000000..bdafe59 --- /dev/null +++ b/backend/api/__init__.py @@ -0,0 +1,18 @@ +"""API layer for the Village Simulation.""" + +from .routes import router +from .schemas import ( + WorldStateResponse, + AgentResponse, + MarketStateResponse, + ControlResponse, +) + +__all__ = [ + "router", + "WorldStateResponse", + "AgentResponse", + "MarketStateResponse", + "ControlResponse", +] + diff --git a/backend/api/routes.py b/backend/api/routes.py new file mode 100644 index 0000000..7d6c0c6 --- /dev/null +++ b/backend/api/routes.py @@ -0,0 +1,303 @@ +"""FastAPI routes for the Village Simulation API.""" + +from fastapi import APIRouter, HTTPException + +from backend.core.engine import get_engine, SimulationMode +from backend.core.world import WorldConfig +from backend.config import get_config, set_config, SimulationConfig, reset_config +from .schemas import ( + WorldStateResponse, + ControlResponse, + SetModeRequest, + InitializeRequest, + ErrorResponse, +) + +router = APIRouter() + + +# ============== State Endpoints ============== + +@router.get( + "/state", + response_model=WorldStateResponse, + summary="Get full simulation state", + description="Returns the complete state of the simulation including agents, market, and world info.", +) +def get_state(): + """Get the full simulation state.""" + engine = get_engine() + return engine.get_state() + + +@router.get( + "/agents", + summary="Get all agents", + description="Returns a list of all agents in the simulation.", +) +def get_agents(): + """Get all agents.""" + engine = get_engine() + return { + "agents": [a.to_dict() for a in engine.world.agents], + "count": len(engine.world.agents), + "alive_count": len(engine.world.get_living_agents()), + } + + +@router.get( + "/agents/{agent_id}", + summary="Get a specific agent", + description="Returns detailed information about a specific agent.", +) +def get_agent(agent_id: str): + """Get a specific agent by ID.""" + engine = get_engine() + agent = engine.world.get_agent(agent_id) + if agent is None: + raise HTTPException(status_code=404, detail=f"Agent {agent_id} not found") + return agent.to_dict() + + +# ============== Market Endpoints ============== + +@router.get( + "/market/orders", + summary="Get market orders", + description="Returns all active market orders.", +) +def get_market_orders(): + """Get all active market orders.""" + engine = get_engine() + return engine.market.get_state_snapshot() + + +@router.get( + "/market/prices", + summary="Get market prices", + description="Returns current market prices for all resource types.", +) +def get_market_prices(): + """Get market price summary.""" + engine = get_engine() + return engine.market.get_market_prices() + + +# ============== Control Endpoints ============== + +@router.post( + "/control/initialize", + response_model=ControlResponse, + summary="Initialize simulation", + description="Initialize or reset the simulation with the specified parameters.", +) +def initialize_simulation(request: InitializeRequest): + """Initialize or reset the simulation.""" + engine = get_engine() + config = WorldConfig( + width=request.world_width, + height=request.world_height, + initial_agents=request.num_agents, + ) + engine.reset(config) + return ControlResponse( + success=True, + message=f"Simulation initialized with {request.num_agents} agents", + turn=engine.world.current_turn, + mode=engine.mode.value, + ) + + +@router.post( + "/control/next_step", + response_model=ControlResponse, + summary="Advance simulation", + description="Advance the simulation by one step. Only works in MANUAL mode.", +) +def next_step(): + """Advance the simulation by one step.""" + engine = get_engine() + + if engine.mode == SimulationMode.AUTO: + raise HTTPException( + status_code=400, + detail="Cannot manually advance while in AUTO mode. Switch to MANUAL first.", + ) + + if not engine.is_running: + raise HTTPException( + status_code=400, + detail="Simulation is not running. Initialize first.", + ) + + turn_log = engine.next_step() + + return ControlResponse( + success=True, + message=f"Advanced to turn {engine.world.current_turn}", + turn=engine.world.current_turn, + mode=engine.mode.value, + ) + + +@router.post( + "/control/mode", + response_model=ControlResponse, + summary="Set simulation mode", + description="Set the simulation mode to MANUAL or AUTO.", +) +def set_mode(request: SetModeRequest): + """Set the simulation mode.""" + engine = get_engine() + + try: + mode = SimulationMode(request.mode) + engine.set_mode(mode) + return ControlResponse( + success=True, + message=f"Mode set to {mode.value}", + turn=engine.world.current_turn, + mode=mode.value, + ) + except ValueError: + raise HTTPException( + status_code=400, + detail=f"Invalid mode: {request.mode}. Use 'manual' or 'auto'.", + ) + + +@router.get( + "/control/status", + summary="Get simulation status", + description="Returns the current simulation status.", +) +def get_status(): + """Get simulation status.""" + engine = get_engine() + return { + "is_running": engine.is_running, + "mode": engine.mode.value, + "current_turn": engine.world.current_turn, + "current_day": engine.world.current_day, + "time_of_day": engine.world.time_of_day.value, + "living_agents": len(engine.world.get_living_agents()), + } + + +# ============== Logs Endpoints ============== + +@router.get( + "/logs", + summary="Get turn logs", + description="Returns the most recent turn logs.", +) +def get_logs(limit: int = 10): + """Get recent turn logs.""" + engine = get_engine() + logs = engine.turn_logs[-limit:] if engine.turn_logs else [] + return { + "logs": [log.to_dict() for log in logs], + "total_turns": len(engine.turn_logs), + } + + +@router.get( + "/logs/{turn}", + summary="Get specific turn log", + description="Returns the log for a specific turn.", +) +def get_turn_log(turn: int): + """Get log for a specific turn.""" + engine = get_engine() + + for log in engine.turn_logs: + if log.turn == turn: + return log.to_dict() + + raise HTTPException(status_code=404, detail=f"Log for turn {turn} not found") + + +# ============== Config Endpoints ============== + +@router.get( + "/config", + summary="Get simulation configuration", + description="Returns all configurable simulation parameters.", +) +def get_simulation_config(): + """Get current simulation configuration.""" + config = get_config() + return config.to_dict() + + +@router.post( + "/config", + response_model=ControlResponse, + summary="Update simulation configuration", + description="Update simulation parameters. Requires simulation restart to take effect.", +) +def update_simulation_config(config_data: dict): + """Update simulation configuration.""" + try: + new_config = SimulationConfig.from_dict(config_data) + set_config(new_config) + return ControlResponse( + success=True, + message="Configuration updated. Restart simulation to apply changes.", + ) + except Exception as e: + raise HTTPException(status_code=400, detail=f"Invalid configuration: {str(e)}") + + +@router.post( + "/config/reset", + response_model=ControlResponse, + summary="Reset configuration to defaults", + description="Reset all configuration parameters to their default values.", +) +def reset_simulation_config(): + """Reset configuration to defaults.""" + reset_config() + return ControlResponse( + success=True, + message="Configuration reset to defaults.", + ) + + +@router.post( + "/config/save", + response_model=ControlResponse, + summary="Save configuration to file", + description="Save current configuration to config.json file.", +) +def save_config_to_file(): + """Save configuration to file.""" + try: + config = get_config() + config.save("config.json") + return ControlResponse( + success=True, + message="Configuration saved to config.json", + ) + except Exception as e: + raise HTTPException(status_code=500, detail=f"Failed to save config: {str(e)}") + + +@router.post( + "/config/load", + response_model=ControlResponse, + summary="Load configuration from file", + description="Load configuration from config.json file.", +) +def load_config_from_file(): + """Load configuration from file.""" + try: + config = SimulationConfig.load("config.json") + set_config(config) + return ControlResponse( + success=True, + message="Configuration loaded from config.json", + ) + except Exception as e: + raise HTTPException(status_code=500, detail=f"Failed to load config: {str(e)}") + diff --git a/backend/api/schemas.py b/backend/api/schemas.py new file mode 100644 index 0000000..8d32fbb --- /dev/null +++ b/backend/api/schemas.py @@ -0,0 +1,185 @@ +"""Pydantic schemas for API request/response models.""" + +from typing import Optional +from pydantic import BaseModel, Field + + +# ============== Resource Schemas ============== + +class ResourceSchema(BaseModel): + """Schema for a resource item.""" + type: str + quantity: int + created_turn: int + decay_rate: Optional[int] = None + + +# ============== Agent Schemas ============== + +class PositionSchema(BaseModel): + """Schema for agent position (float for smooth movement).""" + x: float + y: float + + +class StatsSchema(BaseModel): + """Schema for agent vital stats.""" + energy: int + hunger: int + thirst: int + heat: int + max_energy: int + max_hunger: int + max_thirst: int + max_heat: int + + +class AgentActionSchema(BaseModel): + """Schema for current agent action.""" + action_type: str + target_position: Optional[PositionSchema] = None + target_resource: Optional[str] = None + progress: float + is_moving: bool + message: str + + +class AgentResponse(BaseModel): + """Schema for agent data.""" + id: str + name: str + profession: str + position: PositionSchema + home_position: PositionSchema + stats: StatsSchema + inventory: list[ResourceSchema] + money: int + is_alive: bool + can_act: bool + current_action: AgentActionSchema + last_action_result: str + + +# ============== Market Schemas ============== + +class OrderSchema(BaseModel): + """Schema for a market order.""" + id: str + seller_id: str + resource_type: str + quantity: int + price_per_unit: int + total_price: int + created_turn: int + status: str + turns_without_sale: int + + +class MarketPriceSchema(BaseModel): + """Schema for market price summary.""" + lowest_price: Optional[int] + highest_price: Optional[int] + total_available: int + num_orders: int + + +class TradeResultSchema(BaseModel): + """Schema for a trade result.""" + success: bool + order_id: str = "" + buyer_id: str = "" + seller_id: str = "" + resource_type: Optional[str] = None + quantity: int = 0 + total_paid: int = 0 + message: str = "" + + +class MarketStateResponse(BaseModel): + """Schema for market state.""" + orders: list[OrderSchema] + prices: dict[str, MarketPriceSchema] + recent_trades: list[TradeResultSchema] + + +# ============== World Schemas ============== + +class WorldSizeSchema(BaseModel): + """Schema for world dimensions.""" + width: int + height: int + + +class StatisticsSchema(BaseModel): + """Schema for world statistics.""" + current_turn: int + current_day: int + step_in_day: int + time_of_day: str + living_agents: int + total_agents_spawned: int + total_agents_died: int + total_money_in_circulation: int + professions: dict[str, int] + + +class ActionLogSchema(BaseModel): + """Schema for an action in the turn log.""" + agent_id: str + agent_name: str + decision: dict + result: Optional[dict] = None + + +class TurnLogSchema(BaseModel): + """Schema for a turn log entry.""" + turn: int + agent_actions: list[ActionLogSchema] + deaths: list[str] + trades: list[dict] + + +class WorldStateResponse(BaseModel): + """Full world state response.""" + turn: int + day: int + step_in_day: int + time_of_day: str + world_size: WorldSizeSchema + agents: list[AgentResponse] + statistics: StatisticsSchema + market: MarketStateResponse + mode: str + is_running: bool + recent_logs: list[TurnLogSchema] + + +# ============== Control Schemas ============== + +class ControlResponse(BaseModel): + """Response for control operations.""" + success: bool + message: str + turn: Optional[int] = None + mode: Optional[str] = None + + +class SetModeRequest(BaseModel): + """Request to set simulation mode.""" + mode: str = Field(..., pattern="^(manual|auto)$") + + +class InitializeRequest(BaseModel): + """Request to initialize simulation.""" + num_agents: int = Field(default=8, ge=1, le=50) + world_width: int = Field(default=20, ge=5, le=100) + world_height: int = Field(default=20, ge=5, le=100) + + +# ============== Error Schemas ============== + +class ErrorResponse(BaseModel): + """Error response.""" + error: str + detail: Optional[str] = None + diff --git a/backend/config.py b/backend/config.py new file mode 100644 index 0000000..424ebaf --- /dev/null +++ b/backend/config.py @@ -0,0 +1,255 @@ +"""Centralized configuration for the Village Simulation.""" + +from dataclasses import dataclass, field, asdict +from typing import Optional +import json +from pathlib import Path + + +@dataclass +class AgentStatsConfig: + """Configuration for agent vital stats.""" + # Maximum values + max_energy: int = 50 + max_hunger: int = 100 + max_thirst: int = 100 # Increased from 50 to give more buffer + max_heat: int = 100 + + # Starting values + start_energy: int = 50 + start_hunger: int = 80 + start_thirst: int = 80 # Increased from 40 to start with more buffer + start_heat: int = 100 + + # Decay rates per turn + energy_decay: int = 2 + hunger_decay: int = 2 + thirst_decay: int = 2 # Reduced from 3 to match hunger decay rate + heat_decay: int = 2 + + # Thresholds + critical_threshold: float = 0.25 # 25% triggers survival mode + low_energy_threshold: int = 15 # Minimum energy to work + + +@dataclass +class ResourceConfig: + """Configuration for resource properties.""" + # Decay rates (turns until spoilage, 0 = infinite) + meat_decay: int = 8 # Increased from 5 to give more time to use + berries_decay: int = 25 + clothes_decay: int = 50 + + # Resource effects + meat_hunger: int = 30 + meat_energy: int = 5 + berries_hunger: int = 8 # Increased from 5 + berries_thirst: int = 3 # Increased from 2 + water_thirst: int = 50 # Increased from 40 for better thirst recovery + fire_heat: int = 15 # Increased from 10 + + +@dataclass +class ActionConfig: + """Configuration for action costs and outcomes.""" + # Energy costs (positive = restore, negative = spend) + sleep_energy: int = 60 + rest_energy: int = 10 + hunt_energy: int = -15 + gather_energy: int = -5 + chop_wood_energy: int = -10 + get_water_energy: int = -5 + weave_energy: int = -8 + build_fire_energy: int = -5 + trade_energy: int = -1 + + # Success chances (0.0 to 1.0) + hunt_success: float = 0.7 + chop_wood_success: float = 0.9 + + # Output quantities + hunt_meat_min: int = 1 + hunt_meat_max: int = 3 + hunt_hide_min: int = 0 + hunt_hide_max: int = 1 + gather_min: int = 2 + gather_max: int = 5 + chop_wood_min: int = 1 + chop_wood_max: int = 2 + + +@dataclass +class WorldConfig: + """Configuration for world properties.""" + width: int = 20 + height: int = 20 + initial_agents: int = 8 + day_steps: int = 10 + night_steps: int = 1 + + # Agent configuration + inventory_slots: int = 10 + starting_money: int = 100 + + +@dataclass +class MarketConfig: + """Configuration for market behavior.""" + turns_before_discount: int = 3 + discount_rate: float = 0.15 # 15% discount after waiting + base_price_multiplier: float = 1.2 # Markup over production cost + + +@dataclass +class EconomyConfig: + """Configuration for economic behavior and agent trading. + + These values control how agents perceive the value of money and trading. + Higher values make agents more trade-oriented. + """ + # How much agents value money vs energy + # Higher = agents see money as more valuable (trade more) + energy_to_money_ratio: float = 1.5 # 1 energy ≈ 1.5 coins + + # How strongly agents desire wealth (0-1) + # Higher = agents will prioritize building wealth + wealth_desire: float = 0.3 + + # Buy efficiency threshold (0-1) + # If market price < (threshold * fair_value), buy instead of gather + # 0.7 means: buy if price is 70% or less of the fair value + buy_efficiency_threshold: float = 0.7 + + # Minimum wealth target - agents want at least this much money + min_wealth_target: int = 50 + + # Price adjustment limits + max_price_markup: float = 2.0 # Maximum price = 2x base value + min_price_discount: float = 0.5 # Minimum price = 50% of base value + + +@dataclass +class SimulationConfig: + """Master configuration containing all sub-configs.""" + agent_stats: AgentStatsConfig = field(default_factory=AgentStatsConfig) + resources: ResourceConfig = field(default_factory=ResourceConfig) + actions: ActionConfig = field(default_factory=ActionConfig) + world: WorldConfig = field(default_factory=WorldConfig) + market: MarketConfig = field(default_factory=MarketConfig) + economy: EconomyConfig = field(default_factory=EconomyConfig) + + # Simulation control + auto_step_interval: float = 1.0 # Seconds between auto steps + + def to_dict(self) -> dict: + """Convert to dictionary.""" + return { + "agent_stats": asdict(self.agent_stats), + "resources": asdict(self.resources), + "actions": asdict(self.actions), + "world": asdict(self.world), + "market": asdict(self.market), + "economy": asdict(self.economy), + "auto_step_interval": self.auto_step_interval, + } + + @classmethod + def from_dict(cls, data: dict) -> "SimulationConfig": + """Create from dictionary.""" + return cls( + agent_stats=AgentStatsConfig(**data.get("agent_stats", {})), + resources=ResourceConfig(**data.get("resources", {})), + actions=ActionConfig(**data.get("actions", {})), + world=WorldConfig(**data.get("world", {})), + market=MarketConfig(**data.get("market", {})), + economy=EconomyConfig(**data.get("economy", {})), + auto_step_interval=data.get("auto_step_interval", 1.0), + ) + + def save(self, path: str = "config.json") -> None: + """Save configuration to JSON file.""" + with open(path, "w") as f: + json.dump(self.to_dict(), f, indent=2) + + @classmethod + def load(cls, path: str = "config.json") -> "SimulationConfig": + """Load configuration from JSON file.""" + try: + with open(path, "r") as f: + data = json.load(f) + return cls.from_dict(data) + except FileNotFoundError: + return cls() # Return defaults if file not found + + +# Global configuration instance +_config: Optional[SimulationConfig] = None + + +def get_config() -> SimulationConfig: + """Get the global configuration instance. + + Loads from config.json if not already loaded. + """ + global _config + if _config is None: + _config = load_config() + return _config + + +def load_config(path: str = "config.json") -> SimulationConfig: + """Load configuration from JSON file, falling back to defaults.""" + try: + config_path = Path(path) + if not config_path.is_absolute(): + # Try relative to workspace root (villsim/) + # __file__ is backend/config.py, so .parent.parent is villsim/ + workspace_root = Path(__file__).parent.parent + config_path = workspace_root / path + + if config_path.exists(): + with open(config_path, "r") as f: + data = json.load(f) + return SimulationConfig.from_dict(data) + except (FileNotFoundError, json.JSONDecodeError) as e: + print(f"Warning: Could not load config from {path}: {e}") + + return SimulationConfig() # Return defaults if file not found + + +def set_config(config: SimulationConfig) -> None: + """Set the global configuration instance.""" + global _config + _config = config + + +def reset_config() -> SimulationConfig: + """Reset configuration to defaults.""" + global _config + _config = SimulationConfig() + _reset_all_caches() + return _config + + +def reload_config(path: str = "config.json") -> SimulationConfig: + """Reload configuration from file and reset all caches.""" + global _config + _config = load_config(path) + _reset_all_caches() + return _config + + +def _reset_all_caches() -> None: + """Reset all module caches that depend on config values.""" + try: + from backend.domain.action import reset_action_config_cache + reset_action_config_cache() + except ImportError: + pass + + try: + from backend.domain.resources import reset_resource_cache + reset_resource_cache() + except ImportError: + pass + diff --git a/backend/core/__init__.py b/backend/core/__init__.py new file mode 100644 index 0000000..dda0b83 --- /dev/null +++ b/backend/core/__init__.py @@ -0,0 +1,20 @@ +"""Core game logic for the Village Simulation.""" + +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__ = [ + "World", + "TimeOfDay", + "Order", + "OrderBook", + "GameEngine", + "SimulationMode", + "AgentAI", + "SimulationLogger", + "get_simulation_logger", +] + diff --git a/backend/core/ai.py b/backend/core/ai.py new file mode 100644 index 0000000..3474ba7 --- /dev/null +++ b/backend/core/ai.py @@ -0,0 +1,994 @@ +"""AI decision system for agents in the Village Simulation. + +Major rework to create market-driven economy: +- Agents understand that BUYING saves energy (trading is smart!) +- Wealth accumulation as a goal (money = safety buffer) +- Dynamic pricing based on supply/demand signals +- Proactive trading - buy low, sell high +- Market participation is now central to survival strategy + +Key insight: An agent with money can survive without working. +The market is not a last resort - it's the optimal strategy when prices are good. +""" + +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 + +if TYPE_CHECKING: + from backend.core.market import OrderBook + + +@dataclass +class TradeItem: + """A single item to buy/sell in a trade.""" + order_id: str + resource_type: ResourceType + quantity: int + price_per_unit: int + + +@dataclass +class AIDecision: + """A decision made by the AI for an agent.""" + action: ActionType + target_resource: Optional[ResourceType] = None + order_id: Optional[str] = None + quantity: int = 1 + price: int = 0 + reason: str = "" + # For multi-item trades + trade_items: list[TradeItem] = field(default_factory=list) + # For price adjustments + adjust_order_id: Optional[str] = None + new_price: Optional[int] = None + + def to_dict(self) -> dict: + return { + "action": self.action.value, + "target_resource": self.target_resource.value if self.target_resource else None, + "order_id": self.order_id, + "quantity": self.quantity, + "price": self.price, + "reason": self.reason, + "trade_items": [ + { + "order_id": t.order_id, + "resource_type": t.resource_type.value, + "quantity": t.quantity, + "price_per_unit": t.price_per_unit, + } + for t in self.trade_items + ], + "adjust_order_id": self.adjust_order_id, + "new_price": self.new_price, + } + + +# Resource to action for gathering +RESOURCE_ACTIONS: dict[ResourceType, ActionType] = { + ResourceType.MEAT: ActionType.HUNT, + ResourceType.BERRIES: ActionType.GATHER, + ResourceType.WATER: ActionType.GET_WATER, + ResourceType.WOOD: ActionType.CHOP_WOOD, + ResourceType.HIDE: ActionType.HUNT, + ResourceType.CLOTHES: ActionType.WEAVE, +} + +# Energy cost to gather each resource (used for efficiency calculations) +def get_energy_cost(resource_type: ResourceType) -> int: + """Get the energy cost to produce one unit of a resource.""" + action = RESOURCE_ACTIONS.get(resource_type) + if not action: + return 10 + config = ACTION_CONFIG.get(action) + if not config: + return 10 + energy_cost = abs(config.energy_cost) + avg_output = max(1, (config.min_output + config.max_output) / 2) if config.output_resource else 1 + return int(energy_cost / avg_output) + + +def _get_ai_config(): + """Get AI-relevant configuration values.""" + from backend.config import get_config + config = get_config() + return config.agent_stats + + +def _get_economy_config(): + """Get economy/market configuration values.""" + from backend.config import get_config + config = get_config() + return getattr(config, 'economy', None) + + +class AgentAI: + """AI decision maker with market-driven economy behavior. + + Core philosophy: Trading is SMART, not a last resort. + + The agent now understands: + 1. Buying is often more efficient than gathering (saves energy!) + 2. Money is power - wealth means safety and flexibility + 3. Selling at good prices builds wealth + 4. Adjusting prices responds to supply/demand + 5. The market is a tool for survival, not just emergency trades + + Economic behaviors: + - Calculate "fair value" of resources based on energy cost + - Buy when market price < energy cost to gather + - Sell when market price > production cost + - Adjust prices based on market conditions (supply/demand) + - Accumulate wealth as a safety buffer + """ + + # 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 + MIN_WATER_STOCK = 3 + MIN_FOOD_STOCK = 4 + MIN_WOOD_STOCK = 3 + + # Heat thresholds + HEAT_PROACTIVE_THRESHOLD = 0.50 + + # ECONOMY SETTINGS - These make agents trade more + ENERGY_TO_MONEY_RATIO = 1.5 # 1 energy = ~1.5 coins in perceived value + WEALTH_DESIRE = 0.3 # How much agents want to accumulate wealth (0-1) + BUY_EFFICIENCY_THRESHOLD = 0.7 # Buy if market price < 70% of gather cost + MIN_WEALTH_TARGET = 50 # Agents want at least this much money + MAX_PRICE_MARKUP = 2.0 # Maximum markup over base price + MIN_PRICE_DISCOUNT = 0.5 # Minimum discount (50% of base price) + + 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 + + # Load thresholds from config + config = _get_ai_config() + self.CRITICAL_THRESHOLD = config.critical_threshold + self.LOW_ENERGY_THRESHOLD = config.low_energy_threshold + + # Try to load economy config + economy = _get_economy_config() + if economy: + self.ENERGY_TO_MONEY_RATIO = getattr(economy, 'energy_to_money_ratio', self.ENERGY_TO_MONEY_RATIO) + self.WEALTH_DESIRE = getattr(economy, 'wealth_desire', self.WEALTH_DESIRE) + self.BUY_EFFICIENCY_THRESHOLD = getattr(economy, 'buy_efficiency_threshold', self.BUY_EFFICIENCY_THRESHOLD) + self.MIN_WEALTH_TARGET = getattr(economy, 'min_wealth_target', self.MIN_WEALTH_TARGET) + + @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(1, 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 AND economic optimization. + + Key insight: Trading is often BETTER than gathering because: + 1. Trade uses only 1 energy vs 4-8 for gathering + 2. If market price < energy cost, buying is pure profit + 3. Money = stored energy = safety buffer + """ + # 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: Price adjustment - respond to market conditions + decision = self._check_price_adjustments() + if decision: + return decision + + # Priority 4: Smart shopping - buy good deals on the market! + decision = self._check_market_opportunities() + if decision: + return decision + + # Priority 5: Craft clothes if we have hide + decision = self._check_clothes_crafting() + if decision: + return decision + + # Priority 6: Energy management + decision = self._check_energy() + if decision: + return decision + + # Priority 7: Economic activities (sell excess, build wealth) + decision = self._check_economic() + if decision: + return decision + + # Priority 8: Routine survival work (gather resources we need) + return self._do_survival_work() + + 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 + + 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 + efficiency_score = fair_value / order.price_per_unit # 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 + can_afford = self.agent.money // order.price_per_unit + 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. + + Sell when: + - Market signal says 'sell' (scarcity) + - We have more than minimum stock + - We could use more money (not wealthy) + """ + if self._is_wealthy() and self.agent.inventory_space() > 3: + # Already rich and have space, no rush to sell + return None + + survival_minimums = { + ResourceType.WATER: 2, + ResourceType.MEAT: 1, + ResourceType.BERRIES: 2, + ResourceType.WOOD: 2, + ResourceType.HIDE: 0, + } + + # 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) + + # Calculate optimal price + if signal == "sell": # Scarcity - we can charge more + price = int(fair_value * 1.3) # 30% markup + score = 3 + excess + elif signal == "hold": # Normal market + price = fair_value + 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(1, cheapest.price_per_unit - 1) + else: + price = int(fair_value * 0.8) + score = 0.5 # Not a great time to sell + + 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"Market opportunity: 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(1, suggested) + + def _do_survival_work(self) -> AIDecision: + """Perform work based on what we need most for survival. + + KEY CHANGE: Always consider buying as an alternative! + If there's a good deal on the market, BUY instead of gathering. + This is the core economic behavior we want. + """ + 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 + order = self.market.get_cheapest_order(resource_type) + if order and order.seller_id != self.agent.id and self.agent.money >= order.price_per_unit: + if self._is_good_buy(resource_type, order.price_per_unit): + return AIDecision( + action=ActionType.TRADE, + target_resource=resource_type, + order_id=order.id, + quantity=1, + price=order.price_per_unit, + reason=f"{reason} (buying @ {order.price_per_unit}c - good deal!)", + ) + + # 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) + 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: + # Decide between hunting and gathering based on conditions + # Meat is more valuable (more hunger restored), but hunting costs more energy + hunt_config = ACTION_CONFIG[ActionType.HUNT] + gather_config = ACTION_CONFIG[ActionType.GATHER] + + # Prefer hunting if: + # - We have enough energy for hunt + # - AND (we have no meat OR random chance favors hunting for diversity) + can_hunt = stats.energy >= abs(hunt_config.energy_cost) + 5 + prefer_hunt = (meat_count == 0) or (can_hunt and random.random() < 0.4) # 40% hunt chance + + if prefer_hunt and can_hunt: + 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 (with buy checks) + needs = [] + + if water_count < self.MIN_WATER_STOCK + 2: + needs.append((ResourceType.WATER, ActionType.GET_WATER, "Getting water", 2)) + + if food_count < self.MIN_FOOD_STOCK + 2: + # Both berries and hunting are valid options + needs.append((ResourceType.BERRIES, ActionType.GATHER, "Gathering berries", 2)) + # Add hunting with good weight if we have energy + hunt_config = ACTION_CONFIG[ActionType.HUNT] + if stats.energy >= abs(hunt_config.energy_cost) + 3: + needs.append((ResourceType.MEAT, ActionType.HUNT, "Hunting for meat", 2)) # Same weight as berries + + if wood_count < self.MIN_WOOD_STOCK + 2: + needs.append((ResourceType.WOOD, ActionType.CHOP_WOOD, "Chopping wood", 1)) + + 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: maintain food supply + return AIDecision( + action=ActionType.GATHER, + target_resource=ResourceType.BERRIES, + reason="Maintaining supplies", + ) + + # For each need, check if we can buy cheaply + for resource_type, action, reason, weight in needs: + order = self.market.get_cheapest_order(resource_type) + if order and order.seller_id != self.agent.id and self.agent.money >= order.price_per_unit: + if self._is_good_buy(resource_type, order.price_per_unit): + return AIDecision( + action=ActionType.TRADE, + target_resource=resource_type, + order_id=order.id, + quantity=1, + price=order.price_per_unit, + reason=f"{reason} (buying cheap!)", + ) + + # 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) -> AIDecision: + """Convenience function to get an AI decision for an agent.""" + ai = AgentAI(agent, market, step_in_day, day_steps, current_turn) + return ai.decide() diff --git a/backend/core/engine.py b/backend/core/engine.py new file mode 100644 index 0000000..10e9e6c --- /dev/null +++ b/backend/core/engine.py @@ -0,0 +1,637 @@ +"""Game Engine for the Village Simulation.""" + +import random +import threading +import time +from dataclasses import dataclass, field +from enum import Enum +from typing import Optional + +from backend.domain.agent import Agent +from backend.domain.action import ActionType, ActionResult, ACTION_CONFIG +from backend.domain.resources import Resource, ResourceType +from backend.core.world import World, WorldConfig, TimeOfDay +from backend.core.market import OrderBook +from backend.core.ai import get_ai_decision, AIDecision +from backend.core.logger import get_simulation_logger, reset_simulation_logger +from backend.config import get_config + + +class SimulationMode(Enum): + """Simulation run mode.""" + MANUAL = "manual" # Wait for explicit next_step call + AUTO = "auto" # Run automatically with timer + + +@dataclass +class TurnLog: + """Log of events that happened in a turn.""" + turn: int + agent_actions: list[dict] = field(default_factory=list) + deaths: list[str] = field(default_factory=list) + trades: list[dict] = field(default_factory=list) + + def to_dict(self) -> dict: + return { + "turn": self.turn, + "agent_actions": self.agent_actions, + "deaths": self.deaths, + "trades": self.trades, + } + + +class GameEngine: + """Main game engine singleton.""" + + _instance: Optional["GameEngine"] = None + + def __new__(cls): + if cls._instance is None: + cls._instance = super().__new__(cls) + cls._instance._initialized = False + return cls._instance + + def __init__(self): + if self._initialized: + return + + self.world = World() + self.market = OrderBook() + self.mode = SimulationMode.MANUAL + self.is_running = False + self.auto_step_interval = 1.0 # seconds + self._auto_thread: Optional[threading.Thread] = None + self._stop_event = threading.Event() + self.turn_logs: list[TurnLog] = [] + self.logger = get_simulation_logger() + self._initialized = True + + def reset(self, config: Optional[WorldConfig] = None) -> None: + """Reset the simulation to initial state.""" + # Stop auto mode if running + self._stop_auto_mode() + + if config: + self.world = World(config=config) + else: + self.world = World() + self.market = OrderBook() + self.turn_logs = [] + + # Reset and start new logging session + self.logger = reset_simulation_logger() + sim_config = get_config() + self.logger.start_session(sim_config.to_dict()) + + self.world.initialize() + self.is_running = True + + def initialize(self, num_agents: int = 8) -> None: + """Initialize the simulation with agents.""" + self.world.config.initial_agents = num_agents + self.world.initialize() + + # Start logging session + self.logger = reset_simulation_logger() + sim_config = get_config() + self.logger.start_session(sim_config.to_dict()) + + self.is_running = True + + def next_step(self) -> TurnLog: + """Advance the simulation by one step.""" + if not self.is_running: + return TurnLog(turn=-1) + + turn_log = TurnLog(turn=self.world.current_turn + 1) + current_turn = self.world.current_turn + 1 + + # Start logging this turn + self.logger.start_turn( + turn=current_turn, + day=self.world.current_day, + step_in_day=self.world.step_in_day + 1, + time_of_day=self.world.time_of_day.value, + ) + + # Log market state before + market_orders_before = [o.to_dict() for o in self.market.get_active_orders()] + + # 0. Remove corpses from previous turn (agents who died last turn) + self._remove_old_corpses(current_turn) + + # 1. Collect AI decisions for all living agents (not corpses) + decisions: list[tuple[Agent, AIDecision]] = [] + for agent in self.world.get_living_agents(): + # Log agent state before + self.logger.log_agent_before( + agent_id=agent.id, + agent_name=agent.name, + profession=agent.profession.value, + position=agent.position.to_dict(), + stats=agent.stats.to_dict(), + inventory=[r.to_dict() for r in agent.inventory], + money=agent.money, + ) + + if self.world.is_night(): + # Force sleep at night + decision = AIDecision( + action=ActionType.SLEEP, + reason="Night time: sleeping", + ) + else: + # Pass time info so AI can prepare for night + decision = get_ai_decision( + agent, + self.market, + step_in_day=self.world.step_in_day, + day_steps=self.world.config.day_steps, + current_turn=current_turn, + ) + + decisions.append((agent, decision)) + + # Log decision + self.logger.log_agent_decision(agent.id, decision.to_dict()) + + # 2. Calculate movement targets and move agents + for agent, decision in decisions: + action_name = decision.action.value + agent.set_action( + action_type=action_name, + world_width=self.world.config.width, + world_height=self.world.config.height, + message=decision.reason, + target_resource=decision.target_resource.value if decision.target_resource else None, + ) + agent.update_movement() + + # 3. Execute all actions and update action indicators with results + for agent, decision in decisions: + result = self._execute_action(agent, decision) + + # Complete agent action with result - this updates the indicator to show what was done + if result: + agent.complete_action(result.success, result.message) + + turn_log.agent_actions.append({ + "agent_id": agent.id, + "agent_name": agent.name, + "decision": decision.to_dict(), + "result": result.to_dict() if result else None, + }) + + # Log agent state after action + self.logger.log_agent_after( + agent_id=agent.id, + stats=agent.stats.to_dict(), + inventory=[r.to_dict() for r in agent.inventory], + money=agent.money, + position=agent.position.to_dict(), + action_result=result.to_dict() if result else {}, + ) + + # 4. Resolve pending market orders (price updates) + self.market.update_prices(current_turn) + + # Log market state after + market_orders_after = [o.to_dict() for o in self.market.get_active_orders()] + self.logger.log_market_state(market_orders_before, market_orders_after) + + # 5. Apply passive decay to all living agents + for agent in self.world.get_living_agents(): + agent.apply_passive_decay() + + # 6. Decay resources in inventories + for agent in self.world.get_living_agents(): + expired = agent.decay_inventory(current_turn) + + # 7. Mark newly dead agents as corpses (don't remove yet for visualization) + newly_dead = self._mark_dead_agents(current_turn) + for dead_agent in newly_dead: + cause = dead_agent.death_reason + self.logger.log_death(dead_agent.name, cause) + # Cancel their market orders immediately + self.market.cancel_seller_orders(dead_agent.id) + turn_log.deaths = [a.name for a in newly_dead] + + # Log statistics + self.logger.log_statistics(self.world.get_statistics()) + + # End turn logging + self.logger.end_turn() + + # 8. Advance time + self.world.advance_time() + + # 9. Check win/lose conditions (count only truly living agents, not corpses) + if len(self.world.get_living_agents()) == 0: + self.is_running = False + self.logger.close() + + self.turn_logs.append(turn_log) + return turn_log + + def _mark_dead_agents(self, current_turn: int) -> list[Agent]: + """Mark agents who just died as corpses. Returns list of newly dead agents.""" + newly_dead = [] + for agent in self.world.agents: + if not agent.is_alive() and not agent.is_corpse(): + # Agent just died this turn + cause = agent.stats.get_critical_stat() or "unknown" + agent.mark_dead(current_turn, cause) + # Clear their action to show death state + agent.current_action.action_type = "dead" + agent.current_action.message = f"Died: {cause}" + newly_dead.append(agent) + return newly_dead + + def _remove_old_corpses(self, current_turn: int) -> list[Agent]: + """Remove corpses that have been visible for one turn.""" + to_remove = [] + for agent in self.world.agents: + if agent.is_corpse() and agent.death_turn < current_turn: + # Corpse has been visible for one turn, remove it + to_remove.append(agent) + + for agent in to_remove: + self.world.agents.remove(agent) + self.world.total_agents_died += 1 + + return to_remove + + def _execute_action(self, agent: Agent, decision: AIDecision) -> Optional[ActionResult]: + """Execute an action for an agent.""" + action = decision.action + config = ACTION_CONFIG[action] + + # Handle different action types + if action == ActionType.SLEEP: + agent.restore_energy(config.energy_cost) + return ActionResult( + action_type=action, + success=True, + energy_spent=-config.energy_cost, + message="Sleeping soundly", + ) + + elif action == ActionType.REST: + agent.restore_energy(config.energy_cost) + return ActionResult( + action_type=action, + success=True, + energy_spent=-config.energy_cost, + message="Resting", + ) + + elif action == ActionType.CONSUME: + if decision.target_resource: + success = agent.consume(decision.target_resource) + return ActionResult( + action_type=action, + success=success, + message=f"Consumed {decision.target_resource.value}" if success else "Nothing to consume", + ) + return ActionResult(action_type=action, success=False, message="No resource specified") + + elif action == ActionType.BUILD_FIRE: + if agent.has_resource(ResourceType.WOOD): + agent.remove_from_inventory(ResourceType.WOOD, 1) + if not agent.spend_energy(abs(config.energy_cost)): + return ActionResult(action_type=action, success=False, message="Not enough energy") + # Fire heat from config + from backend.domain.resources import get_fire_heat + fire_heat = get_fire_heat() + agent.apply_heat(fire_heat) + return ActionResult( + action_type=action, + success=True, + energy_spent=abs(config.energy_cost), + heat_gained=fire_heat, + message="Built a warm fire", + ) + return ActionResult(action_type=action, success=False, message="No wood for fire") + + elif action == ActionType.TRADE: + return self._execute_trade(agent, decision) + + elif action in [ActionType.HUNT, ActionType.GATHER, ActionType.CHOP_WOOD, + ActionType.GET_WATER, ActionType.WEAVE]: + return self._execute_work(agent, action, config) + + return ActionResult(action_type=action, success=False, message="Unknown action") + + def _execute_work(self, agent: Agent, action: ActionType, config) -> ActionResult: + """Execute a work action (hunting, gathering, etc.).""" + # Check energy + energy_cost = abs(config.energy_cost) + if not agent.spend_energy(energy_cost): + return ActionResult( + action_type=action, + success=False, + message="Not enough energy", + ) + + # Check required materials + if config.requires_resource: + if not agent.has_resource(config.requires_resource, config.requires_quantity): + agent.restore_energy(energy_cost) # Refund energy + return ActionResult( + action_type=action, + success=False, + message=f"Missing required {config.requires_resource.value}", + ) + agent.remove_from_inventory(config.requires_resource, config.requires_quantity) + + # Check success chance + if random.random() > config.success_chance: + return ActionResult( + action_type=action, + success=False, + energy_spent=energy_cost, + message="Action failed", + ) + + # Generate output + resources_gained = [] + + if config.output_resource: + quantity = random.randint(config.min_output, config.max_output) + if quantity > 0: + resource = Resource( + type=config.output_resource, + quantity=quantity, + created_turn=self.world.current_turn, + ) + added = agent.add_to_inventory(resource) + if added > 0: + resources_gained.append(Resource( + type=config.output_resource, + quantity=added, + created_turn=self.world.current_turn, + )) + + # Secondary output (e.g., hide from hunting) + if config.secondary_output: + quantity = random.randint(config.secondary_min, config.secondary_max) + if quantity > 0: + resource = Resource( + type=config.secondary_output, + quantity=quantity, + created_turn=self.world.current_turn, + ) + added = agent.add_to_inventory(resource) + if added > 0: + resources_gained.append(Resource( + type=config.secondary_output, + quantity=added, + created_turn=self.world.current_turn, + )) + + # Build success message with details + gained_str = ", ".join(f"+{r.quantity} {r.type.value}" for r in resources_gained) + message = f"{action.value}: {gained_str}" if gained_str else f"{action.value} (nothing gained)" + + return ActionResult( + action_type=action, + success=True, + energy_spent=energy_cost, + resources_gained=resources_gained, + message=message, + ) + + def _execute_trade(self, agent: Agent, decision: AIDecision) -> ActionResult: + """Execute a trade action (buy, sell, or price adjustment). Supports multi-item trades.""" + config = ACTION_CONFIG[ActionType.TRADE] + + # Handle price adjustments (no energy cost) + if decision.adjust_order_id and decision.new_price is not None: + return self._execute_price_adjustment(agent, decision) + + # Handle multi-item trades + if decision.trade_items: + return self._execute_multi_buy(agent, decision) + + if decision.order_id: + # Buying single item from market + result = self.market.execute_buy( + buyer_id=agent.id, + order_id=decision.order_id, + quantity=decision.quantity, + buyer_money=agent.money, + ) + + if result.success: + # Log the trade + self.logger.log_trade(result.to_dict()) + + # Record sale for price history tracking + self.market._record_sale( + result.resource_type, + result.total_paid // result.quantity, + result.quantity, + self.world.current_turn, + ) + + # Deduct money from buyer + agent.money -= result.total_paid + + # Add resources to buyer + resource = Resource( + type=result.resource_type, + quantity=result.quantity, + created_turn=self.world.current_turn, + ) + agent.add_to_inventory(resource) + + # Add money to seller + seller = self.world.get_agent(result.seller_id) + if seller: + seller.money += result.total_paid + + agent.spend_energy(abs(config.energy_cost)) + + return ActionResult( + action_type=ActionType.TRADE, + success=True, + energy_spent=abs(config.energy_cost), + resources_gained=[resource], + message=f"Bought {result.quantity} {result.resource_type.value} for {result.total_paid}c", + ) + else: + return ActionResult( + action_type=ActionType.TRADE, + success=False, + message=result.message, + ) + + elif decision.target_resource and decision.quantity > 0: + # Selling to market + if agent.has_resource(decision.target_resource, decision.quantity): + agent.remove_from_inventory(decision.target_resource, decision.quantity) + + order = self.market.place_order( + seller_id=agent.id, + resource_type=decision.target_resource, + quantity=decision.quantity, + price_per_unit=decision.price, + current_turn=self.world.current_turn, + ) + + agent.spend_energy(abs(config.energy_cost)) + + return ActionResult( + action_type=ActionType.TRADE, + success=True, + energy_spent=abs(config.energy_cost), + message=f"Listed {decision.quantity} {decision.target_resource.value} @ {decision.price}c each", + ) + else: + return ActionResult( + action_type=ActionType.TRADE, + success=False, + message="Not enough resources to sell", + ) + + return ActionResult( + action_type=ActionType.TRADE, + success=False, + message="Invalid trade parameters", + ) + + def _execute_price_adjustment(self, agent: Agent, decision: AIDecision) -> ActionResult: + """Execute a price adjustment on an existing order (no energy cost).""" + success = self.market.adjust_order_price( + order_id=decision.adjust_order_id, + seller_id=agent.id, + new_price=decision.new_price, + current_turn=self.world.current_turn, + ) + + if success: + return ActionResult( + action_type=ActionType.TRADE, + success=True, + energy_spent=0, # Price adjustments are free + message=f"Adjusted {decision.target_resource.value} price to {decision.new_price}c", + ) + else: + return ActionResult( + action_type=ActionType.TRADE, + success=False, + message="Failed to adjust price", + ) + + def _execute_multi_buy(self, agent: Agent, decision: AIDecision) -> ActionResult: + """Execute a multi-item buy trade.""" + config = ACTION_CONFIG[ActionType.TRADE] + + # Build list of purchases + purchases = [(item.order_id, item.quantity) for item in decision.trade_items] + + # Execute all purchases + results = self.market.execute_multi_buy( + buyer_id=agent.id, + purchases=purchases, + buyer_money=agent.money, + ) + + # Process results + total_paid = 0 + resources_gained = [] + items_bought = [] + + for result in results: + if result.success: + self.logger.log_trade(result.to_dict()) + agent.money -= result.total_paid + total_paid += result.total_paid + + # Record sale for price history + self.market._record_sale( + result.resource_type, + result.total_paid // result.quantity, + result.quantity, + self.world.current_turn + ) + + resource = Resource( + type=result.resource_type, + quantity=result.quantity, + created_turn=self.world.current_turn, + ) + agent.add_to_inventory(resource) + resources_gained.append(resource) + items_bought.append(f"{result.quantity} {result.resource_type.value}") + + # Add money to seller + seller = self.world.get_agent(result.seller_id) + if seller: + seller.money += result.total_paid + + if resources_gained: + agent.spend_energy(abs(config.energy_cost)) + message = f"Bought {', '.join(items_bought)} for {total_paid}c" + return ActionResult( + action_type=ActionType.TRADE, + success=True, + energy_spent=abs(config.energy_cost), + resources_gained=resources_gained, + message=message, + ) + else: + return ActionResult( + action_type=ActionType.TRADE, + success=False, + message="Failed to buy any items", + ) + + def set_mode(self, mode: SimulationMode) -> None: + """Set the simulation mode.""" + if mode == self.mode: + return + + if mode == SimulationMode.AUTO: + self._start_auto_mode() + else: + self._stop_auto_mode() + + self.mode = mode + + def _start_auto_mode(self) -> None: + """Start automatic step advancement.""" + self._stop_event.clear() + + def auto_step(): + while not self._stop_event.is_set() and self.is_running: + self.next_step() + time.sleep(self.auto_step_interval) + + self._auto_thread = threading.Thread(target=auto_step, daemon=True) + self._auto_thread.start() + + def _stop_auto_mode(self) -> None: + """Stop automatic step advancement.""" + self._stop_event.set() + if self._auto_thread: + self._auto_thread.join(timeout=2.0) + self._auto_thread = None + + def get_state(self) -> dict: + """Get the full simulation state for API.""" + return { + **self.world.get_state_snapshot(), + "market": self.market.get_state_snapshot(), + "mode": self.mode.value, + "is_running": self.is_running, + "recent_logs": [ + log.to_dict() for log in self.turn_logs[-5:] + ], + } + + +# Global engine instance +def get_engine() -> GameEngine: + """Get the global game engine instance.""" + return GameEngine() diff --git a/backend/core/logger.py b/backend/core/logger.py new file mode 100644 index 0000000..18587d3 --- /dev/null +++ b/backend/core/logger.py @@ -0,0 +1,296 @@ +"""Simulation logger for detailed step-by-step logging.""" + +import json +import logging +from dataclasses import dataclass, field, asdict +from datetime import datetime +from pathlib import Path +from typing import Optional, TextIO + + +@dataclass +class AgentLogEntry: + """Log entry for a single agent's turn.""" + agent_id: str + agent_name: str + profession: str + position: dict + stats_before: dict + stats_after: dict + decision: dict + action_result: dict + inventory_before: list + inventory_after: list + money_before: int + money_after: int + + +@dataclass +class TurnLogEntry: + """Complete log entry for a simulation turn.""" + turn: int + day: int + step_in_day: int + time_of_day: str + timestamp: str + agent_entries: list[AgentLogEntry] = field(default_factory=list) + market_orders_before: list = field(default_factory=list) + market_orders_after: list = field(default_factory=list) + trades: list = field(default_factory=list) + deaths: list = field(default_factory=list) + statistics: dict = field(default_factory=dict) + + def to_dict(self) -> dict: + """Convert to dictionary for JSON serialization.""" + return { + "turn": self.turn, + "day": self.day, + "step_in_day": self.step_in_day, + "time_of_day": self.time_of_day, + "timestamp": self.timestamp, + "agent_entries": [asdict(e) for e in self.agent_entries], + "market_orders_before": self.market_orders_before, + "market_orders_after": self.market_orders_after, + "trades": self.trades, + "deaths": self.deaths, + "statistics": self.statistics, + } + + +class SimulationLogger: + """Logger that dumps detailed simulation data to files.""" + + def __init__(self, log_dir: str = "logs"): + self.log_dir = Path(log_dir) + self.log_dir.mkdir(exist_ok=True) + + # Create session-specific log file + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") + self.session_file = self.log_dir / f"sim_{timestamp}.jsonl" + self.summary_file = self.log_dir / f"sim_{timestamp}_summary.txt" + + # File handles + self._json_file: Optional[TextIO] = None + self._summary_file: Optional[TextIO] = None + + # Also set up standard Python logging + self.logger = logging.getLogger("simulation") + self.logger.setLevel(logging.DEBUG) + + # File handler for detailed logs + file_handler = logging.FileHandler(self.log_dir / f"sim_{timestamp}.log") + file_handler.setLevel(logging.DEBUG) + file_handler.setFormatter(logging.Formatter( + "%(asctime)s | %(levelname)s | %(message)s" + )) + self.logger.addHandler(file_handler) + + # Console handler for important events + console_handler = logging.StreamHandler() + console_handler.setLevel(logging.INFO) + console_handler.setFormatter(logging.Formatter( + "%(asctime)s | %(message)s", datefmt="%H:%M:%S" + )) + self.logger.addHandler(console_handler) + + self._entries: list[TurnLogEntry] = [] + self._current_entry: Optional[TurnLogEntry] = None + + def start_session(self, config: dict) -> None: + """Start a new logging session.""" + self._json_file = open(self.session_file, "w") + self._summary_file = open(self.summary_file, "w") + + # Write config as first line + self._json_file.write(json.dumps({"type": "config", "data": config}) + "\n") + self._json_file.flush() + + self._summary_file.write(f"Simulation Session Started: {datetime.now()}\n") + self._summary_file.write("=" * 60 + "\n\n") + self._summary_file.flush() + + self.logger.info(f"Logging session started: {self.session_file}") + + def start_turn(self, turn: int, day: int, step_in_day: int, time_of_day: str) -> None: + """Start logging a new turn.""" + self._current_entry = TurnLogEntry( + turn=turn, + day=day, + step_in_day=step_in_day, + time_of_day=time_of_day, + timestamp=datetime.now().isoformat(), + ) + self.logger.debug(f"Turn {turn} started (Day {day}, Step {step_in_day}, {time_of_day})") + + def log_agent_before( + self, + agent_id: str, + agent_name: str, + profession: str, + position: dict, + stats: dict, + inventory: list, + money: int, + ) -> None: + """Log agent state before action.""" + if self._current_entry is None: + return + + # Create placeholder entry + entry = AgentLogEntry( + agent_id=agent_id, + agent_name=agent_name, + profession=profession, + position=position.copy(), + stats_before=stats.copy(), + stats_after={}, + decision={}, + action_result={}, + inventory_before=inventory.copy(), + inventory_after=[], + money_before=money, + money_after=money, + ) + self._current_entry.agent_entries.append(entry) + + def log_agent_decision(self, agent_id: str, decision: dict) -> None: + """Log agent's AI decision.""" + if self._current_entry is None: + return + + for entry in self._current_entry.agent_entries: + if entry.agent_id == agent_id: + entry.decision = decision.copy() + self.logger.debug( + f" {entry.agent_name}: decided to {decision.get('action', '?')} " + f"- {decision.get('reason', '')}" + ) + break + + def log_agent_after( + self, + agent_id: str, + stats: dict, + inventory: list, + money: int, + position: dict, + action_result: dict, + ) -> None: + """Log agent state after action.""" + if self._current_entry is None: + return + + for entry in self._current_entry.agent_entries: + if entry.agent_id == agent_id: + entry.stats_after = stats.copy() + entry.inventory_after = inventory.copy() + entry.money_after = money + entry.position = position.copy() + entry.action_result = action_result.copy() + break + + def log_market_state(self, orders_before: list, orders_after: list) -> None: + """Log market state.""" + if self._current_entry is None: + return + self._current_entry.market_orders_before = orders_before + self._current_entry.market_orders_after = orders_after + + def log_trade(self, trade: dict) -> None: + """Log a trade transaction.""" + if self._current_entry is None: + return + self._current_entry.trades.append(trade) + self.logger.debug(f" Trade: {trade.get('message', 'Unknown trade')}") + + def log_death(self, agent_name: str, cause: str) -> None: + """Log an agent death.""" + if self._current_entry is None: + return + self._current_entry.deaths.append({"name": agent_name, "cause": cause}) + self.logger.info(f" DEATH: {agent_name} died from {cause}") + + def log_statistics(self, stats: dict) -> None: + """Log end-of-turn statistics.""" + if self._current_entry is None: + return + self._current_entry.statistics = stats.copy() + + def end_turn(self) -> None: + """Finish logging the current turn and write to file.""" + if self._current_entry is None: + return + + self._entries.append(self._current_entry) + + # Write to JSON lines file + if self._json_file: + self._json_file.write( + json.dumps({"type": "turn", "data": self._current_entry.to_dict()}) + "\n" + ) + self._json_file.flush() + + # Write summary + if self._summary_file: + entry = self._current_entry + self._summary_file.write( + f"Turn {entry.turn} | Day {entry.day} Step {entry.step_in_day} ({entry.time_of_day})\n" + ) + + for agent in entry.agent_entries: + action = agent.decision.get("action", "?") + result = "✓" if agent.action_result.get("success", False) else "✗" + self._summary_file.write( + f" [{agent.agent_name}] {action} {result} | " + f"E:{agent.stats_after.get('energy', '?')} " + f"H:{agent.stats_after.get('hunger', '?')} " + f"T:{agent.stats_after.get('thirst', '?')} " + f"${agent.money_after}\n" + ) + + if entry.deaths: + for death in entry.deaths: + self._summary_file.write(f" 💀 {death['name']} died: {death['cause']}\n") + + self._summary_file.write("\n") + self._summary_file.flush() + + self.logger.debug(f"Turn {self._current_entry.turn} completed") + self._current_entry = None + + def close(self) -> None: + """Close log files.""" + if self._json_file: + self._json_file.close() + self._json_file = None + if self._summary_file: + self._summary_file.write(f"\nSession ended: {datetime.now()}\n") + self._summary_file.close() + self._summary_file = None + self.logger.info("Logging session closed") + + def get_entries(self) -> list[TurnLogEntry]: + """Get all logged entries.""" + return self._entries.copy() + + +# Global logger instance +_logger: Optional[SimulationLogger] = None + + +def get_simulation_logger() -> SimulationLogger: + """Get the global simulation logger.""" + global _logger + if _logger is None: + _logger = SimulationLogger() + return _logger + + +def reset_simulation_logger() -> SimulationLogger: + """Reset and create a new simulation logger.""" + global _logger + if _logger: + _logger.close() + _logger = SimulationLogger() + return _logger + diff --git a/backend/core/market.py b/backend/core/market.py new file mode 100644 index 0000000..e640b39 --- /dev/null +++ b/backend/core/market.py @@ -0,0 +1,443 @@ +"""Market system with Order Book for the Village Simulation. + +Implements supply/demand pricing mechanics: +- Sellers can adjust prices based on market conditions +- Scarcity drives prices up, surplus drives prices down +- Historical price tracking for market signals +""" + +from dataclasses import dataclass, field +from enum import Enum +from typing import Optional +from uuid import uuid4 + +from backend.domain.resources import ResourceType + + +class OrderStatus(Enum): + """Status of a market order.""" + ACTIVE = "active" + FILLED = "filled" + CANCELLED = "cancelled" + EXPIRED = "expired" + + +@dataclass +class PriceHistory: + """Track price history for a resource type.""" + last_sale_price: int = 0 + avg_sale_price: float = 0.0 + total_sold: int = 0 + last_sale_turn: int = 0 + demand_score: float = 0.5 # 0-1, higher = more demand + supply_score: float = 0.5 # 0-1, higher = more supply + + +@dataclass +class Order: + """A market order (sell order).""" + id: str = field(default_factory=lambda: str(uuid4())[:8]) + seller_id: str = "" + resource_type: ResourceType = ResourceType.BERRIES + quantity: int = 1 + price_per_unit: int = 1 + created_turn: int = 0 + status: OrderStatus = OrderStatus.ACTIVE + + # Price adjustment tracking + turns_without_sale: int = 0 + original_price: int = 0 + last_adjusted_turn: int = 0 # Track when price was last changed + + def __post_init__(self): + if self.original_price == 0: + self.original_price = self.price_per_unit + if self.last_adjusted_turn == 0: + self.last_adjusted_turn = self.created_turn + + @property + def total_price(self) -> int: + """Get total price for all units.""" + return self.quantity * self.price_per_unit + + def apply_discount(self, percentage: float = 0.1) -> None: + """Apply a discount to the price.""" + reduction = max(1, int(self.price_per_unit * percentage)) + self.price_per_unit = max(1, self.price_per_unit - reduction) + + def adjust_price(self, new_price: int, current_turn: int) -> bool: + """Adjust the order's price. Returns True if successful.""" + if new_price < 1: + return False + self.price_per_unit = new_price + self.last_adjusted_turn = current_turn + return True + + def can_raise_price(self, current_turn: int, min_turns: int = 2) -> bool: + """Check if enough time has passed to raise the price again.""" + return current_turn - self.last_adjusted_turn >= min_turns + + def to_dict(self) -> dict: + """Convert to dictionary for API serialization.""" + return { + "id": self.id, + "seller_id": self.seller_id, + "resource_type": self.resource_type.value, + "quantity": self.quantity, + "price_per_unit": self.price_per_unit, + "total_price": self.total_price, + "created_turn": self.created_turn, + "status": self.status.value, + "turns_without_sale": self.turns_without_sale, + "original_price": self.original_price, + } + + +@dataclass +class TradeResult: + """Result of a trade transaction.""" + success: bool + order_id: str = "" + buyer_id: str = "" + seller_id: str = "" + resource_type: Optional[ResourceType] = None + quantity: int = 0 + total_paid: int = 0 + message: str = "" + + def to_dict(self) -> dict: + return { + "success": self.success, + "order_id": self.order_id, + "buyer_id": self.buyer_id, + "seller_id": self.seller_id, + "resource_type": self.resource_type.value if self.resource_type else None, + "quantity": self.quantity, + "total_paid": self.total_paid, + "message": self.message, + } + + +@dataclass +class OrderBook: + """Central market order book with supply/demand tracking. + + Features: + - Track price history per resource type + - Calculate supply/demand scores + - Suggest prices based on market conditions + - Allow sellers to adjust prices dynamically + """ + orders: list[Order] = field(default_factory=list) + trade_history: list[TradeResult] = field(default_factory=list) + price_history: dict[ResourceType, PriceHistory] = field(default_factory=dict) + + # Configuration + TURNS_BEFORE_DISCOUNT: int = 3 + DISCOUNT_RATE: float = 0.15 # 15% discount after waiting + + # Supply/demand thresholds + LOW_SUPPLY_THRESHOLD: int = 3 # Less than this = scarcity + HIGH_SUPPLY_THRESHOLD: int = 10 # More than this = surplus + DEMAND_DECAY: float = 0.95 # How fast demand score decays per turn + + def __post_init__(self): + """Initialize price history for all resource types.""" + if not self.price_history: + for resource_type in ResourceType: + self.price_history[resource_type] = PriceHistory() + + def place_order( + self, + seller_id: str, + resource_type: ResourceType, + quantity: int, + price_per_unit: int, + current_turn: int, + ) -> Order: + """Place a new sell order.""" + order = Order( + seller_id=seller_id, + resource_type=resource_type, + quantity=quantity, + price_per_unit=price_per_unit, + created_turn=current_turn, + ) + self.orders.append(order) + return order + + def cancel_order(self, order_id: str, seller_id: str) -> bool: + """Cancel an order. Returns True if successful.""" + for order in self.orders: + if order.id == order_id and order.seller_id == seller_id: + if order.status == OrderStatus.ACTIVE: + order.status = OrderStatus.CANCELLED + return True + return False + + def get_active_orders(self) -> list[Order]: + """Get all active orders.""" + return [o for o in self.orders if o.status == OrderStatus.ACTIVE] + + def get_orders_by_type(self, resource_type: ResourceType) -> list[Order]: + """Get all active orders for a specific resource type, sorted by price.""" + orders = [ + o for o in self.orders + if o.status == OrderStatus.ACTIVE and o.resource_type == resource_type + ] + return sorted(orders, key=lambda o: o.price_per_unit) + + def get_cheapest_order(self, resource_type: ResourceType) -> Optional[Order]: + """Get the cheapest active order for a resource type.""" + orders = self.get_orders_by_type(resource_type) + return orders[0] if orders else None + + def get_orders_by_seller(self, seller_id: str) -> list[Order]: + """Get all active orders from a specific seller.""" + return [ + o for o in self.orders + if o.status == OrderStatus.ACTIVE and o.seller_id == seller_id + ] + + def cancel_seller_orders(self, seller_id: str) -> list[Order]: + """Cancel all orders from a seller (e.g., when they die). Returns cancelled orders.""" + cancelled = [] + for order in self.orders: + if order.seller_id == seller_id and order.status == OrderStatus.ACTIVE: + order.status = OrderStatus.CANCELLED + cancelled.append(order) + return cancelled + + def execute_buy( + self, + buyer_id: str, + order_id: str, + quantity: int, + buyer_money: int, + ) -> TradeResult: + """Execute a buy order. Returns trade result.""" + # Find the order + order = None + for o in self.orders: + if o.id == order_id and o.status == OrderStatus.ACTIVE: + order = o + break + + if order is None: + return TradeResult( + success=False, + message="Order not found or no longer active", + ) + + # Check quantity + actual_quantity = min(quantity, order.quantity) + if actual_quantity <= 0: + return TradeResult( + success=False, + message="Invalid quantity", + ) + + # Check buyer has enough money + total_cost = actual_quantity * order.price_per_unit + if buyer_money < total_cost: + # Try to buy what they can afford + actual_quantity = buyer_money // order.price_per_unit + if actual_quantity <= 0: + return TradeResult( + success=False, + buyer_id=buyer_id, + message="Insufficient funds", + ) + total_cost = actual_quantity * order.price_per_unit + + # Execute the trade + order.quantity -= actual_quantity + if order.quantity <= 0: + order.status = OrderStatus.FILLED + + result = TradeResult( + success=True, + order_id=order.id, + buyer_id=buyer_id, + seller_id=order.seller_id, + resource_type=order.resource_type, + quantity=actual_quantity, + total_paid=total_cost, + message=f"Bought {actual_quantity} {order.resource_type.value} for {total_cost} coins", + ) + + # Record sale for price history (we need current_turn but don't have it here) + # The turn will be passed via the _record_sale call from engine + self.trade_history.append(result) + return result + + def execute_multi_buy( + self, + buyer_id: str, + purchases: list[tuple[str, int]], # List of (order_id, quantity) + buyer_money: int, + ) -> list[TradeResult]: + """Execute multiple buy orders in one action. Returns list of trade results.""" + results = [] + remaining_money = buyer_money + + for order_id, quantity in purchases: + result = self.execute_buy(buyer_id, order_id, quantity, remaining_money) + results.append(result) + if result.success: + remaining_money -= result.total_paid + + return results + + def update_prices(self, current_turn: int) -> None: + """Update order prices and supply/demand scores.""" + # Update supply/demand scores + self._update_supply_demand_scores(current_turn) + + # Apply automatic discounts to stale orders (keeping original behavior) + for order in self.orders: + if order.status != OrderStatus.ACTIVE: + continue + + turns_waiting = current_turn - order.created_turn + if turns_waiting > 0 and turns_waiting % self.TURNS_BEFORE_DISCOUNT == 0: + order.turns_without_sale = turns_waiting + order.apply_discount(self.DISCOUNT_RATE) + + def _update_supply_demand_scores(self, current_turn: int) -> None: + """Calculate current supply and demand scores for each resource.""" + for resource_type in ResourceType: + history = self.price_history.get(resource_type) + if not history: + history = PriceHistory() + self.price_history[resource_type] = history + + # Calculate supply score based on available quantity + total_supply = self.get_total_supply(resource_type) + if total_supply <= self.LOW_SUPPLY_THRESHOLD: + history.supply_score = max(0.1, total_supply / self.LOW_SUPPLY_THRESHOLD * 0.5) + elif total_supply >= self.HIGH_SUPPLY_THRESHOLD: + history.supply_score = min(1.0, 0.5 + (total_supply / self.HIGH_SUPPLY_THRESHOLD) * 0.5) + else: + history.supply_score = 0.5 + + # Decay demand score over time + history.demand_score *= self.DEMAND_DECAY + + def get_total_supply(self, resource_type: ResourceType) -> int: + """Get total quantity available for a resource type.""" + return sum(o.quantity for o in self.get_orders_by_type(resource_type)) + + def get_supply_demand_ratio(self, resource_type: ResourceType) -> float: + """Get supply/demand ratio. <1 means scarcity, >1 means surplus.""" + history = self.price_history.get(resource_type, PriceHistory()) + demand = max(0.1, history.demand_score) + supply = max(0.1, history.supply_score) + return supply / demand + + def get_suggested_price(self, resource_type: ResourceType, base_price: int) -> int: + """Suggest a price based on supply/demand conditions. + + Returns an adjusted price that accounts for market conditions: + - Scarcity (low supply, high demand) -> higher price + - Surplus (high supply, low demand) -> lower price + """ + ratio = self.get_supply_demand_ratio(resource_type) + history = self.price_history.get(resource_type, PriceHistory()) + + # Use average sale price as reference if available + reference_price = base_price + if history.avg_sale_price > 0: + reference_price = int((base_price + history.avg_sale_price) / 2) + + # Adjust based on supply/demand + if ratio < 0.7: # Scarcity - raise price + price_multiplier = 1.0 + (0.7 - ratio) * 0.5 # Up to 35% increase + elif ratio > 1.3: # Surplus - lower price + price_multiplier = 1.0 - (ratio - 1.3) * 0.3 # Up to 30% decrease + price_multiplier = max(0.5, price_multiplier) # Floor at 50% + else: + price_multiplier = 1.0 + + suggested = int(reference_price * price_multiplier) + return max(1, suggested) + + def adjust_order_price(self, order_id: str, seller_id: str, new_price: int, current_turn: int) -> bool: + """Adjust the price of an existing order. Returns True if successful.""" + for order in self.orders: + if order.id == order_id and order.seller_id == seller_id: + if order.status == OrderStatus.ACTIVE: + return order.adjust_price(new_price, current_turn) + return False + + def _record_sale(self, resource_type: ResourceType, price: int, quantity: int, current_turn: int) -> None: + """Record a sale for price history tracking.""" + history = self.price_history.get(resource_type) + if not history: + history = PriceHistory() + self.price_history[resource_type] = history + + history.last_sale_price = price + history.last_sale_turn = current_turn + + # Update average sale price + old_total = history.avg_sale_price * history.total_sold + history.total_sold += quantity + history.avg_sale_price = (old_total + price * quantity) / history.total_sold + + # Increase demand score when sales happen + history.demand_score = min(1.0, history.demand_score + 0.1 * quantity) + + def cleanup_old_orders(self, max_age: int = 50) -> list[Order]: + """Remove very old orders. Returns removed orders.""" + # For now, we don't auto-expire orders, but this could be enabled + return [] + + def get_market_prices(self) -> dict[str, dict]: + """Get current market price summary for each resource.""" + prices = {} + for resource_type in ResourceType: + orders = self.get_orders_by_type(resource_type) + history = self.price_history.get(resource_type, PriceHistory()) + + if orders: + prices[resource_type.value] = { + "lowest_price": orders[0].price_per_unit, + "highest_price": orders[-1].price_per_unit, + "total_available": sum(o.quantity for o in orders), + "num_orders": len(orders), + "avg_sale_price": round(history.avg_sale_price, 1) if history.avg_sale_price else None, + "supply_score": round(history.supply_score, 2), + "demand_score": round(history.demand_score, 2), + } + else: + prices[resource_type.value] = { + "lowest_price": None, + "highest_price": None, + "total_available": 0, + "num_orders": 0, + "avg_sale_price": round(history.avg_sale_price, 1) if history.avg_sale_price else None, + "supply_score": round(history.supply_score, 2), + "demand_score": round(history.demand_score, 2), + } + return prices + + def get_market_signal(self, resource_type: ResourceType) -> str: + """Get a simple market signal for a resource: 'sell', 'hold', or 'buy'.""" + ratio = self.get_supply_demand_ratio(resource_type) + if ratio < 0.7: + return "sell" # Good time to sell - scarcity + elif ratio > 1.3: + return "buy" # Good time to buy - surplus + return "hold" + + def get_state_snapshot(self) -> dict: + """Get market state for API.""" + return { + "orders": [o.to_dict() for o in self.get_active_orders()], + "prices": self.get_market_prices(), + "recent_trades": [ + t.to_dict() for t in self.trade_history[-10:] + ], + } + diff --git a/backend/core/world.py b/backend/core/world.py new file mode 100644 index 0000000..a421f4f --- /dev/null +++ b/backend/core/world.py @@ -0,0 +1,146 @@ +"""World container for the Village Simulation.""" + +import random +from dataclasses import dataclass, field +from enum import Enum +from typing import Optional + +from backend.domain.agent import Agent, Position, Profession + + +class TimeOfDay(Enum): + """Current time of day in the simulation.""" + DAY = "day" + NIGHT = "night" + + +@dataclass +class WorldConfig: + """Configuration for the world.""" + width: int = 20 + height: int = 20 + initial_agents: int = 8 + day_steps: int = 10 + night_steps: int = 1 + + +@dataclass +class World: + """Container for all entities in the simulation.""" + config: WorldConfig = field(default_factory=WorldConfig) + agents: list[Agent] = field(default_factory=list) + current_turn: int = 0 + current_day: int = 1 + step_in_day: int = 0 + time_of_day: TimeOfDay = TimeOfDay.DAY + + # Statistics + total_agents_spawned: int = 0 + total_agents_died: int = 0 + + def spawn_agent( + self, + name: Optional[str] = None, + profession: Optional[Profession] = None, + position: Optional[Position] = None, + ) -> Agent: + """Spawn a new agent in the world.""" + # All agents are now generic villagers - profession is not used for decisions + if profession is None: + profession = Profession.VILLAGER + + if position is None: + position = Position( + x=random.randint(0, self.config.width - 1), + y=random.randint(0, self.config.height - 1), + ) + + agent = Agent( + name=name or f"Villager_{self.total_agents_spawned + 1}", + profession=profession, + position=position, + ) + + self.agents.append(agent) + self.total_agents_spawned += 1 + return agent + + def get_agent(self, agent_id: str) -> Optional[Agent]: + """Get an agent by ID.""" + for agent in self.agents: + if agent.id == agent_id: + return agent + return None + + def remove_dead_agents(self) -> list[Agent]: + """Remove all dead agents from the world. Returns list of removed agents. + Note: This is now handled by the engine's corpse system for visualization. + """ + dead_agents = [a for a in self.agents if not a.is_alive() and not a.is_corpse()] + # Don't actually remove here - let the engine handle corpse visualization + return dead_agents + + def advance_time(self) -> None: + """Advance the simulation time by one step.""" + self.current_turn += 1 + self.step_in_day += 1 + + total_steps = self.config.day_steps + self.config.night_steps + + if self.step_in_day > total_steps: + self.step_in_day = 1 + self.current_day += 1 + + # Determine time of day + if self.step_in_day <= self.config.day_steps: + self.time_of_day = TimeOfDay.DAY + else: + self.time_of_day = TimeOfDay.NIGHT + + def is_night(self) -> bool: + """Check if it's currently night.""" + return self.time_of_day == TimeOfDay.NIGHT + + def get_living_agents(self) -> list[Agent]: + """Get all living agents (excludes corpses).""" + return [a for a in self.agents if a.is_alive() and not a.is_corpse()] + + def get_statistics(self) -> dict: + """Get current world statistics.""" + living = self.get_living_agents() + total_money = sum(a.money for a in living) + + profession_counts = {} + for agent in living: + prof = agent.profession.value + profession_counts[prof] = profession_counts.get(prof, 0) + 1 + + return { + "current_turn": self.current_turn, + "current_day": self.current_day, + "step_in_day": self.step_in_day, + "time_of_day": self.time_of_day.value, + "living_agents": len(living), + "total_agents_spawned": self.total_agents_spawned, + "total_agents_died": self.total_agents_died, + "total_money_in_circulation": total_money, + "professions": profession_counts, + } + + def get_state_snapshot(self) -> dict: + """Get a full snapshot of the world state for API.""" + return { + "turn": self.current_turn, + "day": self.current_day, + "step_in_day": self.step_in_day, + "time_of_day": self.time_of_day.value, + "world_size": {"width": self.config.width, "height": self.config.height}, + "agents": [a.to_dict() for a in self.agents], + "statistics": self.get_statistics(), + } + + def initialize(self) -> None: + """Initialize the world with starting agents.""" + for _ in range(self.config.initial_agents): + self.spawn_agent() + diff --git a/backend/domain/__init__.py b/backend/domain/__init__.py new file mode 100644 index 0000000..a72ab3d --- /dev/null +++ b/backend/domain/__init__.py @@ -0,0 +1,19 @@ +"""Domain models for the Village Simulation.""" + +from .resources import ResourceType, Resource, RESOURCE_EFFECTS +from .action import ActionType, ActionConfig, ActionResult, ACTION_CONFIG +from .agent import Agent, AgentStats, Position + +__all__ = [ + "ResourceType", + "Resource", + "RESOURCE_EFFECTS", + "ActionType", + "ActionConfig", + "ActionResult", + "ACTION_CONFIG", + "Agent", + "AgentStats", + "Position", +] + diff --git a/backend/domain/action.py b/backend/domain/action.py new file mode 100644 index 0000000..2b46ecb --- /dev/null +++ b/backend/domain/action.py @@ -0,0 +1,191 @@ +"""Action definitions for the Village Simulation. + +Action configurations are loaded dynamically from the global config. +""" + +from dataclasses import dataclass, field +from enum import Enum +from typing import Optional, TYPE_CHECKING + +from .resources import ResourceType + +if TYPE_CHECKING: + from backend.config import SimulationConfig + + +class ActionType(Enum): + """Types of actions an agent can perform.""" + SLEEP = "sleep" # Night action - restores energy + REST = "rest" # Day action - restores some energy + HUNT = "hunt" # Produces meat and hide + GATHER = "gather" # Produces berries + CHOP_WOOD = "chop_wood" # Produces wood + GET_WATER = "get_water" # Produces water + WEAVE = "weave" # Produces clothes from hide + BUILD_FIRE = "build_fire" # Consumes wood, provides heat + TRADE = "trade" # Market interaction + CONSUME = "consume" # Consume resource from inventory + + +@dataclass +class ActionConfig: + """Configuration for an action type.""" + energy_cost: int # Negative = cost, positive = restoration + success_chance: float = 1.0 # 0.0 to 1.0 + min_output: int = 0 + max_output: int = 0 + output_resource: Optional[ResourceType] = None + secondary_output: Optional[ResourceType] = None + secondary_min: int = 0 + secondary_max: int = 0 + requires_resource: Optional[ResourceType] = None + requires_quantity: int = 0 + + +def get_action_config() -> dict[ActionType, ActionConfig]: + """Get action configurations from the global config. + + This function dynamically builds ACTION_CONFIG from config.json values. + """ + # Import here to avoid circular imports + from backend.config import get_config + + config = get_config() + actions = config.actions + + return { + ActionType.SLEEP: ActionConfig( + energy_cost=actions.sleep_energy, # Restores energy + ), + ActionType.REST: ActionConfig( + energy_cost=actions.rest_energy, # Restores some energy + ), + ActionType.HUNT: ActionConfig( + energy_cost=actions.hunt_energy, + success_chance=actions.hunt_success, + min_output=actions.hunt_meat_min, + max_output=actions.hunt_meat_max, + output_resource=ResourceType.MEAT, + secondary_output=ResourceType.HIDE, + secondary_min=actions.hunt_hide_min, + secondary_max=actions.hunt_hide_max, + ), + ActionType.GATHER: ActionConfig( + energy_cost=actions.gather_energy, + success_chance=1.0, + min_output=actions.gather_min, + max_output=actions.gather_max, + output_resource=ResourceType.BERRIES, + ), + ActionType.CHOP_WOOD: ActionConfig( + energy_cost=actions.chop_wood_energy, + success_chance=actions.chop_wood_success, + min_output=actions.chop_wood_min, + max_output=actions.chop_wood_max, + output_resource=ResourceType.WOOD, + ), + ActionType.GET_WATER: ActionConfig( + energy_cost=actions.get_water_energy, + success_chance=1.0, + min_output=1, + max_output=1, + output_resource=ResourceType.WATER, + ), + ActionType.WEAVE: ActionConfig( + energy_cost=actions.weave_energy, + success_chance=1.0, + min_output=1, + max_output=1, + output_resource=ResourceType.CLOTHES, + requires_resource=ResourceType.HIDE, + requires_quantity=1, + ), + ActionType.BUILD_FIRE: ActionConfig( + energy_cost=actions.build_fire_energy, + success_chance=1.0, + requires_resource=ResourceType.WOOD, + requires_quantity=1, + ), + ActionType.TRADE: ActionConfig( + energy_cost=actions.trade_energy, + ), + ActionType.CONSUME: ActionConfig( + energy_cost=0, + ), + } + + +# Lazy-loaded action config cache +_action_config_cache: Optional[dict[ActionType, ActionConfig]] = None + + +def get_cached_action_config() -> dict[ActionType, ActionConfig]: + """Get cached action config (rebuilds if config changes).""" + global _action_config_cache + if _action_config_cache is None: + _action_config_cache = get_action_config() + return _action_config_cache + + +def reset_action_config_cache() -> None: + """Reset the action config cache (call when config changes).""" + global _action_config_cache + _action_config_cache = None + + +# For backwards compatibility - this is a property-like access +# that returns fresh config each time (use get_cached_action_config for performance) +class _ActionConfigAccessor: + """Accessor class that provides dict-like access to action configs.""" + + def __getitem__(self, action_type: ActionType) -> ActionConfig: + return get_cached_action_config()[action_type] + + def get(self, action_type: ActionType, default=None) -> Optional[ActionConfig]: + return get_cached_action_config().get(action_type, default) + + def __contains__(self, action_type: ActionType) -> bool: + return action_type in get_cached_action_config() + + def items(self): + return get_cached_action_config().items() + + def keys(self): + return get_cached_action_config().keys() + + def values(self): + return get_cached_action_config().values() + + +# This provides the same interface as before but loads from config +ACTION_CONFIG = _ActionConfigAccessor() + + +@dataclass +class ActionResult: + """Result of executing an action.""" + action_type: ActionType + success: bool + energy_spent: int = 0 + resources_gained: list = field(default_factory=list) + resources_consumed: list = field(default_factory=list) + heat_gained: int = 0 + message: str = "" + + def to_dict(self) -> dict: + """Convert to dictionary for API serialization.""" + return { + "action_type": self.action_type.value, + "success": self.success, + "energy_spent": self.energy_spent, + "resources_gained": [ + {"type": r.type.value, "quantity": r.quantity} + for r in self.resources_gained + ], + "resources_consumed": [ + {"type": r.type.value, "quantity": r.quantity} + for r in self.resources_consumed + ], + "heat_gained": self.heat_gained, + "message": self.message, + } diff --git a/backend/domain/agent.py b/backend/domain/agent.py new file mode 100644 index 0000000..1c02b3f --- /dev/null +++ b/backend/domain/agent.py @@ -0,0 +1,459 @@ +"""Agent model for the Village Simulation. + +Agent stats are loaded dynamically from the global config. +""" + +import math +import random +from dataclasses import dataclass, field +from enum import Enum +from typing import Optional +from uuid import uuid4 + +from .resources import Resource, ResourceType, RESOURCE_EFFECTS + + +def _get_agent_stats_config(): + """Get agent stats configuration from global config.""" + from backend.config import get_config + return get_config().agent_stats + + +class Profession(Enum): + """Agent professions - kept for backwards compatibility but no longer used.""" + VILLAGER = "villager" + HUNTER = "hunter" + GATHERER = "gatherer" + WOODCUTTER = "woodcutter" + CRAFTER = "crafter" + + +@dataclass +class Position: + """2D position on the map (floating point for smooth movement).""" + x: float = 0.0 + y: float = 0.0 + + def distance_to(self, other: "Position") -> float: + """Calculate distance to another position.""" + return math.sqrt((self.x - other.x) ** 2 + (self.y - other.y) ** 2) + + def move_towards(self, target: "Position", speed: float = 0.5) -> bool: + """Move towards target position. Returns True if reached.""" + dist = self.distance_to(target) + if dist <= speed: + self.x = target.x + self.y = target.y + return True + + # Calculate direction + dx = target.x - self.x + dy = target.y - self.y + + # Normalize and apply speed + self.x += (dx / dist) * speed + self.y += (dy / dist) * speed + return False + + def to_dict(self) -> dict: + return {"x": round(self.x, 2), "y": round(self.y, 2)} + + def copy(self) -> "Position": + return Position(self.x, self.y) + + +@dataclass +class AgentStats: + """Vital statistics for an agent. + + Values are loaded from config.json. Default values are used as fallback. + """ + # Current values - defaults will be overwritten by factory function + energy: int = field(default=50) + hunger: int = field(default=80) + thirst: int = field(default=70) + heat: int = field(default=100) + + # Maximum values - loaded from config + MAX_ENERGY: int = field(default=50) + MAX_HUNGER: int = field(default=100) + MAX_THIRST: int = field(default=100) + MAX_HEAT: int = field(default=100) + + # Passive decay rates per turn - loaded from config + ENERGY_DECAY: int = field(default=1) + HUNGER_DECAY: int = field(default=2) + THIRST_DECAY: int = field(default=3) + HEAT_DECAY: int = field(default=2) + + # Critical threshold - loaded from config + CRITICAL_THRESHOLD: float = field(default=0.25) + + def apply_passive_decay(self, has_clothes: bool = False) -> None: + """Apply passive stat decay each turn.""" + self.energy = max(0, self.energy - self.ENERGY_DECAY) + self.hunger = max(0, self.hunger - self.HUNGER_DECAY) + self.thirst = max(0, self.thirst - self.THIRST_DECAY) + + # Clothes reduce heat loss by 50% + heat_decay = self.HEAT_DECAY // 2 if has_clothes else self.HEAT_DECAY + self.heat = max(0, self.heat - heat_decay) + + def is_critical(self) -> bool: + """Check if any vital stat is below critical threshold.""" + threshold_hunger = int(self.MAX_HUNGER * self.CRITICAL_THRESHOLD) + threshold_thirst = int(self.MAX_THIRST * self.CRITICAL_THRESHOLD) + threshold_heat = int(self.MAX_HEAT * self.CRITICAL_THRESHOLD) + return ( + self.hunger < threshold_hunger or + self.thirst < threshold_thirst or + self.heat < threshold_heat + ) + + def get_critical_stat(self) -> Optional[str]: + """Get the name of the most critical stat, if any.""" + threshold_thirst = int(self.MAX_THIRST * self.CRITICAL_THRESHOLD) + threshold_hunger = int(self.MAX_HUNGER * self.CRITICAL_THRESHOLD) + threshold_heat = int(self.MAX_HEAT * self.CRITICAL_THRESHOLD) + + if self.thirst < threshold_thirst: + return "thirst" + if self.hunger < threshold_hunger: + return "hunger" + if self.heat < threshold_heat: + return "heat" + return None + + def can_work(self, energy_required: int) -> bool: + """Check if agent has enough energy to perform an action.""" + return self.energy >= abs(energy_required) + + def to_dict(self) -> dict: + return { + "energy": self.energy, + "hunger": self.hunger, + "thirst": self.thirst, + "heat": self.heat, + "max_energy": self.MAX_ENERGY, + "max_hunger": self.MAX_HUNGER, + "max_thirst": self.MAX_THIRST, + "max_heat": self.MAX_HEAT, + } + + +def create_agent_stats() -> AgentStats: + """Factory function to create AgentStats from config.""" + config = _get_agent_stats_config() + return AgentStats( + energy=config.start_energy, + hunger=config.start_hunger, + thirst=config.start_thirst, + heat=config.start_heat, + MAX_ENERGY=config.max_energy, + MAX_HUNGER=config.max_hunger, + MAX_THIRST=config.max_thirst, + MAX_HEAT=config.max_heat, + ENERGY_DECAY=config.energy_decay, + HUNGER_DECAY=config.hunger_decay, + THIRST_DECAY=config.thirst_decay, + HEAT_DECAY=config.heat_decay, + CRITICAL_THRESHOLD=config.critical_threshold, + ) + + +@dataclass +class AgentAction: + """Current action being performed by an agent.""" + action_type: str = "" # e.g., "hunt", "gather", "trade", "rest" + target_position: Optional[Position] = None + target_resource: Optional[str] = None + progress: float = 0.0 # 0.0 to 1.0 + is_moving: bool = False + message: str = "" + + def to_dict(self) -> dict: + return { + "action_type": self.action_type, + "target_position": self.target_position.to_dict() if self.target_position else None, + "target_resource": self.target_resource, + "progress": round(self.progress, 2), + "is_moving": self.is_moving, + "message": self.message, + } + + +# Action location mappings (relative positions on the map for each action type) +ACTION_LOCATIONS = { + "hunt": {"zone": "forest", "offset_range": (0.6, 0.9)}, # Right side (forest) + "gather": {"zone": "bushes", "offset_range": (0.1, 0.4)}, # Left side (bushes) + "chop_wood": {"zone": "forest", "offset_range": (0.7, 0.95)}, # Far right (forest) + "get_water": {"zone": "river", "offset_range": (0.0, 0.15)}, # Far left (river) + "weave": {"zone": "village", "offset_range": (0.4, 0.6)}, # Center (village) + "build_fire": {"zone": "village", "offset_range": (0.45, 0.55)}, + "trade": {"zone": "market", "offset_range": (0.5, 0.6)}, # Center (market) + "rest": {"zone": "home", "offset_range": (0.4, 0.6)}, + "sleep": {"zone": "home", "offset_range": (0.4, 0.6)}, + "consume": {"zone": "current", "offset_range": (0, 0)}, # Stay in place +} + + +def _get_world_config(): + """Get world configuration from global config.""" + from backend.config import get_config + return get_config().world + + +@dataclass +class Agent: + """An agent in the village simulation. + + Stats, inventory slots, and starting money are loaded from config.json. + """ + id: str = field(default_factory=lambda: str(uuid4())[:8]) + name: str = "" + profession: Profession = Profession.VILLAGER # No longer used for decision making + position: Position = field(default_factory=Position) + stats: AgentStats = field(default_factory=create_agent_stats) + inventory: list[Resource] = field(default_factory=list) + money: int = field(default=-1) # -1 signals to use config value + + # Movement and action tracking + home_position: Position = field(default_factory=Position) + current_action: AgentAction = field(default_factory=AgentAction) + last_action_result: str = "" + + # Death tracking for corpse visualization + death_turn: int = -1 # Turn when agent died, -1 if alive + death_reason: str = "" # Cause of death + + # Configuration - loaded from config + INVENTORY_SLOTS: int = field(default=-1) # -1 signals to use config value + MOVE_SPEED: float = 0.8 # Grid cells per turn + + def __post_init__(self): + if not self.name: + self.name = f"Agent_{self.id}" + # Set home position to initial position + self.home_position = self.position.copy() + + # Load config values if defaults were used + config = _get_world_config() + if self.money == -1: + self.money = config.starting_money + if self.INVENTORY_SLOTS == -1: + self.INVENTORY_SLOTS = config.inventory_slots + + def is_alive(self) -> bool: + """Check if the agent is still alive.""" + return ( + self.stats.hunger > 0 and + self.stats.thirst > 0 and + self.stats.heat > 0 + ) + + def is_corpse(self) -> bool: + """Check if this agent is a corpse (died but still visible).""" + return self.death_turn >= 0 + + def can_act(self) -> bool: + """Check if agent can perform active actions.""" + return self.is_alive() and self.stats.energy > 0 + + def has_clothes(self) -> bool: + """Check if agent has clothes equipped.""" + return any(r.type == ResourceType.CLOTHES for r in self.inventory) + + def inventory_space(self) -> int: + """Get remaining inventory slots.""" + total_items = sum(r.quantity for r in self.inventory) + return max(0, self.INVENTORY_SLOTS - total_items) + + def inventory_full(self) -> bool: + """Check if inventory is full.""" + return self.inventory_space() <= 0 + + def set_action( + self, + action_type: str, + world_width: int, + world_height: int, + message: str = "", + target_resource: Optional[str] = None, + ) -> None: + """Set the current action and calculate target position.""" + location = ACTION_LOCATIONS.get(action_type, {"zone": "current", "offset_range": (0, 0)}) + + if location["zone"] == "current": + # Stay in place + target = self.position.copy() + is_moving = False + else: + # Calculate target position based on action zone + offset_range = location["offset_range"] + offset_min = float(offset_range[0]) + offset_max = float(offset_range[1]) + target_x = world_width * random.uniform(offset_min, offset_max) + + # Keep y position somewhat consistent but allow some variation + target_y = self.home_position.y + random.uniform(-2, 2) + target_y = max(0.5, min(world_height - 0.5, target_y)) + + target = Position(target_x, target_y) + is_moving = self.position.distance_to(target) > 0.5 + + self.current_action = AgentAction( + action_type=action_type, + target_position=target, + target_resource=target_resource, + progress=0.0, + is_moving=is_moving, + message=message, + ) + + def update_movement(self) -> None: + """Update agent position moving towards target.""" + if self.current_action.target_position and self.current_action.is_moving: + reached = self.position.move_towards( + self.current_action.target_position, + self.MOVE_SPEED + ) + if reached: + self.current_action.is_moving = False + self.current_action.progress = 0.5 # At location, doing action + + def complete_action(self, success: bool, message: str) -> None: + """Mark current action as complete.""" + self.current_action.progress = 1.0 + self.current_action.is_moving = False + self.last_action_result = message + self.current_action.message = message if success else f"Failed: {message}" + + def add_to_inventory(self, resource: Resource) -> int: + """Add resource to inventory, returns quantity actually added.""" + space = self.inventory_space() + if space <= 0: + return 0 + + quantity_to_add = min(resource.quantity, space) + + # Try to stack with existing resource of same type + for existing in self.inventory: + if existing.type == resource.type: + existing.quantity += quantity_to_add + return quantity_to_add + + # Add as new stack + new_resource = Resource( + type=resource.type, + quantity=quantity_to_add, + created_turn=resource.created_turn, + ) + self.inventory.append(new_resource) + return quantity_to_add + + def remove_from_inventory(self, resource_type: ResourceType, quantity: int = 1) -> int: + """Remove resource from inventory, returns quantity actually removed.""" + removed = 0 + for resource in self.inventory[:]: + if resource.type == resource_type: + take = min(resource.quantity, quantity - removed) + resource.quantity -= take + removed += take + + if resource.quantity <= 0: + self.inventory.remove(resource) + + if removed >= quantity: + break + + return removed + + def get_resource_count(self, resource_type: ResourceType) -> int: + """Get total count of a resource type in inventory.""" + return sum( + r.quantity for r in self.inventory + if r.type == resource_type + ) + + def has_resource(self, resource_type: ResourceType, quantity: int = 1) -> bool: + """Check if agent has at least the specified quantity of a resource.""" + return self.get_resource_count(resource_type) >= quantity + + def consume(self, resource_type: ResourceType) -> bool: + """Consume a resource from inventory and apply its effects.""" + if not self.has_resource(resource_type, 1): + return False + + effect = RESOURCE_EFFECTS[resource_type] + self.stats.hunger = min( + self.stats.MAX_HUNGER, + self.stats.hunger + effect.hunger + ) + self.stats.thirst = min( + self.stats.MAX_THIRST, + self.stats.thirst + effect.thirst + ) + self.stats.heat = min( + self.stats.MAX_HEAT, + self.stats.heat + effect.heat + ) + self.stats.energy = min( + self.stats.MAX_ENERGY, + self.stats.energy + effect.energy + ) + + self.remove_from_inventory(resource_type, 1) + return True + + def apply_heat(self, amount: int) -> None: + """Apply heat from a fire.""" + self.stats.heat = min(self.stats.MAX_HEAT, self.stats.heat + amount) + + def restore_energy(self, amount: int) -> None: + """Restore energy (from sleep/rest).""" + self.stats.energy = min(self.stats.MAX_ENERGY, self.stats.energy + amount) + + def spend_energy(self, amount: int) -> bool: + """Spend energy on an action. Returns False if not enough energy.""" + if self.stats.energy < amount: + return False + self.stats.energy -= amount + return True + + def decay_inventory(self, current_turn: int) -> list[Resource]: + """Remove expired resources from inventory. Returns list of removed resources.""" + expired = [] + for resource in self.inventory[:]: + if resource.is_expired(current_turn): + expired.append(resource) + self.inventory.remove(resource) + return expired + + def apply_passive_decay(self) -> None: + """Apply passive stat decay for this turn.""" + self.stats.apply_passive_decay(has_clothes=self.has_clothes()) + + def mark_dead(self, turn: int, reason: str) -> None: + """Mark this agent as dead.""" + self.death_turn = turn + self.death_reason = reason + + def to_dict(self) -> dict: + """Convert to dictionary for API serialization.""" + return { + "id": self.id, + "name": self.name, + "profession": self.profession.value, + "position": self.position.to_dict(), + "home_position": self.home_position.to_dict(), + "stats": self.stats.to_dict(), + "inventory": [r.to_dict() for r in self.inventory], + "money": self.money, + "is_alive": self.is_alive(), + "is_corpse": self.is_corpse(), + "can_act": self.can_act(), + "current_action": self.current_action.to_dict(), + "last_action_result": self.last_action_result, + "death_turn": self.death_turn, + "death_reason": self.death_reason, + } diff --git a/backend/domain/resources.py b/backend/domain/resources.py new file mode 100644 index 0000000..03af224 --- /dev/null +++ b/backend/domain/resources.py @@ -0,0 +1,180 @@ +"""Resource definitions for the Village Simulation. + +Resource effects and decay rates are loaded dynamically from the global config. +""" + +from dataclasses import dataclass +from enum import Enum +from typing import Optional, TYPE_CHECKING + +if TYPE_CHECKING: + from backend.config import SimulationConfig + + +class ResourceType(Enum): + """Types of resources available in the simulation.""" + MEAT = "meat" + BERRIES = "berries" + WATER = "water" + WOOD = "wood" + HIDE = "hide" + CLOTHES = "clothes" + + +@dataclass +class ResourceEffect: + """Effects applied when consuming a resource.""" + hunger: int = 0 + thirst: int = 0 + heat: int = 0 + energy: int = 0 + + +def get_resource_effects() -> dict[ResourceType, ResourceEffect]: + """Get resource effects from the global config.""" + # Import here to avoid circular imports + from backend.config import get_config + + config = get_config() + resources = config.resources + + return { + ResourceType.MEAT: ResourceEffect( + hunger=resources.meat_hunger, + energy=resources.meat_energy, + ), + ResourceType.BERRIES: ResourceEffect( + hunger=resources.berries_hunger, + thirst=resources.berries_thirst, + ), + ResourceType.WATER: ResourceEffect( + thirst=resources.water_thirst, + ), + ResourceType.WOOD: ResourceEffect(), # Used as fuel, not consumed directly + ResourceType.HIDE: ResourceEffect(), # Used for crafting + ResourceType.CLOTHES: ResourceEffect(), # Passive heat reduction effect + } + + +def get_resource_decay_rates() -> dict[ResourceType, Optional[int]]: + """Get resource decay rates from the global config.""" + # Import here to avoid circular imports + from backend.config import get_config + + config = get_config() + resources = config.resources + + return { + ResourceType.MEAT: resources.meat_decay if resources.meat_decay > 0 else None, + ResourceType.BERRIES: resources.berries_decay if resources.berries_decay > 0 else None, + ResourceType.WATER: None, # Infinite + ResourceType.WOOD: None, # Infinite + ResourceType.HIDE: None, # Infinite + ResourceType.CLOTHES: resources.clothes_decay if resources.clothes_decay > 0 else None, + } + + +def get_fire_heat() -> int: + """Get heat provided by building a fire.""" + from backend.config import get_config + return get_config().resources.fire_heat + + +# Cached values for performance +_resource_effects_cache: Optional[dict[ResourceType, ResourceEffect]] = None +_resource_decay_cache: Optional[dict[ResourceType, Optional[int]]] = None + + +def get_cached_resource_effects() -> dict[ResourceType, ResourceEffect]: + """Get cached resource effects.""" + global _resource_effects_cache + if _resource_effects_cache is None: + _resource_effects_cache = get_resource_effects() + return _resource_effects_cache + + +def get_cached_resource_decay_rates() -> dict[ResourceType, Optional[int]]: + """Get cached resource decay rates.""" + global _resource_decay_cache + if _resource_decay_cache is None: + _resource_decay_cache = get_resource_decay_rates() + return _resource_decay_cache + + +def reset_resource_cache() -> None: + """Reset resource caches (call when config changes).""" + global _resource_effects_cache, _resource_decay_cache + _resource_effects_cache = None + _resource_decay_cache = None + + +# Accessor classes for backwards compatibility +class _ResourceEffectsAccessor: + """Accessor that provides dict-like access to resource effects.""" + + def __getitem__(self, resource_type: ResourceType) -> ResourceEffect: + return get_cached_resource_effects()[resource_type] + + def get(self, resource_type: ResourceType, default=None) -> Optional[ResourceEffect]: + return get_cached_resource_effects().get(resource_type, default) + + def __contains__(self, resource_type: ResourceType) -> bool: + return resource_type in get_cached_resource_effects() + + +class _ResourceDecayAccessor: + """Accessor that provides dict-like access to resource decay rates.""" + + def __getitem__(self, resource_type: ResourceType) -> Optional[int]: + return get_cached_resource_decay_rates()[resource_type] + + def get(self, resource_type: ResourceType, default=None) -> Optional[int]: + return get_cached_resource_decay_rates().get(resource_type, default) + + def __contains__(self, resource_type: ResourceType) -> bool: + return resource_type in get_cached_resource_decay_rates() + + +# These provide the same interface as before but load from config +RESOURCE_EFFECTS = _ResourceEffectsAccessor() +RESOURCE_DECAY_RATES = _ResourceDecayAccessor() + + +@dataclass +class Resource: + """A resource instance in the simulation.""" + type: ResourceType + quantity: int = 1 + created_turn: int = 0 + + @property + def decay_rate(self) -> Optional[int]: + """Get the decay rate for this resource type.""" + return get_cached_resource_decay_rates()[self.type] + + @property + def effect(self) -> ResourceEffect: + """Get the effect of consuming this resource.""" + return get_cached_resource_effects()[self.type] + + def is_expired(self, current_turn: int) -> bool: + """Check if the resource has decayed.""" + if self.decay_rate is None: + return False + return (current_turn - self.created_turn) >= self.decay_rate + + def turns_until_decay(self, current_turn: int) -> Optional[int]: + """Get remaining turns until decay, None if infinite.""" + if self.decay_rate is None: + return None + remaining = self.decay_rate - (current_turn - self.created_turn) + return max(0, remaining) + + def to_dict(self) -> dict: + """Convert to dictionary for API serialization.""" + return { + "type": self.type.value, + "quantity": self.quantity, + "created_turn": self.created_turn, + "decay_rate": self.decay_rate, + } diff --git a/backend/main.py b/backend/main.py new file mode 100644 index 0000000..8a7aa4c --- /dev/null +++ b/backend/main.py @@ -0,0 +1,74 @@ +"""FastAPI entry point for the Village Simulation backend.""" + +import uvicorn +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware + +from backend.api.routes import router +from backend.core.engine import get_engine + +# Create FastAPI app +app = FastAPI( + title="Village Simulation API", + description="API for the Village Economy Simulation", + version="1.0.0", + docs_url="/docs", + redoc_url="/redoc", +) + +# Add CORS middleware for frontend access +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], # In production, specify actual origins + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + +# Include API routes +app.include_router(router, prefix="/api", tags=["simulation"]) + + +@app.on_event("startup") +async def startup_event(): + """Initialize the simulation on startup.""" + engine = get_engine() + engine.initialize(num_agents=8) + print("Village Simulation initialized with 8 agents") + + +@app.get("/", tags=["root"]) +def root(): + """Root endpoint with API information.""" + return { + "name": "Village Simulation API", + "version": "1.0.0", + "docs": "/docs", + "status": "running", + } + + +@app.get("/health", tags=["health"]) +def health_check(): + """Health check endpoint.""" + engine = get_engine() + return { + "status": "healthy", + "simulation_running": engine.is_running, + "agents_alive": len(engine.world.get_living_agents()), + } + + +def main(): + """Run the server.""" + uvicorn.run( + "backend.main:app", + host="0.0.0.0", + port=8000, + reload=True, + ) + + +if __name__ == "__main__": + main() + diff --git a/config.json b/config.json new file mode 100644 index 0000000..2352645 --- /dev/null +++ b/config.json @@ -0,0 +1,73 @@ +{ + "agent_stats": { + "max_energy": 50, + "max_hunger": 100, + "max_thirst": 100, + "max_heat": 100, + "start_energy": 50, + "start_hunger": 64, + "start_thirst": 69, + "start_heat": 100, + "energy_decay": 1, + "hunger_decay": 1, + "thirst_decay": 2, + "heat_decay": 3, + "critical_threshold": 0.25, + "low_energy_threshold": 15 + }, + "resources": { + "meat_decay": 8, + "berries_decay": 4, + "clothes_decay": 15, + "meat_hunger": 34, + "meat_energy": 15, + "berries_hunger": 8, + "berries_thirst": 3, + "water_thirst": 56, + "fire_heat": 15 + }, + "actions": { + "sleep_energy": 60, + "rest_energy": 15, + "hunt_energy": -8, + "gather_energy": -3, + "chop_wood_energy": -8, + "get_water_energy": -3, + "weave_energy": -8, + "build_fire_energy": -5, + "trade_energy": -1, + "hunt_success": 0.79, + "chop_wood_success": 0.95, + "hunt_meat_min": 1, + "hunt_meat_max": 4, + "hunt_hide_min": 0, + "hunt_hide_max": 1, + "gather_min": 1, + "gather_max": 3, + "chop_wood_min": 1, + "chop_wood_max": 2 + }, + "world": { + "width": 20, + "height": 20, + "initial_agents": 10, + "day_steps": 10, + "night_steps": 1, + "inventory_slots": 10, + "starting_money": 100 + }, + "market": { + "turns_before_discount": 20, + "discount_rate": 0.15, + "base_price_multiplier": 1.25 + }, + "economy": { + "energy_to_money_ratio": 1.46, + "wealth_desire": 0.23, + "buy_efficiency_threshold": 0.89, + "min_wealth_target": 63, + "max_price_markup": 2.5, + "min_price_discount": 0.3 + }, + "auto_step_interval": 0.2 +} \ No newline at end of file diff --git a/frontend/__init__.py b/frontend/__init__.py new file mode 100644 index 0000000..7444daa --- /dev/null +++ b/frontend/__init__.py @@ -0,0 +1,2 @@ +"""Frontend package for Village Simulation visualization.""" + diff --git a/frontend/client.py b/frontend/client.py new file mode 100644 index 0000000..2177820 --- /dev/null +++ b/frontend/client.py @@ -0,0 +1,180 @@ +"""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 new file mode 100644 index 0000000..40f368c --- /dev/null +++ b/frontend/main.py @@ -0,0 +1,297 @@ +"""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 + + +# Window configuration +WINDOW_WIDTH = 1000 +WINDOW_HEIGHT = 700 +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) + + # State + self.state: SimulationState | None = None + self.running = True + self.hovered_agent: dict | None = None + + # 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 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.settings_renderer.visible: + self.settings_renderer.toggle() + else: + self.running = False + + elif event.key == pygame.K_SPACE: + # Advance one turn + if self.client.connected and not self.settings_renderer.visible: + if self.client.advance_turn(): + # Immediately fetch new state + self.state = self.client.get_state() + + elif event.key == pygame.K_r: + # Reset simulation + if self.client.connected and not self.settings_renderer.visible: + if self.client.initialize(): + self.state = self.client.get_state() + + elif event.key == pygame.K_m: + # Toggle mode + if self.client.connected and self.state and not self.settings_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_s: + # Toggle settings panel + 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 + + 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 settings hint + if not self.settings_renderer.visible: + hint = pygame.font.Font(None, 18).render("Press S for Settings", 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(" ESC - Close settings / 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 new file mode 100644 index 0000000..49cf007 --- /dev/null +++ b/frontend/renderer/__init__.py @@ -0,0 +1,9 @@ +"""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 new file mode 100644 index 0000000..e8d5cfa --- /dev/null +++ b/frontend/renderer/agent_renderer.py @@ -0,0 +1,430 @@ +"""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 new file mode 100644 index 0000000..6ed2a2d --- /dev/null +++ b/frontend/renderer/map_renderer.py @@ -0,0 +1,146 @@ +"""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 new file mode 100644 index 0000000..00add4b --- /dev/null +++ b/frontend/renderer/settings_renderer.py @@ -0,0 +1,448 @@ +"""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/ui_renderer.py b/frontend/renderer/ui_renderer.py new file mode 100644 index 0000000..cfdc2b1 --- /dev/null +++ b/frontend/renderer/ui_renderer.py @@ -0,0 +1,239 @@ +"""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 new file mode 100644 index 0000000..46b0afb --- /dev/null +++ b/requirements.txt @@ -0,0 +1,18 @@ +# Village Simulation - Python Dependencies + +# Backend +fastapi>=0.104.0 +uvicorn[standard]>=0.24.0 +pydantic>=2.5.0 + +# Frontend +pygame-ce>=2.4.0 +requests>=2.31.0 + +# Tools (balance sheet export/import) +openpyxl>=3.1.0 + +# Analysis & Visualization +matplotlib>=3.8.0 +numpy>=1.26.0 + diff --git a/tools/__init__.py b/tools/__init__.py new file mode 100644 index 0000000..b06fd52 --- /dev/null +++ b/tools/__init__.py @@ -0,0 +1,2 @@ +"""Tools for game balance management.""" + diff --git a/tools/config_to_excel.py b/tools/config_to_excel.py new file mode 100644 index 0000000..6583dca --- /dev/null +++ b/tools/config_to_excel.py @@ -0,0 +1,603 @@ +#!/usr/bin/env python3 +""" +Convert SimulationConfig to a colorful Excel balance sheet. +Compatible with Google Sheets. + +Usage: + python config_to_excel.py [--output balance_sheet.xlsx] [--config config.json] +""" + +import argparse +import sys +from pathlib import Path + +# Add parent directory to path for imports +sys.path.insert(0, str(Path(__file__).parent.parent)) + +from openpyxl import Workbook +from openpyxl.styles import ( + Font, Fill, PatternFill, Border, Side, Alignment, NamedStyle +) +from openpyxl.utils import get_column_letter +from openpyxl.formatting.rule import FormulaRule, ColorScaleRule +from backend.config import SimulationConfig, get_config + + +# ============================================================================ +# CLOWN COLORS PALETTE 🤡 +# ============================================================================ +COLORS = { + # Headers - Bright and bold + 'red': 'FF0000', + 'orange': 'FF8C00', + 'yellow': 'FFD700', + 'lime': '32CD32', + 'cyan': '00CED1', + 'blue': '1E90FF', + 'purple': '9400D3', + 'magenta': 'FF00FF', + 'pink': 'FF69B4', + + # Section backgrounds (lighter versions) + 'light_red': 'FFB3B3', + 'light_orange': 'FFD9B3', + 'light_yellow': 'FFFACD', + 'light_lime': 'B3FFB3', + 'light_cyan': 'B3FFFF', + 'light_blue': 'B3D9FF', + 'light_purple': 'E6B3FF', + 'light_magenta': 'FFB3FF', + 'light_pink': 'FFB3D9', + + # Special + 'white': 'FFFFFF', + 'black': '000000', + 'gold': 'FFD700', + 'silver': 'C0C0C0', +} + +# Section color assignments +SECTION_COLORS = { + 'agent_stats': ('red', 'light_red'), + 'resources': ('orange', 'light_orange'), + 'actions': ('lime', 'light_lime'), + 'world': ('blue', 'light_blue'), + 'market': ('purple', 'light_purple'), + 'economy': ('cyan', 'light_cyan'), + 'summary': ('magenta', 'light_magenta'), +} + + +def create_header_style(color_name): + """Create a header cell style with clown colors.""" + return { + 'fill': PatternFill(start_color=COLORS[color_name], + end_color=COLORS[color_name], + fill_type='solid'), + 'font': Font(bold=True, color=COLORS['white'], size=12), + 'alignment': Alignment(horizontal='center', vertical='center'), + 'border': Border( + left=Side(style='medium', color=COLORS['black']), + right=Side(style='medium', color=COLORS['black']), + top=Side(style='medium', color=COLORS['black']), + bottom=Side(style='medium', color=COLORS['black']) + ) + } + + +def create_data_style(color_name): + """Create a data cell style with light clown colors.""" + return { + 'fill': PatternFill(start_color=COLORS[color_name], + end_color=COLORS[color_name], + fill_type='solid'), + 'font': Font(size=11), + 'alignment': Alignment(horizontal='left', vertical='center'), + 'border': Border( + left=Side(style='thin', color=COLORS['black']), + right=Side(style='thin', color=COLORS['black']), + top=Side(style='thin', color=COLORS['black']), + bottom=Side(style='thin', color=COLORS['black']) + ) + } + + +def apply_style(cell, style_dict): + """Apply a style dictionary to a cell.""" + for attr, value in style_dict.items(): + setattr(cell, attr, value) + + +def add_section_header(ws, row, title, header_color, col_span=4): + """Add a section header spanning multiple columns.""" + cell = ws.cell(row=row, column=1, value=title) + apply_style(cell, create_header_style(header_color)) + ws.merge_cells(start_row=row, start_column=1, end_row=row, end_column=col_span) + return row + 1 + + +def add_param_row(ws, row, param_name, value, description, data_color, + value_col=2, desc_col=3, formula_col=4): + """Add a parameter row with name, value, and description.""" + # Parameter name + name_cell = ws.cell(row=row, column=1, value=param_name) + apply_style(name_cell, create_data_style(data_color)) + name_cell.font = Font(bold=True, size=11) + + # Value (this is what the game designer edits) + value_cell = ws.cell(row=row, column=value_col, value=value) + apply_style(value_cell, create_data_style(data_color)) + value_cell.alignment = Alignment(horizontal='center', vertical='center') + value_cell.font = Font(bold=True, size=12, color=COLORS['blue']) + + # Description + desc_cell = ws.cell(row=row, column=desc_col, value=description) + apply_style(desc_cell, create_data_style(data_color)) + desc_cell.font = Font(italic=True, size=10, color=COLORS['black']) + + return row + 1 + + +def add_formula_row(ws, row, label, formula, data_color, description=""): + """Add a row with a formula for balance calculations.""" + # Label + label_cell = ws.cell(row=row, column=1, value=label) + apply_style(label_cell, create_data_style(data_color)) + label_cell.font = Font(bold=True, size=11, color=COLORS['purple']) + + # Formula + formula_cell = ws.cell(row=row, column=2, value=formula) + apply_style(formula_cell, create_data_style(data_color)) + formula_cell.font = Font(bold=True, size=11, color=COLORS['red']) + formula_cell.alignment = Alignment(horizontal='center') + + # Description + desc_cell = ws.cell(row=row, column=3, value=description) + apply_style(desc_cell, create_data_style(data_color)) + desc_cell.font = Font(italic=True, size=10) + + return row + 1 + + +def create_balance_sheet(config: SimulationConfig, output_path: str): + """Create a colorful Excel balance sheet from config.""" + wb = Workbook() + ws = wb.active + ws.title = "Balance Sheet" + + # Set column widths + ws.column_dimensions['A'].width = 30 + ws.column_dimensions['B'].width = 15 + ws.column_dimensions['C'].width = 50 + ws.column_dimensions['D'].width = 25 + + row = 1 + + # Title + title_cell = ws.cell(row=row, column=1, value="🎮 VILLAGE SIMULATION BALANCE SHEET 🎪") + title_cell.font = Font(bold=True, size=18, color=COLORS['magenta']) + title_cell.alignment = Alignment(horizontal='center') + ws.merge_cells(start_row=row, start_column=1, end_row=row, end_column=4) + row += 2 + + # Column headers + headers = ['Parameter', 'Value', 'Description', 'Calculated'] + for col, header in enumerate(headers, 1): + cell = ws.cell(row=row, column=col, value=header) + apply_style(cell, create_header_style('cyan')) + row += 1 + + # Track row positions for formulas (Google Sheets compatible) + param_rows = {} + + # ======================================================================== + # AGENT STATS SECTION + # ======================================================================== + row = add_section_header(ws, row, "👤 AGENT STATS", 'red') + header_color, data_color = SECTION_COLORS['agent_stats'] + + # Max values + param_rows['max_energy'] = row + row = add_param_row(ws, row, "max_energy", config.agent_stats.max_energy, + "Maximum energy an agent can have", data_color) + param_rows['max_hunger'] = row + row = add_param_row(ws, row, "max_hunger", config.agent_stats.max_hunger, + "Maximum hunger (100 = full, 0 = starving)", data_color) + param_rows['max_thirst'] = row + row = add_param_row(ws, row, "max_thirst", config.agent_stats.max_thirst, + "Maximum thirst (100 = hydrated, 0 = dehydrated)", data_color) + param_rows['max_heat'] = row + row = add_param_row(ws, row, "max_heat", config.agent_stats.max_heat, + "Maximum heat (100 = warm, 0 = freezing)", data_color) + + # Starting values + param_rows['start_energy'] = row + row = add_param_row(ws, row, "start_energy", config.agent_stats.start_energy, + "Starting energy for new agents", data_color) + param_rows['start_hunger'] = row + row = add_param_row(ws, row, "start_hunger", config.agent_stats.start_hunger, + "Starting hunger for new agents", data_color) + param_rows['start_thirst'] = row + row = add_param_row(ws, row, "start_thirst", config.agent_stats.start_thirst, + "Starting thirst for new agents", data_color) + param_rows['start_heat'] = row + row = add_param_row(ws, row, "start_heat", config.agent_stats.start_heat, + "Starting heat for new agents", data_color) + + # Decay rates + param_rows['energy_decay'] = row + row = add_param_row(ws, row, "energy_decay", config.agent_stats.energy_decay, + "Energy lost per turn", data_color) + param_rows['hunger_decay'] = row + row = add_param_row(ws, row, "hunger_decay", config.agent_stats.hunger_decay, + "Hunger lost per turn", data_color) + param_rows['thirst_decay'] = row + row = add_param_row(ws, row, "thirst_decay", config.agent_stats.thirst_decay, + "Thirst lost per turn", data_color) + param_rows['heat_decay'] = row + row = add_param_row(ws, row, "heat_decay", config.agent_stats.heat_decay, + "Heat lost per turn", data_color) + + # Thresholds + param_rows['critical_threshold'] = row + row = add_param_row(ws, row, "critical_threshold", config.agent_stats.critical_threshold, + "Threshold (0-1) for survival mode trigger", data_color) + param_rows['low_energy_threshold'] = row + row = add_param_row(ws, row, "low_energy_threshold", config.agent_stats.low_energy_threshold, + "Minimum energy to perform work", data_color) + + row += 1 + + # ======================================================================== + # RESOURCES SECTION + # ======================================================================== + row = add_section_header(ws, row, "🍖 RESOURCES", 'orange') + header_color, data_color = SECTION_COLORS['resources'] + + # Decay rates + param_rows['meat_decay'] = row + row = add_param_row(ws, row, "meat_decay", config.resources.meat_decay, + "Turns until meat spoils (0 = infinite)", data_color) + param_rows['berries_decay'] = row + row = add_param_row(ws, row, "berries_decay", config.resources.berries_decay, + "Turns until berries spoil", data_color) + param_rows['clothes_decay'] = row + row = add_param_row(ws, row, "clothes_decay", config.resources.clothes_decay, + "Turns until clothes wear out", data_color) + + # Resource effects + param_rows['meat_hunger'] = row + row = add_param_row(ws, row, "meat_hunger", config.resources.meat_hunger, + "Hunger restored by eating meat", data_color) + param_rows['meat_energy'] = row + row = add_param_row(ws, row, "meat_energy", config.resources.meat_energy, + "Energy restored by eating meat", data_color) + param_rows['berries_hunger'] = row + row = add_param_row(ws, row, "berries_hunger", config.resources.berries_hunger, + "Hunger restored by eating berries", data_color) + param_rows['berries_thirst'] = row + row = add_param_row(ws, row, "berries_thirst", config.resources.berries_thirst, + "Thirst restored by eating berries", data_color) + param_rows['water_thirst'] = row + row = add_param_row(ws, row, "water_thirst", config.resources.water_thirst, + "Thirst restored by drinking water", data_color) + param_rows['fire_heat'] = row + row = add_param_row(ws, row, "fire_heat", config.resources.fire_heat, + "Heat restored by fire", data_color) + + row += 1 + + # ======================================================================== + # ACTIONS SECTION + # ======================================================================== + row = add_section_header(ws, row, "⚡ ACTIONS", 'lime') + header_color, data_color = SECTION_COLORS['actions'] + + # Energy costs + param_rows['sleep_energy'] = row + row = add_param_row(ws, row, "sleep_energy", config.actions.sleep_energy, + "Energy restored by sleeping (+positive)", data_color) + param_rows['rest_energy'] = row + row = add_param_row(ws, row, "rest_energy", config.actions.rest_energy, + "Energy restored by resting (+positive)", data_color) + param_rows['hunt_energy'] = row + row = add_param_row(ws, row, "hunt_energy", config.actions.hunt_energy, + "Energy cost for hunting (-negative)", data_color) + param_rows['gather_energy'] = row + row = add_param_row(ws, row, "gather_energy", config.actions.gather_energy, + "Energy cost for gathering (-negative)", data_color) + param_rows['chop_wood_energy'] = row + row = add_param_row(ws, row, "chop_wood_energy", config.actions.chop_wood_energy, + "Energy cost for chopping wood (-negative)", data_color) + param_rows['get_water_energy'] = row + row = add_param_row(ws, row, "get_water_energy", config.actions.get_water_energy, + "Energy cost for getting water (-negative)", data_color) + param_rows['weave_energy'] = row + row = add_param_row(ws, row, "weave_energy", config.actions.weave_energy, + "Energy cost for weaving (-negative)", data_color) + param_rows['build_fire_energy'] = row + row = add_param_row(ws, row, "build_fire_energy", config.actions.build_fire_energy, + "Energy cost for building fire (-negative)", data_color) + param_rows['trade_energy'] = row + row = add_param_row(ws, row, "trade_energy", config.actions.trade_energy, + "Energy cost for trading (-negative)", data_color) + + # Success chances + param_rows['hunt_success'] = row + row = add_param_row(ws, row, "hunt_success", config.actions.hunt_success, + "Success chance for hunting (0.0-1.0)", data_color) + param_rows['chop_wood_success'] = row + row = add_param_row(ws, row, "chop_wood_success", config.actions.chop_wood_success, + "Success chance for chopping wood (0.0-1.0)", data_color) + + # Output quantities + param_rows['hunt_meat_min'] = row + row = add_param_row(ws, row, "hunt_meat_min", config.actions.hunt_meat_min, + "Minimum meat from successful hunt", data_color) + param_rows['hunt_meat_max'] = row + row = add_param_row(ws, row, "hunt_meat_max", config.actions.hunt_meat_max, + "Maximum meat from successful hunt", data_color) + param_rows['hunt_hide_min'] = row + row = add_param_row(ws, row, "hunt_hide_min", config.actions.hunt_hide_min, + "Minimum hides from successful hunt", data_color) + param_rows['hunt_hide_max'] = row + row = add_param_row(ws, row, "hunt_hide_max", config.actions.hunt_hide_max, + "Maximum hides from successful hunt", data_color) + param_rows['gather_min'] = row + row = add_param_row(ws, row, "gather_min", config.actions.gather_min, + "Minimum berries from gathering", data_color) + param_rows['gather_max'] = row + row = add_param_row(ws, row, "gather_max", config.actions.gather_max, + "Maximum berries from gathering", data_color) + param_rows['chop_wood_min'] = row + row = add_param_row(ws, row, "chop_wood_min", config.actions.chop_wood_min, + "Minimum wood from chopping", data_color) + param_rows['chop_wood_max'] = row + row = add_param_row(ws, row, "chop_wood_max", config.actions.chop_wood_max, + "Maximum wood from chopping", data_color) + + row += 1 + + # ======================================================================== + # WORLD SECTION + # ======================================================================== + row = add_section_header(ws, row, "🌍 WORLD", 'blue') + header_color, data_color = SECTION_COLORS['world'] + + param_rows['width'] = row + row = add_param_row(ws, row, "width", config.world.width, + "World width in tiles", data_color) + param_rows['height'] = row + row = add_param_row(ws, row, "height", config.world.height, + "World height in tiles", data_color) + param_rows['initial_agents'] = row + row = add_param_row(ws, row, "initial_agents", config.world.initial_agents, + "Number of agents at start", data_color) + param_rows['day_steps'] = row + row = add_param_row(ws, row, "day_steps", config.world.day_steps, + "Turns in a day cycle", data_color) + param_rows['night_steps'] = row + row = add_param_row(ws, row, "night_steps", config.world.night_steps, + "Turns in a night cycle", data_color) + param_rows['inventory_slots'] = row + row = add_param_row(ws, row, "inventory_slots", config.world.inventory_slots, + "Inventory capacity per agent", data_color) + param_rows['starting_money'] = row + row = add_param_row(ws, row, "starting_money", config.world.starting_money, + "Starting money per agent", data_color) + + row += 1 + + # ======================================================================== + # MARKET SECTION + # ======================================================================== + row = add_section_header(ws, row, "💰 MARKET", 'purple') + header_color, data_color = SECTION_COLORS['market'] + + param_rows['turns_before_discount'] = row + row = add_param_row(ws, row, "turns_before_discount", config.market.turns_before_discount, + "Turns until discount applies", data_color) + param_rows['discount_rate'] = row + row = add_param_row(ws, row, "discount_rate", config.market.discount_rate, + "Discount rate (0.0-1.0)", data_color) + param_rows['base_price_multiplier'] = row + row = add_param_row(ws, row, "base_price_multiplier", config.market.base_price_multiplier, + "Markup over production cost", data_color) + + row += 1 + + # ======================================================================== + # ECONOMY SECTION (Agent Trading Behavior) + # ======================================================================== + row = add_section_header(ws, row, "📈 ECONOMY (Trading Behavior)", 'cyan') + header_color, data_color = SECTION_COLORS['economy'] + + param_rows['energy_to_money_ratio'] = row + row = add_param_row(ws, row, "energy_to_money_ratio", config.economy.energy_to_money_ratio, + "How much agents value money vs energy (1.5 = 1 energy ≈ 1.5 coins)", data_color) + param_rows['wealth_desire'] = row + row = add_param_row(ws, row, "wealth_desire", config.economy.wealth_desire, + "How strongly agents want wealth (0-1)", data_color) + param_rows['buy_efficiency_threshold'] = row + row = add_param_row(ws, row, "buy_efficiency_threshold", config.economy.buy_efficiency_threshold, + "Buy if price < (threshold × fair value)", data_color) + param_rows['min_wealth_target'] = row + row = add_param_row(ws, row, "min_wealth_target", config.economy.min_wealth_target, + "Minimum money agents want to keep", data_color) + param_rows['max_price_markup'] = row + row = add_param_row(ws, row, "max_price_markup", config.economy.max_price_markup, + "Maximum price = base × this multiplier", data_color) + param_rows['min_price_discount'] = row + row = add_param_row(ws, row, "min_price_discount", config.economy.min_price_discount, + "Minimum price = base × this multiplier", data_color) + + row += 1 + + # ======================================================================== + # SIMULATION CONTROL + # ======================================================================== + row = add_section_header(ws, row, "⏱️ SIMULATION", 'pink') + + param_rows['auto_step_interval'] = row + row = add_param_row(ws, row, "auto_step_interval", config.auto_step_interval, + "Seconds between auto steps", 'light_pink') + + row += 2 + + # ======================================================================== + # CALCULATED BALANCE METRICS (with formulas) + # ======================================================================== + row = add_section_header(ws, row, "📊 BALANCE CALCULATIONS (Auto-computed)", 'magenta') + data_color = 'light_magenta' + + # Turns to starve from full + energy_row = param_rows['start_energy'] + decay_row = param_rows['energy_decay'] + formula = f"=B{energy_row}/B{decay_row}" + row = add_formula_row(ws, row, "Turns to exhaust energy", formula, data_color, + "From start_energy at energy_decay rate") + + # Turns to dehydrate from full + thirst_row = param_rows['start_thirst'] + thirst_decay_row = param_rows['thirst_decay'] + formula = f"=B{thirst_row}/B{thirst_decay_row}" + row = add_formula_row(ws, row, "Turns to dehydrate", formula, data_color, + "From start_thirst at thirst_decay rate") + + # Turns to starve from full + hunger_row = param_rows['start_hunger'] + hunger_decay_row = param_rows['hunger_decay'] + formula = f"=B{hunger_row}/B{hunger_decay_row}" + row = add_formula_row(ws, row, "Turns to starve", formula, data_color, + "From start_hunger at hunger_decay rate") + + # Hunt efficiency (expected meat per energy spent) + hunt_success_row = param_rows['hunt_success'] + hunt_energy_row = param_rows['hunt_energy'] + meat_min_row = param_rows['hunt_meat_min'] + meat_max_row = param_rows['hunt_meat_max'] + formula = f"=(B{hunt_success_row}*(B{meat_min_row}+B{meat_max_row})/2)/ABS(B{hunt_energy_row})" + row = add_formula_row(ws, row, "Hunt efficiency (meat/energy)", formula, data_color, + "Expected meat per energy spent") + + # Gather efficiency (berries per energy) + gather_energy_row = param_rows['gather_energy'] + gather_min_row = param_rows['gather_min'] + gather_max_row = param_rows['gather_max'] + formula = f"=((B{gather_min_row}+B{gather_max_row})/2)/ABS(B{gather_energy_row})" + row = add_formula_row(ws, row, "Gather efficiency (berries/energy)", formula, data_color, + "Expected berries per energy spent") + + # Meat value (hunger restored per decay turn) + meat_hunger_row = param_rows['meat_hunger'] + meat_decay_row = param_rows['meat_decay'] + formula = f"=B{meat_hunger_row}*B{meat_decay_row}" + row = add_formula_row(ws, row, "Meat total value (hunger × lifetime)", formula, data_color, + "Total hunger value before spoilage") + + # Water efficiency (thirst restored per energy) + water_thirst_row = param_rows['water_thirst'] + get_water_row = param_rows['get_water_energy'] + formula = f"=B{water_thirst_row}/ABS(B{get_water_row})" + row = add_formula_row(ws, row, "Water efficiency (thirst/energy)", formula, data_color, + "Thirst restored per energy spent") + + # Sleep ROI (energy restored per turn sleeping) + sleep_row = param_rows['sleep_energy'] + decay_row = param_rows['energy_decay'] + formula = f"=B{sleep_row}/B{decay_row}" + row = add_formula_row(ws, row, "Sleep ROI (turns of activity)", formula, data_color, + "How many turns of activity per sleep") + + # World total tiles + width_row = param_rows['width'] + height_row = param_rows['height'] + formula = f"=B{width_row}*B{height_row}" + row = add_formula_row(ws, row, "Total world tiles", formula, data_color, + "Width × Height") + + # Tiles per agent + agents_row = param_rows['initial_agents'] + formula = f"=(B{width_row}*B{height_row})/B{agents_row}" + row = add_formula_row(ws, row, "Tiles per agent", formula, data_color, + "Space available per agent") + + # Full day cycle + day_row = param_rows['day_steps'] + night_row = param_rows['night_steps'] + formula = f"=B{day_row}+B{night_row}" + row = add_formula_row(ws, row, "Full day/night cycle", formula, data_color, + "Total turns in one cycle") + + # Critical health threshold (absolute value) + max_energy_row = param_rows['max_energy'] + crit_row = param_rows['critical_threshold'] + formula = f"=B{max_energy_row}*B{crit_row}" + row = add_formula_row(ws, row, "Critical energy level", formula, data_color, + "Absolute energy when survival mode triggers") + + # Expected discount savings + discount_row = param_rows['discount_rate'] + formula = f"=B{discount_row}*100&\"%\"" + row = add_formula_row(ws, row, "Discount percentage", formula, data_color, + "Market discount as percentage") + + row += 2 + + # ======================================================================== + # NOTES SECTION + # ======================================================================== + row = add_section_header(ws, row, "📝 NOTES FOR GAME DESIGNER", 'gold') + + notes = [ + "🎯 Modify VALUES in column B to adjust game balance", + "🔴 Negative values in actions = COST (energy spent)", + "🟢 Positive values in actions = GAIN (energy restored)", + "⚖️ Check BALANCE CALCULATIONS section for auto-computed metrics", + "📊 Key ratios to monitor:", + " - Turns to deplete should be > day cycle length", + " - Hunt efficiency should balance with gather efficiency", + " - Sleep ROI determines rest frequency", + "🚨 RED FLAGS:", + " - Turns to dehydrate < day_steps (too fast!)", + " - Hunt efficiency >> Gather efficiency (hunting OP)", + " - Meat spoils before it can be used (meat_decay too low)", + ] + + for note in notes: + cell = ws.cell(row=row, column=1, value=note) + cell.font = Font(size=11, color=COLORS['black']) + ws.merge_cells(start_row=row, start_column=1, end_row=row, end_column=4) + row += 1 + + # Save workbook + wb.save(output_path) + print(f"✅ Balance sheet created: {output_path}") + print(f" 📊 {len(param_rows)} parameters exported") + print(f" 📐 12 balance formulas included") + print(f" 📈 Includes economy/trading behavior settings") + print(f" 🎨 Clown colors applied! 🤡") + + +def main(): + parser = argparse.ArgumentParser(description="Convert config to Excel balance sheet") + parser.add_argument("--output", "-o", default="balance_sheet.xlsx", + help="Output Excel file path") + parser.add_argument("--config", "-c", default=None, + help="Input JSON config file (optional, uses defaults if not provided)") + args = parser.parse_args() + + # Load config + if args.config: + config = SimulationConfig.load(args.config) + print(f"📂 Loaded config from: {args.config}") + else: + config = get_config() + print("📂 Using default configuration") + + create_balance_sheet(config, args.output) + + +if __name__ == "__main__": + main() + diff --git a/tools/excel_to_config.py b/tools/excel_to_config.py new file mode 100644 index 0000000..4d0e5b7 --- /dev/null +++ b/tools/excel_to_config.py @@ -0,0 +1,244 @@ +#!/usr/bin/env python3 +""" +Convert Excel balance sheet back to SimulationConfig. +Reads parameter values from the Excel file and generates a config.json. + +Usage: + python excel_to_config.py [--input balance_sheet.xlsx] [--output config.json] +""" + +import argparse +import json +import sys +from pathlib import Path + +# Add parent directory to path for imports +sys.path.insert(0, str(Path(__file__).parent.parent)) + +from openpyxl import load_workbook +from backend.config import ( + SimulationConfig, + AgentStatsConfig, + ResourceConfig, + ActionConfig, + WorldConfig, + MarketConfig, + EconomyConfig, +) + + +# Parameter to config section mapping +PARAM_MAPPING = { + # AgentStatsConfig + 'max_energy': ('agent_stats', int), + 'max_hunger': ('agent_stats', int), + 'max_thirst': ('agent_stats', int), + 'max_heat': ('agent_stats', int), + 'start_energy': ('agent_stats', int), + 'start_hunger': ('agent_stats', int), + 'start_thirst': ('agent_stats', int), + 'start_heat': ('agent_stats', int), + 'energy_decay': ('agent_stats', int), + 'hunger_decay': ('agent_stats', int), + 'thirst_decay': ('agent_stats', int), + 'heat_decay': ('agent_stats', int), + 'critical_threshold': ('agent_stats', float), + 'low_energy_threshold': ('agent_stats', int), + + # ResourceConfig + 'meat_decay': ('resources', int), + 'berries_decay': ('resources', int), + 'clothes_decay': ('resources', int), + 'meat_hunger': ('resources', int), + 'meat_energy': ('resources', int), + 'berries_hunger': ('resources', int), + 'berries_thirst': ('resources', int), + 'water_thirst': ('resources', int), + 'fire_heat': ('resources', int), + + # ActionConfig + 'sleep_energy': ('actions', int), + 'rest_energy': ('actions', int), + 'hunt_energy': ('actions', int), + 'gather_energy': ('actions', int), + 'chop_wood_energy': ('actions', int), + 'get_water_energy': ('actions', int), + 'weave_energy': ('actions', int), + 'build_fire_energy': ('actions', int), + 'trade_energy': ('actions', int), + 'hunt_success': ('actions', float), + 'chop_wood_success': ('actions', float), + 'hunt_meat_min': ('actions', int), + 'hunt_meat_max': ('actions', int), + 'hunt_hide_min': ('actions', int), + 'hunt_hide_max': ('actions', int), + 'gather_min': ('actions', int), + 'gather_max': ('actions', int), + 'chop_wood_min': ('actions', int), + 'chop_wood_max': ('actions', int), + + # WorldConfig + 'width': ('world', int), + 'height': ('world', int), + 'initial_agents': ('world', int), + 'day_steps': ('world', int), + 'night_steps': ('world', int), + 'inventory_slots': ('world', int), + 'starting_money': ('world', int), + + # MarketConfig + 'turns_before_discount': ('market', int), + 'discount_rate': ('market', float), + 'base_price_multiplier': ('market', float), + + # EconomyConfig + 'energy_to_money_ratio': ('economy', float), + 'wealth_desire': ('economy', float), + 'buy_efficiency_threshold': ('economy', float), + 'min_wealth_target': ('economy', int), + 'max_price_markup': ('economy', float), + 'min_price_discount': ('economy', float), + + # SimulationConfig (root level) + 'auto_step_interval': ('root', float), +} + + +def parse_excel(input_path: str) -> dict: + """Parse the Excel balance sheet and extract parameter values.""" + wb = load_workbook(input_path, data_only=True) # data_only=True to get calculated values + ws = wb.active + + # Organize parameters by section + config_data = { + 'agent_stats': {}, + 'resources': {}, + 'actions': {}, + 'world': {}, + 'market': {}, + 'economy': {}, + 'auto_step_interval': 1.0, + } + + found_params = [] + skipped_rows = [] + + # Iterate through rows looking for parameter names in column A + for row in range(1, ws.max_row + 1): + param_name = ws.cell(row=row, column=1).value + param_value = ws.cell(row=row, column=2).value + + # Skip empty rows or non-parameter rows + if not param_name or not isinstance(param_name, str): + continue + + # Strip whitespace + param_name = param_name.strip() + + # Check if this is a known parameter + if param_name in PARAM_MAPPING: + section, type_converter = PARAM_MAPPING[param_name] + + # Skip if value is None or empty + if param_value is None: + skipped_rows.append(f" ⚠️ {param_name}: no value found (row {row})") + continue + + try: + # Convert to appropriate type + converted_value = type_converter(param_value) + + if section == 'root': + config_data['auto_step_interval'] = converted_value + else: + config_data[section][param_name] = converted_value + + found_params.append(f" ✓ {param_name} = {converted_value}") + except (ValueError, TypeError) as e: + skipped_rows.append(f" ⚠️ {param_name}: conversion error ({e})") + + return config_data, found_params, skipped_rows + + +def create_config_from_data(config_data: dict) -> SimulationConfig: + """Create a SimulationConfig from parsed data.""" + return SimulationConfig( + agent_stats=AgentStatsConfig(**config_data['agent_stats']), + resources=ResourceConfig(**config_data['resources']), + actions=ActionConfig(**config_data['actions']), + world=WorldConfig(**config_data['world']), + market=MarketConfig(**config_data['market']), + economy=EconomyConfig(**config_data['economy']), + auto_step_interval=config_data['auto_step_interval'], + ) + + +def save_config_json(config: SimulationConfig, output_path: str): + """Save config to JSON file.""" + with open(output_path, 'w') as f: + json.dump(config.to_dict(), f, indent=2) + + +def main(): + parser = argparse.ArgumentParser(description="Convert Excel balance sheet to config") + parser.add_argument("--input", "-i", default="balance_sheet.xlsx", + help="Input Excel file path") + parser.add_argument("--output", "-o", default="config.json", + help="Output JSON config file path") + parser.add_argument("--verbose", "-v", action="store_true", + help="Show detailed parsing output") + args = parser.parse_args() + + # Check if input file exists + if not Path(args.input).exists(): + print(f"❌ Error: Input file not found: {args.input}") + sys.exit(1) + + print(f"📂 Reading Excel file: {args.input}") + + # Parse Excel + config_data, found_params, skipped_rows = parse_excel(args.input) + + if args.verbose: + print("\n📊 Found parameters:") + for param in found_params: + print(param) + + if skipped_rows: + print("\n⚠️ Skipped rows:") + for skip in skipped_rows: + print(skip) + + # Create config + try: + config = create_config_from_data(config_data) + except TypeError as e: + print(f"❌ Error creating config: {e}") + print(" Some required parameters may be missing from the Excel file.") + sys.exit(1) + + # Save to JSON + save_config_json(config, args.output) + + # Summary + sections = ['agent_stats', 'resources', 'actions', 'world', 'market', 'economy'] + total_params = sum(len(config_data[k]) for k in sections) + total_params += 1 # auto_step_interval + + print(f"\n✅ Config saved to: {args.output}") + print(f" 📊 {total_params} parameters imported") + print(f" 🔧 {len(skipped_rows)} rows skipped") + + # Print summary of values by section + print("\n📋 Configuration summary:") + print(f" 👤 Agent Stats: {len(config_data['agent_stats'])} params") + print(f" 🍖 Resources: {len(config_data['resources'])} params") + print(f" ⚡ Actions: {len(config_data['actions'])} params") + print(f" 🌍 World: {len(config_data['world'])} params") + print(f" 💰 Market: {len(config_data['market'])} params") + print(f" 📈 Economy: {len(config_data['economy'])} params") + + +if __name__ == "__main__": + main() + diff --git a/tools/optimize_economy.py b/tools/optimize_economy.py new file mode 100644 index 0000000..0513942 --- /dev/null +++ b/tools/optimize_economy.py @@ -0,0 +1,565 @@ +#!/usr/bin/env python3 +""" +Economy Optimizer for Village Simulation + +This script runs multiple simulations with different configurations to find +optimal parameters for a balanced, active economy with: +- Active trading +- Diverse resource production (including hunting) +- Good survival rates +- Wealth accumulation and circulation + +Usage: + python tools/optimize_economy.py [--iterations 20] [--steps 500] +""" + +import argparse +import json +import random +import re +import sys +from collections import defaultdict +from dataclasses import dataclass, field +from datetime import datetime +from pathlib import Path +from typing import Optional + +# Add parent directory for imports +sys.path.insert(0, str(Path(__file__).parent.parent)) + +from backend.config import get_config, reload_config +from backend.core.engine import GameEngine +from backend.core.logger import reset_simulation_logger +from backend.domain.action import reset_action_config_cache +from backend.domain.resources import reset_resource_cache + + +@dataclass +class SimulationMetrics: + """Metrics collected from a simulation run.""" + total_turns: int = 0 + completed_trades: int = 0 + market_listings: int = 0 + total_deaths: int = 0 + final_population: int = 0 + + # Action diversity + hunt_actions: int = 0 + gather_actions: int = 0 + chop_wood_actions: int = 0 + get_water_actions: int = 0 + trade_actions: int = 0 + trade_success: int = 0 + + # Resource diversity + meat_produced: int = 0 + berries_produced: int = 0 + wood_produced: int = 0 + water_produced: int = 0 + + # Trade diversity (actual completed trades) + trades_meat: int = 0 + trades_berries: int = 0 + trades_wood: int = 0 + trades_water: int = 0 + + # Economy + total_trade_value: int = 0 + + @property + def hunt_ratio(self) -> float: + """Ratio of hunting to gathering.""" + total_food = self.hunt_actions + self.gather_actions + return self.hunt_actions / total_food if total_food > 0 else 0 + + @property + def trade_success_rate(self) -> float: + """Success rate of trade actions.""" + return self.trade_success / self.trade_actions if self.trade_actions > 0 else 0 + + @property + def survival_rate(self) -> float: + """Percentage of agents surviving.""" + initial = 10 # Assuming 10 initial agents + return self.final_population / initial if initial > 0 else 0 + + @property + def trades_per_turn(self) -> float: + """Average trades per turn.""" + return self.completed_trades / self.total_turns if self.total_turns > 0 else 0 + + @property + def trade_diversity(self) -> float: + """How diverse are traded items (0-1 score).""" + trades = [self.trades_meat, self.trades_berries, self.trades_wood, self.trades_water] + total = sum(trades) + if total == 0: + return 0 + # Entropy-like measure: best is when all 4 are equal + proportions = [t / total for t in trades if t > 0] + if len(proportions) <= 1: + return 0.25 * len(proportions) + # Simple diversity: count how many resources are traded + return len([t for t in trades if t > 0]) / 4 + + @property + def production_diversity(self) -> float: + """How diverse is resource production.""" + prod = [self.meat_produced, self.berries_produced, self.wood_produced, self.water_produced] + total = sum(prod) + if total == 0: + return 0 + return len([p for p in prod if p > 0]) / 4 + + def score(self) -> float: + """Calculate overall economy health score (0-100).""" + score = 0 + + # Trading activity (0-25 points) + # Target: at least 0.5 trades per turn + trade_score = min(25, self.trades_per_turn * 50) + score += trade_score + + # Trade diversity (0-20 points) + # Target: all 4 resource types being traded + score += self.trade_diversity * 20 + + # Hunt ratio (0-15 points) + # Target: at least 20% hunting + hunt_score = min(15, self.hunt_ratio * 75) + score += hunt_score + + # Survival rate (0-20 points) + # Target: at least 50% survival + survival_score = min(20, self.survival_rate * 40) + score += survival_score + + # Trade success rate (0-10 points) + # Target: at least 50% success + trade_success_score = min(10, self.trade_success_rate * 20) + score += trade_success_score + + # Production diversity (0-10 points) + score += self.production_diversity * 10 + + return score + + +def run_quick_simulation(config_overrides: dict, num_steps: int = 500, num_agents: int = 10) -> SimulationMetrics: + """Run a simulation with custom config and return metrics.""" + # Apply config overrides + config_path = Path("config.json") + with open(config_path) as f: + config = json.load(f) + + # Deep merge overrides + for section, values in config_overrides.items(): + if section in config: + config[section].update(values) + else: + config[section] = values + + # Save temp config + temp_config = Path("config_temp.json") + with open(temp_config, 'w') as f: + json.dump(config, f, indent=2) + + # Reload config + reload_config(str(temp_config)) + reset_action_config_cache() + reset_resource_cache() + + # Initialize engine + engine = GameEngine._instance = None # Reset singleton + engine = GameEngine() + engine.reset() + engine.world.config.initial_agents = num_agents + engine.world.initialize() + + # Suppress logging + import logging + logging.getLogger("simulation").setLevel(logging.ERROR) + + metrics = SimulationMetrics() + + # Run simulation + for step in range(num_steps): + if not engine.is_running: + break + + turn_log = engine.next_step() + metrics.total_turns += 1 + + # Process actions + for action_data in turn_log.agent_actions: + decision = action_data.get("decision", {}) + result = action_data.get("result", {}) + action_type = decision.get("action", "") + + # Count actions + if action_type == "hunt": + metrics.hunt_actions += 1 + elif action_type == "gather": + metrics.gather_actions += 1 + elif action_type == "chop_wood": + metrics.chop_wood_actions += 1 + elif action_type == "get_water": + metrics.get_water_actions += 1 + elif action_type == "trade": + metrics.trade_actions += 1 + if result and result.get("success"): + metrics.trade_success += 1 + + # Parse trade message + message = result.get("message", "") + if "Listed" in message: + metrics.market_listings += 1 + elif "Bought" in message: + match = re.search(r"Bought (\d+) (\w+) for (\d+)c", message) + if match: + qty = int(match.group(1)) + res = match.group(2) + value = int(match.group(3)) + metrics.completed_trades += 1 + metrics.total_trade_value += value + + if res == "meat": + metrics.trades_meat += 1 + elif res == "berries": + metrics.trades_berries += 1 + elif res == "wood": + metrics.trades_wood += 1 + elif res == "water": + metrics.trades_water += 1 + + # Track production + if result and result.get("success"): + for res in result.get("resources_gained", []): + res_type = res.get("type", "") + qty = res.get("quantity", 0) + if res_type == "meat": + metrics.meat_produced += qty + elif res_type == "berries": + metrics.berries_produced += qty + elif res_type == "wood": + metrics.wood_produced += qty + elif res_type == "water": + metrics.water_produced += qty + + # Process deaths + metrics.total_deaths += len(turn_log.deaths) + + metrics.final_population = len(engine.world.get_living_agents()) + + # Cleanup + engine.logger.close() + temp_config.unlink(missing_ok=True) + + return metrics + + +def generate_random_config() -> dict: + """Generate a random configuration to test.""" + return { + "agent_stats": { + "start_hunger": random.randint(70, 90), + "start_thirst": random.randint(60, 80), + "hunger_decay": random.randint(1, 3), + "thirst_decay": random.randint(2, 4), + "heat_decay": random.randint(1, 3), + }, + "resources": { + "meat_hunger": random.randint(30, 50), + "berries_hunger": random.randint(8, 15), + "water_thirst": random.randint(40, 60), + }, + "actions": { + "hunt_energy": random.randint(-9, -5), + "gather_energy": random.randint(-5, -3), + "hunt_success": round(random.uniform(0.6, 0.9), 2), + "hunt_meat_min": random.randint(2, 3), + "hunt_meat_max": random.randint(4, 6), + }, + "economy": { + "energy_to_money_ratio": round(random.uniform(1.0, 2.0), 2), + "buy_efficiency_threshold": round(random.uniform(0.6, 0.9), 2), + "wealth_desire": round(random.uniform(0.2, 0.5), 2), + "min_wealth_target": random.randint(30, 80), + }, + } + + +def mutate_config(config: dict, mutation_rate: float = 0.3) -> dict: + """Mutate a configuration slightly.""" + new_config = json.loads(json.dumps(config)) # Deep copy + + for section, values in new_config.items(): + for key, value in values.items(): + if random.random() < mutation_rate: + if isinstance(value, int): + # Mutate by ±20% + delta = max(1, abs(value) // 5) + new_config[section][key] = value + random.randint(-delta, delta) + elif isinstance(value, float): + # Mutate by ±10% + delta = abs(value) * 0.1 + new_config[section][key] = round(value + random.uniform(-delta, delta), 2) + + return new_config + + +def crossover_configs(config1: dict, config2: dict) -> dict: + """Crossover two configurations.""" + new_config = {} + for section in set(config1.keys()) | set(config2.keys()): + if section in config1 and section in config2: + new_config[section] = {} + for key in set(config1[section].keys()) | set(config2[section].keys()): + if random.random() < 0.5: + if key in config1[section]: + new_config[section][key] = config1[section][key] + else: + if key in config2[section]: + new_config[section][key] = config2[section][key] + elif section in config1: + new_config[section] = config1[section].copy() + else: + new_config[section] = config2[section].copy() + return new_config + + +def print_metrics(metrics: SimulationMetrics, config: dict = None): + """Print metrics in a nice format.""" + print(f"\n 📊 Score: {metrics.score():.1f}/100") + print(f" ├─ Trades: {metrics.completed_trades} ({metrics.trades_per_turn:.2f}/turn)") + print(f" ├─ Trade diversity: {metrics.trade_diversity*100:.0f}%") + print(f" │ └─ meat:{metrics.trades_meat} berries:{metrics.trades_berries} wood:{metrics.trades_wood} water:{metrics.trades_water}") + print(f" ├─ Hunt ratio: {metrics.hunt_ratio*100:.1f}% ({metrics.hunt_actions}/{metrics.hunt_actions + metrics.gather_actions})") + print(f" ├─ Survival: {metrics.survival_rate*100:.0f}% ({metrics.final_population} alive)") + print(f" └─ Trade success: {metrics.trade_success_rate*100:.0f}%") + + +def optimize_economy(iterations: int = 20, steps_per_sim: int = 500, population_size: int = 6): + """Run genetic optimization to find best config.""" + print("\n" + "=" * 70) + print("🧬 ECONOMY OPTIMIZER - Genetic Algorithm") + print("=" * 70) + print(f" Iterations: {iterations}") + print(f" Steps per simulation: {steps_per_sim}") + print(f" Population size: {population_size}") + print("=" * 70) + + # Initialize population with random configs + population = [] + + # Include a "sane defaults" config as baseline + baseline_config = { + "agent_stats": { + "start_hunger": 80, + "start_thirst": 70, + "hunger_decay": 2, + "thirst_decay": 3, + "heat_decay": 2, + }, + "resources": { + "meat_hunger": 40, + "berries_hunger": 10, + "water_thirst": 50, + }, + "actions": { + "hunt_energy": -6, # Reduced from -7 + "gather_energy": -4, + "hunt_success": 0.80, # Increased from 0.75 + "hunt_meat_min": 3, # Increased from 2 + "hunt_meat_max": 5, # Increased from 4 + }, + "economy": { + "energy_to_money_ratio": 1.2, + "buy_efficiency_threshold": 0.85, # More willing to buy + "wealth_desire": 0.25, + "min_wealth_target": 40, + }, + } + population.append(baseline_config) + + # Add more hunting-focused config + hunt_focused = { + "agent_stats": { + "start_hunger": 75, + "start_thirst": 65, + "hunger_decay": 2, + "thirst_decay": 3, + "heat_decay": 2, + }, + "resources": { + "meat_hunger": 50, # More valuable meat + "meat_energy": 15, # More energy from meat + "berries_hunger": 8, # Less valuable berries + "water_thirst": 50, + }, + "actions": { + "hunt_energy": -5, # Cheaper hunting + "gather_energy": -5, # Same as hunting + "hunt_success": 0.85, # Higher success + "hunt_meat_min": 3, + "hunt_meat_max": 5, + "hunt_hide_min": 1, # Always get hide + "hunt_hide_max": 2, + }, + "economy": { + "energy_to_money_ratio": 1.5, + "buy_efficiency_threshold": 0.9, # Very willing to buy + "wealth_desire": 0.3, + "min_wealth_target": 30, + }, + } + population.append(hunt_focused) + + # Fill rest with random + while len(population) < population_size: + population.append(generate_random_config()) + + best_config = None + best_score = 0 + best_metrics = None + + for gen in range(iterations): + print(f"\n📍 Generation {gen + 1}/{iterations}") + print("-" * 40) + + # Evaluate all configs + scored_population = [] + for i, config in enumerate(population): + sys.stdout.write(f"\r Evaluating config {i + 1}/{len(population)}...") + sys.stdout.flush() + + metrics = run_quick_simulation(config, steps_per_sim) + score = metrics.score() + scored_population.append((config, metrics, score)) + + # Sort by score + scored_population.sort(key=lambda x: x[2], reverse=True) + + # Print top results + print(f"\r Top configs this generation:") + for i, (config, metrics, score) in enumerate(scored_population[:3]): + print(f"\n #{i + 1}: Score {score:.1f}") + print_metrics(metrics) + + # Track best overall + if scored_population[0][2] > best_score: + best_config = scored_population[0][0] + best_score = scored_population[0][2] + best_metrics = scored_population[0][1] + print(f"\n ⭐ New best score: {best_score:.1f}") + + # Create next generation + new_population = [] + + # Keep top 2 (elitism) + new_population.append(scored_population[0][0]) + new_population.append(scored_population[1][0]) + + # Crossover and mutate + while len(new_population) < population_size: + # Select parents (tournament selection) + parent1 = random.choice(scored_population[:3])[0] + parent2 = random.choice(scored_population[:4])[0] + + # Crossover + child = crossover_configs(parent1, parent2) + + # Mutate + child = mutate_config(child, mutation_rate=0.25) + + new_population.append(child) + + population = new_population + + print("\n" + "=" * 70) + print("🏆 OPTIMIZATION COMPLETE") + print("=" * 70) + + print(f"\n Best Score: {best_score:.1f}/100") + print_metrics(best_metrics) + + print("\n 📝 Best Configuration:") + print("-" * 40) + print(json.dumps(best_config, indent=2)) + + # Save best config + output_path = Path("config_optimized.json") + + # Merge with original config + with open("config.json") as f: + full_config = json.load(f) + + for section, values in best_config.items(): + if section in full_config: + full_config[section].update(values) + else: + full_config[section] = values + + with open(output_path, 'w') as f: + json.dump(full_config, f, indent=2) + + print(f"\n ✅ Saved to: {output_path}") + print("\n To apply: cp config_optimized.json config.json") + + return best_config, best_metrics + + +def quick_test_config(config_overrides: dict, steps: int = 300): + """Quickly test a specific config.""" + print("\n🧪 Testing configuration...") + print("-" * 40) + print(json.dumps(config_overrides, indent=2)) + print("-" * 40) + + metrics = run_quick_simulation(config_overrides, steps) + print_metrics(metrics) + return metrics + + +def main(): + parser = argparse.ArgumentParser(description="Optimize Village Simulation economy") + parser.add_argument("--iterations", "-i", type=int, default=15, help="Optimization iterations") + parser.add_argument("--steps", "-s", type=int, default=400, help="Steps per simulation") + parser.add_argument("--population", "-p", type=int, default=6, help="Population size for GA") + parser.add_argument("--quick-test", "-q", action="store_true", help="Quick test of preset config") + + args = parser.parse_args() + + if args.quick_test: + # Test a specific configuration + test_config = { + "agent_stats": { + "start_hunger": 75, + "hunger_decay": 2, + "thirst_decay": 3, + }, + "resources": { + "meat_hunger": 45, + "meat_energy": 12, + "berries_hunger": 8, + }, + "actions": { + "hunt_energy": -5, + "gather_energy": -5, + "hunt_success": 0.85, + "hunt_meat_min": 3, + "hunt_meat_max": 5, + }, + "economy": { + "buy_efficiency_threshold": 0.9, + "min_wealth_target": 30, + }, + } + quick_test_config(test_config, args.steps) + else: + optimize_economy(args.iterations, args.steps, args.population) + + +if __name__ == "__main__": + main() + diff --git a/tools/run_headless_analysis.py b/tools/run_headless_analysis.py new file mode 100644 index 0000000..0e82294 --- /dev/null +++ b/tools/run_headless_analysis.py @@ -0,0 +1,740 @@ +#!/usr/bin/env python3 +""" +Headless Simulation Runner & Analyzer + +This script runs the Village Simulation in headless mode for a specified number +of steps, then generates comprehensive statistics, plots, and tables for analysis. + +Usage: + python tools/run_headless_analysis.py [--steps 1000] [--agents 10] +""" + +import argparse +import json +import sys +from collections import Counter, defaultdict +from dataclasses import dataclass, field +from datetime import datetime +from pathlib import Path +from typing import Optional + +# Add parent directory for imports +sys.path.insert(0, str(Path(__file__).parent.parent)) + +from backend.core.engine import GameEngine, get_engine +from backend.core.logger import reset_simulation_logger + + +@dataclass +class SimulationStats: + """Collected statistics from a simulation run.""" + total_turns: int = 0 + total_trades: int = 0 # Completed buy transactions + total_listings: int = 0 # Items listed for sale + total_deaths: int = 0 + + # Completed trade statistics (actual buy transactions) + trades_by_resource: dict = field(default_factory=lambda: defaultdict(int)) + trade_volume_by_resource: dict = field(default_factory=lambda: defaultdict(int)) + trade_value_by_resource: dict = field(default_factory=lambda: defaultdict(int)) + + # Market listing statistics (items put up for sale) + listings_by_resource: dict = field(default_factory=lambda: defaultdict(int)) + listings_volume_by_resource: dict = field(default_factory=lambda: defaultdict(int)) + + # Action statistics + actions_count: dict = field(default_factory=lambda: defaultdict(int)) + actions_success: dict = field(default_factory=lambda: defaultdict(int)) + actions_failure: dict = field(default_factory=lambda: defaultdict(int)) + + # Resource production + resources_produced: dict = field(default_factory=lambda: defaultdict(int)) + resources_consumed: dict = field(default_factory=lambda: defaultdict(int)) + + # Agent statistics + deaths_by_cause: dict = field(default_factory=lambda: defaultdict(int)) + agent_survival_turns: dict = field(default_factory=dict) + living_agents_over_time: list = field(default_factory=list) + + # Economy + money_circulation_over_time: list = field(default_factory=list) + avg_agent_money_over_time: list = field(default_factory=list) + price_history: dict = field(default_factory=lambda: defaultdict(list)) + + # Time tracking + actions_by_time_of_day: dict = field(default_factory=lambda: {"day": defaultdict(int), "night": defaultdict(int)}) + + +def run_headless_simulation(num_steps: int = 1000, num_agents: int = 10) -> tuple[str, SimulationStats]: + """ + Run the simulation in headless mode. + + Returns: + Tuple of (log_file_path, collected_stats) + """ + print(f"\n{'='*60}") + print(f"🏘️ VILLAGE SIMULATION - HEADLESS MODE") + print(f"{'='*60}") + print(f" Steps: {num_steps}") + print(f" Agents: {num_agents}") + print(f" Started: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}") + print(f"{'='*60}\n") + + # Initialize engine + engine = get_engine() + engine.reset() + engine.world.config.initial_agents = num_agents + engine.world.initialize() + + # Get log file path + log_file = engine.logger.session_file + + # Statistics collector + stats = SimulationStats() + agent_first_seen = {} + + # Progress tracking + progress_interval = max(1, num_steps // 20) + + # Run simulation + for step in range(num_steps): + if not engine.is_running: + print(f"\n⚠️ Simulation stopped at step {step} - all agents died!") + break + + turn_log = engine.next_step() + stats.total_turns += 1 + + # Collect turn statistics + current_turn = turn_log.turn + time_of_day = engine.world.time_of_day.value + + # Track living agents + living = len(engine.world.get_living_agents()) + stats.living_agents_over_time.append(living) + + # Track money + total_money = sum(a.money for a in engine.world.agents) + stats.money_circulation_over_time.append(total_money) + if living > 0: + stats.avg_agent_money_over_time.append(total_money / living) + else: + stats.avg_agent_money_over_time.append(0) + + # Process agent actions + for action_data in turn_log.agent_actions: + agent_id = action_data.get("agent_id") + agent_name = action_data.get("agent_name") + decision = action_data.get("decision", {}) + result = action_data.get("result", {}) + + if agent_id and agent_id not in agent_first_seen: + agent_first_seen[agent_id] = current_turn + + action_type = decision.get("action", "unknown") + stats.actions_count[action_type] += 1 + stats.actions_by_time_of_day[time_of_day][action_type] += 1 + + if result: + if result.get("success", False): + stats.actions_success[action_type] += 1 + + # Track resources gained + for res in result.get("resources_gained", []): + res_type = res.get("type", "unknown") + quantity = res.get("quantity", 0) + stats.resources_produced[res_type] += quantity + + # Track resources consumed + for res in result.get("resources_consumed", []): + res_type = res.get("type", "unknown") + quantity = res.get("quantity", 0) + stats.resources_consumed[res_type] += quantity + + # Track trade-specific results + if action_type == "trade": + message = result.get("message", "") + # Check if it's a listing (sell) or a purchase (buy) + if "Listed" in message: + # Parse "Listed X resource @ Yc each" + import re + match = re.search(r"Listed (\d+) (\w+) @ (\d+)c", message) + if match: + qty = int(match.group(1)) + res_type = match.group(2) + stats.total_listings += 1 + stats.listings_by_resource[res_type] += 1 + stats.listings_volume_by_resource[res_type] += qty + elif "Bought" in message: + # Parse "Bought X resource for Yc" + import re + match = re.search(r"Bought (\d+) (\w+) for (\d+)c", message) + if match: + qty = int(match.group(1)) + res_type = match.group(2) + value = int(match.group(3)) + stats.total_trades += 1 + stats.trades_by_resource[res_type] += 1 + stats.trade_volume_by_resource[res_type] += qty + stats.trade_value_by_resource[res_type] += value + + # Track price + if qty > 0: + stats.price_history[res_type].append((current_turn, value / qty)) + else: + stats.actions_failure[action_type] += 1 + + # Process deaths + for death_name in turn_log.deaths: + stats.total_deaths += 1 + # Find the agent's death cause from the world + for agent in engine.world.agents: + if agent.name == death_name and agent.death_reason: + stats.deaths_by_cause[agent.death_reason] += 1 + if agent.id in agent_first_seen: + survival = current_turn - agent_first_seen[agent.id] + stats.agent_survival_turns[agent.id] = survival + break + + # Progress update + if (step + 1) % progress_interval == 0 or step == num_steps - 1: + pct = (step + 1) / num_steps * 100 + bar_len = 30 + filled = int(bar_len * pct / 100) + bar = "█" * filled + "░" * (bar_len - filled) + print(f"\r Progress: [{bar}] {pct:5.1f}% | Turn {step+1}/{num_steps} | Alive: {living}", end="") + + print("\n") + + # Close logger + engine.logger.close() + + return str(log_file), stats + + +def analyze_log_file(log_file: str) -> SimulationStats: + """ + Parse a simulation log file and extract statistics. + Useful for analyzing existing log files. + """ + stats = SimulationStats() + agent_first_seen = {} + + with open(log_file, 'r') as f: + for line in f: + data = json.loads(line) + + if data.get("type") == "config": + continue + + if data.get("type") == "turn": + turn_data = data.get("data", {}) + current_turn = turn_data.get("turn", 0) + time_of_day = turn_data.get("time_of_day", "day") + stats.total_turns += 1 + + # Track living agents + agent_entries = turn_data.get("agent_entries", []) + stats.living_agents_over_time.append(len(agent_entries)) + + # Track money + total_money = sum(a.get("money_after", 0) for a in agent_entries) + stats.money_circulation_over_time.append(total_money) + if agent_entries: + stats.avg_agent_money_over_time.append(total_money / len(agent_entries)) + else: + stats.avg_agent_money_over_time.append(0) + + # Process agent entries + for agent in agent_entries: + agent_id = agent.get("agent_id") + decision = agent.get("decision", {}) + result = agent.get("action_result", {}) + + if agent_id and agent_id not in agent_first_seen: + agent_first_seen[agent_id] = current_turn + + action_type = decision.get("action", "unknown") + stats.actions_count[action_type] += 1 + stats.actions_by_time_of_day[time_of_day][action_type] += 1 + + if result.get("success", False): + stats.actions_success[action_type] += 1 + for res in result.get("resources_gained", []): + stats.resources_produced[res.get("type", "unknown")] += res.get("quantity", 0) + + # Track trade-specific results + if action_type == "trade": + import re + message = result.get("message", "") + if "Listed" in message: + match = re.search(r"Listed (\d+) (\w+) @ (\d+)c", message) + if match: + qty = int(match.group(1)) + res_type = match.group(2) + stats.total_listings += 1 + stats.listings_by_resource[res_type] += 1 + stats.listings_volume_by_resource[res_type] += qty + elif "Bought" in message: + match = re.search(r"Bought (\d+) (\w+) for (\d+)c", message) + if match: + qty = int(match.group(1)) + res_type = match.group(2) + value = int(match.group(3)) + stats.total_trades += 1 + stats.trades_by_resource[res_type] += 1 + stats.trade_volume_by_resource[res_type] += qty + stats.trade_value_by_resource[res_type] += value + if qty > 0: + stats.price_history[res_type].append((current_turn, value / qty)) + else: + stats.actions_failure[action_type] += 1 + + # Process deaths + for death in turn_data.get("deaths", []): + stats.total_deaths += 1 + cause = death.get("cause", "unknown") if isinstance(death, dict) else "unknown" + stats.deaths_by_cause[cause] += 1 + + return stats + + +def generate_text_report(stats: SimulationStats) -> str: + """Generate a text-based statistical report.""" + lines = [] + + lines.append("\n" + "=" * 70) + lines.append("📊 SIMULATION ANALYSIS REPORT") + lines.append("=" * 70) + + # Overview + lines.append("\n📋 OVERVIEW") + lines.append("-" * 40) + lines.append(f" Total Turns Simulated: {stats.total_turns:,}") + lines.append(f" Market Listings: {stats.total_listings:,}") + lines.append(f" Completed Trades: {stats.total_trades:,}") + lines.append(f" Total Deaths: {stats.total_deaths:,}") + lines.append(f" Final Living Agents: {stats.living_agents_over_time[-1] if stats.living_agents_over_time else 0}") + + # Market Listings Statistics + lines.append("\n\n🏪 MARKET LISTINGS (Items Listed for Sale)") + lines.append("-" * 40) + if stats.listings_by_resource: + lines.append(f" {'Resource':<15} {'Listings':>10} {'Volume':>10}") + lines.append(f" {'-'*15} {'-'*10} {'-'*10}") + for resource in sorted(stats.listings_by_resource.keys()): + count = stats.listings_by_resource[resource] + volume = stats.listings_volume_by_resource[resource] + lines.append(f" {resource:<15} {count:>10,} {volume:>10,}") + + total_listings_volume = sum(stats.listings_volume_by_resource.values()) + lines.append(f" {'-'*15} {'-'*10} {'-'*10}") + lines.append(f" {'TOTAL':<15} {stats.total_listings:>10,} {total_listings_volume:>10,}") + else: + lines.append(" No items listed for sale") + + # Completed Trade Statistics + lines.append("\n\n💰 COMPLETED TRADES (Items Purchased)") + lines.append("-" * 40) + if stats.trades_by_resource: + lines.append(f" {'Resource':<15} {'Trades':>10} {'Volume':>10} {'Value':>12}") + lines.append(f" {'-'*15} {'-'*10} {'-'*10} {'-'*12}") + for resource in sorted(stats.trades_by_resource.keys()): + count = stats.trades_by_resource[resource] + volume = stats.trade_volume_by_resource[resource] + value = stats.trade_value_by_resource[resource] + lines.append(f" {resource:<15} {count:>10,} {volume:>10,} {value:>10,} ¢") + + total_volume = sum(stats.trade_volume_by_resource.values()) + total_value = sum(stats.trade_value_by_resource.values()) + lines.append(f" {'-'*15} {'-'*10} {'-'*10} {'-'*12}") + lines.append(f" {'TOTAL':<15} {stats.total_trades:>10,} {total_volume:>10,} {total_value:>10,} ¢") + + # Average price per resource + lines.append("\n 📊 Average Prices:") + for resource in sorted(stats.trades_by_resource.keys()): + volume = stats.trade_volume_by_resource[resource] + value = stats.trade_value_by_resource[resource] + if volume > 0: + avg_price = value / volume + lines.append(f" {resource}: {avg_price:.2f}¢ per unit") + else: + lines.append(" No completed trades recorded") + lines.append(" (Items may be listed but no buyers matched)") + + # Most traded items + if stats.trades_by_resource: + lines.append("\n 🏆 Most Traded Items (by number of trades):") + for i, (resource, count) in enumerate(sorted(stats.trades_by_resource.items(), key=lambda x: -x[1])[:5], 1): + lines.append(f" {i}. {resource}: {count:,} trades") + + # Action Statistics + lines.append("\n\n⚡ ACTION STATISTICS") + lines.append("-" * 40) + lines.append(f" {'Action':<15} {'Total':>10} {'Success':>10} {'Failure':>10} {'Rate':>10}") + lines.append(f" {'-'*15} {'-'*10} {'-'*10} {'-'*10} {'-'*10}") + for action in sorted(stats.actions_count.keys()): + total = stats.actions_count[action] + success = stats.actions_success.get(action, 0) + failure = stats.actions_failure.get(action, 0) + rate = (success / total * 100) if total > 0 else 0 + lines.append(f" {action:<15} {total:>10,} {success:>10,} {failure:>10,} {rate:>9.1f}%") + + # Resource Production + lines.append("\n\n🌾 RESOURCE PRODUCTION") + lines.append("-" * 40) + if stats.resources_produced: + lines.append(f" {'Resource':<15} {'Produced':>12}") + lines.append(f" {'-'*15} {'-'*12}") + for resource in sorted(stats.resources_produced.keys()): + produced = stats.resources_produced[resource] + lines.append(f" {resource:<15} {produced:>12,}") + else: + lines.append(" No resources produced") + + # Deaths by Cause + lines.append("\n\n💀 DEATHS BY CAUSE") + lines.append("-" * 40) + if stats.deaths_by_cause: + for cause, count in sorted(stats.deaths_by_cause.items(), key=lambda x: -x[1]): + pct = (count / stats.total_deaths * 100) if stats.total_deaths > 0 else 0 + lines.append(f" {cause:<20} {count:>5} ({pct:5.1f}%)") + else: + lines.append(" No deaths recorded") + + # Day vs Night Activity + lines.append("\n\n🌓 ACTIVITY BY TIME OF DAY") + lines.append("-" * 40) + day_total = sum(stats.actions_by_time_of_day["day"].values()) + night_total = sum(stats.actions_by_time_of_day["night"].values()) + lines.append(f" Day actions: {day_total:>10,}") + lines.append(f" Night actions: {night_total:>10,}") + + if stats.actions_by_time_of_day["day"]: + lines.append("\n Top Day Activities:") + for action, count in sorted(stats.actions_by_time_of_day["day"].items(), key=lambda x: -x[1])[:5]: + lines.append(f" - {action}: {count:,}") + + if stats.actions_by_time_of_day["night"]: + lines.append("\n Top Night Activities:") + for action, count in sorted(stats.actions_by_time_of_day["night"].items(), key=lambda x: -x[1])[:5]: + lines.append(f" - {action}: {count:,}") + + lines.append("\n" + "=" * 70) + + return "\n".join(lines) + + +def generate_plots(stats: SimulationStats, output_dir: Path): + """Generate matplotlib plots for visualization.""" + try: + import matplotlib.pyplot as plt + import matplotlib.patches as mpatches + import numpy as np + except ImportError: + print("⚠️ matplotlib not installed. Skipping plot generation.") + print(" Install with: pip install matplotlib") + return + + # Set style + plt.style.use('seaborn-v0_8-darkgrid' if 'seaborn-v0_8-darkgrid' in plt.style.available else 'ggplot') + + # Color palette + colors = ['#FF6B6B', '#4ECDC4', '#45B7D1', '#96CEB4', '#FFEAA7', '#DDA0DD', '#98D8C8', '#F7DC6F'] + + # Create output directory + output_dir.mkdir(exist_ok=True) + + # 1. Population Over Time + if stats.living_agents_over_time: + fig, ax = plt.subplots(figsize=(12, 5)) + turns = range(1, len(stats.living_agents_over_time) + 1) + ax.fill_between(turns, stats.living_agents_over_time, alpha=0.3, color=colors[0]) + ax.plot(turns, stats.living_agents_over_time, color=colors[0], linewidth=2) + ax.set_xlabel('Turn', fontsize=12) + ax.set_ylabel('Living Agents', fontsize=12) + ax.set_title('🏘️ Population Over Time', fontsize=14, fontweight='bold') + ax.set_ylim(bottom=0) + plt.tight_layout() + plt.savefig(output_dir / 'population_over_time.png', dpi=150, bbox_inches='tight') + plt.close() + print(f" 📈 Saved: population_over_time.png") + + # 2. Money Circulation + if stats.avg_agent_money_over_time: + fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(14, 5)) + turns = range(1, len(stats.avg_agent_money_over_time) + 1) + + ax1.plot(turns, stats.money_circulation_over_time, color=colors[1], linewidth=2) + ax1.set_xlabel('Turn', fontsize=12) + ax1.set_ylabel('Total Money (¢)', fontsize=12) + ax1.set_title('💰 Total Money in Circulation', fontsize=14, fontweight='bold') + + ax2.plot(turns, stats.avg_agent_money_over_time, color=colors[2], linewidth=2) + ax2.set_xlabel('Turn', fontsize=12) + ax2.set_ylabel('Average Money (¢)', fontsize=12) + ax2.set_title('💵 Average Money per Agent', fontsize=14, fontweight='bold') + + plt.tight_layout() + plt.savefig(output_dir / 'money_circulation.png', dpi=150, bbox_inches='tight') + plt.close() + print(f" 📈 Saved: money_circulation.png") + + # 3. Action Distribution (Pie Chart) + if stats.actions_count: + fig, ax = plt.subplots(figsize=(10, 8)) + actions = list(stats.actions_count.keys()) + counts = list(stats.actions_count.values()) + + # Sort by count + sorted_pairs = sorted(zip(actions, counts), key=lambda x: -x[1]) + actions, counts = zip(*sorted_pairs) + + wedges, texts, autotexts = ax.pie( + counts, + labels=actions, + autopct=lambda pct: f'{pct:.1f}%' if pct > 3 else '', + colors=colors[:len(actions)], + explode=[0.02] * len(actions), + shadow=True + ) + ax.set_title('⚡ Action Distribution', fontsize=14, fontweight='bold') + plt.tight_layout() + plt.savefig(output_dir / 'action_distribution.png', dpi=150, bbox_inches='tight') + plt.close() + print(f" 📈 Saved: action_distribution.png") + + # 4. Trade Volume by Resource (Bar Chart) + if stats.trades_by_resource: + fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(14, 6)) + + resources = list(stats.trades_by_resource.keys()) + trade_counts = [stats.trades_by_resource[r] for r in resources] + volumes = [stats.trade_volume_by_resource[r] for r in resources] + + # Sort by trade count + sorted_data = sorted(zip(resources, trade_counts, volumes), key=lambda x: -x[1]) + resources, trade_counts, volumes = zip(*sorted_data) if sorted_data else ([], [], []) + + bars1 = ax1.barh(resources, trade_counts, color=colors[:len(resources)]) + ax1.set_xlabel('Number of Trades', fontsize=12) + ax1.set_title('📊 Trades by Resource', fontsize=14, fontweight='bold') + ax1.invert_yaxis() + + # Add value labels + for bar, val in zip(bars1, trade_counts): + ax1.text(bar.get_width() + 0.5, bar.get_y() + bar.get_height()/2, + f'{val:,}', va='center', fontsize=10) + + bars2 = ax2.barh(resources, volumes, color=colors[:len(resources)]) + ax2.set_xlabel('Total Volume', fontsize=12) + ax2.set_title('📦 Trade Volume by Resource', fontsize=14, fontweight='bold') + ax2.invert_yaxis() + + for bar, val in zip(bars2, volumes): + ax2.text(bar.get_width() + 0.5, bar.get_y() + bar.get_height()/2, + f'{val:,}', va='center', fontsize=10) + + plt.tight_layout() + plt.savefig(output_dir / 'trade_statistics.png', dpi=150, bbox_inches='tight') + plt.close() + print(f" 📈 Saved: trade_statistics.png") + + # 5. Resource Production (Horizontal Bar) + if stats.resources_produced: + fig, ax = plt.subplots(figsize=(10, 6)) + + resources = list(stats.resources_produced.keys()) + produced = list(stats.resources_produced.values()) + + sorted_data = sorted(zip(resources, produced), key=lambda x: -x[1]) + resources, produced = zip(*sorted_data) + + bars = ax.barh(resources, produced, color=colors[:len(resources)]) + ax.set_xlabel('Quantity Produced', fontsize=12) + ax.set_title('🌾 Resource Production', fontsize=14, fontweight='bold') + ax.invert_yaxis() + + for bar, val in zip(bars, produced): + ax.text(bar.get_width() + 0.5, bar.get_y() + bar.get_height()/2, + f'{val:,}', va='center', fontsize=10) + + plt.tight_layout() + plt.savefig(output_dir / 'resource_production.png', dpi=150, bbox_inches='tight') + plt.close() + print(f" 📈 Saved: resource_production.png") + + # 6. Deaths by Cause (Pie Chart) + if stats.deaths_by_cause: + fig, ax = plt.subplots(figsize=(8, 8)) + causes = list(stats.deaths_by_cause.keys()) + counts = list(stats.deaths_by_cause.values()) + + death_colors = ['#FF6B6B', '#FF8E72', '#FFA07A', '#FFB6A3', '#FFCCBC'] + + wedges, texts, autotexts = ax.pie( + counts, + labels=causes, + autopct='%1.1f%%', + colors=death_colors[:len(causes)], + explode=[0.02] * len(causes), + shadow=True + ) + ax.set_title('💀 Deaths by Cause', fontsize=14, fontweight='bold') + plt.tight_layout() + plt.savefig(output_dir / 'deaths_by_cause.png', dpi=150, bbox_inches='tight') + plt.close() + print(f" 📈 Saved: deaths_by_cause.png") + + # 7. Action Success Rates + if stats.actions_count: + fig, ax = plt.subplots(figsize=(12, 6)) + + actions = list(stats.actions_count.keys()) + success_rates = [] + totals = [] + + for action in actions: + total = stats.actions_count[action] + success = stats.actions_success.get(action, 0) + rate = (success / total * 100) if total > 0 else 0 + success_rates.append(rate) + totals.append(total) + + # Sort by success rate + sorted_data = sorted(zip(actions, success_rates, totals), key=lambda x: -x[1]) + actions, success_rates, totals = zip(*sorted_data) + + bars = ax.barh(actions, success_rates, color=colors[:len(actions)]) + ax.set_xlabel('Success Rate (%)', fontsize=12) + ax.set_xlim(0, 105) + ax.set_title('✅ Action Success Rates', fontsize=14, fontweight='bold') + ax.invert_yaxis() + + for bar, rate, total in zip(bars, success_rates, totals): + ax.text(bar.get_width() + 1, bar.get_y() + bar.get_height()/2, + f'{rate:.1f}% (n={total:,})', va='center', fontsize=10) + + plt.tight_layout() + plt.savefig(output_dir / 'action_success_rates.png', dpi=150, bbox_inches='tight') + plt.close() + print(f" 📈 Saved: action_success_rates.png") + + # 8. Price History (if we have trade data) + if stats.price_history: + fig, ax = plt.subplots(figsize=(12, 6)) + + for i, (resource, prices) in enumerate(stats.price_history.items()): + if prices: + turns, price_values = zip(*prices) + # Rolling average for smoother lines + if len(price_values) > 10: + window = min(10, len(price_values) // 5) + smoothed = np.convolve(price_values, np.ones(window)/window, mode='valid') + ax.plot(range(len(smoothed)), smoothed, label=resource, + color=colors[i % len(colors)], linewidth=2) + else: + ax.scatter(turns, price_values, label=resource, + color=colors[i % len(colors)], s=30, alpha=0.7) + + ax.set_xlabel('Turn', fontsize=12) + ax.set_ylabel('Price per Unit (¢)', fontsize=12) + ax.set_title('📈 Price History by Resource', fontsize=14, fontweight='bold') + ax.legend(loc='upper right') + plt.tight_layout() + plt.savefig(output_dir / 'price_history.png', dpi=150, bbox_inches='tight') + plt.close() + print(f" 📈 Saved: price_history.png") + + # 9. Day vs Night Activity Comparison + if any(stats.actions_by_time_of_day["day"].values()) or any(stats.actions_by_time_of_day["night"].values()): + fig, ax = plt.subplots(figsize=(12, 6)) + + all_actions = set(stats.actions_by_time_of_day["day"].keys()) | set(stats.actions_by_time_of_day["night"].keys()) + all_actions = sorted(all_actions) + + day_counts = [stats.actions_by_time_of_day["day"].get(a, 0) for a in all_actions] + night_counts = [stats.actions_by_time_of_day["night"].get(a, 0) for a in all_actions] + + x = np.arange(len(all_actions)) + width = 0.35 + + bars1 = ax.bar(x - width/2, day_counts, width, label='Day', color='#FFD93D') + bars2 = ax.bar(x + width/2, night_counts, width, label='Night', color='#6C5CE7') + + ax.set_xlabel('Action', fontsize=12) + ax.set_ylabel('Count', fontsize=12) + ax.set_title('🌓 Actions by Time of Day', fontsize=14, fontweight='bold') + ax.set_xticks(x) + ax.set_xticklabels(all_actions, rotation=45, ha='right') + ax.legend() + + plt.tight_layout() + plt.savefig(output_dir / 'day_night_activity.png', dpi=150, bbox_inches='tight') + plt.close() + print(f" 📈 Saved: day_night_activity.png") + + print(f"\n 📁 All plots saved to: {output_dir}") + + +def main(): + parser = argparse.ArgumentParser( + description="Run Village Simulation in headless mode and generate analysis" + ) + parser.add_argument( + "--steps", "-s", type=int, default=1000, + help="Number of simulation steps to run (default: 1000)" + ) + parser.add_argument( + "--agents", "-a", type=int, default=10, + help="Number of initial agents (default: 10)" + ) + parser.add_argument( + "--analyze-only", "-f", type=str, default=None, + help="Skip simulation, analyze existing log file instead" + ) + parser.add_argument( + "--output-dir", "-o", type=str, default=None, + help="Directory for output plots (default: logs/analysis_TIMESTAMP)" + ) + + args = parser.parse_args() + + # Determine output directory + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") + if args.output_dir: + output_dir = Path(args.output_dir) + else: + output_dir = Path("logs") / f"analysis_{timestamp}" + + # Run simulation or load existing log + if args.analyze_only: + print(f"\n📂 Analyzing existing log file: {args.analyze_only}") + stats = analyze_log_file(args.analyze_only) + log_file = args.analyze_only + else: + log_file, stats = run_headless_simulation(args.steps, args.agents) + + # Generate text report + report = generate_text_report(stats) + print(report) + + # Save report to file + report_file = output_dir / "analysis_report.txt" + output_dir.mkdir(parents=True, exist_ok=True) + with open(report_file, 'w') as f: + f.write(report) + print(f"\n📄 Report saved to: {report_file}") + + # Generate plots + print("\n🎨 Generating plots...") + generate_plots(stats, output_dir) + + print(f"\n✅ Analysis complete!") + print(f" Log file: {log_file}") + print(f" Output: {output_dir}") + + +if __name__ == "__main__": + main() +