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
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
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 ``;
+ };
+
+ // 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 = `
+
+
+ 💰
+ ${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);
+ }
+}
+