From 67dc007283ed40ea37c6dccb3bf20bbb83909b82 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=A1=D0=BD=D0=B5=D1=81=D0=B0=D1=80=D0=B5=D0=B2=20=D0=9C?= =?UTF-8?q?=D0=B0=D0=BA=D1=81=D0=B8=D0=BC?= Date: Mon, 19 Jan 2026 19:44:38 +0300 Subject: [PATCH] [new] web-based frontend --- backend/api/schemas.py | 19 + backend/core/engine.py | 311 +++-- backend/domain/agent.py | 147 ++- backend/main.py | 16 +- web_frontend/index.html | 279 +++++ web_frontend/src/api.js | 132 +++ web_frontend/src/constants.js | 71 ++ web_frontend/src/main.js | 73 ++ web_frontend/src/scenes/BootScene.js | 141 +++ web_frontend/src/scenes/GameScene.js | 1633 ++++++++++++++++++++++++++ web_frontend/src/scenes/index.js | 7 + web_frontend/styles.css | 1053 +++++++++++++++++ 12 files changed, 3712 insertions(+), 170 deletions(-) create mode 100644 web_frontend/index.html create mode 100644 web_frontend/src/api.js create mode 100644 web_frontend/src/constants.js create mode 100644 web_frontend/src/main.js create mode 100644 web_frontend/src/scenes/BootScene.js create mode 100644 web_frontend/src/scenes/GameScene.js create mode 100644 web_frontend/src/scenes/index.js create mode 100644 web_frontend/styles.css diff --git a/backend/api/schemas.py b/backend/api/schemas.py index 8d32fbb..da1681e 100644 --- a/backend/api/schemas.py +++ b/backend/api/schemas.py @@ -121,6 +121,12 @@ class StatisticsSchema(BaseModel): total_agents_died: int total_money_in_circulation: int professions: dict[str, int] + # Wealth inequality metrics + avg_money: float = 0.0 + median_money: int = 0 + richest_agent: int = 0 + poorest_agent: int = 0 + gini_coefficient: float = 0.0 class ActionLogSchema(BaseModel): @@ -137,6 +143,18 @@ class TurnLogSchema(BaseModel): agent_actions: list[ActionLogSchema] deaths: list[str] trades: list[dict] + resources_produced: dict[str, int] = {} + resources_consumed: dict[str, int] = {} + resources_spoiled: dict[str, int] = {} + + +class ResourceStatsSchema(BaseModel): + """Schema for resource statistics.""" + produced: dict[str, int] = {} + consumed: dict[str, int] = {} + spoiled: dict[str, int] = {} + in_inventory: dict[str, int] = {} + in_market: dict[str, int] = {} class WorldStateResponse(BaseModel): @@ -152,6 +170,7 @@ class WorldStateResponse(BaseModel): mode: str is_running: bool recent_logs: list[TurnLogSchema] + resource_stats: ResourceStatsSchema = ResourceStatsSchema() # ============== Control Schemas ============== diff --git a/backend/core/engine.py b/backend/core/engine.py index 8aeefa6..df7b892 100644 --- a/backend/core/engine.py +++ b/backend/core/engine.py @@ -31,31 +31,38 @@ class TurnLog: agent_actions: list[dict] = field(default_factory=list) deaths: list[str] = field(default_factory=list) trades: list[dict] = field(default_factory=list) - + # Resource tracking for this turn + resources_produced: dict = field(default_factory=dict) + resources_consumed: dict = field(default_factory=dict) + resources_spoiled: dict = field(default_factory=dict) + def to_dict(self) -> dict: return { "turn": self.turn, "agent_actions": self.agent_actions, "deaths": self.deaths, "trades": self.trades, + "resources_produced": self.resources_produced, + "resources_consumed": self.resources_consumed, + "resources_spoiled": self.resources_spoiled, } class GameEngine: """Main game engine singleton.""" - + _instance: Optional["GameEngine"] = None - + def __new__(cls): if cls._instance is None: cls._instance = super().__new__(cls) cls._instance._initialized = False return cls._instance - + def __init__(self): if self._initialized: return - + self.world = World() self.market = OrderBook() self.mode = SimulationMode.MANUAL @@ -66,55 +73,76 @@ class GameEngine: self._stop_event = threading.Event() self.turn_logs: list[TurnLog] = [] self.logger = get_simulation_logger() + + # Resource statistics tracking (cumulative) + self.resource_stats = { + "produced": {}, # Total resources produced + "consumed": {}, # Total resources consumed + "spoiled": {}, # Total resources spoiled + "traded": {}, # Total resources traded (bought/sold) + "in_market": {}, # Currently in market + "in_inventory": {}, # Currently in all inventories + } + self._initialized = True - + def reset(self, config: Optional[WorldConfig] = None) -> None: """Reset the simulation to initial state.""" # Stop auto mode if running self._stop_auto_mode() - + if config: self.world = World(config=config) else: self.world = World() self.market = OrderBook() self.turn_logs = [] - + + # Reset resource statistics + self.resource_stats = { + "produced": {}, + "consumed": {}, + "spoiled": {}, + "traded": {}, + "in_market": {}, + "in_inventory": {}, + } + # Reset and start new logging session self.logger = reset_simulation_logger() sim_config = get_config() self.logger.start_session(sim_config.to_dict()) - + self.world.initialize() self.is_running = True - + def initialize(self, num_agents: Optional[int] = None) -> None: """Initialize the simulation with agents. - + Args: num_agents: Number of agents to spawn. If None, uses config.json value. """ if num_agents is not None: self.world.config.initial_agents = num_agents # Otherwise use the value already loaded from config.json - + self.world.initialize() - + # Start logging session self.logger = reset_simulation_logger() sim_config = get_config() self.logger.start_session(sim_config.to_dict()) - + self.is_running = True - + def next_step(self) -> TurnLog: """Advance the simulation by one step.""" if not self.is_running: return TurnLog(turn=-1) - + turn_log = TurnLog(turn=self.world.current_turn + 1) current_turn = self.world.current_turn + 1 - + # Start logging this turn self.logger.start_turn( turn=current_turn, @@ -122,13 +150,13 @@ class GameEngine: step_in_day=self.world.step_in_day + 1, time_of_day=self.world.time_of_day.value, ) - + # Log market state before market_orders_before = [o.to_dict() for o in self.market.get_active_orders()] - + # 0. Remove corpses from previous turn (agents who died last turn) self._remove_old_corpses(current_turn) - + # 1. Collect AI decisions for all living agents (not corpses) decisions: list[tuple[Agent, AIDecision]] = [] for agent in self.world.get_living_agents(): @@ -142,7 +170,7 @@ class GameEngine: inventory=[r.to_dict() for r in agent.inventory], money=agent.money, ) - + if self.world.is_night(): # Force sleep at night decision = AIDecision( @@ -152,18 +180,18 @@ class GameEngine: else: # Pass time info so AI can prepare for night decision = get_ai_decision( - agent, + agent, self.market, step_in_day=self.world.step_in_day, day_steps=self.world.config.day_steps, current_turn=current_turn, ) - + decisions.append((agent, decision)) - + # Log decision self.logger.log_agent_decision(agent.id, decision.to_dict()) - + # 2. Calculate movement targets and move agents for agent, decision in decisions: action_name = decision.action.value @@ -175,22 +203,41 @@ class GameEngine: target_resource=decision.target_resource.value if decision.target_resource else None, ) agent.update_movement() - + # 3. Execute all actions and update action indicators with results for agent, decision in decisions: result = self._execute_action(agent, decision) - + # Complete agent action with result - this updates the indicator to show what was done if result: agent.complete_action(result.success, result.message) - + # Log to agent's personal history + agent.log_action( + turn=current_turn, + action_type=decision.action.value, + result=result.message, + success=result.success, + ) + + # Track resources produced + for res in result.resources_gained: + res_type = res.type.value + turn_log.resources_produced[res_type] = turn_log.resources_produced.get(res_type, 0) + res.quantity + self.resource_stats["produced"][res_type] = self.resource_stats["produced"].get(res_type, 0) + res.quantity + + # Track resources consumed + for res in result.resources_consumed: + res_type = res.type.value + turn_log.resources_consumed[res_type] = turn_log.resources_consumed.get(res_type, 0) + res.quantity + self.resource_stats["consumed"][res_type] = self.resource_stats["consumed"].get(res_type, 0) + res.quantity + turn_log.agent_actions.append({ "agent_id": agent.id, "agent_name": agent.name, "decision": decision.to_dict(), "result": result.to_dict() if result else None, }) - + # Log agent state after action self.logger.log_agent_after( agent_id=agent.id, @@ -200,22 +247,27 @@ class GameEngine: position=agent.position.to_dict(), action_result=result.to_dict() if result else {}, ) - + # 4. Resolve pending market orders (price updates) self.market.update_prices(current_turn) - + # Log market state after market_orders_after = [o.to_dict() for o in self.market.get_active_orders()] self.logger.log_market_state(market_orders_before, market_orders_after) - + # 5. Apply passive decay to all living agents for agent in self.world.get_living_agents(): agent.apply_passive_decay() - + # 6. Decay resources in inventories for agent in self.world.get_living_agents(): expired = agent.decay_inventory(current_turn) - + # Track spoiled resources + for res in expired: + res_type = res.type.value + turn_log.resources_spoiled[res_type] = turn_log.resources_spoiled.get(res_type, 0) + res.quantity + self.resource_stats["spoiled"][res_type] = self.resource_stats["spoiled"].get(res_type, 0) + res.quantity + # 7. Mark newly dead agents as corpses (don't remove yet for visualization) newly_dead = self._mark_dead_agents(current_turn) for dead_agent in newly_dead: @@ -224,24 +276,24 @@ class GameEngine: # Cancel their market orders immediately self.market.cancel_seller_orders(dead_agent.id) turn_log.deaths = [a.name for a in newly_dead] - + # Log statistics self.logger.log_statistics(self.world.get_statistics()) - + # End turn logging self.logger.end_turn() - + # 8. Advance time self.world.advance_time() - + # 9. Check win/lose conditions (count only truly living agents, not corpses) if len(self.world.get_living_agents()) == 0: self.is_running = False self.logger.close() - + self.turn_logs.append(turn_log) return turn_log - + def _mark_dead_agents(self, current_turn: int) -> list[Agent]: """Mark agents who just died as corpses. Returns list of newly dead agents.""" newly_dead = [] @@ -255,7 +307,7 @@ class GameEngine: agent.current_action.message = f"Died: {cause}" newly_dead.append(agent) return newly_dead - + def _remove_old_corpses(self, current_turn: int) -> list[Agent]: """Remove corpses that have been visible for one turn.""" to_remove = [] @@ -263,18 +315,18 @@ class GameEngine: if agent.is_corpse() and agent.death_turn < current_turn: # Corpse has been visible for one turn, remove it to_remove.append(agent) - + for agent in to_remove: self.world.agents.remove(agent) self.world.total_agents_died += 1 - + return to_remove - + def _execute_action(self, agent: Agent, decision: AIDecision) -> Optional[ActionResult]: """Execute an action for an agent.""" action = decision.action config = ACTION_CONFIG[action] - + # Handle different action types if action == ActionType.SLEEP: agent.restore_energy(config.energy_cost) @@ -284,7 +336,7 @@ class GameEngine: energy_spent=-config.energy_cost, message="Sleeping soundly", ) - + elif action == ActionType.REST: agent.restore_energy(config.energy_cost) return ActionResult( @@ -293,17 +345,25 @@ class GameEngine: energy_spent=-config.energy_cost, message="Resting", ) - + elif action == ActionType.CONSUME: if decision.target_resource: success = agent.consume(decision.target_resource) + consumed_list = [] + if success: + consumed_list.append(Resource( + type=decision.target_resource, + quantity=1, + created_turn=self.world.current_turn, + )) return ActionResult( action_type=action, success=success, + resources_consumed=consumed_list, message=f"Consumed {decision.target_resource.value}" if success else "Nothing to consume", ) return ActionResult(action_type=action, success=False, message="No resource specified") - + elif action == ActionType.BUILD_FIRE: if agent.has_resource(ResourceType.WOOD): agent.remove_from_inventory(ResourceType.WOOD, 1) @@ -318,22 +378,23 @@ class GameEngine: success=True, energy_spent=abs(config.energy_cost), heat_gained=fire_heat, + resources_consumed=[Resource(type=ResourceType.WOOD, quantity=1, created_turn=self.world.current_turn)], message="Built a warm fire", ) return ActionResult(action_type=action, success=False, message="No wood for fire") - + elif action == ActionType.TRADE: return self._execute_trade(agent, decision) - - elif action in [ActionType.HUNT, ActionType.GATHER, ActionType.CHOP_WOOD, + + elif action in [ActionType.HUNT, ActionType.GATHER, ActionType.CHOP_WOOD, ActionType.GET_WATER, ActionType.WEAVE]: return self._execute_work(agent, action, config) - + return ActionResult(action_type=action, success=False, message="Unknown action") - + def _execute_work(self, agent: Agent, action: ActionType, config) -> ActionResult: """Execute a work action (hunting, gathering, etc.). - + Skills now affect outcomes: - Hunting skill affects hunt success rate - Gathering skill affects gather output @@ -348,8 +409,9 @@ class GameEngine: success=False, message="Not enough energy", ) - + # Check required materials + resources_consumed = [] if config.requires_resource: if not agent.has_resource(config.requires_resource, config.requires_quantity): agent.restore_energy(energy_cost) # Refund energy @@ -359,12 +421,17 @@ class GameEngine: message=f"Missing required {config.requires_resource.value}", ) agent.remove_from_inventory(config.requires_resource, config.requires_quantity) - + resources_consumed.append(Resource( + type=config.requires_resource, + quantity=config.requires_quantity, + created_turn=self.world.current_turn, + )) + # Get relevant skill for this action skill_name = self._get_skill_for_action(action) skill_value = getattr(agent.skills, skill_name, 1.0) if skill_name else 1.0 skill_modifier = get_action_skill_modifier(skill_value) - + # Check success chance (modified by skill) # Higher skill = higher effective success chance effective_success_chance = min(0.98, config.success_chance * skill_modifier) @@ -379,15 +446,15 @@ class GameEngine: energy_spent=energy_cost, message="Action failed", ) - + # Generate output (modified by skill for quantity) resources_gained = [] - + if config.output_resource: # Skill affects output quantity base_quantity = random.randint(config.min_output, config.max_output) quantity = max(config.min_output, int(base_quantity * skill_modifier)) - + if quantity > 0: resource = Resource( type=config.output_resource, @@ -401,7 +468,7 @@ class GameEngine: quantity=added, created_turn=self.world.current_turn, )) - + # Secondary output (e.g., hide from hunting) - also affected by skill if config.secondary_output: base_quantity = random.randint(config.secondary_min, config.secondary_max) @@ -419,24 +486,25 @@ class GameEngine: quantity=added, created_turn=self.world.current_turn, )) - + # Record action and improve skill agent.record_action(action.value) if skill_name: agent.skills.improve(skill_name, 0.015) # Skill improves with successful use - + # Build success message with details gained_str = ", ".join(f"+{r.quantity} {r.type.value}" for r in resources_gained) message = f"{action.value}: {gained_str}" if gained_str else f"{action.value} (nothing gained)" - + return ActionResult( action_type=action, success=True, energy_spent=energy_cost, resources_gained=resources_gained, + resources_consumed=resources_consumed, message=message, ) - + def _get_skill_for_action(self, action: ActionType) -> Optional[str]: """Get the skill name that affects a given action.""" skill_map = { @@ -446,22 +514,22 @@ class GameEngine: ActionType.WEAVE: "crafting", } return skill_map.get(action) - + def _execute_trade(self, agent: Agent, decision: AIDecision) -> ActionResult: """Execute a trade action (buy, sell, or price adjustment). Supports multi-item trades. - + Trading skill improves with successful trades and affects prices slightly. """ config = ACTION_CONFIG[ActionType.TRADE] - + # Handle price adjustments (no energy cost) if decision.adjust_order_id and decision.new_price is not None: return self._execute_price_adjustment(agent, decision) - + # Handle multi-item trades if decision.trade_items: return self._execute_multi_buy(agent, decision) - + if decision.order_id: # Buying single item from market result = self.market.execute_buy( @@ -470,11 +538,11 @@ class GameEngine: quantity=decision.quantity, buyer_money=agent.money, ) - + if result.success: # Log the trade self.logger.log_trade(result.to_dict()) - + # Record sale for price history tracking self.market._record_sale( result.resource_type, @@ -482,10 +550,14 @@ class GameEngine: result.quantity, self.world.current_turn, ) - + + # Track traded resources + res_type = result.resource_type.value + self.resource_stats["traded"][res_type] = self.resource_stats["traded"].get(res_type, 0) + result.quantity + # Deduct money from buyer agent.money -= result.total_paid - + # Add resources to buyer resource = Resource( type=result.resource_type, @@ -493,20 +565,20 @@ class GameEngine: created_turn=self.world.current_turn, ) agent.add_to_inventory(resource) - + # Add money to seller and record their trade seller = self.world.get_agent(result.seller_id) if seller: seller.money += result.total_paid seller.record_trade(result.total_paid) seller.skills.improve("trading", 0.02) # Seller skill improves - + agent.spend_energy(abs(config.energy_cost)) - + # Record buyer's trade and improve skill agent.record_action("trade") agent.skills.improve("trading", 0.01) # Buyer skill improves less - + return ActionResult( action_type=ActionType.TRADE, success=True, @@ -520,12 +592,12 @@ class GameEngine: success=False, message=result.message, ) - + elif decision.target_resource and decision.quantity > 0: # Selling to market (listing) if agent.has_resource(decision.target_resource, decision.quantity): agent.remove_from_inventory(decision.target_resource, decision.quantity) - + order = self.market.place_order( seller_id=agent.id, resource_type=decision.target_resource, @@ -533,10 +605,10 @@ class GameEngine: price_per_unit=decision.price, current_turn=self.world.current_turn, ) - + agent.spend_energy(abs(config.energy_cost)) agent.record_action("trade") # Track listing action - + return ActionResult( action_type=ActionType.TRADE, success=True, @@ -549,13 +621,13 @@ class GameEngine: success=False, message="Not enough resources to sell", ) - + return ActionResult( action_type=ActionType.TRADE, success=False, message="Invalid trade parameters", ) - + def _execute_price_adjustment(self, agent: Agent, decision: AIDecision) -> ActionResult: """Execute a price adjustment on an existing order (no energy cost).""" success = self.market.adjust_order_price( @@ -564,7 +636,7 @@ class GameEngine: new_price=decision.new_price, current_turn=self.world.current_turn, ) - + if success: return ActionResult( action_type=ActionType.TRADE, @@ -578,40 +650,44 @@ class GameEngine: success=False, message="Failed to adjust price", ) - + def _execute_multi_buy(self, agent: Agent, decision: AIDecision) -> ActionResult: """Execute a multi-item buy trade.""" config = ACTION_CONFIG[ActionType.TRADE] - + # Build list of purchases purchases = [(item.order_id, item.quantity) for item in decision.trade_items] - + # Execute all purchases results = self.market.execute_multi_buy( buyer_id=agent.id, purchases=purchases, buyer_money=agent.money, ) - + # Process results total_paid = 0 resources_gained = [] items_bought = [] - + for result in results: if result.success: self.logger.log_trade(result.to_dict()) agent.money -= result.total_paid total_paid += result.total_paid - + # Record sale for price history self.market._record_sale( - result.resource_type, - result.total_paid // result.quantity, - result.quantity, + result.resource_type, + result.total_paid // result.quantity, + result.quantity, self.world.current_turn ) - + + # Track traded resources + res_type = result.resource_type.value + self.resource_stats["traded"][res_type] = self.resource_stats["traded"].get(res_type, 0) + result.quantity + resource = Resource( type=result.resource_type, quantity=result.quantity, @@ -620,12 +696,12 @@ class GameEngine: agent.add_to_inventory(resource) resources_gained.append(resource) items_bought.append(f"{result.quantity} {result.resource_type.value}") - + # Add money to seller seller = self.world.get_agent(result.seller_id) if seller: seller.money += result.total_paid - + if resources_gained: agent.spend_energy(abs(config.energy_cost)) message = f"Bought {', '.join(items_bought)} for {total_paid}c" @@ -642,38 +718,38 @@ class GameEngine: success=False, message="Failed to buy any items", ) - + def set_mode(self, mode: SimulationMode) -> None: """Set the simulation mode.""" if mode == self.mode: return - + if mode == SimulationMode.AUTO: self._start_auto_mode() else: self._stop_auto_mode() - + self.mode = mode - + def _start_auto_mode(self) -> None: """Start automatic step advancement.""" self._stop_event.clear() - + def auto_step(): while not self._stop_event.is_set() and self.is_running: self.next_step() time.sleep(self.auto_step_interval) - + self._auto_thread = threading.Thread(target=auto_step, daemon=True) self._auto_thread.start() - + def _stop_auto_mode(self) -> None: """Stop automatic step advancement.""" self._stop_event.set() if self._auto_thread: self._auto_thread.join(timeout=2.0) self._auto_thread = None - + def get_state(self) -> dict: """Get the full simulation state for API.""" return { @@ -684,6 +760,31 @@ class GameEngine: "recent_logs": [ log.to_dict() for log in self.turn_logs[-5:] ], + "resource_stats": self._get_resource_stats(), + } + + def _get_resource_stats(self) -> dict: + """Get comprehensive resource statistics.""" + # Calculate current inventory totals + in_inventory = {} + for agent in self.world.get_living_agents(): + for res in agent.inventory: + res_type = res.type.value + in_inventory[res_type] = in_inventory.get(res_type, 0) + res.quantity + + # Calculate current market totals + in_market = {} + for order in self.market.get_active_orders(): + res_type = order.resource_type.value + in_market[res_type] = in_market.get(res_type, 0) + order.quantity + + return { + "produced": self.resource_stats["produced"].copy(), + "consumed": self.resource_stats["consumed"].copy(), + "spoiled": self.resource_stats["spoiled"].copy(), + "traded": self.resource_stats["traded"].copy(), + "in_inventory": in_inventory, + "in_market": in_market, } diff --git a/backend/domain/agent.py b/backend/domain/agent.py index 3b54ba6..9cbdc6b 100644 --- a/backend/domain/agent.py +++ b/backend/domain/agent.py @@ -40,11 +40,11 @@ class Position: """2D position on the map (floating point for smooth movement).""" x: float = 0.0 y: float = 0.0 - + def distance_to(self, other: "Position") -> float: """Calculate distance to another position.""" return math.sqrt((self.x - other.x) ** 2 + (self.y - other.y) ** 2) - + def move_towards(self, target: "Position", speed: float = 0.5) -> bool: """Move towards target position. Returns True if reached.""" dist = self.distance_to(target) @@ -52,19 +52,19 @@ class Position: self.x = target.x self.y = target.y return True - + # Calculate direction dx = target.x - self.x dy = target.y - self.y - + # Normalize and apply speed self.x += (dx / dist) * speed self.y += (dy / dist) * speed return False - + def to_dict(self) -> dict: return {"x": round(self.x, 2), "y": round(self.y, 2)} - + def copy(self) -> "Position": return Position(self.x, self.y) @@ -72,7 +72,7 @@ class Position: @dataclass class AgentStats: """Vital statistics for an agent. - + Values are loaded from config.json. Default values are used as fallback. """ # Current values - defaults will be overwritten by factory function @@ -80,32 +80,32 @@ class AgentStats: hunger: int = field(default=80) thirst: int = field(default=70) heat: int = field(default=100) - + # Maximum values - loaded from config MAX_ENERGY: int = field(default=50) MAX_HUNGER: int = field(default=100) MAX_THIRST: int = field(default=100) MAX_HEAT: int = field(default=100) - + # Passive decay rates per turn - loaded from config ENERGY_DECAY: int = field(default=1) HUNGER_DECAY: int = field(default=2) THIRST_DECAY: int = field(default=3) HEAT_DECAY: int = field(default=2) - + # Critical threshold - loaded from config CRITICAL_THRESHOLD: float = field(default=0.25) - + def apply_passive_decay(self, has_clothes: bool = False) -> None: """Apply passive stat decay each turn.""" self.energy = max(0, self.energy - self.ENERGY_DECAY) self.hunger = max(0, self.hunger - self.HUNGER_DECAY) self.thirst = max(0, self.thirst - self.THIRST_DECAY) - + # Clothes reduce heat loss by 50% heat_decay = self.HEAT_DECAY // 2 if has_clothes else self.HEAT_DECAY self.heat = max(0, self.heat - heat_decay) - + def is_critical(self) -> bool: """Check if any vital stat is below critical threshold.""" threshold_hunger = int(self.MAX_HUNGER * self.CRITICAL_THRESHOLD) @@ -116,13 +116,13 @@ class AgentStats: self.thirst < threshold_thirst or self.heat < threshold_heat ) - + def get_critical_stat(self) -> Optional[str]: """Get the name of the most critical stat, if any.""" threshold_thirst = int(self.MAX_THIRST * self.CRITICAL_THRESHOLD) threshold_hunger = int(self.MAX_HUNGER * self.CRITICAL_THRESHOLD) threshold_heat = int(self.MAX_HEAT * self.CRITICAL_THRESHOLD) - + if self.thirst < threshold_thirst: return "thirst" if self.hunger < threshold_hunger: @@ -130,11 +130,11 @@ class AgentStats: if self.heat < threshold_heat: return "heat" return None - + def can_work(self, energy_required: int) -> bool: """Check if agent has enough energy to perform an action.""" return self.energy >= abs(energy_required) - + def to_dict(self) -> dict: return { "energy": self.energy, @@ -177,7 +177,7 @@ class AgentAction: progress: float = 0.0 # 0.0 to 1.0 is_moving: bool = False message: str = "" - + def to_dict(self) -> dict: return { "action_type": self.action_type, @@ -213,7 +213,7 @@ def _get_world_config(): @dataclass class Agent: """An agent in the village simulation. - + Stats, inventory slots, and starting money are loaded from config.json. Each agent now has unique personality traits and skills that create emergent behaviors and professions. @@ -225,47 +225,51 @@ class Agent: stats: AgentStats = field(default_factory=create_agent_stats) inventory: list[Resource] = field(default_factory=list) money: int = field(default=-1) # -1 signals to use config value - + # Personality and skills - create agent diversity personality: PersonalityTraits = field(default_factory=PersonalityTraits) skills: Skills = field(default_factory=Skills) - + # Movement and action tracking home_position: Position = field(default_factory=Position) current_action: AgentAction = field(default_factory=AgentAction) last_action_result: str = "" - + # Death tracking for corpse visualization death_turn: int = -1 # Turn when agent died, -1 if alive death_reason: str = "" # Cause of death - + # Statistics tracking for profession determination actions_performed: dict = field(default_factory=lambda: { "hunt": 0, "gather": 0, "chop_wood": 0, "trade": 0, "craft": 0 }) total_trades_completed: int = 0 total_money_earned: int = 0 - + + # Personal action log (recent actions with results) + action_history: list = field(default_factory=list) + MAX_HISTORY_SIZE: int = 20 + # Configuration - loaded from config INVENTORY_SLOTS: int = field(default=-1) # -1 signals to use config value MOVE_SPEED: float = 0.8 # Grid cells per turn - + def __post_init__(self): if not self.name: self.name = f"Agent_{self.id}" # Set home position to initial position self.home_position = self.position.copy() - + # Load config values if defaults were used config = _get_world_config() if self.money == -1: self.money = config.starting_money if self.INVENTORY_SLOTS == -1: self.INVENTORY_SLOTS = config.inventory_slots - + # Update profession based on personality and skills self._update_profession() - + def _update_profession(self) -> None: """Update profession based on personality and skills.""" prof_type = determine_profession(self.personality, self.skills) @@ -277,18 +281,31 @@ class Agent: ProfessionType.GENERALIST: Profession.VILLAGER, } self.profession = profession_map.get(prof_type, Profession.VILLAGER) - + def record_action(self, action_type: str) -> None: """Record an action for profession tracking.""" if action_type in self.actions_performed: self.actions_performed[action_type] += 1 - + + def log_action(self, turn: int, action_type: str, result: str, success: bool = True) -> None: + """Add an action to the agent's personal history log.""" + entry = { + "turn": turn, + "action": action_type, + "result": result, + "success": success, + } + self.action_history.append(entry) + # Keep only recent history + if len(self.action_history) > self.MAX_HISTORY_SIZE: + self.action_history = self.action_history[-self.MAX_HISTORY_SIZE:] + def record_trade(self, money_earned: int) -> None: """Record a completed trade for statistics.""" self.total_trades_completed += 1 if money_earned > 0: self.total_money_earned += money_earned - + def is_alive(self) -> bool: """Check if the agent is still alive.""" return ( @@ -296,28 +313,28 @@ class Agent: self.stats.thirst > 0 and self.stats.heat > 0 ) - + def is_corpse(self) -> bool: """Check if this agent is a corpse (died but still visible).""" return self.death_turn >= 0 - + def can_act(self) -> bool: """Check if agent can perform active actions.""" return self.is_alive() and self.stats.energy > 0 - + def has_clothes(self) -> bool: """Check if agent has clothes equipped.""" return any(r.type == ResourceType.CLOTHES for r in self.inventory) - + def inventory_space(self) -> int: """Get remaining inventory slots.""" total_items = sum(r.quantity for r in self.inventory) return max(0, self.INVENTORY_SLOTS - total_items) - + def inventory_full(self) -> bool: """Check if inventory is full.""" return self.inventory_space() <= 0 - + def set_action( self, action_type: str, @@ -328,7 +345,7 @@ class Agent: ) -> None: """Set the current action and calculate target position.""" location = ACTION_LOCATIONS.get(action_type, {"zone": "current", "offset_range": (0, 0)}) - + if location["zone"] == "current": # Stay in place target = self.position.copy() @@ -339,14 +356,14 @@ class Agent: offset_min = float(offset_range[0]) offset_max = float(offset_range[1]) target_x = world_width * random.uniform(offset_min, offset_max) - + # Keep y position somewhat consistent but allow some variation target_y = self.home_position.y + random.uniform(-2, 2) target_y = max(0.5, min(world_height - 0.5, target_y)) - + target = Position(target_x, target_y) is_moving = self.position.distance_to(target) > 0.5 - + self.current_action = AgentAction( action_type=action_type, target_position=target, @@ -355,7 +372,7 @@ class Agent: is_moving=is_moving, message=message, ) - + def update_movement(self) -> None: """Update agent position moving towards target.""" if self.current_action.target_position and self.current_action.is_moving: @@ -366,28 +383,28 @@ class Agent: if reached: self.current_action.is_moving = False self.current_action.progress = 0.5 # At location, doing action - + def complete_action(self, success: bool, message: str) -> None: """Mark current action as complete.""" self.current_action.progress = 1.0 self.current_action.is_moving = False self.last_action_result = message self.current_action.message = message if success else f"Failed: {message}" - + def add_to_inventory(self, resource: Resource) -> int: """Add resource to inventory, returns quantity actually added.""" space = self.inventory_space() if space <= 0: return 0 - + quantity_to_add = min(resource.quantity, space) - + # Try to stack with existing resource of same type for existing in self.inventory: if existing.type == resource.type: existing.quantity += quantity_to_add return quantity_to_add - + # Add as new stack new_resource = Resource( type=resource.type, @@ -396,7 +413,7 @@ class Agent: ) self.inventory.append(new_resource) return quantity_to_add - + def remove_from_inventory(self, resource_type: ResourceType, quantity: int = 1) -> int: """Remove resource from inventory, returns quantity actually removed.""" removed = 0 @@ -405,31 +422,31 @@ class Agent: take = min(resource.quantity, quantity - removed) resource.quantity -= take removed += take - + if resource.quantity <= 0: self.inventory.remove(resource) - + if removed >= quantity: break - + return removed - + def get_resource_count(self, resource_type: ResourceType) -> int: """Get total count of a resource type in inventory.""" return sum( r.quantity for r in self.inventory if r.type == resource_type ) - + def has_resource(self, resource_type: ResourceType, quantity: int = 1) -> bool: """Check if agent has at least the specified quantity of a resource.""" return self.get_resource_count(resource_type) >= quantity - + def consume(self, resource_type: ResourceType) -> bool: """Consume a resource from inventory and apply its effects.""" if not self.has_resource(resource_type, 1): return False - + effect = RESOURCE_EFFECTS[resource_type] self.stats.hunger = min( self.stats.MAX_HUNGER, @@ -447,25 +464,25 @@ class Agent: self.stats.MAX_ENERGY, self.stats.energy + effect.energy ) - + self.remove_from_inventory(resource_type, 1) return True - + def apply_heat(self, amount: int) -> None: """Apply heat from a fire.""" self.stats.heat = min(self.stats.MAX_HEAT, self.stats.heat + amount) - + def restore_energy(self, amount: int) -> None: """Restore energy (from sleep/rest).""" self.stats.energy = min(self.stats.MAX_ENERGY, self.stats.energy + amount) - + def spend_energy(self, amount: int) -> bool: """Spend energy on an action. Returns False if not enough energy.""" if self.stats.energy < amount: return False self.stats.energy -= amount return True - + def decay_inventory(self, current_turn: int) -> list[Resource]: """Remove expired resources from inventory. Returns list of removed resources.""" expired = [] @@ -474,21 +491,21 @@ class Agent: expired.append(resource) self.inventory.remove(resource) return expired - + def apply_passive_decay(self) -> None: """Apply passive stat decay for this turn.""" self.stats.apply_passive_decay(has_clothes=self.has_clothes()) - + def mark_dead(self, turn: int, reason: str) -> None: """Mark this agent as dead.""" self.death_turn = turn self.death_reason = reason - + def to_dict(self) -> dict: """Convert to dictionary for API serialization.""" # Update profession before serializing self._update_profession() - + return { "id": self.id, "name": self.name, @@ -511,4 +528,6 @@ class Agent: "actions_performed": self.actions_performed.copy(), "total_trades": self.total_trades_completed, "total_money_earned": self.total_money_earned, + # Personal action history + "action_history": self.action_history.copy(), } diff --git a/backend/main.py b/backend/main.py index 6626a31..61b2b07 100644 --- a/backend/main.py +++ b/backend/main.py @@ -1,12 +1,17 @@ """FastAPI entry point for the Village Simulation backend.""" +import os import uvicorn from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware +from fastapi.staticfiles import StaticFiles from backend.api.routes import router from backend.core.engine import get_engine +# Path to web frontend +WEB_FRONTEND_PATH = os.path.join(os.path.dirname(os.path.dirname(__file__)), "web_frontend") + # Create FastAPI app app = FastAPI( title="Village Simulation API", @@ -33,7 +38,7 @@ app.include_router(router, prefix="/api", tags=["simulation"]) async def startup_event(): """Initialize the simulation on startup with config.json values.""" from backend.config import get_config - + config = get_config() engine = get_engine() # Use reset() which automatically loads config values @@ -48,6 +53,7 @@ def root(): "name": "Village Simulation API", "version": "1.0.0", "docs": "/docs", + "web_frontend": "/web/", "status": "running", } @@ -63,6 +69,14 @@ def health_check(): } +# ============== Web Frontend Static Files ============== + +# Mount static files for web frontend +# Access at http://localhost:8000/web/ +if os.path.exists(WEB_FRONTEND_PATH): + app.mount("/web", StaticFiles(directory=WEB_FRONTEND_PATH, html=True), name="web_frontend") + + def main(): """Run the server.""" uvicorn.run( diff --git a/web_frontend/index.html b/web_frontend/index.html new file mode 100644 index 0000000..664d493 --- /dev/null +++ b/web_frontend/index.html @@ -0,0 +1,279 @@ + + + + + + VillSim - Village Economy Simulation + + + + + + + + +
+ + +
+ + +
+ +
+ + +
+ +
+
+ + + + +
+
+ + + 150ms +
+
+ + + +
+ + + + + + diff --git a/web_frontend/src/api.js b/web_frontend/src/api.js new file mode 100644 index 0000000..704cbaf --- /dev/null +++ b/web_frontend/src/api.js @@ -0,0 +1,132 @@ +/** + * VillSim API Client + * Handles all communication with the backend simulation server. + */ + +// Auto-detect API base from current page location (same origin) +function getApiBase() { + // When served by the backend, use same origin + if (typeof window !== 'undefined') { + return window.location.origin; + } + // Fallback for development + return 'http://localhost:8000'; +} + +class SimulationAPI { + constructor() { + this.baseUrl = getApiBase(); + this.connected = false; + this.lastState = null; + } + + async request(endpoint, options = {}) { + const url = `${this.baseUrl}${endpoint}`; + try { + const response = await fetch(url, { + ...options, + headers: { + 'Content-Type': 'application/json', + ...options.headers, + }, + }); + + if (!response.ok) { + throw new Error(`HTTP ${response.status}: ${response.statusText}`); + } + + this.connected = true; + return await response.json(); + } catch (error) { + console.error(`API Error (${endpoint}):`, error.message); + this.connected = false; + throw error; + } + } + + // Health check + async checkHealth() { + try { + const data = await this.request('/health'); + this.connected = data.status === 'healthy'; + return this.connected; + } catch { + this.connected = false; + return false; + } + } + + // Get full simulation state + async getState() { + const data = await this.request('/api/state'); + this.lastState = data; + return data; + } + + // Get all agents + async getAgents() { + return await this.request('/api/agents'); + } + + // Get specific agent + async getAgent(agentId) { + return await this.request(`/api/agents/${agentId}`); + } + + // Get market orders + async getMarketOrders() { + return await this.request('/api/market/orders'); + } + + // Get market prices + async getMarketPrices() { + return await this.request('/api/market/prices'); + } + + // Control: Initialize simulation + async initialize(numAgents = 8, worldWidth = 20, worldHeight = 20) { + return await this.request('/api/control/initialize', { + method: 'POST', + body: JSON.stringify({ + num_agents: numAgents, + world_width: worldWidth, + world_height: worldHeight, + }), + }); + } + + // Control: Advance one step + async nextStep() { + return await this.request('/api/control/next_step', { + method: 'POST', + }); + } + + // Control: Set mode (manual/auto) + async setMode(mode) { + return await this.request('/api/control/mode', { + method: 'POST', + body: JSON.stringify({ mode }), + }); + } + + // Control: Get status + async getStatus() { + return await this.request('/api/control/status'); + } + + // Config: Get configuration + async getConfig() { + return await this.request('/api/config'); + } + + // Logs: Get recent logs + async getLogs(limit = 10) { + return await this.request(`/api/logs?limit=${limit}`); + } +} + +// Export singleton instance +export const api = new SimulationAPI(); +export default api; + diff --git a/web_frontend/src/constants.js b/web_frontend/src/constants.js new file mode 100644 index 0000000..2fc9175 --- /dev/null +++ b/web_frontend/src/constants.js @@ -0,0 +1,71 @@ +/** + * VillSim Constants + * Shared constants for the Phaser game. + */ + +// Profession icons and colors +export const PROFESSIONS = { + hunter: { icon: '🏹', color: 0xc45c5c, name: 'Hunter' }, + gatherer: { icon: '🌿', color: 0x6bab5e, name: 'Gatherer' }, + woodcutter: { icon: '🪓', color: 0xa67c52, name: 'Woodcutter' }, + trader: { icon: '💰', color: 0xd4a84b, name: 'Trader' }, + crafter: { icon: '🧵', color: 0x8b6fc0, name: 'Crafter' }, + villager: { icon: '👤', color: 0x7a8899, name: 'Villager' }, +}; + +// Resource icons and colors +export const RESOURCES = { + meat: { icon: '🥩', color: 0xc45c5c, name: 'Meat' }, + berries: { icon: '🫐', color: 0xa855a8, name: 'Berries' }, + water: { icon: '💧', color: 0x5a8cc8, name: 'Water' }, + wood: { icon: '🪵', color: 0xa67c52, name: 'Wood' }, + hide: { icon: '🦴', color: 0x8b7355, name: 'Hide' }, + clothes: { icon: '👕', color: 0x6b6560, name: 'Clothes' }, +}; + +// Action icons +export const ACTIONS = { + hunt: { icon: '🏹', verb: 'hunting' }, + gather: { icon: '🌿', verb: 'gathering' }, + chop_wood: { icon: '🪓', verb: 'chopping wood' }, + get_water: { icon: '💧', verb: 'getting water' }, + weave: { icon: '🧵', verb: 'weaving' }, + build_fire: { icon: '🔥', verb: 'building fire' }, + trade: { icon: '💰', verb: 'trading' }, + rest: { icon: '💤', verb: 'resting' }, + sleep: { icon: '😴', verb: 'sleeping' }, + consume: { icon: '🍽️', verb: 'consuming' }, + idle: { icon: '⏳', verb: 'idle' }, +}; + +// Time of day +export const TIME_OF_DAY = { + day: { icon: '☀️', name: 'Day' }, + night: { icon: '🌙', name: 'Night' }, +}; + +// World zones (approximate x-positions as percentages) +export const WORLD_ZONES = { + river: { start: 0.0, end: 0.15, color: 0x3a6ea5, name: 'River' }, + bushes: { start: 0.15, end: 0.35, color: 0x4a7c59, name: 'Berry Bushes' }, + village: { start: 0.35, end: 0.65, color: 0x8b7355, name: 'Village' }, + forest: { start: 0.65, end: 1.0, color: 0x2d5016, name: 'Forest' }, +}; + +// Colors for stats +export const STAT_COLORS = { + energy: 0xd4a84b, + hunger: 0xc87f5a, + thirst: 0x5a8cc8, + heat: 0xc45c5c, +}; + +// Game display settings +export const DISPLAY = { + TILE_SIZE: 32, + AGENT_SIZE: 24, + MIN_ZOOM: 0.5, + MAX_ZOOM: 2.0, + DEFAULT_ZOOM: 1.0, +}; + diff --git a/web_frontend/src/main.js b/web_frontend/src/main.js new file mode 100644 index 0000000..1a6cfe1 --- /dev/null +++ b/web_frontend/src/main.js @@ -0,0 +1,73 @@ +/** + * VillSim - Phaser Web Frontend + * Main entry point + */ + +import BootScene from './scenes/BootScene.js'; +import GameScene from './scenes/GameScene.js'; + +// Calculate game dimensions based on container +function getGameDimensions() { + const container = document.getElementById('game-container'); + if (!container) { + return { width: 800, height: 600 }; + } + + const rect = container.getBoundingClientRect(); + return { + width: Math.floor(rect.width), + height: Math.floor(rect.height), + }; +} + +// Phaser game configuration +const { width, height } = getGameDimensions(); + +const config = { + type: Phaser.AUTO, + parent: 'game-container', + width: width, + height: height, + backgroundColor: '#151921', + scene: [BootScene, GameScene], + scale: { + mode: Phaser.Scale.RESIZE, + autoCenter: Phaser.Scale.CENTER_BOTH, + }, + render: { + antialias: true, + pixelArt: false, + roundPixels: true, + }, + physics: { + default: 'arcade', + arcade: { + gravity: { y: 0 }, + debug: false, + }, + }, + dom: { + createContainer: true, + }, +}; + +// Initialize game when DOM is ready +document.addEventListener('DOMContentLoaded', () => { + console.log('VillSim Web Frontend starting...'); + + // Create Phaser game + const game = new Phaser.Game(config); + + // Handle window resize + window.addEventListener('resize', () => { + const { width, height } = getGameDimensions(); + game.scale.resize(width, height); + }); + + // Store game reference globally for debugging + window.villsimGame = game; +}); + +// Export for debugging +export { config }; + diff --git a/web_frontend/src/scenes/BootScene.js b/web_frontend/src/scenes/BootScene.js new file mode 100644 index 0000000..78ab508 --- /dev/null +++ b/web_frontend/src/scenes/BootScene.js @@ -0,0 +1,141 @@ +/** + * BootScene - Initial loading and setup + */ + +import { api } from '../api.js'; + +export default class BootScene extends Phaser.Scene { + constructor() { + super({ key: 'BootScene' }); + } + + preload() { + // Create loading graphics + const { width, height } = this.cameras.main; + + // Background + this.add.rectangle(width / 2, height / 2, width, height, 0x151921); + + // Loading text + this.loadingText = this.add.text(width / 2, height / 2 - 40, 'VillSim', { + fontSize: '48px', + fontFamily: 'Crimson Pro, Georgia, serif', + color: '#d4a84b', + }).setOrigin(0.5); + + this.statusText = this.add.text(width / 2, height / 2 + 20, 'Connecting to server...', { + fontSize: '18px', + fontFamily: 'Crimson Pro, Georgia, serif', + color: '#a8a095', + }).setOrigin(0.5); + + // Progress bar background + const barWidth = 300; + const barHeight = 8; + this.progressBg = this.add.rectangle( + width / 2, height / 2 + 60, + barWidth, barHeight, + 0x242b3d + ).setOrigin(0.5); + + this.progressBar = this.add.rectangle( + width / 2 - barWidth / 2, height / 2 + 60, + 0, barHeight, + 0xd4a84b + ).setOrigin(0, 0.5); + } + + async create() { + // Animate progress bar + this.tweens.add({ + targets: this.progressBar, + width: 100, + duration: 500, + ease: 'Power2', + }); + + // Attempt to connect to server + this.statusText.setText('Connecting to server...'); + + let connected = false; + let retries = 0; + const maxRetries = 10; + + while (!connected && retries < maxRetries) { + try { + connected = await api.checkHealth(); + if (connected) { + this.statusText.setText('Connected! Loading simulation...'); + this.tweens.add({ + targets: this.progressBar, + width: 200, + duration: 300, + ease: 'Power2', + }); + } else { + retries++; + this.statusText.setText(`Connecting... (attempt ${retries}/${maxRetries})`); + await this.delay(1000); + } + } catch (error) { + retries++; + this.statusText.setText(`Connection failed. Retrying... (${retries}/${maxRetries})`); + await this.delay(1000); + } + } + + if (!connected) { + this.statusText.setText('Could not connect to server. Is the backend running?'); + this.statusText.setColor('#c45c5c'); + + // Add retry button + const retryBtn = this.add.text( + this.cameras.main.width / 2, + this.cameras.main.height / 2 + 100, + '[ Click to Retry ]', + { + fontSize: '16px', + fontFamily: 'Crimson Pro, Georgia, serif', + color: '#d4a84b', + } + ).setOrigin(0.5).setInteractive({ useHandCursor: true }); + + retryBtn.on('pointerup', () => { + this.scene.restart(); + }); + + retryBtn.on('pointerover', () => retryBtn.setColor('#e8e4dc')); + retryBtn.on('pointerout', () => retryBtn.setColor('#d4a84b')); + + return; + } + + // Load initial state + try { + const state = await api.getState(); + this.registry.set('simulationState', state); + + this.tweens.add({ + targets: this.progressBar, + width: 300, + duration: 300, + ease: 'Power2', + onComplete: () => { + this.statusText.setText('Starting simulation...'); + this.time.delayedCall(500, () => { + this.scene.start('GameScene'); + }); + } + }); + } catch (error) { + this.statusText.setText('Error loading simulation state'); + this.statusText.setColor('#c45c5c'); + console.error('Failed to load state:', error); + } + } + + delay(ms) { + return new Promise(resolve => setTimeout(resolve, ms)); + } +} + diff --git a/web_frontend/src/scenes/GameScene.js b/web_frontend/src/scenes/GameScene.js new file mode 100644 index 0000000..88dd5f3 --- /dev/null +++ b/web_frontend/src/scenes/GameScene.js @@ -0,0 +1,1633 @@ +/** + * GameScene - Main game rendering + * Optimized to prevent memory leaks and CPU accumulation + */ + +import { api } from '../api.js'; +import { PROFESSIONS, RESOURCES, ACTIONS, WORLD_ZONES, DISPLAY } from '../constants.js'; + +export default class GameScene extends Phaser.Scene { + constructor() { + super({ key: 'GameScene' }); + + this.agents = new Map(); + this.selectedAgent = null; + this.worldWidth = 20; + this.worldHeight = 20; + this.tileSize = DISPLAY.TILE_SIZE; + this.isAutoMode = false; + this.isStepPending = false; + this.autoInterval = null; + this.autoSpeed = 150; + this.pollingInterval = null; + + // Cache DOM elements to avoid repeated queries + this.domCache = {}; + + // Bound handlers for cleanup + this.boundHandlers = {}; + + // Statistics history for charts + this.statsHistory = { + turns: [], + population: [], + deaths: [], + money: [], + avgWealth: [], + giniCoefficient: [], + professions: {}, + resourcePrices: {}, + tradeVolume: [], + // Resource tracking (per turn) + resourcesProduced: {}, + resourcesConsumed: {}, + resourcesSpoiled: {}, + // Resource tracking (cumulative) + resourcesProducedCumulative: {}, + resourcesConsumedCumulative: {}, + resourcesSpoiledCumulative: {}, + resourcesTraded: {}, // from market trades + }; + this.maxHistoryPoints = 200; + + // Chart instances + this.charts = {}; + + // Current state cache for stats + this.currentState = null; + + // Stats view state + this.statsViewActive = false; + } + + create() { + const state = this.registry.get('simulationState'); + if (state) { + this.worldWidth = state.world_size?.width || 20; + this.worldHeight = state.world_size?.height || 20; + } + + const gameWidth = this.worldWidth * this.tileSize; + const gameHeight = this.worldHeight * this.tileSize; + + this.createWorld(gameWidth, gameHeight); + this.agentContainer = this.add.container(0, 0); + + this.cameras.main.setBounds(0, 0, gameWidth, gameHeight); + this.cameras.main.setBackgroundColor(0x151921); + this.fitWorldToView(gameWidth, gameHeight); + this.setupCameraControls(); + + // Cache DOM elements once + this.cacheDOMElements(); + + // Setup UI with proper cleanup tracking + this.setupUIControls(); + + if (state) { + this.updateFromState(state); + } + + this.startStatePolling(); + this.updateConnectionStatus(true); + + // Clean up on scene shutdown + this.events.on('shutdown', this.cleanup, this); + this.events.on('destroy', this.cleanup, this); + } + + cacheDOMElements() { + this.domCache = { + dayDisplay: document.getElementById('day-display'), + timeDisplay: document.getElementById('time-display'), + turnDisplay: document.getElementById('turn-display'), + statAlive: document.getElementById('stat-alive'), + statDead: document.getElementById('stat-dead'), + statMoney: document.getElementById('stat-money'), + professionList: document.getElementById('profession-list'), + marketPrices: document.getElementById('market-prices'), + agentDetails: document.getElementById('agent-details'), + activityLog: document.getElementById('activity-log'), + connectionStatus: document.getElementById('connection-status'), + btnStep: document.getElementById('btn-step'), + btnAuto: document.getElementById('btn-auto'), + btnInitialize: document.getElementById('btn-initialize'), + btnStats: document.getElementById('btn-stats'), + speedSlider: document.getElementById('speed-slider'), + speedDisplay: document.getElementById('speed-display'), + // Stats screen elements + statsScreen: document.getElementById('stats-screen'), + btnCloseStats: document.getElementById('btn-close-stats'), + tabButtons: document.querySelectorAll('.tab-btn'), + tabPanels: document.querySelectorAll('.tab-panel'), + // Stats summary elements + statsTurn: document.getElementById('stats-turn'), + statsLiving: document.getElementById('stats-living'), + statsDeaths: document.getElementById('stats-deaths'), + statsGold: document.getElementById('stats-gold'), + statsAvgWealth: document.getElementById('stats-avg-wealth'), + statsGini: document.getElementById('stats-gini'), + }; + } + + cleanup() { + // Clear intervals + if (this.autoInterval) { + clearInterval(this.autoInterval); + this.autoInterval = null; + } + if (this.pollingInterval) { + clearInterval(this.pollingInterval); + this.pollingInterval = null; + } + + // Remove event listeners + const { btnStep, btnAuto, btnInitialize, btnStats, btnCloseStats, speedSlider } = this.domCache; + + if (btnStep && this.boundHandlers.step) { + btnStep.removeEventListener('click', this.boundHandlers.step); + } + if (btnAuto && this.boundHandlers.auto) { + btnAuto.removeEventListener('click', this.boundHandlers.auto); + } + if (btnInitialize && this.boundHandlers.init) { + btnInitialize.removeEventListener('click', this.boundHandlers.init); + } + if (speedSlider && this.boundHandlers.speed) { + speedSlider.removeEventListener('input', this.boundHandlers.speed); + } + if (btnStats && this.boundHandlers.openStats) { + btnStats.removeEventListener('click', this.boundHandlers.openStats); + } + if (btnCloseStats && this.boundHandlers.closeStats) { + btnCloseStats.removeEventListener('click', this.boundHandlers.closeStats); + } + + // Destroy charts + Object.values(this.charts).forEach(chart => chart?.destroy()); + this.charts = {}; + + // Clear agents + this.agents.forEach(sprite => sprite.destroy()); + this.agents.clear(); + } + + createWorld(gameWidth, gameHeight) { + const zones = Object.values(WORLD_ZONES); + + zones.forEach(zone => { + const x = gameWidth * zone.start; + const width = gameWidth * (zone.end - zone.start); + + this.add.rectangle( + x + width / 2, gameHeight / 2, + width, gameHeight, + zone.color, 0.3 + ); + }); + + zones.forEach(zone => { + const x = gameWidth * ((zone.start + zone.end) / 2); + this.add.text(x, 10, zone.name, { + fontSize: '12px', + fontFamily: 'JetBrains Mono, monospace', + color: '#6b6560', + }).setOrigin(0.5, 0); + }); + + const gridGraphics = this.add.graphics(); + gridGraphics.lineStyle(1, 0x3a4359, 0.15); + + for (let x = 0; x <= gameWidth; x += this.tileSize) { + gridGraphics.lineBetween(x, 0, x, gameHeight); + } + for (let y = 0; y <= gameHeight; y += this.tileSize) { + gridGraphics.lineBetween(0, y, gameWidth, y); + } + + this.addZoneDecorations(gameWidth, gameHeight); + } + + addZoneDecorations(gameWidth, gameHeight) { + const decoGraphics = this.add.graphics(); + + const riverEnd = gameWidth * WORLD_ZONES.river.end; + decoGraphics.lineStyle(2, 0x5a8cc8, 0.3); + for (let y = 20; y < gameHeight; y += 30) { + const waveY = y + Math.sin(y * 0.1) * 5; + decoGraphics.lineBetween(5, waveY, riverEnd - 5, waveY + 5); + } + + const bushStart = gameWidth * WORLD_ZONES.bushes.start; + const bushEnd = gameWidth * WORLD_ZONES.bushes.end; + decoGraphics.fillStyle(0x6bab5e, 0.2); + for (let i = 0; i < 15; i++) { + const x = bushStart + Math.random() * (bushEnd - bushStart); + const y = 30 + Math.random() * (gameHeight - 60); + decoGraphics.fillCircle(x, y, 8 + Math.random() * 8); + } + + const forestStart = gameWidth * WORLD_ZONES.forest.start; + decoGraphics.fillStyle(0x2d5016, 0.25); + for (let i = 0; i < 20; i++) { + const x = forestStart + 20 + Math.random() * (gameWidth - forestStart - 40); + const y = 30 + Math.random() * (gameHeight - 60); + decoGraphics.fillRect(x - 2, y, 4, 12); + decoGraphics.fillTriangle(x, y - 15, x - 10, y + 5, x + 10, y + 5); + } + + const villageStart = gameWidth * WORLD_ZONES.village.start; + const villageEnd = gameWidth * WORLD_ZONES.village.end; + decoGraphics.fillStyle(0x8b7355, 0.15); + decoGraphics.lineStyle(1, 0x8b7355, 0.3); + for (let i = 0; i < 5; i++) { + const x = villageStart + 30 + (i * ((villageEnd - villageStart - 60) / 4)); + const y = gameHeight / 2 + (Math.random() - 0.5) * 100; + decoGraphics.fillRect(x - 10, y - 5, 20, 15); + decoGraphics.strokeRect(x - 10, y - 5, 20, 15); + decoGraphics.fillTriangle(x, y - 15, x - 12, y - 5, x + 12, y - 5); + } + } + + fitWorldToView(gameWidth, gameHeight) { + const cam = this.cameras.main; + const padding = 40; + const scaleX = (cam.width - padding * 2) / gameWidth; + const scaleY = (cam.height - padding * 2) / gameHeight; + const scale = Math.min(scaleX, scaleY, DISPLAY.MAX_ZOOM); + cam.setZoom(Math.max(scale, DISPLAY.MIN_ZOOM)); + cam.centerOn(gameWidth / 2, gameHeight / 2); + } + + setupCameraControls() { + const cam = this.cameras.main; + + this.input.on('wheel', (pointer, gameObjects, deltaX, deltaY) => { + const zoom = cam.zoom - deltaY * 0.001; + cam.setZoom(Phaser.Math.Clamp(zoom, DISPLAY.MIN_ZOOM, DISPLAY.MAX_ZOOM)); + }); + + this.input.on('pointermove', (pointer) => { + if (pointer.isDown && pointer.button === 1) { + cam.scrollX -= (pointer.x - pointer.prevPosition.x) / cam.zoom; + cam.scrollY -= (pointer.y - pointer.prevPosition.y) / cam.zoom; + } + }); + } + + setupUIControls() { + const { btnStep, btnAuto, btnInitialize, btnStats, btnCloseStats, speedSlider, speedDisplay, tabButtons } = this.domCache; + + // Create bound handlers for later cleanup + this.boundHandlers.step = () => this.handleStep(); + this.boundHandlers.auto = () => this.toggleAutoMode(); + this.boundHandlers.init = () => this.handleInitialize(); + this.boundHandlers.speed = (e) => { + this.autoSpeed = parseInt(e.target.value); + if (speedDisplay) speedDisplay.textContent = `${this.autoSpeed}ms`; + if (this.isAutoMode) this.restartAutoMode(); + }; + this.boundHandlers.openStats = () => this.showStatsScreen(); + this.boundHandlers.closeStats = () => this.hideStatsScreen(); + + if (btnStep) btnStep.addEventListener('click', this.boundHandlers.step); + if (btnAuto) btnAuto.addEventListener('click', this.boundHandlers.auto); + if (btnInitialize) btnInitialize.addEventListener('click', this.boundHandlers.init); + if (speedSlider) speedSlider.addEventListener('input', this.boundHandlers.speed); + if (btnStats) btnStats.addEventListener('click', this.boundHandlers.openStats); + if (btnCloseStats) btnCloseStats.addEventListener('click', this.boundHandlers.closeStats); + + // Tab switching + tabButtons?.forEach(btn => { + btn.addEventListener('click', (e) => this.switchTab(e.target.dataset.tab)); + }); + } + + async handleStep() { + if (this.isAutoMode || this.isStepPending) return; + + this.isStepPending = true; + + try { + await api.nextStep(); + const state = await api.getState(); + this.updateFromState(state); + } catch (error) { + console.error('Step failed:', error); + this.updateConnectionStatus(false); + } finally { + this.isStepPending = false; + } + } + + async handleInitialize() { + try { + if (this.isAutoMode) this.toggleAutoMode(); + + await api.initialize(); + const state = await api.getState(); + + // Clear all agents + this.agents.forEach(sprite => sprite.destroy()); + this.agents.clear(); + this.selectedAgent = null; + + // Clear activity log + const logEl = this.domCache.activityLog; + if (logEl) logEl.innerHTML = ''; + + // Reset statistics history + this.statsHistory = { + turns: [], + population: [], + deaths: [], + money: [], + avgWealth: [], + giniCoefficient: [], + professions: {}, + resourcePrices: {}, + tradeVolume: [], + resourcesProduced: {}, + resourcesConsumed: {}, + resourcesSpoiled: {}, + resourcesProducedCumulative: {}, + resourcesConsumedCumulative: {}, + resourcesSpoiledCumulative: {}, + resourcesTraded: {}, + }; + this.currentState = null; + + // Destroy existing charts + Object.values(this.charts).forEach(chart => chart?.destroy()); + this.charts = {}; + + this.updateFromState(state); + this.updateAgentDetails(null); + } catch (error) { + console.error('Initialize failed:', error); + this.updateConnectionStatus(false); + } + } + + toggleAutoMode() { + this.isAutoMode = !this.isAutoMode; + const { btnAuto, btnStep } = this.domCache; + + if (this.isAutoMode) { + btnAuto?.classList.add('active'); + btnStep?.setAttribute('disabled', 'true'); + this.startAutoMode(); + } else { + btnAuto?.classList.remove('active'); + btnStep?.removeAttribute('disabled'); + this.stopAutoMode(); + } + } + + startAutoMode() { + this.stopAutoMode(); + + this.autoInterval = setInterval(async () => { + if (this.isStepPending) return; + this.isStepPending = true; + + try { + await api.nextStep(); + const state = await api.getState(); + this.updateFromState(state); + } catch (error) { + console.error('Auto step failed:', error); + this.toggleAutoMode(); + } finally { + this.isStepPending = false; + } + }, this.autoSpeed); + } + + stopAutoMode() { + if (this.autoInterval) { + clearInterval(this.autoInterval); + this.autoInterval = null; + } + } + + restartAutoMode() { + if (this.isAutoMode) this.startAutoMode(); + } + + startStatePolling() { + if (this.pollingInterval) { + clearInterval(this.pollingInterval); + } + + this.pollingInterval = setInterval(async () => { + if (!this.isAutoMode && !this.isStepPending) { + try { + await api.getStatus(); + this.updateConnectionStatus(true); + } catch { + this.updateConnectionStatus(false); + } + } + }, 5000); + } + + updateFromState(state) { + if (!state) return; + + this.currentState = state; + this.registry.set('simulationState', state); + this.updateHeaderDisplay(state); + this.updateAgents(state.agents || []); + this.updateLeftPanel(state); + this.updateRightPanel(state); + this.updateActivityLog(state.recent_logs || []); + this.recordStatsHistory(state); + + // Update selected agent if still exists + if (this.selectedAgent) { + const agentData = (state.agents || []).find(a => a.id === this.selectedAgent); + if (agentData) { + this.updateAgentDetails(agentData); + } + } + + // Update stats screen if visible + if (this.statsViewActive) { + this.updateStatsScreen(); + } + } + + updateHeaderDisplay(state) { + const { dayDisplay, timeDisplay, turnDisplay } = this.domCache; + + if (dayDisplay) dayDisplay.textContent = `Day ${state.day || 1}`; + if (timeDisplay) { + timeDisplay.textContent = state.time_of_day === 'night' ? '🌙 Night' : '☀️ Day'; + } + if (turnDisplay) turnDisplay.textContent = `Turn ${state.turn || 0}`; + } + + updateAgents(agentsData) { + const seenIds = new Set(); + + agentsData.forEach(agentData => { + seenIds.add(agentData.id); + + let agentSprite = this.agents.get(agentData.id); + + if (!agentSprite) { + agentSprite = this.createAgentSprite(agentData); + this.agents.set(agentData.id, agentSprite); + } + + this.updateAgentSprite(agentSprite, agentData); + }); + + // Remove agents that no longer exist + this.agents.forEach((sprite, id) => { + if (!seenIds.has(id)) { + sprite.destroy(); + this.agents.delete(id); + } + }); + } + + createAgentSprite(agentData) { + const container = this.add.container(0, 0); + const profData = PROFESSIONS[agentData.profession] || PROFESSIONS.villager; + + const body = this.add.circle(0, 0, DISPLAY.AGENT_SIZE / 2, profData.color); + body.setStrokeStyle(2, 0xe8e4dc); + container.add(body); + + const icon = this.add.text(0, 0, profData.icon, { fontSize: '14px' }).setOrigin(0.5); + container.add(icon); + + const nameLabel = this.add.text(0, -DISPLAY.AGENT_SIZE / 2 - 8, agentData.name, { + fontSize: '10px', + fontFamily: 'JetBrains Mono, monospace', + color: '#e8e4dc', + backgroundColor: '#151921', + padding: { x: 3, y: 1 }, + }).setOrigin(0.5); + container.add(nameLabel); + + const healthBg = this.add.rectangle(0, DISPLAY.AGENT_SIZE / 2 + 6, 24, 4, 0x151921); + container.add(healthBg); + + const healthBar = this.add.rectangle(-12, DISPLAY.AGENT_SIZE / 2 + 6, 24, 4, 0x4a9c6d); + healthBar.setOrigin(0, 0.5); + container.add(healthBar); + + container.setData('body', body); + container.setData('icon', icon); + container.setData('nameLabel', nameLabel); + container.setData('healthBar', healthBar); + container.setData('agentId', agentData.id); + + body.setInteractive({ useHandCursor: true }); + body.on('pointerdown', () => this.selectAgent(agentData.id)); + body.on('pointerover', () => { + body.setStrokeStyle(3, 0xd4a84b); + container.setScale(1.1); + }); + body.on('pointerout', () => { + const isSelected = this.selectedAgent === agentData.id; + body.setStrokeStyle(isSelected ? 3 : 2, isSelected ? 0xd4a84b : 0xe8e4dc); + container.setScale(1); + }); + + this.agentContainer.add(container); + return container; + } + + updateAgentSprite(sprite, agentData) { + // Direct position update - NO TWEENS to prevent memory accumulation + const targetX = agentData.position.x * this.tileSize; + const targetY = agentData.position.y * this.tileSize; + + sprite.x = targetX; + sprite.y = targetY; + + const body = sprite.getData('body'); + const icon = sprite.getData('icon'); + const healthBar = sprite.getData('healthBar'); + + if (!agentData.is_alive) { + body.setFillStyle(0x4a4a4a); + body.setStrokeStyle(2, 0x6b6560); + icon.setText('💀'); + healthBar.setFillStyle(0x4a4a4a); + healthBar.setScale(0, 1); + } else { + const profData = PROFESSIONS[agentData.profession] || PROFESSIONS.villager; + body.setFillStyle(profData.color); + icon.setText(profData.icon); + + const stats = agentData.stats; + const minStat = Math.min( + stats.hunger / stats.max_hunger, + stats.thirst / stats.max_thirst, + stats.heat / stats.max_heat + ); + + healthBar.setScale(minStat, 1); + + if (minStat < 0.25) { + healthBar.setFillStyle(0xc45c5c); + } else if (minStat < 0.5) { + healthBar.setFillStyle(0xd4a84b); + } else { + healthBar.setFillStyle(0x4a9c6d); + } + } + + sprite.setData('agentData', agentData); + } + + selectAgent(agentId) { + if (this.selectedAgent) { + const prevSprite = this.agents.get(this.selectedAgent); + if (prevSprite) { + const prevBody = prevSprite.getData('body'); + prevBody.setStrokeStyle(2, 0xe8e4dc); + } + } + + this.selectedAgent = agentId; + const sprite = this.agents.get(agentId); + + if (sprite) { + const body = sprite.getData('body'); + body.setStrokeStyle(3, 0xd4a84b); + this.updateAgentDetails(sprite.getData('agentData')); + } + } + + updateLeftPanel(state) { + const { statAlive, statDead, statMoney, professionList } = this.domCache; + const stats = state.statistics || {}; + + if (statAlive) statAlive.textContent = stats.living_agents || 0; + if (statDead) statDead.textContent = stats.total_agents_died || 0; + if (statMoney) statMoney.textContent = `${stats.total_money_in_circulation || 0}g`; + + if (professionList) { + const professions = stats.professions || {}; + professionList.innerHTML = Object.entries(professions) + .filter(([_, count]) => count > 0) + .map(([prof, count]) => { + const profData = PROFESSIONS[prof] || PROFESSIONS.villager; + return `
+ + ${profData.icon} + ${profData.name} + + ${count} +
`; + }).join(''); + } + } + + updateRightPanel(state) { + const { marketPrices } = this.domCache; + + if (marketPrices && state.market?.prices) { + const prices = state.market.prices; + marketPrices.innerHTML = Object.entries(prices) + .map(([resource, priceData]) => { + const resData = RESOURCES[resource] || { icon: '📦', name: resource }; + const price = priceData.lowest_price !== null ? `${priceData.lowest_price}g` : '-'; + const available = priceData.total_available || 0; + return `
+ ${resData.icon} ${resData.name} + ${price} + (${available}) +
`; + }).join(''); + } + } + + updateAgentDetails(agentData) { + const { agentDetails } = this.domCache; + if (!agentDetails) return; + + if (!agentData) { + agentDetails.innerHTML = '

Click an agent to view details

'; + return; + } + + const profData = PROFESSIONS[agentData.profession] || PROFESSIONS.villager; + const stats = agentData.stats; + const action = agentData.current_action; + const actionData = ACTIONS[action.action_type] || ACTIONS.idle; + + const renderBar = (label, current, max, type) => { + const pct = Math.round((current / max) * 100); + return `
+
${label}${current}/${max}
+
+
`; + }; + + // Render skills if available + const renderSkills = () => { + if (!agentData.skills) return ''; + const skills = agentData.skills; + const skillNames = ['hunting', 'gathering', 'woodcutting', 'crafting', 'trading']; + const skillEmojis = { hunting: '🏹', gathering: '🌿', woodcutting: '🪓', crafting: '🧵', trading: '💰' }; + return `
+ ${skillNames.map(s => { + const val = skills[s] || 1.0; + if (val <= 1.0) return ''; // Skip base skills + return ` + ${skillEmojis[s]} + ${val.toFixed(2)} + `; + }).filter(Boolean).join('') || 'No skills yet'} +
`; + }; + + // Render personal action log + const renderActionLog = () => { + const history = agentData.action_history || []; + if (history.length === 0) { + return '
No actions yet
'; + } + // Show most recent first + return history.slice().reverse().slice(0, 12).map(entry => { + const actionIcon = ACTIONS[entry.action]?.icon || '❓'; + return `
+ T${entry.turn} + ${actionIcon} + ${entry.result} +
`; + }).join(''); + }; + + agentDetails.innerHTML = `
+
+
${profData.icon}
+
+

${agentData.name}

+ ${profData.name} +
+
+
+ 💰 + ${agentData.money}g + Gold +
+
+ ${renderBar('Energy', stats.energy, stats.max_energy, 'energy')} + ${renderBar('Hunger', stats.hunger, stats.max_hunger, 'hunger')} + ${renderBar('Thirst', stats.thirst, stats.max_thirst, 'thirst')} + ${renderBar('Heat', stats.heat, stats.max_heat, 'heat')} +
+
+
Inventory
+
+ ${agentData.inventory.length === 0 ? 'Empty' : + agentData.inventory.map(item => { + const resData = RESOURCES[item.type] || { icon: '📦' }; + return `${resData.icon} ${item.quantity}`; + }).join('')} +
+
+
Skills
+ ${renderSkills()} +
+ Current Action +
${actionData.icon} ${action.message || actionData.verb}
+
+
Personal Log
+
+ ${renderActionLog()} +
+
`; + } + + updateActivityLog(logs) { + const logEl = this.domCache.activityLog; + if (!logEl || !logs.length) return; + + const recentLog = logs[logs.length - 1]; + if (!recentLog) return; + + // Build new entries + const fragment = document.createDocumentFragment(); + + // Add deaths first + (recentLog.deaths || []).slice(0, 3).forEach(agentId => { + const div = document.createElement('div'); + div.className = 'log-entry action-death'; + div.innerHTML = `${agentId} died`; + fragment.appendChild(div); + }); + + // Add recent actions (limit to 5) + (recentLog.agent_actions || []).slice(-5).forEach(action => { + const actionType = action.decision?.action || 'idle'; + const actionData = ACTIONS[actionType] || ACTIONS.idle; + const div = document.createElement('div'); + div.className = `log-entry action-${actionType}`; + div.innerHTML = `${action.agent_name} ${actionData.icon} ${actionData.verb}`; + fragment.appendChild(div); + }); + + // Insert at the beginning + if (logEl.firstChild) { + logEl.insertBefore(fragment, logEl.firstChild); + } else { + logEl.appendChild(fragment); + } + + // Remove excess entries - keep max 12 + while (logEl.children.length > 12) { + logEl.removeChild(logEl.lastChild); + } + } + + updateConnectionStatus(connected) { + const statusEl = this.domCache.connectionStatus; + if (!statusEl) return; + + const dot = statusEl.querySelector('.status-dot'); + const text = statusEl.querySelector('.status-text'); + + if (connected) { + dot?.classList.remove('disconnected'); + dot?.classList.add('connected'); + if (text) text.textContent = 'Connected'; + } else { + dot?.classList.remove('connected'); + dot?.classList.add('disconnected'); + if (text) text.textContent = 'Disconnected'; + } + } + + // ============== Stats History & Charts ============== + + recordStatsHistory(state) { + const stats = state.statistics || {}; + const turn = state.turn || 0; + + // Avoid duplicate entries + if (this.statsHistory.turns.includes(turn)) return; + + this.statsHistory.turns.push(turn); + this.statsHistory.population.push(stats.living_agents || 0); + this.statsHistory.deaths.push(stats.total_agents_died || 0); + this.statsHistory.money.push(stats.total_money_in_circulation || 0); + this.statsHistory.avgWealth.push(stats.avg_money || 0); + this.statsHistory.giniCoefficient.push(stats.gini_coefficient || 0); + this.statsHistory.tradeVolume.push(state.market?.orders?.length || 0); + + // Track professions + const professions = stats.professions || {}; + for (const [prof, count] of Object.entries(professions)) { + if (!this.statsHistory.professions[prof]) { + this.statsHistory.professions[prof] = []; + } + // Pad with zeros if needed + while (this.statsHistory.professions[prof].length < this.statsHistory.turns.length - 1) { + this.statsHistory.professions[prof].push(0); + } + this.statsHistory.professions[prof].push(count); + } + + // Track resource prices + if (state.market?.prices) { + for (const [resource, priceData] of Object.entries(state.market.prices)) { + if (!this.statsHistory.resourcePrices[resource]) { + this.statsHistory.resourcePrices[resource] = []; + } + while (this.statsHistory.resourcePrices[resource].length < this.statsHistory.turns.length - 1) { + this.statsHistory.resourcePrices[resource].push(null); + } + this.statsHistory.resourcePrices[resource].push(priceData.lowest_price); + } + } + + // Track resource production/consumption/spoilage from recent logs and cumulative stats + const recentLog = state.recent_logs?.[state.recent_logs?.length - 1]; + const resourceStats = state.resource_stats || {}; + const resourceTypes = ['meat', 'berries', 'water', 'wood', 'hide', 'clothes']; + + for (const resType of resourceTypes) { + // Per-turn produced + if (!this.statsHistory.resourcesProduced[resType]) { + this.statsHistory.resourcesProduced[resType] = []; + } + while (this.statsHistory.resourcesProduced[resType].length < this.statsHistory.turns.length - 1) { + this.statsHistory.resourcesProduced[resType].push(0); + } + this.statsHistory.resourcesProduced[resType].push(recentLog?.resources_produced?.[resType] || 0); + + // Per-turn consumed + if (!this.statsHistory.resourcesConsumed[resType]) { + this.statsHistory.resourcesConsumed[resType] = []; + } + while (this.statsHistory.resourcesConsumed[resType].length < this.statsHistory.turns.length - 1) { + this.statsHistory.resourcesConsumed[resType].push(0); + } + this.statsHistory.resourcesConsumed[resType].push(recentLog?.resources_consumed?.[resType] || 0); + + // Per-turn spoiled + if (!this.statsHistory.resourcesSpoiled[resType]) { + this.statsHistory.resourcesSpoiled[resType] = []; + } + while (this.statsHistory.resourcesSpoiled[resType].length < this.statsHistory.turns.length - 1) { + this.statsHistory.resourcesSpoiled[resType].push(0); + } + this.statsHistory.resourcesSpoiled[resType].push(recentLog?.resources_spoiled?.[resType] || 0); + + // Cumulative produced (from backend totals) + if (!this.statsHistory.resourcesProducedCumulative[resType]) { + this.statsHistory.resourcesProducedCumulative[resType] = []; + } + while (this.statsHistory.resourcesProducedCumulative[resType].length < this.statsHistory.turns.length - 1) { + this.statsHistory.resourcesProducedCumulative[resType].push(0); + } + this.statsHistory.resourcesProducedCumulative[resType].push(resourceStats.produced?.[resType] || 0); + + // Cumulative consumed + if (!this.statsHistory.resourcesConsumedCumulative[resType]) { + this.statsHistory.resourcesConsumedCumulative[resType] = []; + } + while (this.statsHistory.resourcesConsumedCumulative[resType].length < this.statsHistory.turns.length - 1) { + this.statsHistory.resourcesConsumedCumulative[resType].push(0); + } + this.statsHistory.resourcesConsumedCumulative[resType].push(resourceStats.consumed?.[resType] || 0); + + // Cumulative spoiled + if (!this.statsHistory.resourcesSpoiledCumulative[resType]) { + this.statsHistory.resourcesSpoiledCumulative[resType] = []; + } + while (this.statsHistory.resourcesSpoiledCumulative[resType].length < this.statsHistory.turns.length - 1) { + this.statsHistory.resourcesSpoiledCumulative[resType].push(0); + } + this.statsHistory.resourcesSpoiledCumulative[resType].push(resourceStats.spoiled?.[resType] || 0); + + // Cumulative traded (from backend) + if (!this.statsHistory.resourcesTraded[resType]) { + this.statsHistory.resourcesTraded[resType] = []; + } + while (this.statsHistory.resourcesTraded[resType].length < this.statsHistory.turns.length - 1) { + this.statsHistory.resourcesTraded[resType].push(0); + } + this.statsHistory.resourcesTraded[resType].push(resourceStats.traded?.[resType] || 0); + } + + // Trim history if too long + if (this.statsHistory.turns.length > this.maxHistoryPoints) { + this.statsHistory.turns.shift(); + this.statsHistory.population.shift(); + this.statsHistory.deaths.shift(); + this.statsHistory.money.shift(); + this.statsHistory.avgWealth.shift(); + this.statsHistory.giniCoefficient.shift(); + this.statsHistory.tradeVolume.shift(); + for (const prof in this.statsHistory.professions) { + this.statsHistory.professions[prof].shift(); + } + for (const res in this.statsHistory.resourcePrices) { + this.statsHistory.resourcePrices[res].shift(); + } + for (const res in this.statsHistory.resourcesProduced) { + this.statsHistory.resourcesProduced[res].shift(); + } + for (const res in this.statsHistory.resourcesConsumed) { + this.statsHistory.resourcesConsumed[res].shift(); + } + for (const res in this.statsHistory.resourcesSpoiled) { + this.statsHistory.resourcesSpoiled[res].shift(); + } + for (const res in this.statsHistory.resourcesProducedCumulative) { + this.statsHistory.resourcesProducedCumulative[res].shift(); + } + for (const res in this.statsHistory.resourcesConsumedCumulative) { + this.statsHistory.resourcesConsumedCumulative[res].shift(); + } + for (const res in this.statsHistory.resourcesSpoiledCumulative) { + this.statsHistory.resourcesSpoiledCumulative[res].shift(); + } + for (const res in this.statsHistory.resourcesTraded) { + this.statsHistory.resourcesTraded[res].shift(); + } + } + } + + showStatsScreen() { + const { statsScreen } = this.domCache; + if (statsScreen) { + statsScreen.classList.remove('hidden'); + this.statsViewActive = true; + this.updateStatsScreen(); + } + } + + hideStatsScreen() { + const { statsScreen } = this.domCache; + if (statsScreen) { + statsScreen.classList.add('hidden'); + this.statsViewActive = false; + } + } + + switchTab(tabName) { + const { tabButtons, tabPanels } = this.domCache; + + tabButtons?.forEach(btn => { + btn.classList.toggle('active', btn.dataset.tab === tabName); + }); + + tabPanels?.forEach(panel => { + panel.classList.toggle('active', panel.id === `tab-${tabName}`); + }); + + // Update charts for the active tab + this.updateActiveTabCharts(tabName); + } + + updateStatsScreen() { + this.updateStatsSummary(); + // Find active tab + const activeTab = document.querySelector('.tab-btn.active'); + if (activeTab) { + this.updateActiveTabCharts(activeTab.dataset.tab); + } + } + + updateStatsSummary() { + const { statsTurn, statsLiving, statsDeaths, statsGold, statsAvgWealth, statsGini } = this.domCache; + const state = this.currentState; + if (!state) return; + + const stats = state.statistics || {}; + if (statsTurn) statsTurn.textContent = state.turn || 0; + if (statsLiving) statsLiving.textContent = stats.living_agents || 0; + if (statsDeaths) statsDeaths.textContent = stats.total_agents_died || 0; + if (statsGold) statsGold.textContent = `${stats.total_money_in_circulation || 0}g`; + if (statsAvgWealth) statsAvgWealth.textContent = `${Math.round(stats.avg_money || 0)}g`; + if (statsGini) statsGini.textContent = (stats.gini_coefficient || 0).toFixed(2); + } + + updateActiveTabCharts(tabName) { + switch (tabName) { + case 'prices': this.renderPricesChart(); break; + case 'wealth': this.renderWealthCharts(); break; + case 'population': this.renderPopulationChart(); break; + case 'professions': this.renderProfessionCharts(); break; + case 'resources': this.renderResourceCharts(); break; + case 'market': this.renderMarketCharts(); break; + case 'agents': this.renderAgentStatsCharts(); break; + } + } + + renderPricesChart() { + const canvas = document.getElementById('chart-prices'); + if (!canvas) return; + + if (this.charts.prices) this.charts.prices.destroy(); + + const resColors = { + meat: '#c45c5c', berries: '#a855a8', water: '#5a8cc8', + wood: '#a67c52', hide: '#8b7355', clothes: '#6b6560', + }; + + const datasets = []; + for (const [resource, data] of Object.entries(this.statsHistory.resourcePrices)) { + if (data.some(v => v !== null)) { + datasets.push({ + label: resource.charAt(0).toUpperCase() + resource.slice(1), + data: data, + borderColor: resColors[resource] || '#888', + backgroundColor: 'transparent', + tension: 0.3, + spanGaps: true, + borderWidth: 2, + }); + } + } + + this.charts.prices = new Chart(canvas.getContext('2d'), { + type: 'line', + data: { labels: this.statsHistory.turns, datasets }, + options: this.getChartOptions('Market Prices Over Time'), + }); + } + + renderWealthCharts() { + // Wealth distribution bar chart (per agent) + const distCanvas = document.getElementById('chart-wealth-dist'); + if (distCanvas && this.currentState) { + if (this.charts.wealthDist) this.charts.wealthDist.destroy(); + + const agents = (this.currentState.agents || []) + .filter(a => a.is_alive) + .sort((a, b) => b.money - a.money); + + const labels = agents.map(a => a.name.substring(0, 8)); + const data = agents.map(a => a.money); + const colors = agents.map((_, i) => { + const ratio = i / Math.max(1, agents.length - 1); + return `hsl(${180 - ratio * 180}, 70%, 55%)`; + }); + + this.charts.wealthDist = new Chart(distCanvas.getContext('2d'), { + type: 'bar', + data: { + labels, + datasets: [{ label: 'Gold', data, backgroundColor: colors, borderWidth: 0 }], + }, + options: { + responsive: true, + maintainAspectRatio: false, + animation: false, + indexAxis: 'y', + plugins: { + legend: { display: false }, + title: { + display: true, + text: 'Wealth by Agent', + color: '#e8e4dc', + font: { family: "'Crimson Pro', serif", size: 16, weight: '600' }, + }, + tooltip: { + callbacks: { + label: (ctx) => `${ctx.raw}g`, + }, + }, + }, + scales: { + x: { beginAtZero: true, ticks: { color: '#6b6560' }, grid: { color: 'rgba(58, 67, 89, 0.3)' } }, + y: { ticks: { color: '#6b6560', font: { size: 9 } }, grid: { display: false } }, + }, + interaction: { intersect: true, mode: 'nearest' }, + }, + }); + } + + // Wealth by profession chart + const profCanvas = document.getElementById('chart-wealth-prof'); + if (profCanvas && this.currentState) { + if (this.charts.wealthProf) this.charts.wealthProf.destroy(); + + const agents = (this.currentState.agents || []).filter(a => a.is_alive); + const profWealth = {}; + const profCount = {}; + + agents.forEach(a => { + const prof = a.profession || 'villager'; + profWealth[prof] = (profWealth[prof] || 0) + a.money; + profCount[prof] = (profCount[prof] || 0) + 1; + }); + + const profColors = { + hunter: '#c45c5c', gatherer: '#6bab5e', woodcutter: '#a67c52', + trader: '#d4a84b', crafter: '#8b6fc0', villager: '#7a8899', + }; + + const labels = Object.keys(profWealth); + const totalData = labels.map(p => profWealth[p]); + const avgData = labels.map(p => Math.round(profWealth[p] / profCount[p])); + const colors = labels.map(p => profColors[p] || '#888'); + + this.charts.wealthProf = new Chart(profCanvas.getContext('2d'), { + type: 'bar', + data: { + labels: labels.map(l => l.charAt(0).toUpperCase() + l.slice(1)), + datasets: [ + { label: 'Total Gold', data: totalData, backgroundColor: colors, borderWidth: 0 }, + { label: 'Avg per Agent', data: avgData, backgroundColor: colors.map(c => c + '80'), borderWidth: 0 }, + ], + }, + options: { + responsive: true, + maintainAspectRatio: false, + animation: false, + plugins: { + legend: { position: 'bottom', labels: { color: '#a8a095', boxWidth: 12 } }, + title: { + display: true, + text: 'Wealth by Profession', + color: '#e8e4dc', + font: { family: "'Crimson Pro', serif", size: 16, weight: '600' }, + }, + }, + scales: { + x: { ticks: { color: '#6b6560' }, grid: { color: 'rgba(58, 67, 89, 0.3)' } }, + y: { beginAtZero: true, ticks: { color: '#6b6560' }, grid: { color: 'rgba(58, 67, 89, 0.3)' } }, + }, + }, + }); + } + + // Wealth over time chart + const timeCanvas = document.getElementById('chart-wealth-time'); + if (timeCanvas) { + if (this.charts.wealthTime) this.charts.wealthTime.destroy(); + + this.charts.wealthTime = new Chart(timeCanvas.getContext('2d'), { + type: 'line', + data: { + labels: this.statsHistory.turns, + datasets: [ + { + label: 'Total Gold', + data: this.statsHistory.money, + borderColor: '#00d4ff', + backgroundColor: 'rgba(0, 212, 255, 0.1)', + fill: true, + yAxisID: 'y', + }, + { + label: 'Avg Wealth', + data: this.statsHistory.avgWealth, + borderColor: '#39ff14', + borderDash: [5, 5], + yAxisID: 'y1', + }, + { + label: 'Gini Index', + data: this.statsHistory.giniCoefficient, + borderColor: '#ff0099', + yAxisID: 'y2', + }, + ], + }, + options: { + responsive: true, + maintainAspectRatio: false, + animation: false, + plugins: { + legend: { position: 'bottom', labels: { color: '#a8a095', boxWidth: 12, padding: 10 } }, + title: { + display: true, + text: 'Wealth Over Time', + color: '#e8e4dc', + font: { family: "'Crimson Pro', serif", size: 16, weight: '600' }, + }, + }, + scales: { + x: { ticks: { color: '#6b6560' }, grid: { color: 'rgba(58, 67, 89, 0.3)' } }, + y: { type: 'linear', position: 'left', beginAtZero: true, ticks: { color: '#00d4ff' }, grid: { color: 'rgba(58, 67, 89, 0.3)' } }, + y1: { type: 'linear', position: 'right', beginAtZero: true, grid: { drawOnChartArea: false }, ticks: { color: '#39ff14' } }, + y2: { type: 'linear', position: 'right', min: 0, max: 1, grid: { drawOnChartArea: false }, ticks: { color: '#ff0099' } }, + }, + interaction: { intersect: false, mode: 'index' }, + }, + }); + } + } + + renderPopulationChart() { + const canvas = document.getElementById('chart-population'); + if (!canvas) return; + + if (this.charts.population) this.charts.population.destroy(); + + this.charts.population = new Chart(canvas.getContext('2d'), { + type: 'line', + data: { + labels: this.statsHistory.turns, + datasets: [ + { + label: 'Living', + data: this.statsHistory.population, + borderColor: '#00d4ff', + backgroundColor: 'rgba(0, 212, 255, 0.15)', + fill: true, + tension: 0.3, + }, + { + label: 'Deaths (Cumulative)', + data: this.statsHistory.deaths, + borderColor: '#ff0099', + borderDash: [5, 5], + tension: 0.3, + }, + ], + }, + options: this.getChartOptions('Population Over Time'), + }); + } + + renderProfessionCharts() { + // Pie chart + const pieCanvas = document.getElementById('chart-prof-pie'); + if (pieCanvas && this.currentState) { + if (this.charts.profPie) this.charts.profPie.destroy(); + + const professions = this.currentState.statistics?.professions || {}; + const labels = Object.keys(professions); + const data = Object.values(professions); + const colors = ['#00d4ff', '#ff0099', '#39ff14', '#ff6600', '#9d4edd', '#ffcc00']; + + this.charts.profPie = new Chart(pieCanvas.getContext('2d'), { + type: 'doughnut', + data: { + labels, + datasets: [{ data, backgroundColor: colors.slice(0, labels.length), borderWidth: 0 }], + }, + options: { + ...this.getChartOptions('Current Distribution'), + animation: false, + cutout: '50%', + }, + }); + } + + // Stacked area chart over time + const timeCanvas = document.getElementById('chart-prof-time'); + if (timeCanvas) { + if (this.charts.profTime) this.charts.profTime.destroy(); + + const colors = { hunter: '#c45c5c', gatherer: '#6bab5e', woodcutter: '#a67c52', trader: '#d4a84b', crafter: '#8b6fc0', villager: '#7a8899' }; + const datasets = []; + for (const [prof, data] of Object.entries(this.statsHistory.professions)) { + datasets.push({ + label: prof.charAt(0).toUpperCase() + prof.slice(1), + data, + backgroundColor: colors[prof] || '#888', + fill: true, + tension: 0.3, + }); + } + + this.charts.profTime = new Chart(timeCanvas.getContext('2d'), { + type: 'line', + data: { labels: this.statsHistory.turns, datasets }, + options: { + ...this.getChartOptions('Professions Over Time'), + animation: false, + scales: { ...this.getChartOptions('').scales, y: { stacked: true, beginAtZero: true, ticks: { color: '#6b6560' }, grid: { color: 'rgba(58, 67, 89, 0.3)' } } }, + plugins: { ...this.getChartOptions('').plugins, filler: { propagate: true } }, + }, + }); + } + } + + renderResourceCharts() { + const resColors = { + meat: '#c45c5c', berries: '#a855a8', water: '#5a8cc8', + wood: '#a67c52', hide: '#8b7355', clothes: '#6b6560', + }; + const resourceTypes = ['meat', 'berries', 'water', 'wood', 'hide', 'clothes']; + + // Resources Produced chart + const producedCanvas = document.getElementById('chart-res-produced'); + if (producedCanvas) { + if (this.charts.resProduced) this.charts.resProduced.destroy(); + + const datasets = resourceTypes.map(res => ({ + label: res.charAt(0).toUpperCase() + res.slice(1), + data: this.statsHistory.resourcesProduced[res] || [], + borderColor: resColors[res], + backgroundColor: 'transparent', + tension: 0.3, + })).filter(ds => ds.data.some(v => v > 0)); + + this.charts.resProduced = new Chart(producedCanvas.getContext('2d'), { + type: 'line', + data: { labels: this.statsHistory.turns, datasets }, + options: this.getChartOptions('Resources Produced (per turn)'), + }); + } + + // Resources Consumed chart + const consumedCanvas = document.getElementById('chart-res-consumed'); + if (consumedCanvas) { + if (this.charts.resConsumed) this.charts.resConsumed.destroy(); + + const datasets = resourceTypes.map(res => ({ + label: res.charAt(0).toUpperCase() + res.slice(1), + data: this.statsHistory.resourcesConsumed[res] || [], + borderColor: resColors[res], + backgroundColor: 'transparent', + tension: 0.3, + })).filter(ds => ds.data.some(v => v > 0)); + + this.charts.resConsumed = new Chart(consumedCanvas.getContext('2d'), { + type: 'line', + data: { labels: this.statsHistory.turns, datasets }, + options: this.getChartOptions('Resources Consumed (per turn)'), + }); + } + + // Resources Spoiled chart + const spoiledCanvas = document.getElementById('chart-res-spoiled'); + if (spoiledCanvas) { + if (this.charts.resSpoiled) this.charts.resSpoiled.destroy(); + + const datasets = resourceTypes.map(res => ({ + label: res.charAt(0).toUpperCase() + res.slice(1), + data: this.statsHistory.resourcesSpoiled[res] || [], + borderColor: resColors[res], + backgroundColor: 'transparent', + tension: 0.3, + })).filter(ds => ds.data.some(v => v > 0)); + + this.charts.resSpoiled = new Chart(spoiledCanvas.getContext('2d'), { + type: 'line', + data: { labels: this.statsHistory.turns, datasets }, + options: this.getChartOptions('Resources Spoiled (per turn)'), + }); + } + + // Current Stock chart (bar chart showing inventory + market) + const stockCanvas = document.getElementById('chart-res-stock'); + if (stockCanvas && this.currentState) { + if (this.charts.resStock) this.charts.resStock.destroy(); + + const resStats = this.currentState.resource_stats || {}; + const inInventory = resStats.in_inventory || {}; + const inMarket = resStats.in_market || {}; + + const labels = resourceTypes.map(r => r.charAt(0).toUpperCase() + r.slice(1)); + const invData = resourceTypes.map(r => inInventory[r] || 0); + const marketData = resourceTypes.map(r => inMarket[r] || 0); + const colors = resourceTypes.map(r => resColors[r]); + + this.charts.resStock = new Chart(stockCanvas.getContext('2d'), { + type: 'bar', + data: { + labels, + datasets: [ + { label: 'In Inventory', data: invData, backgroundColor: colors }, + { label: 'In Market', data: marketData, backgroundColor: colors.map(c => c + '80') }, + ], + }, + options: { + ...this.getChartOptions('Current Resource Stock'), + animation: false, + scales: { + x: { stacked: true, ticks: { color: '#6b6560' }, grid: { color: 'rgba(58, 67, 89, 0.3)' } }, + y: { stacked: true, beginAtZero: true, ticks: { color: '#6b6560' }, grid: { color: 'rgba(58, 67, 89, 0.3)' } }, + }, + }, + }); + } + + // Cumulative Produced chart + const cumProducedCanvas = document.getElementById('chart-res-cum-produced'); + if (cumProducedCanvas) { + if (this.charts.resCumProduced) this.charts.resCumProduced.destroy(); + + const datasets = resourceTypes.map(res => ({ + label: res.charAt(0).toUpperCase() + res.slice(1), + data: this.statsHistory.resourcesProducedCumulative[res] || [], + borderColor: resColors[res], + backgroundColor: 'transparent', + tension: 0.3, + })).filter(ds => ds.data.some(v => v > 0)); + + this.charts.resCumProduced = new Chart(cumProducedCanvas.getContext('2d'), { + type: 'line', + data: { labels: this.statsHistory.turns, datasets }, + options: this.getChartOptions('Cumulative Produced (total)'), + }); + } + + // Cumulative Consumed chart + const cumConsumedCanvas = document.getElementById('chart-res-cum-consumed'); + if (cumConsumedCanvas) { + if (this.charts.resCumConsumed) this.charts.resCumConsumed.destroy(); + + const datasets = resourceTypes.map(res => ({ + label: res.charAt(0).toUpperCase() + res.slice(1), + data: this.statsHistory.resourcesConsumedCumulative[res] || [], + borderColor: resColors[res], + backgroundColor: 'transparent', + tension: 0.3, + })).filter(ds => ds.data.some(v => v > 0)); + + this.charts.resCumConsumed = new Chart(cumConsumedCanvas.getContext('2d'), { + type: 'line', + data: { labels: this.statsHistory.turns, datasets }, + options: this.getChartOptions('Cumulative Consumed (total)'), + }); + } + + // Cumulative Spoiled chart + const cumSpoiledCanvas = document.getElementById('chart-res-cum-spoiled'); + if (cumSpoiledCanvas) { + if (this.charts.resCumSpoiled) this.charts.resCumSpoiled.destroy(); + + const datasets = resourceTypes.map(res => ({ + label: res.charAt(0).toUpperCase() + res.slice(1), + data: this.statsHistory.resourcesSpoiledCumulative[res] || [], + borderColor: resColors[res], + backgroundColor: 'transparent', + tension: 0.3, + })).filter(ds => ds.data.some(v => v > 0)); + + this.charts.resCumSpoiled = new Chart(cumSpoiledCanvas.getContext('2d'), { + type: 'line', + data: { labels: this.statsHistory.turns, datasets }, + options: this.getChartOptions('Cumulative Spoiled (total)'), + }); + } + + // Cumulative Traded chart + const cumTradedCanvas = document.getElementById('chart-res-cum-traded'); + if (cumTradedCanvas) { + if (this.charts.resCumTraded) this.charts.resCumTraded.destroy(); + + const datasets = resourceTypes.map(res => ({ + label: res.charAt(0).toUpperCase() + res.slice(1), + data: this.statsHistory.resourcesTraded[res] || [], + borderColor: resColors[res], + backgroundColor: 'transparent', + tension: 0.3, + })).filter(ds => ds.data.some(v => v > 0)); + + this.charts.resCumTraded = new Chart(cumTradedCanvas.getContext('2d'), { + type: 'line', + data: { labels: this.statsHistory.turns, datasets }, + options: this.getChartOptions('Cumulative Traded (total)'), + }); + } + } + + renderMarketCharts() { + // Market supply bar chart + const supplyCanvas = document.getElementById('chart-market-supply'); + if (supplyCanvas && this.currentState) { + if (this.charts.marketSupply) this.charts.marketSupply.destroy(); + + const prices = this.currentState.market?.prices || {}; + const labels = Object.keys(prices); + const data = labels.map(r => prices[r].total_available || 0); + const colors = ['#c45c5c', '#a855a8', '#5a8cc8', '#a67c52', '#8b7355', '#6b6560']; + + this.charts.marketSupply = new Chart(supplyCanvas.getContext('2d'), { + type: 'bar', + data: { + labels: labels.map(l => l.charAt(0).toUpperCase() + l.slice(1)), + datasets: [{ label: 'Available', data, backgroundColor: colors.slice(0, labels.length) }], + }, + options: this.getChartOptions('Market Supply'), + }); + } + + // Trade activity over time + const activityCanvas = document.getElementById('chart-market-activity'); + if (activityCanvas) { + if (this.charts.marketActivity) this.charts.marketActivity.destroy(); + + this.charts.marketActivity = new Chart(activityCanvas.getContext('2d'), { + type: 'line', + data: { + labels: this.statsHistory.turns, + datasets: [{ + label: 'Active Orders', + data: this.statsHistory.tradeVolume, + borderColor: '#00ffa3', + backgroundColor: 'rgba(0, 255, 163, 0.1)', + fill: true, + tension: 0.3, + }], + }, + options: this.getChartOptions('Market Activity'), + }); + } + } + + renderAgentStatsCharts() { + if (!this.currentState) return; + + const agents = (this.currentState.agents || []).filter(a => a.is_alive); + const statTypes = [ + { id: 'energy', label: 'Energy', max: 'max_energy', color: '#39ff14' }, + { id: 'hunger', label: 'Hunger', max: 'max_hunger', color: '#ff6600' }, + { id: 'thirst', label: 'Thirst', max: 'max_thirst', color: '#00d4ff' }, + { id: 'heat', label: 'Heat', max: 'max_heat', color: '#ff0099' }, + ]; + + statTypes.forEach(stat => { + const canvas = document.getElementById(`chart-stat-${stat.id}`); + if (!canvas) return; + + if (this.charts[`stat${stat.id}`]) this.charts[`stat${stat.id}`].destroy(); + + const values = agents.map(a => a.stats?.[stat.id] || 0); + const maxVal = agents[0]?.stats?.[stat.max] || 100; + + // Create histogram bins + const bins = 10; + const binSize = maxVal / bins; + const histogram = new Array(bins).fill(0); + values.forEach(v => { + const binIndex = Math.min(Math.floor(v / binSize), bins - 1); + histogram[binIndex]++; + }); + const binLabels = histogram.map((_, i) => `${Math.round(i * binSize)}-${Math.round((i + 1) * binSize)}`); + + this.charts[`stat${stat.id}`] = new Chart(canvas.getContext('2d'), { + type: 'bar', + data: { + labels: binLabels, + datasets: [{ + label: 'Agents', + data: histogram, + backgroundColor: stat.color, + borderWidth: 0, + }], + }, + options: { + ...this.getChartOptions(`${stat.label} Distribution`), + animation: false, + plugins: { ...this.getChartOptions('').plugins, legend: { display: false } }, + }, + }); + }); + } + + getChartOptions(title) { + return { + responsive: true, + maintainAspectRatio: false, + animation: false, // Disable animations for smooth real-time updates + plugins: { + legend: { + position: 'bottom', + labels: { + color: '#a8a095', + font: { family: "'JetBrains Mono', monospace", size: 11 }, + boxWidth: 12, + padding: 15, + }, + }, + title: { + display: true, + text: title, + color: '#e8e4dc', + font: { family: "'Crimson Pro', serif", size: 16, weight: '600' }, + padding: { bottom: 15 }, + }, + }, + scales: { + x: { + ticks: { color: '#6b6560', font: { family: "'JetBrains Mono', monospace", size: 10 } }, + grid: { color: 'rgba(58, 67, 89, 0.3)' }, + }, + y: { + beginAtZero: true, + ticks: { color: '#6b6560', font: { family: "'JetBrains Mono', monospace", size: 10 } }, + grid: { color: 'rgba(58, 67, 89, 0.3)' }, + }, + }, + interaction: { + intersect: false, + mode: 'index', + }, + }; + } + + update(time, delta) { + // Minimal update loop - no heavy operations here + } +} diff --git a/web_frontend/src/scenes/index.js b/web_frontend/src/scenes/index.js new file mode 100644 index 0000000..8028d14 --- /dev/null +++ b/web_frontend/src/scenes/index.js @@ -0,0 +1,7 @@ +/** + * Scene exports + */ + +export { default as BootScene } from './BootScene.js'; +export { default as GameScene } from './GameScene.js'; + diff --git a/web_frontend/styles.css b/web_frontend/styles.css new file mode 100644 index 0000000..f808bf3 --- /dev/null +++ b/web_frontend/styles.css @@ -0,0 +1,1053 @@ +/* VillSim - Dark Medieval Fantasy Theme */ + +:root { + /* Color palette - earthy medieval tones with golden accents */ + --bg-deep: #0d0f14; + --bg-primary: #151921; + --bg-secondary: #1c2230; + --bg-elevated: #242b3d; + --bg-hover: #2d3548; + + --border-color: #3a4359; + --border-light: #4a5673; + + --text-primary: #e8e4dc; + --text-secondary: #a8a095; + --text-muted: #6b6560; + + --accent-gold: #d4a84b; + --accent-gold-dim: #9c7a35; + --accent-copper: #c87f5a; + --accent-emerald: #4a9c6d; + --accent-ruby: #c45c5c; + --accent-sapphire: #5a8cc8; + + /* Profession colors */ + --prof-hunter: #c45c5c; + --prof-gatherer: #6bab5e; + --prof-woodcutter: #a67c52; + --prof-trader: #d4a84b; + --prof-crafter: #8b6fc0; + --prof-villager: #7a8899; + + /* Resource colors */ + --res-meat: #c45c5c; + --res-berries: #a855a8; + --res-water: #5a8cc8; + --res-wood: #a67c52; + --res-hide: #8b7355; + --res-clothes: #6b6560; + + /* Stat colors */ + --stat-energy: #d4a84b; + --stat-hunger: #c87f5a; + --stat-thirst: #5a8cc8; + --stat-heat: #c45c5c; + + /* Spacing */ + --space-xs: 4px; + --space-sm: 8px; + --space-md: 16px; + --space-lg: 24px; + --space-xl: 32px; + + /* Typography */ + --font-display: 'Crimson Pro', Georgia, serif; + --font-mono: 'JetBrains Mono', monospace; + + /* Borders & Shadows */ + --radius-sm: 4px; + --radius-md: 8px; + --radius-lg: 12px; + --shadow-sm: 0 2px 4px rgba(0, 0, 0, 0.3); + --shadow-md: 0 4px 12px rgba(0, 0, 0, 0.4); + --shadow-lg: 0 8px 24px rgba(0, 0, 0, 0.5); +} + +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +html, body { + height: 100%; + overflow: hidden; +} + +body { + font-family: var(--font-display); + background: var(--bg-deep); + color: var(--text-primary); + line-height: 1.5; +} + +#app { + display: flex; + flex-direction: column; + height: 100vh; + background: + radial-gradient(ellipse at 20% 20%, rgba(212, 168, 75, 0.03) 0%, transparent 50%), + radial-gradient(ellipse at 80% 80%, rgba(200, 127, 90, 0.02) 0%, transparent 50%), + var(--bg-deep); +} + +/* Header */ +#header { + display: flex; + align-items: center; + justify-content: space-between; + padding: var(--space-sm) var(--space-lg); + background: var(--bg-primary); + border-bottom: 1px solid var(--border-color); + height: 56px; + flex-shrink: 0; +} + +.header-left { + display: flex; + align-items: baseline; + gap: var(--space-sm); +} + +.title { + font-size: 1.5rem; + font-weight: 700; + color: var(--accent-gold); + letter-spacing: 0.5px; + text-shadow: 0 0 20px rgba(212, 168, 75, 0.3); +} + +.subtitle { + font-size: 0.85rem; + color: var(--text-muted); + font-weight: 400; +} + +.header-center { + display: flex; + align-items: center; +} + +.time-display { + display: flex; + align-items: center; + gap: var(--space-sm); + font-family: var(--font-mono); + font-size: 0.9rem; + color: var(--text-secondary); + background: var(--bg-secondary); + padding: var(--space-xs) var(--space-md); + border-radius: var(--radius-md); + border: 1px solid var(--border-color); +} + +.separator { + color: var(--text-muted); +} + +.header-right { + display: flex; + align-items: center; +} + +.connection-status { + display: flex; + align-items: center; + gap: var(--space-xs); + font-size: 0.8rem; + color: var(--text-secondary); +} + +.status-dot { + width: 8px; + height: 8px; + border-radius: 50%; + transition: background 0.3s; +} + +.status-dot.connected { + background: var(--accent-emerald); + box-shadow: 0 0 8px var(--accent-emerald); +} + +.status-dot.disconnected { + background: var(--accent-ruby); + box-shadow: 0 0 8px var(--accent-ruby); +} + +/* Main Content */ +#main-content { + display: flex; + flex: 1; + overflow: hidden; +} + +/* Panels */ +.panel { + width: 280px; + background: var(--bg-primary); + border: 1px solid var(--border-color); + display: flex; + flex-direction: column; + overflow: hidden; + flex-shrink: 0; +} + +#left-panel { + border-left: none; + border-top: none; + border-bottom: none; +} + +#right-panel { + border-right: none; + border-top: none; + border-bottom: none; +} + +.panel-section { + padding: var(--space-md); + border-bottom: 1px solid var(--border-color); + overflow: hidden; + display: flex; + flex-direction: column; +} + +.panel-section:last-child { + border-bottom: none; + flex: 1; + min-height: 0; +} + +.section-title { + font-size: 0.75rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 1px; + color: var(--text-muted); + margin-bottom: var(--space-sm); +} + +/* Stats Grid */ +.stat-grid { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: var(--space-sm); +} + +.stat-item { + display: flex; + flex-direction: column; + align-items: center; + padding: var(--space-sm); + background: var(--bg-secondary); + border-radius: var(--radius-sm); +} + +.stat-value { + font-family: var(--font-mono); + font-size: 1.5rem; + font-weight: 500; + color: var(--text-primary); +} + +.stat-label { + font-size: 0.7rem; + color: var(--text-muted); + text-transform: uppercase; + letter-spacing: 0.5px; +} + +/* Profession List */ +.profession-list { + display: flex; + flex-direction: column; + gap: var(--space-xs); +} + +.profession-item { + display: flex; + align-items: center; + justify-content: space-between; + padding: var(--space-xs) var(--space-sm); + background: var(--bg-secondary); + border-radius: var(--radius-sm); + font-size: 0.85rem; +} + +.profession-name { + display: flex; + align-items: center; + gap: var(--space-xs); +} + +.profession-icon { + font-size: 1rem; +} + +.profession-count { + font-family: var(--font-mono); + font-weight: 500; + color: var(--text-secondary); +} + +/* Economy Stats */ +.economy-stats { + display: flex; + flex-direction: column; + gap: var(--space-sm); +} + +.economy-item { + display: flex; + justify-content: space-between; + align-items: center; + padding: var(--space-sm); + background: var(--bg-secondary); + border-radius: var(--radius-sm); +} + +.economy-label { + font-size: 0.8rem; + color: var(--text-secondary); +} + +.economy-value { + font-family: var(--font-mono); + font-weight: 500; + color: var(--accent-gold); +} + +/* Agent Details */ +.agent-details { + min-height: 150px; +} + +.agent-details .no-selection { + color: var(--text-muted); + font-style: italic; + font-size: 0.85rem; + text-align: center; + padding: var(--space-lg); +} + +.agent-card { + display: flex; + flex-direction: column; + gap: var(--space-sm); +} + +.agent-header { + display: flex; + align-items: center; + gap: var(--space-sm); + padding-bottom: var(--space-sm); + border-bottom: 1px solid var(--border-color); +} + +.agent-avatar { + width: 40px; + height: 40px; + border-radius: var(--radius-sm); + background: var(--bg-elevated); + display: flex; + align-items: center; + justify-content: center; + font-size: 1.5rem; +} + +.agent-info h4 { + font-size: 1rem; + font-weight: 600; + color: var(--text-primary); +} + +.agent-info .profession { + font-size: 0.75rem; + color: var(--text-secondary); +} + +.agent-stats { + display: flex; + flex-direction: column; + gap: var(--space-xs); +} + +.agent-stat-bar { + display: flex; + flex-direction: column; + gap: 2px; +} + +.agent-stat-bar .stat-header { + display: flex; + justify-content: space-between; + font-size: 0.7rem; + color: var(--text-muted); +} + +.stat-bar { + height: 6px; + background: var(--bg-deep); + border-radius: 3px; + overflow: hidden; +} + +.stat-bar-fill { + height: 100%; + border-radius: 3px; + transition: width 0.3s ease; +} + +.stat-bar-fill.energy { background: var(--stat-energy); } +.stat-bar-fill.hunger { background: var(--stat-hunger); } +.stat-bar-fill.thirst { background: var(--stat-thirst); } +.stat-bar-fill.heat { background: var(--stat-heat); } + +.agent-inventory { + padding-top: var(--space-sm); + border-top: 1px solid var(--border-color); +} + +.agent-inventory h5 { + font-size: 0.7rem; + color: var(--text-muted); + text-transform: uppercase; + margin-bottom: var(--space-xs); +} + +.inventory-grid { + display: flex; + flex-wrap: wrap; + gap: var(--space-xs); +} + +.inventory-item { + display: flex; + align-items: center; + gap: 4px; + padding: 2px 6px; + background: var(--bg-elevated); + border-radius: var(--radius-sm); + font-size: 0.75rem; +} + +.agent-action { + padding-top: var(--space-sm); + border-top: 1px solid var(--border-color); + font-size: 0.8rem; + color: var(--text-secondary); +} + +.agent-action .action-label { + font-size: 0.7rem; + color: var(--text-muted); + text-transform: uppercase; +} + +/* Market Prices */ +.market-prices { + display: flex; + flex-direction: column; + gap: var(--space-xs); +} + +.market-price-item { + display: flex; + justify-content: space-between; + align-items: center; + padding: var(--space-xs) var(--space-sm); + background: var(--bg-secondary); + border-radius: var(--radius-sm); + font-size: 0.8rem; +} + +.market-resource { + display: flex; + align-items: center; + gap: var(--space-xs); +} + +.market-price { + font-family: var(--font-mono); + color: var(--accent-gold); +} + +.market-available { + font-size: 0.7rem; + color: var(--text-muted); + margin-left: var(--space-xs); +} + +/* Activity Log */ +.activity-log { + flex: 1; + overflow-y: auto; + display: flex; + flex-direction: column; + gap: var(--space-xs); + min-height: 150px; + max-height: 250px; + font-size: 0.75rem; +} + +.log-entry { + padding: var(--space-xs) var(--space-sm); + background: var(--bg-secondary); + border-radius: var(--radius-sm); + border-left: 2px solid var(--border-color); +} + +.log-entry.action-hunt { border-left-color: var(--prof-hunter); } +.log-entry.action-gather { border-left-color: var(--prof-gatherer); } +.log-entry.action-chop_wood { border-left-color: var(--prof-woodcutter); } +.log-entry.action-trade { border-left-color: var(--prof-trader); } +.log-entry.action-death { border-left-color: var(--accent-ruby); background: rgba(196, 92, 92, 0.1); } + +.log-agent { + font-weight: 600; + color: var(--text-primary); +} + +.log-action { + color: var(--text-secondary); +} + +/* Game Container */ +#game-container { + flex: 1; + display: flex; + align-items: center; + justify-content: center; + background: var(--bg-secondary); + position: relative; + overflow: hidden; +} + +#game-container canvas { + border-radius: var(--radius-sm); + box-shadow: var(--shadow-lg); +} + +/* Footer Controls */ +#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); + height: 56px; + flex-shrink: 0; +} + +.controls { + display: flex; + gap: var(--space-sm); +} + +.btn { + display: flex; + align-items: center; + gap: var(--space-xs); + padding: var(--space-xs) var(--space-md); + border: 1px solid var(--border-color); + border-radius: var(--radius-sm); + font-family: var(--font-display); + font-size: 0.9rem; + font-weight: 600; + cursor: pointer; + transition: all 0.2s; +} + +.btn-icon { + font-size: 0.8rem; +} + +.btn-primary { + background: var(--accent-gold); + border-color: var(--accent-gold); + color: var(--bg-deep); +} + +.btn-primary:hover { + background: var(--accent-gold-dim); + border-color: var(--accent-gold-dim); +} + +.btn-secondary { + background: var(--bg-secondary); + color: var(--text-primary); +} + +.btn-secondary:hover { + background: var(--bg-elevated); +} + +.btn-toggle { + background: var(--bg-secondary); + color: var(--text-primary); +} + +.btn-toggle.active { + background: var(--accent-emerald); + border-color: var(--accent-emerald); + color: var(--bg-deep); +} + +.btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.speed-control { + display: flex; + align-items: center; + gap: var(--space-sm); + font-size: 0.85rem; + color: var(--text-secondary); +} + +.speed-control label { + font-weight: 500; +} + +#speed-slider { + width: 120px; + height: 4px; + -webkit-appearance: none; + appearance: none; + background: var(--bg-elevated); + border-radius: 2px; + cursor: pointer; +} + +#speed-slider::-webkit-slider-thumb { + -webkit-appearance: none; + appearance: none; + width: 14px; + height: 14px; + border-radius: 50%; + background: var(--accent-gold); + cursor: pointer; +} + +#speed-display { + font-family: var(--font-mono); + min-width: 50px; +} + +/* Scrollbar styling */ +::-webkit-scrollbar { + width: 6px; +} + +::-webkit-scrollbar-track { + background: var(--bg-secondary); +} + +::-webkit-scrollbar-thumb { + background: var(--border-color); + border-radius: 3px; +} + +::-webkit-scrollbar-thumb:hover { + background: var(--border-light); +} + +/* Animations */ +@keyframes pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.5; } +} + +.loading { + animation: pulse 1.5s ease-in-out infinite; +} + +/* Enhanced Agent Details */ +.agent-section { + display: flex; + flex-direction: column; + min-height: 0; + flex: 1 1 auto; +} + +.scrollable-section { + overflow-y: auto; + max-height: calc(100vh - 400px); +} + +.agent-details { + min-height: auto; + overflow: visible; +} + +.agent-money { + display: flex; + align-items: center; + gap: var(--space-xs); + padding: var(--space-sm); + background: linear-gradient(135deg, rgba(212, 168, 75, 0.15), rgba(212, 168, 75, 0.05)); + border: 1px solid var(--accent-gold-dim); + border-radius: var(--radius-sm); + margin-top: var(--space-sm); +} + +.agent-money .money-icon { + font-size: 1.2rem; +} + +.agent-money .money-value { + font-family: var(--font-mono); + font-size: 1.1rem; + font-weight: 600; + color: var(--accent-gold); +} + +.agent-money .money-label { + font-size: 0.75rem; + color: var(--text-muted); + margin-left: auto; +} + +.subsection-title { + font-size: 0.7rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.5px; + color: var(--text-muted); + margin: var(--space-sm) 0 var(--space-xs); + padding-top: var(--space-sm); + border-top: 1px solid var(--border-color); +} + +/* Agent Personal Log */ +.agent-log { + overflow-y: auto; + display: flex; + flex-direction: column; + gap: 2px; + max-height: 180px; + font-size: 0.7rem; + margin-top: var(--space-xs); +} + +.agent-log-entry { + display: flex; + align-items: flex-start; + gap: var(--space-xs); + padding: 3px 6px; + background: var(--bg-secondary); + border-radius: var(--radius-sm); + border-left: 2px solid var(--border-color); +} + +.agent-log-entry.success { + border-left-color: var(--accent-emerald); +} + +.agent-log-entry.failure { + border-left-color: var(--accent-ruby); + background: rgba(196, 92, 92, 0.08); +} + +.agent-log-turn { + font-family: var(--font-mono); + font-size: 0.65rem; + color: var(--text-muted); + min-width: 24px; +} + +.agent-log-action { + font-weight: 500; + color: var(--text-secondary); +} + +.agent-log-result { + color: var(--text-muted); + flex: 1; + word-break: break-word; +} + +/* Skills Display */ +.agent-skills { + display: flex; + flex-wrap: wrap; + gap: var(--space-xs); + margin-top: var(--space-xs); +} + +.skill-badge { + display: flex; + align-items: center; + gap: 3px; + padding: 2px 6px; + background: var(--bg-elevated); + border-radius: var(--radius-sm); + font-size: 0.7rem; +} + +.skill-badge .skill-name { + color: var(--text-secondary); +} + +.skill-badge .skill-value { + font-family: var(--font-mono); + color: var(--accent-sapphire); + font-weight: 500; +} + +/* Stats Screen (Full View) */ +.stats-screen { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + z-index: 1000; + display: flex; + flex-direction: column; + background: var(--bg-deep); +} + +.stats-screen.hidden { + display: none; +} + +.stats-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: var(--space-sm) var(--space-lg); + background: var(--bg-primary); + border-bottom: 1px solid var(--border-color); + height: 56px; + flex-shrink: 0; +} + +.stats-header-left { + display: flex; + align-items: baseline; + gap: var(--space-sm); +} + +.stats-header-left h2 { + font-size: 1.3rem; + font-weight: 700; + color: var(--accent-gold); +} + +.stats-subtitle { + font-size: 0.8rem; + color: var(--text-muted); +} + +.stats-header-center { + flex: 1; + display: flex; + justify-content: center; +} + +.stats-header-right { + display: flex; + align-items: center; +} + +/* Stats Tabs */ +.stats-tabs { + display: flex; + gap: var(--space-xs); + background: var(--bg-secondary); + padding: var(--space-xs); + border-radius: var(--radius-md); + border: 1px solid var(--border-color); +} + +.tab-btn { + padding: var(--space-xs) var(--space-md); + background: transparent; + border: none; + border-radius: var(--radius-sm); + font-family: var(--font-display); + font-size: 0.85rem; + font-weight: 500; + color: var(--text-secondary); + cursor: pointer; + transition: all 0.2s; +} + +.tab-btn:hover { + color: var(--text-primary); + background: var(--bg-hover); +} + +.tab-btn.active { + color: var(--bg-deep); + background: var(--accent-gold); +} + +.stats-body { + flex: 1; + overflow-y: auto; + padding: var(--space-lg); +} + +.tab-panel { + display: none; + height: 100%; +} + +.tab-panel.active { + display: block; +} + +/* Chart Containers */ +.chart-wrapper { + background: var(--bg-primary); + border: 1px solid var(--border-color); + border-radius: var(--radius-md); + padding: var(--space-md); + height: calc(100vh - 200px); + min-height: 400px; +} + +.chart-grid { + display: grid; + gap: var(--space-md); + height: calc(100vh - 200px); +} + +.chart-grid.two-col { + grid-template-columns: 1fr 1fr; +} + +.chart-grid.three-col { + grid-template-columns: repeat(3, 1fr); +} + +.chart-grid.four-col { + grid-template-columns: repeat(2, 1fr); + grid-template-rows: repeat(2, 1fr); +} + +.chart-grid .chart-wrapper { + height: 100%; + min-height: 280px; +} + +/* Stats Footer */ +.stats-footer { + padding: var(--space-sm) var(--space-lg); + background: var(--bg-primary); + border-top: 1px solid var(--border-color); + flex-shrink: 0; +} + +.stats-summary-bar { + display: flex; + justify-content: center; + gap: var(--space-xl); +} + +.summary-item { + display: flex; + flex-direction: column; + align-items: center; + gap: 2px; +} + +.summary-label { + font-size: 0.7rem; + color: var(--text-muted); + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.summary-value { + font-family: var(--font-mono); + font-size: 1.1rem; + font-weight: 600; + color: var(--text-primary); +} + +.summary-value.highlight { + color: var(--accent-emerald); +} + +.summary-value.danger { + color: var(--accent-ruby); +} + +.summary-value.gold { + color: var(--accent-gold); +} + +/* Hidden utility */ +.hidden { + display: none !important; +} + +/* Responsive adjustments */ +@media (max-width: 1200px) { + .panel { + width: 240px; + } +} + +@media (max-width: 900px) { + #main-content { + flex-direction: column; + } + + .panel { + width: 100%; + max-height: 200px; + } + + #left-panel, #right-panel { + flex-direction: row; + overflow-x: auto; + } + + .panel-section { + min-width: 200px; + border-bottom: none; + border-right: 1px solid var(--border-color); + } + + .stats-header { + flex-wrap: wrap; + height: auto; + gap: var(--space-sm); + } + + .stats-header-center { + order: 3; + width: 100%; + } + + .stats-tabs { + width: 100%; + justify-content: center; + flex-wrap: wrap; + } + + .chart-grid.two-col, + .chart-grid.three-col, + .chart-grid.four-col { + grid-template-columns: 1fr; + } + + .chart-wrapper { + height: 350px; + min-height: 300px; + } + + .stats-summary-bar { + flex-wrap: wrap; + gap: var(--space-md); + } +} +