#!/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)}) # Profession and wealth tracking (new) professions_over_time: list = field(default_factory=list) gini_over_time: list = field(default_factory=list) richest_agent_money: list = field(default_factory=list) poorest_agent_money: list = field(default_factory=list) final_agent_stats: list = field(default_factory=list) # List of agent dicts at end 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_agents = engine.world.get_living_agents() living = len(living_agents) stats.living_agents_over_time.append(living) # Track money and wealth inequality if living_agents: moneys = sorted([a.money for a in living_agents]) total_money = sum(moneys) stats.money_circulation_over_time.append(total_money) stats.avg_agent_money_over_time.append(total_money / living) stats.richest_agent_money.append(moneys[-1]) stats.poorest_agent_money.append(moneys[0]) # Track Gini coefficient n = len(moneys) if n > 1 and total_money > 0: sum_of_diffs = sum(abs(m1 - m2) for m1 in moneys for m2 in moneys) gini = sum_of_diffs / (2 * n * total_money) else: gini = 0 stats.gini_over_time.append(gini) # Track profession distribution professions = {} for agent in living_agents: agent._update_profession() prof = agent.profession.value professions[prof] = professions.get(prof, 0) + 1 stats.professions_over_time.append(professions) else: stats.money_circulation_over_time.append(0) stats.avg_agent_money_over_time.append(0) stats.richest_agent_money.append(0) stats.poorest_agent_money.append(0) stats.gini_over_time.append(0) stats.professions_over_time.append({}) # 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") # Collect final agent statistics for agent in engine.world.get_living_agents(): agent._update_profession() stats.final_agent_stats.append({ "name": agent.name, "profession": agent.profession.value, "money": agent.money, "trades": agent.total_trades_completed, "money_earned": agent.total_money_earned, "skills": agent.skills.to_dict(), "personality": { "wealth_desire": round(agent.personality.wealth_desire, 2), "trade_preference": round(agent.personality.trade_preference, 2), "hoarding_rate": round(agent.personality.hoarding_rate, 2), "market_affinity": round(agent.personality.market_affinity, 2), }, "actions": agent.actions_performed.copy(), }) # 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:,}") # Profession Distribution (new) lines.append("\n\n๐Ÿ‘ค PROFESSION DISTRIBUTION") lines.append("-" * 40) if stats.professions_over_time: final_profs = stats.professions_over_time[-1] if final_profs: total_agents = sum(final_profs.values()) lines.append(f" {'Profession':<15} {'Count':>8} {'Percentage':>12}") lines.append(f" {'-'*15} {'-'*8} {'-'*12}") for prof, count in sorted(final_profs.items(), key=lambda x: -x[1]): pct = count / total_agents * 100 if total_agents > 0 else 0 lines.append(f" {prof:<15} {count:>8} {pct:>10.1f}%") else: lines.append(" No agents remaining") else: lines.append(" No profession data") # Wealth Inequality (new) lines.append("\n\n๐Ÿ’Ž WEALTH INEQUALITY") lines.append("-" * 40) if stats.gini_over_time: final_gini = stats.gini_over_time[-1] avg_gini = sum(stats.gini_over_time) / len(stats.gini_over_time) max_gini = max(stats.gini_over_time) lines.append(f" Final Gini Coefficient: {final_gini:.3f}") lines.append(f" Average Gini: {avg_gini:.3f}") lines.append(f" Peak Gini: {max_gini:.3f}") lines.append("") lines.append(f" (0 = perfect equality, 1 = maximum inequality)") if stats.richest_agent_money and stats.poorest_agent_money: lines.append("") lines.append(f" Richest agent at end: {stats.richest_agent_money[-1]}ยข") lines.append(f" Poorest agent at end: {stats.poorest_agent_money[-1]}ยข") wealth_ratio = stats.richest_agent_money[-1] / max(1, stats.poorest_agent_money[-1]) lines.append(f" Wealth ratio (rich/poor): {wealth_ratio:.1f}x") else: lines.append(" No wealth data") # Top Agents by Wealth (new) lines.append("\n\n๐Ÿ† TOP AGENTS BY WEALTH") lines.append("-" * 40) if stats.final_agent_stats: sorted_agents = sorted(stats.final_agent_stats, key=lambda x: -x["money"]) lines.append(f" {'Name':<15} {'Prof':<12} {'Money':>8} {'Trades':>8}") lines.append(f" {'-'*15} {'-'*12} {'-'*8} {'-'*8}") for agent in sorted_agents[:10]: lines.append(f" {agent['name']:<15} {agent['profession']:<12} {agent['money']:>7}ยข {agent['trades']:>8}") # Skill leaders lines.append("\n ๐Ÿ“ˆ Highest Skills:") skill_leaders = {} for agent in stats.final_agent_stats: for skill, value in agent["skills"].items(): if skill not in skill_leaders or value > skill_leaders[skill][1]: skill_leaders[skill] = (agent["name"], value) for skill, (name, value) in sorted(skill_leaders.items()): lines.append(f" {skill}: {name} ({value:.2f})") else: lines.append(" No agent data") 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()