[new] web-based frontend
This commit is contained in:
parent
1423fc0dc9
commit
67dc007283
@ -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 ==============
|
||||
|
||||
@ -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,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@ -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(),
|
||||
}
|
||||
|
||||
@ -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(
|
||||
|
||||
279
web_frontend/index.html
Normal file
279
web_frontend/index.html
Normal file
@ -0,0 +1,279 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>VillSim - Village Economy Simulation</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Crimson+Pro:wght@400;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="styles.css">
|
||||
<script src="https://cdn.jsdelivr.net/npm/phaser@3.80.1/dist/phaser.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.min.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app">
|
||||
<header id="header">
|
||||
<div class="header-left">
|
||||
<h1 class="title">VillSim</h1>
|
||||
<span class="subtitle">Village Economy Simulation</span>
|
||||
</div>
|
||||
<div class="header-center">
|
||||
<div class="time-display">
|
||||
<span id="day-display">Day 1</span>
|
||||
<span class="separator">·</span>
|
||||
<span id="time-display">☀️ Day</span>
|
||||
<span class="separator">·</span>
|
||||
<span id="turn-display">Turn 0</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="header-right">
|
||||
<div class="connection-status" id="connection-status">
|
||||
<span class="status-dot disconnected"></span>
|
||||
<span class="status-text">Disconnected</span>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main id="main-content">
|
||||
<aside id="left-panel" class="panel">
|
||||
<div class="panel-section">
|
||||
<h3 class="section-title">Population</h3>
|
||||
<div class="stat-grid" id="population-stats">
|
||||
<div class="stat-item">
|
||||
<span class="stat-value" id="stat-alive">0</span>
|
||||
<span class="stat-label">Alive</span>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<span class="stat-value" id="stat-dead">0</span>
|
||||
<span class="stat-label">Dead</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="panel-section">
|
||||
<h3 class="section-title">Professions</h3>
|
||||
<div class="profession-list" id="profession-list">
|
||||
<!-- Filled by JS -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="panel-section">
|
||||
<h3 class="section-title">Economy</h3>
|
||||
<div class="economy-stats" id="economy-stats">
|
||||
<div class="economy-item">
|
||||
<span class="economy-label">Money in Circulation</span>
|
||||
<span class="economy-value" id="stat-money">0</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<div id="game-container">
|
||||
<!-- Phaser canvas will be inserted here -->
|
||||
</div>
|
||||
|
||||
<aside id="right-panel" class="panel">
|
||||
<div class="panel-section agent-section scrollable-section">
|
||||
<h3 class="section-title">Selected Agent</h3>
|
||||
<div id="agent-details" class="agent-details">
|
||||
<p class="no-selection">Click an agent to view details</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="panel-section">
|
||||
<h3 class="section-title">Market Prices</h3>
|
||||
<div class="market-prices" id="market-prices">
|
||||
<!-- Filled by JS -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="panel-section">
|
||||
<h3 class="section-title">Activity Log</h3>
|
||||
<div class="activity-log" id="activity-log">
|
||||
<!-- Filled by JS -->
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</main>
|
||||
|
||||
<footer id="footer">
|
||||
<div class="controls">
|
||||
<button id="btn-initialize" class="btn btn-secondary" title="Reset Simulation">
|
||||
<span class="btn-icon">⟳</span> Reset
|
||||
</button>
|
||||
<button id="btn-step" class="btn btn-primary" title="Advance one turn">
|
||||
<span class="btn-icon">▶</span> Step
|
||||
</button>
|
||||
<button id="btn-auto" class="btn btn-toggle" title="Toggle auto mode">
|
||||
<span class="btn-icon">⏯</span> Auto
|
||||
</button>
|
||||
<button id="btn-stats" class="btn btn-secondary" title="View Statistics">
|
||||
<span class="btn-icon">📊</span> Stats
|
||||
</button>
|
||||
</div>
|
||||
<div class="speed-control">
|
||||
<label for="speed-slider">Speed</label>
|
||||
<input type="range" id="speed-slider" min="50" max="1000" value="150" step="50">
|
||||
<span id="speed-display">150ms</span>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<!-- Stats Screen (Full View) -->
|
||||
<div id="stats-screen" class="stats-screen hidden">
|
||||
<div class="stats-header">
|
||||
<div class="stats-header-left">
|
||||
<h2>📊 Simulation Statistics</h2>
|
||||
<span class="stats-subtitle">Real-time metrics and charts</span>
|
||||
</div>
|
||||
<div class="stats-header-center">
|
||||
<div class="stats-tabs">
|
||||
<button class="tab-btn active" data-tab="prices">Prices</button>
|
||||
<button class="tab-btn" data-tab="wealth">Wealth</button>
|
||||
<button class="tab-btn" data-tab="population">Population</button>
|
||||
<button class="tab-btn" data-tab="professions">Professions</button>
|
||||
<button class="tab-btn" data-tab="resources">Resources</button>
|
||||
<button class="tab-btn" data-tab="market">Market</button>
|
||||
<button class="tab-btn" data-tab="agents">Agents</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stats-header-right">
|
||||
<button id="btn-close-stats" class="btn btn-secondary">
|
||||
<span class="btn-icon">◀</span> Back to Game
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stats-body">
|
||||
<!-- Prices Tab -->
|
||||
<div id="tab-prices" class="tab-panel active">
|
||||
<div class="chart-wrapper">
|
||||
<canvas id="chart-prices"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Wealth Tab -->
|
||||
<div id="tab-wealth" class="tab-panel">
|
||||
<div class="chart-grid three-col">
|
||||
<div class="chart-wrapper">
|
||||
<canvas id="chart-wealth-dist"></canvas>
|
||||
</div>
|
||||
<div class="chart-wrapper">
|
||||
<canvas id="chart-wealth-prof"></canvas>
|
||||
</div>
|
||||
<div class="chart-wrapper">
|
||||
<canvas id="chart-wealth-time"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Population Tab -->
|
||||
<div id="tab-population" class="tab-panel">
|
||||
<div class="chart-wrapper">
|
||||
<canvas id="chart-population"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Professions Tab -->
|
||||
<div id="tab-professions" class="tab-panel">
|
||||
<div class="chart-grid two-col">
|
||||
<div class="chart-wrapper">
|
||||
<canvas id="chart-prof-pie"></canvas>
|
||||
</div>
|
||||
<div class="chart-wrapper">
|
||||
<canvas id="chart-prof-time"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Resources Tab -->
|
||||
<div id="tab-resources" class="tab-panel">
|
||||
<div class="chart-grid four-col">
|
||||
<div class="chart-wrapper">
|
||||
<canvas id="chart-res-produced"></canvas>
|
||||
</div>
|
||||
<div class="chart-wrapper">
|
||||
<canvas id="chart-res-consumed"></canvas>
|
||||
</div>
|
||||
<div class="chart-wrapper">
|
||||
<canvas id="chart-res-spoiled"></canvas>
|
||||
</div>
|
||||
<div class="chart-wrapper">
|
||||
<canvas id="chart-res-stock"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
<div class="chart-grid four-col" style="margin-top: 16px;">
|
||||
<div class="chart-wrapper">
|
||||
<canvas id="chart-res-cum-produced"></canvas>
|
||||
</div>
|
||||
<div class="chart-wrapper">
|
||||
<canvas id="chart-res-cum-consumed"></canvas>
|
||||
</div>
|
||||
<div class="chart-wrapper">
|
||||
<canvas id="chart-res-cum-spoiled"></canvas>
|
||||
</div>
|
||||
<div class="chart-wrapper">
|
||||
<canvas id="chart-res-cum-traded"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Market Tab -->
|
||||
<div id="tab-market" class="tab-panel">
|
||||
<div class="chart-grid two-col">
|
||||
<div class="chart-wrapper">
|
||||
<canvas id="chart-market-supply"></canvas>
|
||||
</div>
|
||||
<div class="chart-wrapper">
|
||||
<canvas id="chart-market-activity"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Agents Tab -->
|
||||
<div id="tab-agents" class="tab-panel">
|
||||
<div class="chart-grid four-col">
|
||||
<div class="chart-wrapper">
|
||||
<canvas id="chart-stat-energy"></canvas>
|
||||
</div>
|
||||
<div class="chart-wrapper">
|
||||
<canvas id="chart-stat-hunger"></canvas>
|
||||
</div>
|
||||
<div class="chart-wrapper">
|
||||
<canvas id="chart-stat-thirst"></canvas>
|
||||
</div>
|
||||
<div class="chart-wrapper">
|
||||
<canvas id="chart-stat-heat"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stats-footer">
|
||||
<div class="stats-summary-bar">
|
||||
<div class="summary-item">
|
||||
<span class="summary-label">Turn</span>
|
||||
<span class="summary-value" id="stats-turn">0</span>
|
||||
</div>
|
||||
<div class="summary-item">
|
||||
<span class="summary-label">Living</span>
|
||||
<span class="summary-value highlight" id="stats-living">0</span>
|
||||
</div>
|
||||
<div class="summary-item">
|
||||
<span class="summary-label">Deaths</span>
|
||||
<span class="summary-value danger" id="stats-deaths">0</span>
|
||||
</div>
|
||||
<div class="summary-item">
|
||||
<span class="summary-label">Total Gold</span>
|
||||
<span class="summary-value gold" id="stats-gold">0</span>
|
||||
</div>
|
||||
<div class="summary-item">
|
||||
<span class="summary-label">Avg Wealth</span>
|
||||
<span class="summary-value" id="stats-avg-wealth">0</span>
|
||||
</div>
|
||||
<div class="summary-item">
|
||||
<span class="summary-label">Gini Index</span>
|
||||
<span class="summary-value" id="stats-gini">0.00</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Load game modules -->
|
||||
<script type="module" src="src/main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
132
web_frontend/src/api.js
Normal file
132
web_frontend/src/api.js
Normal file
@ -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;
|
||||
|
||||
71
web_frontend/src/constants.js
Normal file
71
web_frontend/src/constants.js
Normal file
@ -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,
|
||||
};
|
||||
|
||||
73
web_frontend/src/main.js
Normal file
73
web_frontend/src/main.js
Normal file
@ -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 };
|
||||
|
||||
141
web_frontend/src/scenes/BootScene.js
Normal file
141
web_frontend/src/scenes/BootScene.js
Normal file
@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
1633
web_frontend/src/scenes/GameScene.js
Normal file
1633
web_frontend/src/scenes/GameScene.js
Normal file
File diff suppressed because it is too large
Load Diff
7
web_frontend/src/scenes/index.js
Normal file
7
web_frontend/src/scenes/index.js
Normal file
@ -0,0 +1,7 @@
|
||||
/**
|
||||
* Scene exports
|
||||
*/
|
||||
|
||||
export { default as BootScene } from './BootScene.js';
|
||||
export { default as GameScene } from './GameScene.js';
|
||||
|
||||
1053
web_frontend/styles.css
Normal file
1053
web_frontend/styles.css
Normal file
File diff suppressed because it is too large
Load Diff
Loading…
x
Reference in New Issue
Block a user