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:
parent
59ac1133ce
commit
396980a523
4
.gitignore
vendored
Normal file
4
.gitignore
vendored
Normal file
@ -0,0 +1,4 @@
|
||||
.venv
|
||||
__pycache__
|
||||
logs
|
||||
*.xlsx
|
||||
234
README.md
Normal file
234
README.md
Normal 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
2
backend/__init__.py
Normal file
@ -0,0 +1,2 @@
|
||||
"""Backend package for the Village Simulation."""
|
||||
|
||||
18
backend/api/__init__.py
Normal file
18
backend/api/__init__.py
Normal 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
303
backend/api/routes.py
Normal 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
185
backend/api/schemas.py
Normal 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
255
backend/config.py
Normal 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
20
backend/core/__init__.py
Normal 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
994
backend/core/ai.py
Normal 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
637
backend/core/engine.py
Normal 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
296
backend/core/logger.py
Normal 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
443
backend/core/market.py
Normal 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
146
backend/core/world.py
Normal 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()
|
||||
|
||||
19
backend/domain/__init__.py
Normal file
19
backend/domain/__init__.py
Normal 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
191
backend/domain/action.py
Normal 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
459
backend/domain/agent.py
Normal 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
180
backend/domain/resources.py
Normal 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
74
backend/main.py
Normal 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
73
config.json
Normal 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
2
frontend/__init__.py
Normal file
@ -0,0 +1,2 @@
|
||||
"""Frontend package for Village Simulation visualization."""
|
||||
|
||||
180
frontend/client.py
Normal file
180
frontend/client.py
Normal 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
297
frontend/main.py
Normal 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()
|
||||
9
frontend/renderer/__init__.py
Normal file
9
frontend/renderer/__init__.py
Normal 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"]
|
||||
|
||||
430
frontend/renderer/agent_renderer.py
Normal file
430
frontend/renderer/agent_renderer.py
Normal 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))
|
||||
146
frontend/renderer/map_renderer.py
Normal file
146
frontend/renderer/map_renderer.py
Normal 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)
|
||||
|
||||
448
frontend/renderer/settings_renderer.py
Normal file
448
frontend/renderer/settings_renderer.py
Normal 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)
|
||||
|
||||
239
frontend/renderer/ui_renderer.py
Normal file
239
frontend/renderer/ui_renderer.py
Normal 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
18
requirements.txt
Normal 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
2
tools/__init__.py
Normal file
@ -0,0 +1,2 @@
|
||||
"""Tools for game balance management."""
|
||||
|
||||
603
tools/config_to_excel.py
Normal file
603
tools/config_to_excel.py
Normal 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
244
tools/excel_to_config.py
Normal 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
565
tools/optimize_economy.py
Normal 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()
|
||||
|
||||
740
tools/run_headless_analysis.py
Normal file
740
tools/run_headless_analysis.py
Normal 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()
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user