diff --git a/backend/core/goap/action.py b/backend/core/goap/action.py index 2429cb2..9b58bcd 100644 --- a/backend/core/goap/action.py +++ b/backend/core/goap/action.py @@ -202,10 +202,28 @@ def create_gather_action( if success_chance < 1.0: 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: - # Cautious agents slightly prefer gathering - base_cost *= (0.9 + state.risk_tolerance * 0.2) + # Compare gather_preference to other preferences + # 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 @@ -280,8 +298,28 @@ def create_buy_action(resource_type: ResourceType) -> GOAPAction: # Trading cost is low (1 energy) base_cost = 0.5 - # Market-oriented agents prefer buying - base_cost *= (1.5 - state.market_affinity) + # MILD profession effect for trading (everyone should be able to trade) + # 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 if resource_type == ResourceType.MEAT: diff --git a/backend/core/goap/actions.py b/backend/core/goap/actions.py index f20e7a6..e734f0e 100644 --- a/backend/core/goap/actions.py +++ b/backend/core/goap/actions.py @@ -159,9 +159,28 @@ def _create_hunt() -> GOAPAction: if config.success_chance < 1.0: base_cost *= 1.0 + (1.0 - config.success_chance) * 0.2 - # Risk-tolerant agents prefer hunting - # Range: 0.85 (high risk tolerance) to 1.15 (low risk tolerance) - risk_modifier = 1.0 + (0.5 - state.risk_tolerance) * 0.3 + # STRONG profession specialization effect for hunting + # Compare hunt_preference to other preferences + 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 # 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 def cost(state: WorldState) -> float: - # Selling has low cost + # Selling has low cost - everyone should be able to sell excess base_cost = 1.0 - # Hoarders reluctant to sell - base_cost *= (0.5 + state.hoarding_rate) + # MILD profession effect for selling (everyone should be able to trade) + 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 diff --git a/backend/core/goap/world_state.py b/backend/core/goap/world_state.py index 991f5cb..72ae107 100644 --- a/backend/core/goap/world_state.py +++ b/backend/core/goap/world_state.py @@ -73,6 +73,16 @@ class WorldState: market_affinity: float = 0.5 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_threshold: float = 0.25 low_threshold: float = 0.45 @@ -297,6 +307,12 @@ def create_world_state( risk_tolerance=agent.personality.risk_tolerance, market_affinity=agent.personality.market_affinity, 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, low_threshold=0.45, # Could also be in config ) diff --git a/frontend/renderer/stats_renderer.py b/frontend/renderer/stats_renderer.py index 7a2d5ea..9459936 100644 --- a/frontend/renderer/stats_renderer.py +++ b/frontend/renderer/stats_renderer.py @@ -28,7 +28,7 @@ class ChartColors: GRID = '#2f3545' TEXT = '#e0e0e8' TEXT_DIM = '#7a7e8c' - + # Neon accents for data series CYAN = '#00d4ff' MAGENTA = '#ff0099' @@ -38,7 +38,7 @@ class ChartColors: YELLOW = '#ffcc00' TEAL = '#00ffa3' PINK = '#ff1493' - + # Series colors for different resources/categories SERIES = [CYAN, MAGENTA, LIME, ORANGE, PURPLE, YELLOW, TEAL, PINK] @@ -60,26 +60,26 @@ class UIColors: class HistoryData: """Stores historical simulation data for charting.""" max_history: int = 200 - + # Time series data turns: 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)) - + # Money/Wealth data total_money: 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)) - + # Price history per resource prices: dict = field(default_factory=dict) # resource -> deque of prices - + # Trade statistics trade_volume: deque = field(default_factory=lambda: deque(maxlen=200)) - + # Profession counts over time professions: dict = field(default_factory=dict) # profession -> deque of counts - + def clear(self) -> None: """Clear all history data.""" self.turns.clear() @@ -91,51 +91,51 @@ class HistoryData: self.prices.clear() self.trade_volume.clear() self.professions.clear() - + def update(self, state: "SimulationState") -> None: """Update history with new state data.""" turn = state.turn - + # Avoid duplicate entries for the same turn if self.turns and self.turns[-1] == turn: return - + self.turns.append(turn) - + # Population living = len([a for a in state.agents if a.get("is_alive", False)]) self.population.append(living) self.deaths_cumulative.append(state.statistics.get("total_agents_died", 0)) - + # Wealth data stats = state.statistics self.total_money.append(stats.get("total_money_in_circulation", 0)) self.avg_wealth.append(stats.get("avg_money", 0)) self.gini_coefficient.append(stats.get("gini_coefficient", 0)) - + # Price history from market for resource, data in state.market_prices.items(): if resource not in self.prices: self.prices[resource] = deque(maxlen=self.max_history) - + # Track lowest price (current market rate) lowest = data.get("lowest_price") avg = data.get("avg_sale_price") # Use lowest price if available, else avg sale price price = lowest if lowest is not None else avg self.prices[resource].append(price) - + # Trade volume (from recent trades in market orders) trades = len(state.market_orders) # Active orders as proxy self.trade_volume.append(trades) - + # Profession distribution professions = stats.get("professions", {}) for prof, count in professions.items(): if prof not in self.professions: self.professions[prof] = deque(maxlen=self.max_history) self.professions[prof].append(count) - + # Pad missing professions with 0 for prof in self.professions: if prof not in professions: @@ -144,12 +144,12 @@ class HistoryData: class ChartRenderer: """Renders matplotlib charts to pygame surfaces.""" - + def __init__(self, width: int, height: int): self.width = width self.height = height self.dpi = 100 - + # Configure matplotlib style plt.style.use('dark_background') plt.rcParams.update({ @@ -168,27 +168,27 @@ class ChartRenderer: 'axes.titlesize': 11, 'axes.titleweight': 'bold', }) - + def _fig_to_surface(self, fig: Figure) -> pygame.Surface: """Convert a matplotlib figure to a pygame surface.""" buf = io.BytesIO() - fig.savefig(buf, format='png', dpi=self.dpi, + fig.savefig(buf, format='png', dpi=self.dpi, facecolor=ChartColors.BG, edgecolor='none', bbox_inches='tight', pad_inches=0.1) buf.seek(0) - + surface = pygame.image.load(buf, 'png') buf.close() plt.close(fig) - + return surface - + def render_price_history(self, history: HistoryData, width: int, height: int) -> pygame.Surface: """Render price history chart for all resources.""" fig, ax = plt.subplots(figsize=(width/self.dpi, height/self.dpi), dpi=self.dpi) - + turns = list(history.turns) if history.turns else [0] - + has_data = False for i, (resource, prices) in enumerate(history.prices.items()): 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] # Align with turns 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) has_data = True - + ax.set_title('Market Prices', color=ChartColors.CYAN) ax.set_xlabel('Turn') ax.set_ylabel('Price (coins)') ax.grid(True, alpha=0.2) - + if has_data: ax.legend(loc='upper left', fontsize=8, framealpha=0.8) - + ax.set_ylim(bottom=0) ax.yaxis.set_major_locator(mticker.MaxNLocator(integer=True)) - + fig.tight_layout() return self._fig_to_surface(fig) - + def render_population(self, history: HistoryData, width: int, height: int) -> pygame.Surface: """Render population over time chart.""" fig, ax = plt.subplots(figsize=(width/self.dpi, height/self.dpi), dpi=self.dpi) - + turns = list(history.turns) if history.turns else [0] population = list(history.population) if history.population else [0] deaths = list(history.deaths_cumulative) if history.deaths_cumulative else [0] - + min_len = min(len(turns), len(population)) - + # 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) - ax.plot(turns[-min_len:], population[-min_len:], + ax.plot(turns[-min_len:], population[-min_len:], color=ChartColors.CYAN, linewidth=2, label='Living') - + # Deaths line if deaths: - ax.plot(turns[-min_len:], deaths[-min_len:], - color=ChartColors.MAGENTA, linewidth=1.5, linestyle='--', + ax.plot(turns[-min_len:], deaths[-min_len:], + color=ChartColors.MAGENTA, linewidth=1.5, linestyle='--', label='Total Deaths', alpha=0.8) - + ax.set_title('Population Over Time', color=ChartColors.LIME) ax.set_xlabel('Turn') ax.set_ylabel('Count') @@ -244,28 +244,28 @@ class ChartRenderer: ax.legend(loc='upper right', fontsize=8) ax.set_ylim(bottom=0) ax.yaxis.set_major_locator(mticker.MaxNLocator(integer=True)) - + fig.tight_layout() return self._fig_to_surface(fig) - + def render_wealth_distribution(self, state: "SimulationState", width: int, height: int) -> pygame.Surface: """Render current wealth distribution as a bar chart.""" fig, ax = plt.subplots(figsize=(width/self.dpi, height/self.dpi), dpi=self.dpi) - + # Get agent wealth data agents = [a for a in state.agents if a.get("is_alive", False)] 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) ax.set_title('Wealth Distribution', color=ChartColors.ORANGE) fig.tight_layout() return self._fig_to_surface(fig) - + # Sort by wealth agents_sorted = sorted(agents, key=lambda a: a.get("money", 0), reverse=True) names = [a.get("name", "?")[:8] for a in agents_sorted] wealth = [a.get("money", 0) for a in agents_sorted] - + # Create gradient colors based on wealth ranking colors = [] for i in range(len(agents_sorted)): @@ -275,86 +275,86 @@ class ChartRenderer: g = int(212 - ratio * 212) b = int(255 - ratio * 102) colors.append(f'#{r:02x}{g:02x}{b:02x}') - + bars = ax.barh(range(len(agents_sorted)), wealth, color=colors, alpha=0.85) ax.set_yticks(range(len(agents_sorted))) ax.set_yticklabels(names, fontsize=7) ax.invert_yaxis() # Rich at top - + # Add value labels for bar, val in zip(bars, wealth): ax.text(bar.get_width() + 1, bar.get_y() + bar.get_height()/2, f'{val}', va='center', fontsize=7, color=ChartColors.TEXT_DIM) - + ax.set_title('Wealth Distribution', color=ChartColors.ORANGE) ax.set_xlabel('Coins') ax.grid(True, alpha=0.2, axis='x') - + fig.tight_layout() return self._fig_to_surface(fig) - + def render_wealth_over_time(self, history: HistoryData, width: int, height: int) -> pygame.Surface: """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]) - + turns = list(history.turns) if history.turns else [0] total = list(history.total_money) if history.total_money else [0] avg = list(history.avg_wealth) if history.avg_wealth else [0] gini = list(history.gini_coefficient) if history.gini_coefficient else [0] - + min_len = min(len(turns), len(total), len(avg)) - + # 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') - ax1.fill_between(turns[-min_len:], total[-min_len:], + ax1.fill_between(turns[-min_len:], total[-min_len:], alpha=0.2, color=ChartColors.CYAN) - + 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') ax1_twin.set_ylabel('Avg Wealth', color=ChartColors.LIME) ax1_twin.tick_params(axis='y', labelcolor=ChartColors.LIME) - + ax1.set_title('Money in Circulation', color=ChartColors.YELLOW) ax1.set_ylabel('Total Money', color=ChartColors.CYAN) ax1.tick_params(axis='y', labelcolor=ChartColors.CYAN) ax1.grid(True, alpha=0.2) ax1.set_ylim(bottom=0) - + # Gini coefficient (inequality) 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) - 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) ax2.set_xlabel('Turn') ax2.set_ylabel('Gini') ax2.set_title('Inequality Index', color=ChartColors.MAGENTA, fontsize=9) ax2.set_ylim(0, 1) ax2.grid(True, alpha=0.2) - + # Add reference lines for gini 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) - + fig.tight_layout() 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: """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) - + # Current profession pie chart professions = state.statistics.get("professions", {}) if professions: labels = list(professions.keys()) sizes = list(professions.values()) colors = [ChartColors.SERIES[i % len(ChartColors.SERIES)] for i in range(len(labels))] - + wedges, texts, autotexts = ax1.pie( sizes, labels=labels, colors=colors, autopct='%1.0f%%', startangle=90, pctdistance=0.75, @@ -367,7 +367,7 @@ class ChartRenderer: else: ax1.text(0.5, 0.5, 'No data', ha='center', va='center', color=ChartColors.TEXT_DIM) ax1.set_title('Current Distribution', color=ChartColors.PURPLE) - + # Profession history as stacked area turns = list(history.turns) if history.turns else [0] if history.professions and turns: @@ -379,37 +379,37 @@ class ChartRenderer: while len(prof_data) < len(turns): prof_data.insert(0, 0) data.append(prof_data[-len(turns):]) - + 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.legend(loc='upper left', fontsize=7, framealpha=0.8) ax2.set_xlabel('Turn') ax2.set_ylabel('Count') - + ax2.set_title('Over Time', color=ChartColors.PURPLE, fontsize=10) ax2.grid(True, alpha=0.2) - + fig.tight_layout() return self._fig_to_surface(fig) - + def render_market_activity(self, state: "SimulationState", history: HistoryData, width: int, height: int) -> pygame.Surface: """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) - + # Current market orders by resource type prices = state.market_prices resources = [] quantities = [] colors = [] - + for i, (resource, data) in enumerate(prices.items()): qty = data.get("total_available", 0) if qty > 0: resources.append(resource.capitalize()) quantities.append(qty) colors.append(ChartColors.SERIES[i % len(ChartColors.SERIES)]) - + if resources: bars = ax1.bar(resources, quantities, color=colors, alpha=0.85) ax1.set_ylabel('Available') @@ -418,93 +418,93 @@ class ChartRenderer: str(val), ha='center', fontsize=8, color=ChartColors.TEXT) else: 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.tick_params(axis='x', rotation=45, labelsize=7) ax1.grid(True, alpha=0.2, axis='y') - + # Supply/Demand scores resources_sd = [] supply_scores = [] demand_scores = [] - + for resource, data in prices.items(): resources_sd.append(resource[:6]) supply_scores.append(data.get("supply_score", 0.5)) demand_scores.append(data.get("demand_score", 0.5)) - + if resources_sd: x = np.arange(len(resources_sd)) 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) - 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) - + ax2.set_xticks(x) ax2.set_xticklabels(resources_sd, fontsize=7, rotation=45) ax2.set_ylabel('Score') ax2.legend(fontsize=7) ax2.set_ylim(0, 1.2) - + ax2.set_title('Supply/Demand', color=ChartColors.TEAL, fontsize=10) ax2.grid(True, alpha=0.2, axis='y') - + fig.tight_layout() return self._fig_to_surface(fig) - + def render_agent_stats(self, state: "SimulationState", width: int, height: int) -> pygame.Surface: """Render aggregate agent statistics - energy, hunger, thirst distributions.""" 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)] - + if not agents: for ax in axes.flat: ax.text(0.5, 0.5, 'No agents', ha='center', va='center', color=ChartColors.TEXT_DIM) fig.suptitle('Agent Statistics', color=ChartColors.CYAN) fig.tight_layout() return self._fig_to_surface(fig) - + # Extract stats energies = [a.get("stats", {}).get("energy", 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] heats = [a.get("stats", {}).get("heat", 0) for a in agents] - + max_energy = agents[0].get("stats", {}).get("max_energy", 100) max_hunger = agents[0].get("stats", {}).get("max_hunger", 100) max_thirst = agents[0].get("stats", {}).get("max_thirst", 100) max_heat = agents[0].get("stats", {}).get("max_heat", 100) - + stats_data = [ (energies, max_energy, 'Energy', ChartColors.LIME), (hungers, max_hunger, 'Hunger', ChartColors.ORANGE), (thirsts, max_thirst, 'Thirst', ChartColors.CYAN), (heats, max_heat, 'Heat', ChartColors.MAGENTA), ] - + for ax, (values, max_val, name, color) in zip(axes.flat, stats_data): # Histogram bins = np.linspace(0, max_val, 11) ax.hist(values, bins=bins, color=color, alpha=0.7, edgecolor=ChartColors.PANEL) - + # Mean line 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}') - + # Critical threshold 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) - + ax.set_title(name, color=color, fontsize=9) ax.set_xlim(0, max_val) ax.legend(fontsize=7, loc='upper right') ax.grid(True, alpha=0.2) - + fig.suptitle('Agent Statistics Distribution', color=ChartColors.CYAN, fontsize=11) fig.tight_layout() return self._fig_to_surface(fig) @@ -512,7 +512,7 @@ class ChartRenderer: class StatsRenderer: """Main statistics panel with tabs and charts.""" - + TABS = [ ("Prices", "price_history"), ("Wealth", "wealth"), @@ -521,35 +521,35 @@ class StatsRenderer: ("Market", "market"), ("Agent Stats", "agent_stats"), ] - + def __init__(self, screen: pygame.Surface): self.screen = screen self.visible = False - + self.font = pygame.font.Font(None, 24) self.small_font = pygame.font.Font(None, 18) self.title_font = pygame.font.Font(None, 32) - + self.current_tab = 0 self.tab_hovered = -1 - + # History data self.history = HistoryData() - + # Chart renderer self.chart_renderer: Optional[ChartRenderer] = None - + # Cached chart surfaces self._chart_cache: dict[str, pygame.Surface] = {} self._cache_turn: int = -1 - + # Layout self._calculate_layout() - + def _calculate_layout(self) -> None: """Calculate panel layout based on screen size.""" screen_w, screen_h = self.screen.get_size() - + # Panel takes most of the screen with some margin margin = 30 self.panel_rect = pygame.Rect( @@ -557,7 +557,7 @@ class StatsRenderer: screen_w - margin * 2, screen_h - margin * 2 ) - + # Tab bar self.tab_height = 40 self.tab_rect = pygame.Rect( @@ -566,7 +566,7 @@ class StatsRenderer: self.panel_rect.width, self.tab_height ) - + # Chart area self.chart_rect = pygame.Rect( self.panel_rect.x + 10, @@ -574,52 +574,52 @@ class StatsRenderer: self.panel_rect.width - 20, self.panel_rect.height - self.tab_height - 20 ) - + # Initialize chart renderer with chart area size self.chart_renderer = ChartRenderer( self.chart_rect.width, self.chart_rect.height ) - + # Calculate tab widths self.tab_width = self.panel_rect.width // len(self.TABS) - + def toggle(self) -> None: """Toggle visibility of the stats panel.""" self.visible = not self.visible if self.visible: self._invalidate_cache() - + def update_history(self, state: "SimulationState") -> None: """Update history data with new state.""" if state: self.history.update(state) - + def clear_history(self) -> None: """Clear all history data (e.g., on simulation reset).""" self.history.clear() self._invalidate_cache() - + def _invalidate_cache(self) -> None: """Invalidate chart cache to force re-render.""" self._chart_cache.clear() self._cache_turn = -1 - + def handle_event(self, event: pygame.event.Event) -> bool: """Handle input events. Returns True if event was consumed.""" if not self.visible: return False - + if event.type == pygame.MOUSEMOTION: self._handle_mouse_motion(event.pos) return True - + elif event.type == pygame.MOUSEBUTTONDOWN: if self._handle_click(event.pos): return True # Consume clicks when visible return True - + elif event.type == pygame.KEYDOWN: if event.key == pygame.K_ESCAPE: self.toggle() @@ -632,19 +632,19 @@ class StatsRenderer: self.current_tab = (self.current_tab + 1) % len(self.TABS) self._invalidate_cache() return True - + return False - + def _handle_mouse_motion(self, pos: tuple[int, int]) -> None: """Handle mouse motion for tab hover effects.""" self.tab_hovered = -1 - + if self.tab_rect.collidepoint(pos): rel_x = pos[0] - self.tab_rect.x tab_idx = rel_x // self.tab_width if 0 <= tab_idx < len(self.TABS): self.tab_hovered = tab_idx - + def _handle_click(self, pos: tuple[int, int]) -> bool: """Handle mouse click. Returns True if click was on a tab.""" if self.tab_rect.collidepoint(pos): @@ -655,20 +655,20 @@ class StatsRenderer: self._invalidate_cache() return True return False - + def _render_chart(self, state: "SimulationState") -> pygame.Surface: """Render the current tab's chart.""" tab_name, tab_key = self.TABS[self.current_tab] - + # Check cache current_turn = state.turn if state else 0 if tab_key in self._chart_cache and self._cache_turn == current_turn: return self._chart_cache[tab_key] - + # Render chart based on current tab width = self.chart_rect.width height = self.chart_rect.height - + if tab_key == "price_history": surface = self.chart_renderer.render_price_history(self.history, width, height) elif tab_key == "wealth": @@ -676,7 +676,7 @@ class StatsRenderer: half_height = height // 2 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) - + surface = pygame.Surface((width, height)) surface.fill(UIColors.BG) surface.blit(dist_surface, (0, 0)) @@ -693,48 +693,48 @@ class StatsRenderer: # Fallback empty surface surface = pygame.Surface((width, height)) surface.fill(UIColors.BG) - + # Cache the result self._chart_cache[tab_key] = surface self._cache_turn = current_turn - + return surface - + def draw(self, state: "SimulationState") -> None: """Draw the statistics panel.""" if not self.visible: return - + # Dim background overlay = pygame.Surface(self.screen.get_size(), pygame.SRCALPHA) overlay.fill((0, 0, 0, 220)) self.screen.blit(overlay, (0, 0)) - + # Panel background 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) - + # Draw tabs self._draw_tabs() - + # Draw chart if state: chart_surface = self._render_chart(state) self.screen.blit(chart_surface, self.chart_rect.topleft) - + # 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) - 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) self.screen.blit(hint, hint_rect) - + def _draw_tabs(self) -> None: """Draw the tab bar.""" for i, (tab_name, _) in enumerate(self.TABS): 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 background if i == self.current_tab: color = UIColors.TAB_ACTIVE @@ -742,26 +742,26 @@ class StatsRenderer: color = UIColors.TAB_HOVER else: color = UIColors.TAB_INACTIVE - + # Draw tab with rounded top corners 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) - + if i == self.current_tab: # Active tab - solid color tab_surface.set_alpha(255) else: tab_surface.set_alpha(180) - + self.screen.blit(tab_surface, (tab_x, self.tab_rect.y)) - + # Tab text text_color = UIColors.BG if i == self.current_tab else UIColors.TEXT_PRIMARY text = self.small_font.render(tab_name, True, text_color) text_rect = text.get_rect(center=tab_rect.center) self.screen.blit(text, text_rect) - + # Tab border if i != self.current_tab: pygame.draw.line(self.screen, UIColors.PANEL_BORDER, diff --git a/web_frontend/index.html b/web_frontend/index.html index c6a99ab..e8b6cc4 100644 --- a/web_frontend/index.html +++ b/web_frontend/index.html @@ -278,6 +278,17 @@ diff --git a/web_frontend/src/scenes/GameScene.js b/web_frontend/src/scenes/GameScene.js index d9ac547..1bcb931 100644 --- a/web_frontend/src/scenes/GameScene.js +++ b/web_frontend/src/scenes/GameScene.js @@ -132,6 +132,12 @@ export default class GameScene extends Phaser.Scene { goapPlanView: document.getElementById('goap-plan-view'), goapActionsList: document.getElementById('goap-actions-list'), 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 @@ -172,6 +178,21 @@ export default class GameScene extends Phaser.Scene { 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 Object.values(this.charts).forEach(chart => chart?.destroy()); this.charts = {}; @@ -286,19 +307,34 @@ export default class GameScene extends Phaser.Scene { setupUIControls() { 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 this.boundHandlers.step = () => this.handleStep(); this.boundHandlers.auto = () => this.toggleAutoMode(); this.boundHandlers.init = () => this.handleInitialize(); + + // Speed handler that syncs both sliders this.boundHandlers.speed = (e) => { this.autoSpeed = parseInt(e.target.value); 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(); }; + + 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.closeStats = () => this.hideStatsScreen(); + // Main controls if (btnStep) btnStep.addEventListener('click', this.boundHandlers.step); if (btnAuto) btnAuto.addEventListener('click', this.boundHandlers.auto); 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 (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 tabButtons?.forEach(btn => { btn.addEventListener('click', (e) => this.switchTab(e.target.dataset.tab)); @@ -380,15 +422,19 @@ export default class GameScene extends Phaser.Scene { toggleAutoMode() { this.isAutoMode = !this.isAutoMode; - const { btnAuto, btnStep } = this.domCache; + const { btnAuto, btnStep, btnAutoStats, btnStepStats } = this.domCache; if (this.isAutoMode) { btnAuto?.classList.add('active'); + btnAutoStats?.classList.add('active'); btnStep?.setAttribute('disabled', 'true'); + btnStepStats?.setAttribute('disabled', 'true'); this.startAutoMode(); } else { btnAuto?.classList.remove('active'); + btnAutoStats?.classList.remove('active'); btnStep?.removeAttribute('disabled'); + btnStepStats?.removeAttribute('disabled'); this.stopAutoMode(); } } diff --git a/web_frontend/styles.css b/web_frontend/styles.css index ee881c9..5fd86b2 100644 --- a/web_frontend/styles.css +++ b/web_frontend/styles.css @@ -613,7 +613,8 @@ body { font-weight: 500; } -#speed-slider { +#speed-slider, +#speed-slider-stats { width: 120px; height: 4px; -webkit-appearance: none; @@ -623,7 +624,8 @@ body { cursor: pointer; } -#speed-slider::-webkit-slider-thumb { +#speed-slider::-webkit-slider-thumb, +#speed-slider-stats::-webkit-slider-thumb { -webkit-appearance: none; appearance: none; width: 14px; @@ -633,7 +635,8 @@ body { cursor: pointer; } -#speed-display { +#speed-display, +#speed-display-stats { font-family: var(--font-mono); min-width: 50px; } @@ -939,16 +942,21 @@ body { /* Stats Footer */ .stats-footer { + display: flex; + align-items: center; + justify-content: space-between; padding: var(--space-sm) var(--space-lg); background: var(--bg-primary); border-top: 1px solid var(--border-color); flex-shrink: 0; + height: 56px; } .stats-summary-bar { display: flex; justify-content: center; gap: var(--space-xl); + flex: 1; } .summary-item { @@ -1049,6 +1057,28 @@ body { flex-wrap: wrap; 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; + } } /* =================================