[upd] rebalance professions + add game controls to the stats page

This commit is contained in:
Снесарев Максим 2026-01-19 21:03:30 +03:00
parent 308f738c37
commit 25bd13e001
7 changed files with 350 additions and 167 deletions

View File

@ -202,10 +202,28 @@ def create_gather_action(
if success_chance < 1.0: if success_chance < 1.0:
base_cost *= 1.0 + (1.0 - success_chance) * 0.3 base_cost *= 1.0 + (1.0 - success_chance) * 0.3
# Mild personality adjustments (shouldn't dominate the cost) # STRONG profession specialization effect for gathering
if action_type == ActionType.GATHER: if action_type == ActionType.GATHER:
# Cautious agents slightly prefer gathering # Compare gather_preference to other preferences
base_cost *= (0.9 + state.risk_tolerance * 0.2) # Specialists get big discounts, generalists pay penalty
other_prefs = (state.hunt_preference + state.trade_preference) / 2
relative_strength = state.gather_preference / max(0.1, other_prefs)
# relative_strength > 1.0 means gathering is your specialty
# relative_strength < 1.0 means you're NOT a gatherer
if relative_strength >= 1.0:
# Specialist discount: up to 50% off
preference_modifier = 1.0 / relative_strength
else:
# Non-specialist penalty: up to 3x cost
preference_modifier = 1.0 + (1.0 - relative_strength) * 2.0
base_cost *= preference_modifier
# Skill reduces cost further (experienced = efficient)
# skill 0: no bonus, skill 1.0: 40% discount
skill_modifier = 1.0 - state.gathering_skill * 0.4
base_cost *= skill_modifier
return base_cost return base_cost
@ -280,8 +298,28 @@ def create_buy_action(resource_type: ResourceType) -> GOAPAction:
# Trading cost is low (1 energy) # Trading cost is low (1 energy)
base_cost = 0.5 base_cost = 0.5
# Market-oriented agents prefer buying # MILD profession effect for trading (everyone should be able to trade)
base_cost *= (1.5 - state.market_affinity) # Traders get a bonus, but non-traders shouldn't be heavily penalized
# (trading benefits the whole economy)
other_prefs = (state.hunt_preference + state.gather_preference) / 2
relative_strength = state.trade_preference / max(0.1, other_prefs)
if relative_strength >= 1.0:
# Specialist discount: up to 40% off for dedicated traders
preference_modifier = max(0.6, 1.0 / relative_strength)
else:
# Mild non-specialist penalty: up to 50% cost increase
preference_modifier = 1.0 + (1.0 - relative_strength) * 0.5
base_cost *= preference_modifier
# Skill reduces cost (experienced traders are efficient)
# skill 0: no bonus, skill 1.0: 40% discount
skill_modifier = 1.0 - state.trading_skill * 0.4
base_cost *= skill_modifier
# Market affinity still has mild effect
base_cost *= (1.2 - state.market_affinity * 0.4)
# Check if it's a good deal # Check if it's a good deal
if resource_type == ResourceType.MEAT: if resource_type == ResourceType.MEAT:

View File

@ -159,9 +159,28 @@ def _create_hunt() -> GOAPAction:
if config.success_chance < 1.0: if config.success_chance < 1.0:
base_cost *= 1.0 + (1.0 - config.success_chance) * 0.2 base_cost *= 1.0 + (1.0 - config.success_chance) * 0.2
# Risk-tolerant agents prefer hunting # STRONG profession specialization effect for hunting
# Range: 0.85 (high risk tolerance) to 1.15 (low risk tolerance) # Compare hunt_preference to other preferences
risk_modifier = 1.0 + (0.5 - state.risk_tolerance) * 0.3 other_prefs = (state.gather_preference + state.trade_preference) / 2
relative_strength = state.hunt_preference / max(0.1, other_prefs)
# relative_strength > 1.0 means hunting is your specialty
if relative_strength >= 1.0:
# Specialist discount: up to 50% off
preference_modifier = 1.0 / relative_strength
else:
# Non-specialist penalty: up to 3x cost
preference_modifier = 1.0 + (1.0 - relative_strength) * 2.0
base_cost *= preference_modifier
# Skill reduces cost further (experienced hunters are efficient)
# skill 0: no bonus, skill 1.0: 40% discount
skill_modifier = 1.0 - state.hunting_skill * 0.4
base_cost *= skill_modifier
# Risk tolerance still has mild effect
risk_modifier = 1.0 + (0.5 - state.risk_tolerance) * 0.15
base_cost *= risk_modifier base_cost *= risk_modifier
# Big bonus if we have no meat - prioritize getting some # Big bonus if we have no meat - prioritize getting some
@ -295,11 +314,29 @@ def _create_sell_action(resource_type: ResourceType, min_keep: int = 1) -> GOAPA
return result return result
def cost(state: WorldState) -> float: def cost(state: WorldState) -> float:
# Selling has low cost # Selling has low cost - everyone should be able to sell excess
base_cost = 1.0 base_cost = 1.0
# Hoarders reluctant to sell # MILD profession effect for selling (everyone should be able to trade)
base_cost *= (0.5 + state.hoarding_rate) other_prefs = (state.hunt_preference + state.gather_preference) / 2
relative_strength = state.trade_preference / max(0.1, other_prefs)
if relative_strength >= 1.0:
# Specialist discount: up to 40% off for dedicated traders
preference_modifier = max(0.6, 1.0 / relative_strength)
else:
# Mild non-specialist penalty: up to 50% cost increase
preference_modifier = 1.0 + (1.0 - relative_strength) * 0.5
base_cost *= preference_modifier
# Skill reduces cost (experienced traders know the market)
# skill 0: no bonus, skill 1.0: 40% discount
skill_modifier = 1.0 - state.trading_skill * 0.4
base_cost *= skill_modifier
# Hoarders reluctant to sell (mild effect)
base_cost *= (0.8 + state.hoarding_rate * 0.4)
return base_cost return base_cost

View File

@ -73,6 +73,16 @@ class WorldState:
market_affinity: float = 0.5 market_affinity: float = 0.5
is_trader: bool = False is_trader: bool = False
# Profession preferences (0.5-1.5 range, higher = more preferred)
gather_preference: float = 1.0
hunt_preference: float = 1.0
trade_preference: float = 1.0
# Skill levels (0.0-1.0, higher = more skilled)
hunting_skill: float = 0.0
gathering_skill: float = 0.0
trading_skill: float = 0.0
# Critical thresholds (from config) # Critical thresholds (from config)
critical_threshold: float = 0.25 critical_threshold: float = 0.25
low_threshold: float = 0.45 low_threshold: float = 0.45
@ -297,6 +307,12 @@ def create_world_state(
risk_tolerance=agent.personality.risk_tolerance, risk_tolerance=agent.personality.risk_tolerance,
market_affinity=agent.personality.market_affinity, market_affinity=agent.personality.market_affinity,
is_trader=is_trader, is_trader=is_trader,
gather_preference=agent.personality.gather_preference,
hunt_preference=agent.personality.hunt_preference,
trade_preference=agent.personality.trade_preference,
hunting_skill=agent.skills.hunting,
gathering_skill=agent.skills.gathering,
trading_skill=agent.skills.trading,
critical_threshold=agent_config.critical_threshold, critical_threshold=agent_config.critical_threshold,
low_threshold=0.45, # Could also be in config low_threshold=0.45, # Could also be in config
) )

View File

