Compare commits

...

1 Commits

Author SHA1 Message Date
Снесарев Максим
67dc007283 [new] web-based frontend 2026-01-19 19:44:38 +03:00
12 changed files with 3712 additions and 170 deletions

View File

@ -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 ==============

View File

@ -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,
}

View File

@ -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(),
}

View File

@ -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
View 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
View 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;

View 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
View 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 };

View 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));
}
}

File diff suppressed because it is too large Load Diff

View 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

File diff suppressed because it is too large Load Diff