Add initial project structure for Village Simulation, including backend and frontend components, configuration files, and essential documentation. Introduced .gitignore to exclude unnecessary files and added README for project overview and setup instructions.

This commit is contained in:
elit3guzhva 2026-01-18 23:20:42 +03:00
parent 59ac1133ce
commit 396980a523
33 changed files with 8456 additions and 0 deletions

4
.gitignore vendored Normal file
View File

@ -0,0 +1,4 @@
.venv
__pycache__
logs
*.xlsx

234
README.md Normal file
View File

@ -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 <repository-url>
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

2
backend/__init__.py Normal file
View File

@ -0,0 +1,2 @@
"""Backend package for the Village Simulation."""

18
backend/api/__init__.py Normal file
View File

@ -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",
]

303
backend/api/routes.py Normal file
View File

@ -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)}")

185
backend/api/schemas.py Normal file
View File

@ -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

255
backend/config.py Normal file
View File

@ -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

20
backend/core/__init__.py Normal file
View File

@ -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",
]

994
backend/core/ai.py Normal file
View File

@ -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()

637
backend/core/engine.py Normal file
View File

@ -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()

296
backend/core/logger.py Normal file
View File

@ -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

443
backend/core/market.py Normal file
View File

@ -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:]
],
}

146
backend/core/world.py Normal file
View File

@ -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()

View File

@ -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",
]

191
backend/domain/action.py Normal file
View File

@ -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,
}

459
backend/domain/agent.py Normal file
View File

@ -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,
}

180
backend/domain/resources.py Normal file
View File

@ -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,
}

74
backend/main.py Normal file
View File

@ -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()

73
config.json Normal file
View File

@ -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
}

2
frontend/__init__.py Normal file
View File

@ -0,0 +1,2 @@
"""Frontend package for Village Simulation visualization."""

180
frontend/client.py Normal file
View File

@ -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)

297
frontend/main.py Normal file
View File

@ -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()

View File

@ -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"]

View File

@ -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))

View File

@ -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)

View File

@ -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)

View File

@ -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)

18
requirements.txt Normal file
View File

@ -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

2
tools/__init__.py Normal file
View File

@ -0,0 +1,2 @@
"""Tools for game balance management."""

603
tools/config_to_excel.py Normal file
View File

@ -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()

244
tools/excel_to_config.py Normal file
View File

@ -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()

565
tools/optimize_economy.py Normal file
View File

@ -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()

View File

@ -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()