@ -28,7 +28,7 @@ class ChartColors:
GRID = '#2f3545' GRID = '#2f3545'
TEXT = '#e0e0e8' TEXT = '#e0e0e8'
TEXT_DIM = '#7a7e8c' TEXT_DIM = '#7a7e8c'
# Neon accents for data series # Neon accents for data series
CYAN = '#00d4ff' CYAN = '#00d4ff'
MAGENTA = '#ff0099' MAGENTA = '#ff0099'
@ -38,7 +38,7 @@ class ChartColors:
YELLOW = '#ffcc00' YELLOW = '#ffcc00'
TEAL = '#00ffa3' TEAL = '#00ffa3'
PINK = '#ff1493' PINK = '#ff1493'
# Series colors for different resources/categories # Series colors for different resources/categories
SERIES = [CYAN, MAGENTA, LIME, ORANGE, PURPLE, YELLOW, TEAL, PINK] SERIES = [CYAN, MAGENTA, LIME, ORANGE, PURPLE, YELLOW, TEAL, PINK]
@ -60,26 +60,26 @@ class UIColors:
class HistoryData: class HistoryData:
"""Stores historical simulation data for charting.""" """Stores historical simulation data for charting."""
max_history: int = 200 max_history: int = 200
# Time series data # Time series data
turns: deque = field(default_factory=lambda: deque(maxlen=200)) turns: deque = field(default_factory=lambda: deque(maxlen=200))
population: deque = field(default_factory=lambda: deque(maxlen=200)) population: deque = field(default_factory=lambda: deque(maxlen=200))
deaths_cumulative: deque = field(default_factory=lambda: deque(maxlen=200)) deaths_cumulative: deque = field(default_factory=lambda: deque(maxlen=200))
# Money/Wealth data # Money/Wealth data
total_money: deque = field(default_factory=lambda: deque(maxlen=200)) total_money: deque = field(default_factory=lambda: deque(maxlen=200))
avg_wealth: deque = field(default_factory=lambda: deque(maxlen=200)) avg_wealth: deque = field(default_factory=lambda: deque(maxlen=200))
gini_coefficient: deque = field(default_factory=lambda: deque(maxlen=200)) gini_coefficient: deque = field(default_factory=lambda: deque(maxlen=200))
# Price history per resource # Price history per resource
prices: dict = field(default_factory=dict) # resource -> deque of prices prices: dict = field(default_factory=dict) # resource -> deque of prices
# Trade statistics # Trade statistics
trade_volume: deque = field(default_factory=lambda: deque(maxlen=200)) trade_volume: deque = field(default_factory=lambda: deque(maxlen=200))
# Profession counts over time # Profession counts over time
professions: dict = field(default_factory=dict) # profession -> deque of counts professions: dict = field(default_factory=dict) # profession -> deque of counts
def clear(self) -> None: def clear(self) -> None:
"""Clear all history data.""" """Clear all history data."""
self.turns.clear() self.turns.clear()
@ -91,51 +91,51 @@ class HistoryData:
self.prices.clear() self.prices.clear()
self.trade_volume.clear() self.trade_volume.clear()
self.professions.clear() self.professions.clear()
def update(self, state: "SimulationState") -> None: def update(self, state: "SimulationState") -> None:
"""Update history with new state data.""" """Update history with new state data."""
turn = state.turn turn = state.turn
# Avoid duplicate entries for the same turn # Avoid duplicate entries for the same turn
if self.turns and self.turns[-1] == turn: if self.turns and self.turns[-1] == turn:
return return
self.turns.append(turn) self.turns.append(turn)
# Population # Population
living = len([a for a in state.agents if a.get("is_alive", False)]) living = len([a for a in state.agents if a.get("is_alive", False)])
self.population.append(living) self.population.append(living)
self.deaths_cumulative.append(state.statistics.get("total_agents_died", 0)) self.deaths_cumulative.append(state.statistics.get("total_agents_died", 0))
# Wealth data # Wealth data
stats = state.statistics stats = state.statistics
self.total_money.append(stats.get("total_money_in_circulation", 0)) self.total_money.append(stats.get("total_money_in_circulation", 0))
self.avg_wealth.append(stats.get("avg_money", 0)) self.avg_wealth.append(stats.get("avg_money", 0))
self.gini_coefficient.append(stats.get("gini_coefficient", 0)) self.gini_coefficient.append(stats.get("gini_coefficient", 0))
# Price history from market # Price history from market
for resource, data in state.market_prices.items(): for resource, data in state.market_prices.items():
if resource not in self.prices: if resource not in self.prices:
self.prices[resource] = deque(maxlen=self.max_history) self.prices[resource] = deque(maxlen=self.max_history)
# Track lowest price (current market rate) # Track lowest price (current market rate)
lowest = data.get("lowest_price") lowest = data.get("lowest_price")
avg = data.get("avg_sale_price") avg = data.get("avg_sale_price")
# Use lowest price if available, else avg sale price # Use lowest price if available, else avg sale price
price = lowest if lowest is not None else avg price = lowest if lowest is not None else avg
self.prices[resource].append(price) self.prices[resource].append(price)
# Trade volume (from recent trades in market orders) # Trade volume (from recent trades in market orders)
trades = len(state.market_orders) # Active orders as proxy trades = len(state.market_orders) # Active orders as proxy
self.trade_volume.append(trades) self.trade_volume.append(trades)
# Profession distribution # Profession distribution
professions = stats.get("professions", {}) professions = stats.get("professions", {})
for prof, count in professions.items(): for prof, count in professions.items():
if prof not in self.professions: if prof not in self.professions:
self.professions[prof] = deque(maxlen=self.max_history) self.professions[prof] = deque(maxlen=self.max_history)
self.professions[prof].append(count) self.professions[prof].append(count)
# Pad missing professions with 0 # Pad missing professions with 0
for prof in self.professions: for prof in self.professions:
if prof not in professions: if prof not in professions:
@ -144,12 +144,12 @@ class HistoryData:
class ChartRenderer: class ChartRenderer:
"""Renders matplotlib charts to pygame surfaces.""" """Renders matplotlib charts to pygame surfaces."""
def __init__(self, width: int, height: int): def __init__(self, width: int, height: int):
self.width = width self.width = width
self.height = height self.height = height
self.dpi = 100 self.dpi = 100
# Configure matplotlib style # Configure matplotlib style
plt.style.use('dark_background') plt.style.use('dark_background')
plt.rcParams.update({ plt.rcParams.update({
@ -168,27 +168,27 @@ class ChartRenderer:
'axes.titlesize': 11, 'axes.titlesize': 11,
'axes.titleweight': 'bold', 'axes.titleweight': 'bold',
}) })
def _fig_to_surface(self, fig: Figure) -> pygame.Surface: def _fig_to_surface(self, fig: Figure) -> pygame.Surface:
"""Convert a matplotlib figure to a pygame surface.""" """Convert a matplotlib figure to a pygame surface."""
buf = io.BytesIO() buf = io.BytesIO()
fig.savefig(buf, format='png', dpi=self.dpi, fig.savefig(buf, format='png', dpi=self.dpi,
facecolor=ChartColors.BG, edgecolor='none', facecolor=ChartColors.BG, edgecolor='none',
bbox_inches='tight', pad_inches=0.1) bbox_inches='tight', pad_inches=0.1)
buf.seek(0) buf.seek(0)
surface = pygame.image.load(buf, 'png') surface = pygame.image.load(buf, 'png')
buf.close() buf.close()
plt.close(fig) plt.close(fig)
return surface return surface
def render_price_history(self, history: HistoryData, width: int, height: int) -> pygame.Surface: def render_price_history(self, history: HistoryData, width: int, height: int) -> pygame.Surface:
"""Render price history chart for all resources.""" """Render price history chart for all resources."""
fig, ax = plt.subplots(figsize=(width/self.dpi, height/self.dpi), dpi=self.dpi) fig, ax = plt.subplots(figsize=(width/self.dpi, height/self.dpi), dpi=self.dpi)
turns = list(history.turns) if history.turns else [0] turns = list(history.turns) if history.turns else [0]
has_data = False has_data = False
for i, (resource, prices) in enumerate(history.prices.items()): for i, (resource, prices) in enumerate(history.prices.items()):
if prices and any(p is not None for p in prices): if prices and any(p is not None for p in prices):
@ -197,46 +197,46 @@ class ChartRenderer:
valid_prices = [p if p is not None else 0 for p in prices] valid_prices = [p if p is not None else 0 for p in prices]
# Align with turns # Align with turns
min_len = min(len(turns), len(valid_prices)) min_len = min(len(turns), len(valid_prices))
ax.plot(list(turns)[-min_len:], valid_prices[-min_len:], ax.plot(list(turns)[-min_len:], valid_prices[-min_len:],
color=color, linewidth=1.5, label=resource.capitalize(), alpha=0.9) color=color, linewidth=1.5, label=resource.capitalize(), alpha=0.9)
has_data = True has_data = True
ax.set_title('Market Prices', color=ChartColors.CYAN) ax.set_title('Market Prices', color=ChartColors.CYAN)
ax.set_xlabel('Turn') ax.set_xlabel('Turn')
ax.set_ylabel('Price (coins)') ax.set_ylabel('Price (coins)')
ax.grid(True, alpha=0.2) ax.grid(True, alpha=0.2)
if has_data: if has_data:
ax.legend(loc='upper left', fontsize=8, framealpha=0.8) ax.legend(loc='upper left', fontsize=8, framealpha=0.8)
ax.set_ylim(bottom=0) ax.set_ylim(bottom=0)
ax.yaxis.set_major_locator(mticker.MaxNLocator(integer=True)) ax.yaxis.set_major_locator(mticker.MaxNLocator(integer=True))
fig.tight_layout() fig.tight_layout()
return self._fig_to_surface(fig) return self._fig_to_surface(fig)
def render_population(self, history: HistoryData, width: int, height: int) -> pygame.Surface: def render_population(self, history: HistoryData, width: int, height: int) -> pygame.Surface:
"""Render population over time chart.""" """Render population over time chart."""
fig, ax = plt.subplots(figsize=(width/self.dpi, height/self.dpi), dpi=self.dpi) fig, ax = plt.subplots(figsize=(width/self.dpi, height/self.dpi), dpi=self.dpi)
turns = list(history.turns) if history.turns else [0] turns = list(history.turns) if history.turns else [0]
population = list(history.population) if history.population else [0] population = list(history.population) if history.population else [0]
deaths = list(history.deaths_cumulative) if history.deaths_cumulative else [0] deaths = list(history.deaths_cumulative) if history.deaths_cumulative else [0]
min_len = min(len(turns), len(population)) min_len = min(len(turns), len(population))
# Population line # Population line
ax.fill_between(turns[-min_len:], population[-min_len:], ax.fill_between(turns[-min_len:], population[-min_len:],
alpha=0.3, color=ChartColors.CYAN) alpha=0.3, color=ChartColors.CYAN)
ax.plot(turns[-min_len:], population[-min_len:], ax.plot(turns[-min_len:], population[-min_len:],
color=ChartColors.CYAN, linewidth=2, label='Living') color=ChartColors.CYAN, linewidth=2, label='Living')
# Deaths line # Deaths line
if deaths: if deaths:
ax.plot(turns[-min_len:], deaths[-min_len:], ax.plot(turns[-min_len:], deaths[-min_len:],
color=ChartColors.MAGENTA, linewidth=1.5, linestyle='--', color=ChartColors.MAGENTA, linewidth=1.5, linestyle='--',
label='Total Deaths', alpha=0.8) label='Total Deaths', alpha=0.8)
ax.set_title('Population Over Time', color=ChartColors.LIME) ax.set_title('Population Over Time', color=ChartColors.LIME)
ax.set_xlabel('Turn') ax.set_xlabel('Turn')
ax.set_ylabel('Count') ax.set_ylabel('Count')
@ -244,28 +244,28 @@ class ChartRenderer:
ax.legend(loc='upper right', fontsize=8) ax.legend(loc='upper right', fontsize=8)
ax.set_ylim(bottom=0) ax.set_ylim(bottom=0)
ax.yaxis.set_major_locator(mticker.MaxNLocator(integer=True)) ax.yaxis.set_major_locator(mticker.MaxNLocator(integer=True))
fig.tight_layout() fig.tight_layout()
return self._fig_to_surface(fig) return self._fig_to_surface(fig)
def render_wealth_distribution(self, state: "SimulationState", width: int, height: int) -> pygame.Surface: def render_wealth_distribution(self, state: "SimulationState", width: int, height: int) -> pygame.Surface:
"""Render current wealth distribution as a bar chart.""" """Render current wealth distribution as a bar chart."""
fig, ax = plt.subplots(figsize=(width/self.dpi, height/self.dpi), dpi=self.dpi) fig, ax = plt.subplots(figsize=(width/self.dpi, height/self.dpi), dpi=self.dpi)
# Get agent wealth data # Get agent wealth data
agents = [a for a in state.agents if a.get("is_alive", False)] agents = [a for a in state.agents if a.get("is_alive", False)]
if not agents: if not agents:
ax.text(0.5, 0.5, 'No living agents', ha='center', va='center', ax.text(0.5, 0.5, 'No living agents', ha='center', va='center',
color=ChartColors.TEXT_DIM, fontsize=12) color=ChartColors.TEXT_DIM, fontsize=12)
ax.set_title('Wealth Distribution', color=ChartColors.ORANGE) ax.set_title('Wealth Distribution', color=ChartColors.ORANGE)
fig.tight_layout() fig.tight_layout()
return self._fig_to_surface(fig) return self._fig_to_surface(fig)
# Sort by wealth # Sort by wealth
agents_sorted = sorted(agents, key=lambda a: a.get("money", 0), reverse=True) agents_sorted = sorted(agents, key=lambda a: a.get("money", 0), reverse=True)
names = [a.get("name", "?")[:8] for a in agents_sorted] names = [a.get("name", "?")[:8] for a in agents_sorted]
wealth = [a.get("money", 0) for a in agents_sorted] wealth = [a.get("money", 0) for a in agents_sorted]
# Create gradient colors based on wealth ranking # Create gradient colors based on wealth ranking
colors = [] colors = []
for i in range(len(agents_sorted)): for i in range(len(agents_sorted)):
@ -275,86 +275,86 @@ class ChartRenderer:
g = int(212 - ratio * 212) g = int(212 - ratio * 212)
b = int(255 - ratio * 102) b = int(255 - ratio * 102)
colors.append(f'#{r:02x}{g:02x}{b:02x}') colors.append(f'#{r:02x}{g:02x}{b:02x}')
bars = ax.barh(range(len(agents_sorted)), wealth, color=colors, alpha=0.85) bars = ax.barh(range(len(agents_sorted)), wealth, color=colors, alpha=0.85)
ax.set_yticks(range(len(agents_sorted))) ax.set_yticks(range(len(agents_sorted)))
ax.set_yticklabels(names, fontsize=7) ax.set_yticklabels(names, fontsize=7)
ax.invert_yaxis() # Rich at top ax.invert_yaxis() # Rich at top
# Add value labels # Add value labels
for bar, val in zip(bars, wealth): for bar, val in zip(bars, wealth):
ax.text(bar.get_width() + 1, bar.get_y() + bar.get_height()/2, ax.text(bar.get_width() + 1, bar.get_y() + bar.get_height()/2,
f'{val}', va='center', fontsize=7, color=ChartColors.TEXT_DIM) f'{val}', va='center', fontsize=7, color=ChartColors.TEXT_DIM)
ax.set_title('Wealth Distribution', color=ChartColors.ORANGE) ax.set_title('Wealth Distribution', color=ChartColors.ORANGE)
ax.set_xlabel('Coins') ax.set_xlabel('Coins')
ax.grid(True, alpha=0.2, axis='x') ax.grid(True, alpha=0.2, axis='x')
fig.tight_layout() fig.tight_layout()
return self._fig_to_surface(fig) return self._fig_to_surface(fig)
def render_wealth_over_time(self, history: HistoryData, width: int, height: int) -> pygame.Surface: def render_wealth_over_time(self, history: HistoryData, width: int, height: int) -> pygame.Surface:
"""Render wealth metrics over time (total money, avg, gini).""" """Render wealth metrics over time (total money, avg, gini)."""
fig, (ax1, ax2) = plt.subplots(2, 1, figsize=(width/self.dpi, height/self.dpi), fig, (ax1, ax2) = plt.subplots(2, 1, figsize=(width/self.dpi, height/self.dpi),
dpi=self.dpi, height_ratios=[2, 1]) dpi=self.dpi, height_ratios=[2, 1])
turns = list(history.turns) if history.turns else [0] turns = list(history.turns) if history.turns else [0]
total = list(history.total_money) if history.total_money else [0] total = list(history.total_money) if history.total_money else [0]
avg = list(history.avg_wealth) if history.avg_wealth else [0] avg = list(history.avg_wealth) if history.avg_wealth else [0]
gini = list(history.gini_coefficient) if history.gini_coefficient else [0] gini = list(history.gini_coefficient) if history.gini_coefficient else [0]
min_len = min(len(turns), len(total), len(avg)) min_len = min(len(turns), len(total), len(avg))
# Total and average wealth # Total and average wealth
ax1.plot(turns[-min_len:], total[-min_len:], ax1.plot(turns[-min_len:], total[-min_len:],
color=ChartColors.CYAN, linewidth=2, label='Total Money') color=ChartColors.CYAN, linewidth=2, label='Total Money')
ax1.fill_between(turns[-min_len:], total[-min_len:], ax1.fill_between(turns[-min_len:], total[-min_len:],
alpha=0.2, color=ChartColors.CYAN) alpha=0.2, color=ChartColors.CYAN)
ax1_twin = ax1.twinx() ax1_twin = ax1.twinx()
ax1_twin.plot(turns[-min_len:], avg[-min_len:], ax1_twin.plot(turns[-min_len:], avg[-min_len:],
color=ChartColors.LIME, linewidth=1.5, linestyle='--', label='Avg Wealth') color=ChartColors.LIME, linewidth=1.5, linestyle='--', label='Avg Wealth')
ax1_twin.set_ylabel('Avg Wealth', color=ChartColors.LIME) ax1_twin.set_ylabel('Avg Wealth', color=ChartColors.LIME)
ax1_twin.tick_params(axis='y', labelcolor=ChartColors.LIME) ax1_twin.tick_params(axis='y', labelcolor=ChartColors.LIME)
ax1.set_title('Money in Circulation', color=ChartColors.YELLOW) ax1.set_title('Money in Circulation', color=ChartColors.YELLOW)
ax1.set_ylabel('Total Money', color=ChartColors.CYAN) ax1.set_ylabel('Total Money', color=ChartColors.CYAN)
ax1.tick_params(axis='y', labelcolor=ChartColors.CYAN) ax1.tick_params(axis='y', labelcolor=ChartColors.CYAN)
ax1.grid(True, alpha=0.2) ax1.grid(True, alpha=0.2)
ax1.set_ylim(bottom=0) ax1.set_ylim(bottom=0)
# Gini coefficient (inequality) # Gini coefficient (inequality)
min_len_gini = min(len(turns), len(gini)) min_len_gini = min(len(turns), len(gini))
ax2.fill_between(turns[-min_len_gini:], gini[-min_len_gini:], ax2.fill_between(turns[-min_len_gini:], gini[-min_len_gini:],
alpha=0.4, color=ChartColors.MAGENTA) alpha=0.4, color=ChartColors.MAGENTA)
ax2.plot(turns[-min_len_gini:], gini[-min_len_gini:], ax2.plot(turns[-min_len_gini:], gini[-min_len_gini:],
color=ChartColors.MAGENTA, linewidth=1.5) color=ChartColors.MAGENTA, linewidth=1.5)
ax2.set_xlabel('Turn') ax2.set_xlabel('Turn')
ax2.set_ylabel('Gini') ax2.set_ylabel('Gini')
ax2.set_title('Inequality Index', color=ChartColors.MAGENTA, fontsize=9) ax2.set_title('Inequality Index', color=ChartColors.MAGENTA, fontsize=9)
ax2.set_ylim(0, 1) ax2.set_ylim(0, 1)
ax2.grid(True, alpha=0.2) ax2.grid(True, alpha=0.2)
# Add reference lines for gini # Add reference lines for gini
ax2.axhline(y=0.4, color=ChartColors.YELLOW, linestyle=':', alpha=0.5, linewidth=1) ax2.axhline(y=0.4, color=ChartColors.YELLOW, linestyle=':', alpha=0.5, linewidth=1)
ax2.text(turns[-1] if turns else 0, 0.42, 'Moderate', fontsize=7, ax2.text(turns[-1] if turns else 0, 0.42, 'Moderate', fontsize=7,
color=ChartColors.YELLOW, alpha=0.7) color=ChartColors.YELLOW, alpha=0.7)
fig.tight_layout() fig.tight_layout()
return self._fig_to_surface(fig) return self._fig_to_surface(fig)
def render_professions(self, state: "SimulationState", history: HistoryData, def render_professions(self, state: "SimulationState", history: HistoryData,
width: int, height: int) -> pygame.Surface: width: int, height: int) -> pygame.Surface:
"""Render profession distribution as pie chart and area chart.""" """Render profession distribution as pie chart and area chart."""
fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(width/self.dpi, height/self.dpi), dpi=self.dpi) fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(width/self.dpi, height/self.dpi), dpi=self.dpi)
# Current profession pie chart # Current profession pie chart
professions = state.statistics.get("professions", {}) professions = state.statistics.get("professions", {})
if professions: if professions:
labels = list(professions.keys()) labels = list(professions.keys())
sizes = list(professions.values()) sizes = list(professions.values())
colors = [ChartColors.SERIES[i % len(ChartColors.SERIES)] for i in range(len(labels))] colors = [ChartColors.SERIES[i % len(ChartColors.SERIES)] for i in range(len(labels))]
wedges, texts, autotexts = ax1.pie( wedges, texts, autotexts = ax1.pie(
sizes, labels=labels, colors=colors, autopct='%1.0f%%', sizes, labels=labels, colors=colors, autopct='%1.0f%%',
startangle=90, pctdistance=0.75, startangle=90, pctdistance=0.75,
@ -367,7 +367,7 @@ class ChartRenderer:
else: else:
ax1.text(0.5, 0.5, 'No data', ha='center', va='center', color=ChartColors.TEXT_DIM) ax1.text(0.5, 0.5, 'No data', ha='center', va='center', color=ChartColors.TEXT_DIM)
ax1.set_title('Current Distribution', color=ChartColors.PURPLE) ax1.set_title('Current Distribution', color=ChartColors.PURPLE)
# Profession history as stacked area # Profession history as stacked area
turns = list(history.turns) if history.turns else [0] turns = list(history.turns) if history.turns else [0]
if history.professions and turns: if history.professions and turns:
@ -379,37 +379,37 @@ class ChartRenderer:
while len(prof_data) < len(turns): while len(prof_data) < len(turns):
prof_data.insert(0, 0) prof_data.insert(0, 0)
data.append(prof_data[-len(turns):]) data.append(prof_data[-len(turns):])
colors = [ChartColors.SERIES[i % len(ChartColors.SERIES)] for i in range(len(profs_list))] colors = [ChartColors.SERIES[i % len(ChartColors.SERIES)] for i in range(len(profs_list))]
ax2.stackplot(turns, *data, labels=profs_list, colors=colors, alpha=0.8) ax2.stackplot(turns, *data, labels=profs_list, colors=colors, alpha=0.8)
ax2.legend(loc='upper left', fontsize=7, framealpha=0.8) ax2.legend(loc='upper left', fontsize=7, framealpha=0.8)
ax2.set_xlabel('Turn') ax2.set_xlabel('Turn')
ax2.set_ylabel('Count') ax2.set_ylabel('Count')
ax2.set_title('Over Time', color=ChartColors.PURPLE, fontsize=10) ax2.set_title('Over Time', color=ChartColors.PURPLE, fontsize=10)
ax2.grid(True, alpha=0.2) ax2.grid(True, alpha=0.2)
fig.tight_layout() fig.tight_layout()
return self._fig_to_surface(fig) return self._fig_to_surface(fig)
def render_market_activity(self, state: "SimulationState", history: HistoryData, def render_market_activity(self, state: "SimulationState", history: HistoryData,
width: int, height: int) -> pygame.Surface: width: int, height: int) -> pygame.Surface:
"""Render market activity - orders by resource, supply/demand.""" """Render market activity - orders by resource, supply/demand."""
fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(width/self.dpi, height/self.dpi), dpi=self.dpi) fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(width/self.dpi, height/self.dpi), dpi=self.dpi)
# Current market orders by resource type # Current market orders by resource type
prices = state.market_prices prices = state.market_prices
resources = [] resources = []
quantities = [] quantities = []
colors = [] colors = []
for i, (resource, data) in enumerate(prices.items()): for i, (resource, data) in enumerate(prices.items()):
qty = data.get("total_available", 0) qty = data.get("total_available", 0)
if qty > 0: if qty > 0:
resources.append(resource.capitalize()) resources.append(resource.capitalize())
quantities.append(qty) quantities.append(qty)
colors.append(ChartColors.SERIES[i % len(ChartColors.SERIES)]) colors.append(ChartColors.SERIES[i % len(ChartColors.SERIES)])
if resources: if resources:
bars = ax1.bar(resources, quantities, color=colors, alpha=0.85) bars = ax1.bar(resources, quantities, color=colors, alpha=0.85)
ax1.set_ylabel('Available') ax1.set_ylabel('Available')
@ -418,93 +418,93 @@ class ChartRenderer:
str(val), ha='center', fontsize=8, color=ChartColors.TEXT) str(val), ha='center', fontsize=8, color=ChartColors.TEXT)
else: else:
ax1.text(0.5, 0.5, 'No orders', ha='center', va='center', color=ChartColors.TEXT_DIM) ax1.text(0.5, 0.5, 'No orders', ha='center', va='center', color=ChartColors.TEXT_DIM)
ax1.set_title('Market Supply', color=ChartColors.TEAL, fontsize=10) ax1.set_title('Market Supply', color=ChartColors.TEAL, fontsize=10)
ax1.tick_params(axis='x', rotation=45, labelsize=7) ax1.tick_params(axis='x', rotation=45, labelsize=7)
ax1.grid(True, alpha=0.2, axis='y') ax1.grid(True, alpha=0.2, axis='y')
# Supply/Demand scores # Supply/Demand scores
resources_sd = [] resources_sd = []
supply_scores = [] supply_scores = []
demand_scores = [] demand_scores = []
for resource, data in prices.items(): for resource, data in prices.items():
resources_sd.append(resource[:6]) resources_sd.append(resource[:6])
supply_scores.append(data.get("supply_score", 0.5)) supply_scores.append(data.get("supply_score", 0.5))
demand_scores.append(data.get("demand_score", 0.5)) demand_scores.append(data.get("demand_score", 0.5))
if resources_sd: if resources_sd:
x = np.arange(len(resources_sd)) x = np.arange(len(resources_sd))
width_bar = 0.35 width_bar = 0.35
ax2.bar(x - width_bar/2, supply_scores, width_bar, label='Supply', ax2.bar(x - width_bar/2, supply_scores, width_bar, label='Supply',
color=ChartColors.CYAN, alpha=0.8) color=ChartColors.CYAN, alpha=0.8)
ax2.bar(x + width_bar/2, demand_scores, width_bar, label='Demand', ax2.bar(x + width_bar/2, demand_scores, width_bar, label='Demand',
color=ChartColors.MAGENTA, alpha=0.8) color=ChartColors.MAGENTA, alpha=0.8)
ax2.set_xticks(x) ax2.set_xticks(x)
ax2.set_xticklabels(resources_sd, fontsize=7, rotation=45) ax2.set_xticklabels(resources_sd, fontsize=7, rotation=45)
ax2.set_ylabel('Score') ax2.set_ylabel('Score')
ax2.legend(fontsize=7) ax2.legend(fontsize=7)
ax2.set_ylim(0, 1.2) ax2.set_ylim(0, 1.2)
ax2.set_title('Supply/Demand', color=ChartColors.TEAL, fontsize=10) ax2.set_title('Supply/Demand', color=ChartColors.TEAL, fontsize=10)
ax2.grid(True, alpha=0.2, axis='y') ax2.grid(True, alpha=0.2, axis='y')
fig.tight_layout() fig.tight_layout()
return self._fig_to_surface(fig) return self._fig_to_surface(fig)
def render_agent_stats(self, state: "SimulationState", width: int, height: int) -> pygame.Surface: def render_agent_stats(self, state: "SimulationState", width: int, height: int) -> pygame.Surface:
"""Render aggregate agent statistics - energy, hunger, thirst distributions.""" """Render aggregate agent statistics - energy, hunger, thirst distributions."""
fig, axes = plt.subplots(2, 2, figsize=(width/self.dpi, height/self.dpi), dpi=self.dpi) fig, axes = plt.subplots(2, 2, figsize=(width/self.dpi, height/self.dpi), dpi=self.dpi)
agents = [a for a in state.agents if a.get("is_alive", False)] agents = [a for a in state.agents if a.get("is_alive", False)]
if not agents: if not agents:
for ax in axes.flat: for ax in axes.flat:
ax.text(0.5, 0.5, 'No agents', ha='center', va='center', color=ChartColors.TEXT_DIM) ax.text(0.5, 0.5, 'No agents', ha='center', va='center', color=ChartColors.TEXT_DIM)
fig.suptitle('Agent Statistics', color=ChartColors.CYAN) fig.suptitle('Agent Statistics', color=ChartColors.CYAN)
fig.tight_layout() fig.tight_layout()
return self._fig_to_surface(fig) return self._fig_to_surface(fig)
# Extract stats # Extract stats
energies = [a.get("stats", {}).get("energy", 0) for a in agents] energies = [a.get("stats", {}).get("energy", 0) for a in agents]
hungers = [a.get("stats", {}).get("hunger", 0) for a in agents] hungers = [a.get("stats", {}).get("hunger", 0) for a in agents]
thirsts = [a.get("stats", {}).get("thirst", 0) for a in agents] thirsts = [a.get("stats", {}).get("thirst", 0) for a in agents]
heats = [a.get("stats", {}).get("heat", 0) for a in agents] heats = [a.get("stats", {}).get("heat", 0) for a in agents]
max_energy = agents[0].get("stats", {}).get("max_energy", 100) max_energy = agents[0].get("stats", {}).get("max_energy", 100)
max_hunger = agents[0].get("stats", {}).get("max_hunger", 100) max_hunger = agents[0].get("stats", {}).get("max_hunger", 100)
max_thirst = agents[0].get("stats", {}).get("max_thirst", 100) max_thirst = agents[0].get("stats", {}).get("max_thirst", 100)
max_heat = agents[0].get("stats", {}).get("max_heat", 100) max_heat = agents[0].get("stats", {}).get("max_heat", 100)
stats_data = [ stats_data = [
(energies, max_energy, 'Energy', ChartColors.LIME), (energies, max_energy, 'Energy', ChartColors.LIME),
(hungers, max_hunger, 'Hunger', ChartColors.ORANGE), (hungers, max_hunger, 'Hunger', ChartColors.ORANGE),
(thirsts, max_thirst, 'Thirst', ChartColors.CYAN), (thirsts, max_thirst, 'Thirst', ChartColors.CYAN),
(heats, max_heat, 'Heat', ChartColors.MAGENTA), (heats, max_heat, 'Heat', ChartColors.MAGENTA),
] ]
for ax, (values, max_val, name, color) in zip(axes.flat, stats_data): for ax, (values, max_val, name, color) in zip(axes.flat, stats_data):
# Histogram # Histogram
bins = np.linspace(0, max_val, 11) bins = np.linspace(0, max_val, 11)
ax.hist(values, bins=bins, color=color, alpha=0.7, edgecolor=ChartColors.PANEL) ax.hist(values, bins=bins, color=color, alpha=0.7, edgecolor=ChartColors.PANEL)
# Mean line # Mean line
mean_val = np.mean(values) mean_val = np.mean(values)
ax.axvline(x=mean_val, color=ChartColors.TEXT, linestyle='--', ax.axvline(x=mean_val, color=ChartColors.TEXT, linestyle='--',
linewidth=1.5, label=f'Avg: {mean_val:.0f}') linewidth=1.5, label=f'Avg: {mean_val:.0f}')
# Critical threshold # Critical threshold
critical = max_val * 0.25 critical = max_val * 0.25
ax.axvline(x=critical, color=ChartColors.MAGENTA, linestyle=':', ax.axvline(x=critical, color=ChartColors.MAGENTA, linestyle=':',
linewidth=1, alpha=0.7) linewidth=1, alpha=0.7)
ax.set_title(name, color=color, fontsize=9) ax.set_title(name, color=color, fontsize=9)
ax.set_xlim(0, max_val) ax.set_xlim(0, max_val)
ax.legend(fontsize=7, loc='upper right') ax.legend(fontsize=7, loc='upper right')
ax.grid(True, alpha=0.2) ax.grid(True, alpha=0.2)
fig.suptitle('Agent Statistics Distribution', color=ChartColors.CYAN, fontsize=11) fig.suptitle('Agent Statistics Distribution', color=ChartColors.CYAN, fontsize=11)
fig.tight_layout() fig.tight_layout()
return self._fig_to_surface(fig) return self._fig_to_surface(fig)
@ -512,7 +512,7 @@ class ChartRenderer:
class StatsRenderer: class StatsRenderer:
"""Main statistics panel with tabs and charts.""" """Main statistics panel with tabs and charts."""
TABS = [ TABS = [
("Prices", "price_history"), ("Prices", "price_history"),
("Wealth", "wealth"), ("Wealth", "wealth"),
@ -521,35 +521,35 @@ class StatsRenderer:
("Market", "market"), ("Market", "market"),
("Agent Stats", "agent_stats"), ("Agent Stats", "agent_stats"),
] ]
def __init__(self, screen: pygame.Surface): def __init__(self, screen: pygame.Surface):
self.screen = screen self.screen = screen
self.visible = False self.visible = False
self.font = pygame.font.Font(None, 24) self.font = pygame.font.Font(None, 24)
self.small_font = pygame.font.Font(None, 18) self.small_font = pygame.font.Font(None, 18)
self.title_font = pygame.font.Font(None, 32) self.title_font = pygame.font.Font(None, 32)
self.current_tab = 0 self.current_tab = 0
self.tab_hovered = -1 self.tab_hovered = -1
# History data # History data
self.history = HistoryData() self.history = HistoryData()
# Chart renderer # Chart renderer
self.chart_renderer: Optional[ChartRenderer] = None self.chart_renderer: Optional[ChartRenderer] = None
# Cached chart surfaces # Cached chart surfaces
self._chart_cache: dict[str, pygame.Surface] = {} self._chart_cache: dict[str, pygame.Surface] = {}
self._cache_turn: int = -1 self._cache_turn: int = -1
# Layout # Layout
self._calculate_layout() self._calculate_layout()
def _calculate_layout(self) -> None: def _calculate_layout(self) -> None:
"""Calculate panel layout based on screen size.""" """Calculate panel layout based on screen size."""
screen_w, screen_h = self.screen.get_size() screen_w, screen_h = self.screen.get_size()
# Panel takes most of the screen with some margin # Panel takes most of the screen with some margin
margin = 30 margin = 30
self.panel_rect = pygame.Rect( self.panel_rect = pygame.Rect(
@ -557,7 +557,7 @@ class StatsRenderer:
screen_w - margin * 2, screen_w - margin * 2,
screen_h - margin * 2 screen_h - margin * 2
) )
# Tab bar # Tab bar
self.tab_height = 40 self.tab_height = 40
self.tab_rect = pygame.Rect( self.tab_rect = pygame.Rect(
@ -566,7 +566,7 @@ class StatsRenderer:
self.panel_rect.width, self.panel_rect.width,
self.tab_height self.tab_height
) )
# Chart area # Chart area
self.chart_rect = pygame.Rect( self.chart_rect = pygame.Rect(
self.panel_rect.x + 10, self.panel_rect.x + 10,
@ -574,52 +574,52 @@ class StatsRenderer:
self.panel_rect.width - 20, self.panel_rect.width - 20,
self.panel_rect.height - self.tab_height - 20 self.panel_rect.height - self.tab_height - 20
) )
# Initialize chart renderer with chart area size # Initialize chart renderer with chart area size
self.chart_renderer = ChartRenderer( self.chart_renderer = ChartRenderer(
self.chart_rect.width, self.chart_rect.width,
self.chart_rect.height self.chart_rect.height
) )
# Calculate tab widths # Calculate tab widths
self.tab_width = self.panel_rect.width // len(self.TABS) self.tab_width = self.panel_rect.width // len(self.TABS)
def toggle(self) -> None: def toggle(self) -> None:
"""Toggle visibility of the stats panel.""" """Toggle visibility of the stats panel."""
self.visible = not self.visible self.visible = not self.visible
if self.visible: if self.visible:
self._invalidate_cache() self._invalidate_cache()
def update_history(self, state: "SimulationState") -> None: def update_history(self, state: "SimulationState") -> None:
"""Update history data with new state.""" """Update history data with new state."""
if state: if state:
self.history.update(state) self.history.update(state)
def clear_history(self) -> None: def clear_history(self) -> None:
"""Clear all history data (e.g., on simulation reset).""" """Clear all history data (e.g., on simulation reset)."""
self.history.clear() self.history.clear()
self._invalidate_cache() self._invalidate_cache()
def _invalidate_cache(self) -> None: def _invalidate_cache(self) -> None:
"""Invalidate chart cache to force re-render.""" """Invalidate chart cache to force re-render."""
self._chart_cache.clear() self._chart_cache.clear()
self._cache_turn = -1 self._cache_turn = -1
def handle_event(self, event: pygame.event.Event) -> bool: def handle_event(self, event: pygame.event.Event) -> bool:
"""Handle input events. Returns True if event was consumed.""" """Handle input events. Returns True if event was consumed."""
if not self.visible: if not self.visible:
return False return False
if event.type == pygame.MOUSEMOTION: if event.type == pygame.MOUSEMOTION:
self._handle_mouse_motion(event.pos) self._handle_mouse_motion(event.pos)
return True return True
elif event.type == pygame.MOUSEBUTTONDOWN: elif event.type == pygame.MOUSEBUTTONDOWN:
if self._handle_click(event.pos): if self._handle_click(event.pos):
return True return True
# Consume clicks when visible # Consume clicks when visible
return True return True
elif event.type == pygame.KEYDOWN: elif event.type == pygame.KEYDOWN:
if event.key == pygame.K_ESCAPE: if event.key == pygame.K_ESCAPE:
self.toggle() self.toggle()
@ -632,19 +632,19 @@ class StatsRenderer:
self.current_tab = (self.current_tab + 1) % len(self.TABS) self.current_tab = (self.current_tab + 1) % len(self.TABS)
self._invalidate_cache() self._invalidate_cache()
return True return True
return False return False
def _handle_mouse_motion(self, pos: tuple[int, int]) -> None: def _handle_mouse_motion(self, pos: tuple[int, int]) -> None:
"""Handle mouse motion for tab hover effects.""" """Handle mouse motion for tab hover effects."""
self.tab_hovered = -1 self.tab_hovered = -1
if self.tab_rect.collidepoint(pos): if self.tab_rect.collidepoint(pos):
rel_x = pos[0] - self.tab_rect.x rel_x = pos[0] - self.tab_rect.x
tab_idx = rel_x // self.tab_width tab_idx = rel_x // self.tab_width
if 0 <= tab_idx < len(self.TABS): if 0 <= tab_idx < len(self.TABS):
self.tab_hovered = tab_idx self.tab_hovered = tab_idx
def _handle_click(self, pos: tuple[int, int]) -> bool: def _handle_click(self, pos: tuple[int, int]) -> bool:
"""Handle mouse click. Returns True if click was on a tab.""" """Handle mouse click. Returns True if click was on a tab."""
if self.tab_rect.collidepoint(pos): if self.tab_rect.collidepoint(pos):
@ -655,20 +655,20 @@ class StatsRenderer:
self._invalidate_cache() self._invalidate_cache()
return True return True
return False return False
def _render_chart(self, state: "SimulationState") -> pygame.Surface: def _render_chart(self, state: "SimulationState") -> pygame.Surface:
"""Render the current tab's chart.""" """Render the current tab's chart."""
tab_name, tab_key = self.TABS[self.current_tab] tab_name, tab_key = self.TABS[self.current_tab]
# Check cache # Check cache
current_turn = state.turn if state else 0 current_turn = state.turn if state else 0
if tab_key in self._chart_cache and self._cache_turn == current_turn: if tab_key in self._chart_cache and self._cache_turn == current_turn:
return self._chart_cache[tab_key] return self._chart_cache[tab_key]
# Render chart based on current tab # Render chart based on current tab
width = self.chart_rect.width width = self.chart_rect.width
height = self.chart_rect.height height = self.chart_rect.height
if tab_key == "price_history": if tab_key == "price_history":
surface = self.chart_renderer.render_price_history(self.history, width, height) surface = self.chart_renderer.render_price_history(self.history, width, height)
elif tab_key == "wealth": elif tab_key == "wealth":
@ -676,7 +676,7 @@ class StatsRenderer:
half_height = height // 2 half_height = height // 2
dist_surface = self.chart_renderer.render_wealth_distribution(state, width, half_height) dist_surface = self.chart_renderer.render_wealth_distribution(state, width, half_height)
time_surface = self.chart_renderer.render_wealth_over_time(self.history, width, half_height) time_surface = self.chart_renderer.render_wealth_over_time(self.history, width, half_height)
surface = pygame.Surface((width, height)) surface = pygame.Surface((width, height))
surface.fill(UIColors.BG) surface.fill(UIColors.BG)
surface.blit(dist_surface, (0, 0)) surface.blit(dist_surface, (0, 0))
@ -693,48 +693,48 @@ class StatsRenderer:
# Fallback empty surface # Fallback empty surface
surface = pygame.Surface((width, height)) surface = pygame.Surface((width, height))
surface.fill(UIColors.BG) surface.fill(UIColors.BG)
# Cache the result # Cache the result
self._chart_cache[tab_key] = surface self._chart_cache[tab_key] = surface
self._cache_turn = current_turn self._cache_turn = current_turn
return surface return surface
def draw(self, state: "SimulationState") -> None: def draw(self, state: "SimulationState") -> None:
"""Draw the statistics panel.""" """Draw the statistics panel."""
if not self.visible: if not self.visible:
return return
# Dim background # Dim background
overlay = pygame.Surface(self.screen.get_size(), pygame.SRCALPHA) overlay = pygame.Surface(self.screen.get_size(), pygame.SRCALPHA)
overlay.fill((0, 0, 0, 220)) overlay.fill((0, 0, 0, 220))
self.screen.blit(overlay, (0, 0)) self.screen.blit(overlay, (0, 0))
# Panel background # Panel background
pygame.draw.rect(self.screen, UIColors.PANEL_BG, self.panel_rect, border_radius=12) pygame.draw.rect(self.screen, UIColors.PANEL_BG, self.panel_rect, border_radius=12)
pygame.draw.rect(self.screen, UIColors.PANEL_BORDER, self.panel_rect, 2, border_radius=12) pygame.draw.rect(self.screen, UIColors.PANEL_BORDER, self.panel_rect, 2, border_radius=12)
# Draw tabs # Draw tabs
self._draw_tabs() self._draw_tabs()
# Draw chart # Draw chart
if state: if state:
chart_surface = self._render_chart(state) chart_surface = self._render_chart(state)
self.screen.blit(chart_surface, self.chart_rect.topleft) self.screen.blit(chart_surface, self.chart_rect.topleft)
# Draw close hint # Draw close hint
hint = self.small_font.render("Press G or ESC to close | ← → to switch tabs", hint = self.small_font.render("Press G or ESC to close | ← → to switch tabs",
True, UIColors.TEXT_SECONDARY) True, UIColors.TEXT_SECONDARY)
hint_rect = hint.get_rect(centerx=self.panel_rect.centerx, hint_rect = hint.get_rect(centerx=self.panel_rect.centerx,
y=self.panel_rect.bottom - 25) y=self.panel_rect.bottom - 25)
self.screen.blit(hint, hint_rect) self.screen.blit(hint, hint_rect)
def _draw_tabs(self) -> None: def _draw_tabs(self) -> None:
"""Draw the tab bar.""" """Draw the tab bar."""
for i, (tab_name, _) in enumerate(self.TABS): for i, (tab_name, _) in enumerate(self.TABS):
tab_x = self.tab_rect.x + i * self.tab_width tab_x = self.tab_rect.x + i * self.tab_width
tab_rect = pygame.Rect(tab_x, self.tab_rect.y, self.tab_width, self.tab_height) tab_rect = pygame.Rect(tab_x, self.tab_rect.y, self.tab_width, self.tab_height)
# Tab background # Tab background
if i == self.current_tab: if i == self.current_tab:
color = UIColors.TAB_ACTIVE color = UIColors.TAB_ACTIVE
@ -742,26 +742,26 @@ class StatsRenderer:
color = UIColors.TAB_HOVER color = UIColors.TAB_HOVER
else: else:
color = UIColors.TAB_INACTIVE color = UIColors.TAB_INACTIVE
# Draw tab with rounded top corners # Draw tab with rounded top corners
tab_surface = pygame.Surface((self.tab_width, self.tab_height), pygame.SRCALPHA) tab_surface = pygame.Surface((self.tab_width, self.tab_height), pygame.SRCALPHA)
pygame.draw.rect(tab_surface, color, (0, 0, self.tab_width, self.tab_height), pygame.draw.rect(tab_surface, color, (0, 0, self.tab_width, self.tab_height),
border_top_left_radius=8, border_top_right_radius=8) border_top_left_radius=8, border_top_right_radius=8)
if i == self.current_tab: if i == self.current_tab:
# Active tab - solid color # Active tab - solid color
tab_surface.set_alpha(255) tab_surface.set_alpha(255)
else: else:
tab_surface.set_alpha(180) tab_surface.set_alpha(180)
self.screen.blit(tab_surface, (tab_x, self.tab_rect.y)) self.screen.blit(tab_surface, (tab_x, self.tab_rect.y))
# Tab text # Tab text
text_color = UIColors.BG if i == self.current_tab else UIColors.TEXT_PRIMARY text_color = UIColors.BG if i == self.current_tab else UIColors.TEXT_PRIMARY
text = self.small_font.render(tab_name, True, text_color) text = self.small_font.render(tab_name, True, text_color)
text_rect = text.get_rect(center=tab_rect.center) text_rect = text.get_rect(center=tab_rect.center)
self.screen.blit(text, text_rect) self.screen.blit(text, text_rect)
# Tab border # Tab border
if i != self.current_tab: if i != self.current_tab:
pygame.draw.line(self.screen, UIColors.PANEL_BORDER, pygame.draw.line(self.screen, UIColors.PANEL_BORDER,

View File

@ -278,6 +278,17 @@
</div> </div>
</div> </div>
<div class="stats-footer"> <div class="stats-footer">
<div class="controls">
<button id="btn-initialize-stats" class="btn btn-secondary" title="Reset Simulation">
<span class="btn-icon"></span> Reset
</button>
<button id="btn-step-stats" class="btn btn-primary" title="Advance one turn">
<span class="btn-icon"></span> Step
</button>
<button id="btn-auto-stats" class="btn btn-toggle" title="Toggle auto mode">
<span class="btn-icon"></span> Auto
</button>
</div>
<div class="stats-summary-bar"> <div class="stats-summary-bar">
<div class="summary-item"> <div class="summary-item">
<span class="summary-label">Turn</span> <span class="summary-label">Turn</span>
@ -304,6 +315,11 @@
<span class="summary-value" id="stats-gini">0.00</span> <span class="summary-value" id="stats-gini">0.00</span>
</div> </div>
</div> </div>
<div class="speed-control">
<label for="speed-slider-stats">Speed</label>
<input type="range" id="speed-slider-stats" min="50" max="1000" value="150" step="50">
<span id="speed-display-stats">150ms</span>
</div>
</div> </div>
</div> </div>
</div> </div>

View File

@ -132,6 +132,12 @@ export default class GameScene extends Phaser.Scene {
goapPlanView: document.getElementById('goap-plan-view'), goapPlanView: document.getElementById('goap-plan-view'),
goapActionsList: document.getElementById('goap-actions-list'), goapActionsList: document.getElementById('goap-actions-list'),
chartGoapGoals: document.getElementById('chart-goap-goals'), chartGoapGoals: document.getElementById('chart-goap-goals'),
// Stats screen controls (duplicated for stats page)
btnStepStats: document.getElementById('btn-step-stats'),
btnAutoStats: document.getElementById('btn-auto-stats'),
btnInitializeStats: document.getElementById('btn-initialize-stats'),
speedSliderStats: document.getElementById('speed-slider-stats'),
speedDisplayStats: document.getElementById('speed-display-stats'),
}; };
// GOAP state // GOAP state
@ -172,6 +178,21 @@ export default class GameScene extends Phaser.Scene {
btnCloseStats.removeEventListener('click', this.boundHandlers.closeStats); btnCloseStats.removeEventListener('click', this.boundHandlers.closeStats);
} }
// Stats screen controls cleanup
const { btnStepStats, btnAutoStats, btnInitializeStats, speedSliderStats } = this.domCache;
if (btnStepStats && this.boundHandlers.step) {
btnStepStats.removeEventListener('click', this.boundHandlers.step);
}
if (btnAutoStats && this.boundHandlers.auto) {
btnAutoStats.removeEventListener('click', this.boundHandlers.auto);
}
if (btnInitializeStats && this.boundHandlers.init) {
btnInitializeStats.removeEventListener('click', this.boundHandlers.init);
}
if (speedSliderStats && this.boundHandlers.speedStats) {
speedSliderStats.removeEventListener('input', this.boundHandlers.speedStats);
}
// Destroy charts // Destroy charts
Object.values(this.charts).forEach(chart => chart?.destroy()); Object.values(this.charts).forEach(chart => chart?.destroy());
this.charts = {}; this.charts = {};
@ -286,19 +307,34 @@ export default class GameScene extends Phaser.Scene {
setupUIControls() { setupUIControls() {
const { btnStep, btnAuto, btnInitialize, btnStats, btnCloseStats, speedSlider, speedDisplay, tabButtons } = this.domCache; const { btnStep, btnAuto, btnInitialize, btnStats, btnCloseStats, speedSlider, speedDisplay, tabButtons } = this.domCache;
const { btnStepStats, btnAutoStats, btnInitializeStats, speedSliderStats, speedDisplayStats } = this.domCache;
// Create bound handlers for later cleanup // Create bound handlers for later cleanup
this.boundHandlers.step = () => this.handleStep(); this.boundHandlers.step = () => this.handleStep();
this.boundHandlers.auto = () => this.toggleAutoMode(); this.boundHandlers.auto = () => this.toggleAutoMode();
this.boundHandlers.init = () => this.handleInitialize(); this.boundHandlers.init = () => this.handleInitialize();
// Speed handler that syncs both sliders
this.boundHandlers.speed = (e) => { this.boundHandlers.speed = (e) => {
this.autoSpeed = parseInt(e.target.value); this.autoSpeed = parseInt(e.target.value);
if (speedDisplay) speedDisplay.textContent = `${this.autoSpeed}ms`; if (speedDisplay) speedDisplay.textContent = `${this.autoSpeed}ms`;
if (speedDisplayStats) speedDisplayStats.textContent = `${this.autoSpeed}ms`;
if (speedSliderStats) speedSliderStats.value = this.autoSpeed;
if (this.isAutoMode) this.restartAutoMode(); if (this.isAutoMode) this.restartAutoMode();
}; };
this.boundHandlers.speedStats = (e) => {
this.autoSpeed = parseInt(e.target.value);
if (speedDisplay) speedDisplay.textContent = `${this.autoSpeed}ms`;
if (speedDisplayStats) speedDisplayStats.textContent = `${this.autoSpeed}ms`;
if (speedSlider) speedSlider.value = this.autoSpeed;
if (this.isAutoMode) this.restartAutoMode();
};
this.boundHandlers.openStats = () => this.showStatsScreen(); this.boundHandlers.openStats = () => this.showStatsScreen();
this.boundHandlers.closeStats = () => this.hideStatsScreen(); this.boundHandlers.closeStats = () => this.hideStatsScreen();
// Main controls
if (btnStep) btnStep.addEventListener('click', this.boundHandlers.step); if (btnStep) btnStep.addEventListener('click', this.boundHandlers.step);
if (btnAuto) btnAuto.addEventListener('click', this.boundHandlers.auto); if (btnAuto) btnAuto.addEventListener('click', this.boundHandlers.auto);
if (btnInitialize) btnInitialize.addEventListener('click', this.boundHandlers.init); if (btnInitialize) btnInitialize.addEventListener('click', this.boundHandlers.init);
@ -306,6 +342,12 @@ export default class GameScene extends Phaser.Scene {
if (btnStats) btnStats.addEventListener('click', this.boundHandlers.openStats); if (btnStats) btnStats.addEventListener('click', this.boundHandlers.openStats);
if (btnCloseStats) btnCloseStats.addEventListener('click', this.boundHandlers.closeStats); if (btnCloseStats) btnCloseStats.addEventListener('click', this.boundHandlers.closeStats);
// Stats screen controls (same handlers)
if (btnStepStats) btnStepStats.addEventListener('click', this.boundHandlers.step);
if (btnAutoStats) btnAutoStats.addEventListener('click', this.boundHandlers.auto);
if (btnInitializeStats) btnInitializeStats.addEventListener('click', this.boundHandlers.init);
if (speedSliderStats) speedSliderStats.addEventListener('input', this.boundHandlers.speedStats);
// Tab switching // Tab switching
tabButtons?.forEach(btn => { tabButtons?.forEach(btn => {
btn.addEventListener('click', (e) => this.switchTab(e.target.dataset.tab)); btn.addEventListener('click', (e) => this.switchTab(e.target.dataset.tab));
@ -380,15 +422,19 @@ export default class GameScene extends Phaser.Scene {
toggleAutoMode() { toggleAutoMode() {
this.isAutoMode = !this.isAutoMode; this.isAutoMode = !this.isAutoMode;
const { btnAuto, btnStep } = this.domCache; const { btnAuto, btnStep, btnAutoStats, btnStepStats } = this.domCache;
if (this.isAutoMode) { if (this.isAutoMode) {
btnAuto?.classList.add('active'); btnAuto?.classList.add('active');
btnAutoStats?.classList.add('active');
btnStep?.setAttribute('disabled', 'true'); btnStep?.setAttribute('disabled', 'true');
btnStepStats?.setAttribute('disabled', 'true');
this.startAutoMode(); this.startAutoMode();
} else { } else {
btnAuto?.classList.remove('active'); btnAuto?.classList.remove('active');
btnAutoStats?.classList.remove('active');
btnStep?.removeAttribute('disabled'); btnStep?.removeAttribute('disabled');
btnStepStats?.removeAttribute('disabled');
this.stopAutoMode(); this.stopAutoMode();
} }
} }

View File

@ -613,7 +613,8 @@ body {
font-weight: 500; font-weight: 500;
} }
#speed-slider { #speed-slider,
#speed-slider-stats {
width: 120px; width: 120px;
height: 4px; height: 4px;
-webkit-appearance: none; -webkit-appearance: none;
@ -623,7 +624,8 @@ body {
cursor: pointer; cursor: pointer;
} }
#speed-slider::-webkit-slider-thumb { #speed-slider::-webkit-slider-thumb,
#speed-slider-stats::-webkit-slider-thumb {
-webkit-appearance: none; -webkit-appearance: none;
appearance: none; appearance: none;
width: 14px; width: 14px;
@ -633,7 +635,8 @@ body {
cursor: pointer; cursor: pointer;
} }
#speed-display { #speed-display,
#speed-display-stats {
font-family: var(--font-mono); font-family: var(--font-mono);
min-width: 50px; min-width: 50px;
} }
@ -939,16 +942,21 @@ body {
/* Stats Footer */ /* Stats Footer */
.stats-footer { .stats-footer {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--space-sm) var(--space-lg); padding: var(--space-sm) var(--space-lg);
background: var(--bg-primary); background: var(--bg-primary);
border-top: 1px solid var(--border-color); border-top: 1px solid var(--border-color);
flex-shrink: 0; flex-shrink: 0;
height: 56px;
} }
.stats-summary-bar { .stats-summary-bar {
display: flex; display: flex;
justify-content: center; justify-content: center;
gap: var(--space-xl); gap: var(--space-xl);
flex: 1;
} }
.summary-item { .summary-item {
@ -1049,6 +1057,28 @@ body {
flex-wrap: wrap; flex-wrap: wrap;
gap: var(--space-md); gap: var(--space-md);
} }
.stats-footer {
flex-wrap: wrap;
height: auto;
gap: var(--space-sm);
padding: var(--space-sm);
}
.stats-footer .controls {
order: 1;
width: auto;
}
.stats-footer .stats-summary-bar {
order: 2;
width: 100%;
}
.stats-footer .speed-control {
order: 3;
width: auto;
}
} }
/* ================================= /* =================================