741 lines
31 KiB
Python
741 lines
31 KiB
Python
#!/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()
|
|
|