villsim/backend/core/engine.py

638 lines
24 KiB
Python

"""Game Engine for the Village Simulation."""
import random
import threading
import time
from dataclasses import dataclass, field
from enum import Enum
from typing import Optional
from backend.domain.agent import Agent
from backend.domain.action import ActionType, ActionResult, ACTION_CONFIG
from backend.domain.resources import Resource, ResourceType
from backend.core.world import World, WorldConfig, TimeOfDay
from backend.core.market import OrderBook
from backend.core.ai import get_ai_decision, AIDecision
from backend.core.logger import get_simulation_logger, reset_simulation_logger
from backend.config import get_config
class SimulationMode(Enum):
"""Simulation run mode."""
MANUAL = "manual" # Wait for explicit next_step call
AUTO = "auto" # Run automatically with timer
@dataclass
class TurnLog:
"""Log of events that happened in a turn."""
turn: int
agent_actions: list[dict] = field(default_factory=list)
deaths: list[str] = field(default_factory=list)
trades: list[dict] = field(default_factory=list)
def to_dict(self) -> dict:
return {
"turn": self.turn,
"agent_actions": self.agent_actions,
"deaths": self.deaths,
"trades": self.trades,
}
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
self.is_running = False
self.auto_step_interval = 1.0 # seconds
self._auto_thread: Optional[threading.Thread] = None
self._stop_event = threading.Event()
self.turn_logs: list[TurnLog] = []
self.logger = get_simulation_logger()
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 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: int = 8) -> None:
"""Initialize the simulation with agents."""
self.world.config.initial_agents = num_agents
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,
day=self.world.current_day,
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():
# Log agent state before
self.logger.log_agent_before(
agent_id=agent.id,
agent_name=agent.name,
profession=agent.profession.value,
position=agent.position.to_dict(),
stats=agent.stats.to_dict(),
inventory=[r.to_dict() for r in agent.inventory],
money=agent.money,
)
if self.world.is_night():
# Force sleep at night
decision = AIDecision(
action=ActionType.SLEEP,
reason="Night time: sleeping",
)
else:
# Pass time info so AI can prepare for night
decision = get_ai_decision(
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
agent.set_action(
action_type=action_name,
world_width=self.world.config.width,
world_height=self.world.config.height,
message=decision.reason,
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)
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,
stats=agent.stats.to_dict(),
inventory=[r.to_dict() for r in agent.inventory],
money=agent.money,
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)
# 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:
cause = dead_agent.death_reason
self.logger.log_death(dead_agent.name, cause)
# 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 = []
for agent in self.world.agents:
if not agent.is_alive() and not agent.is_corpse():
# Agent just died this turn
cause = agent.stats.get_critical_stat() or "unknown"
agent.mark_dead(current_turn, cause)
# Clear their action to show death state
agent.current_action.action_type = "dead"
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 = []
for agent in self.world.agents:
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)
return ActionResult(
action_type=action,
success=True,
energy_spent=-config.energy_cost,
message="Sleeping soundly",
)
elif action == ActionType.REST:
agent.restore_energy(config.energy_cost)
return ActionResult(
action_type=action,
success=True,
energy_spent=-config.energy_cost,
message="Resting",
)
elif action == ActionType.CONSUME:
if decision.target_resource:
success = agent.consume(decision.target_resource)
return ActionResult(
action_type=action,
success=success,
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)
if not agent.spend_energy(abs(config.energy_cost)):
return ActionResult(action_type=action, success=False, message="Not enough energy")
# Fire heat from config
from backend.domain.resources import get_fire_heat
fire_heat = get_fire_heat()
agent.apply_heat(fire_heat)
return ActionResult(
action_type=action,
success=True,
energy_spent=abs(config.energy_cost),
heat_gained=fire_heat,
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,
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.)."""
# Check energy
energy_cost = abs(config.energy_cost)
if not agent.spend_energy(energy_cost):
return ActionResult(
action_type=action,
success=False,
message="Not enough energy",
)
# Check required materials
if config.requires_resource:
if not agent.has_resource(config.requires_resource, config.requires_quantity):
agent.restore_energy(energy_cost) # Refund energy
return ActionResult(
action_type=action,
success=False,
message=f"Missing required {config.requires_resource.value}",
)
agent.remove_from_inventory(config.requires_resource, config.requires_quantity)
# Check success chance
if random.random() > config.success_chance:
return ActionResult(
action_type=action,
success=False,
energy_spent=energy_cost,
message="Action failed",
)
# Generate output
resources_gained = []
if config.output_resource:
quantity = random.randint(config.min_output, config.max_output)
if quantity > 0:
resource = Resource(
type=config.output_resource,
quantity=quantity,
created_turn=self.world.current_turn,
)
added = agent.add_to_inventory(resource)
if added > 0:
resources_gained.append(Resource(
type=config.output_resource,
quantity=added,
created_turn=self.world.current_turn,
))
# Secondary output (e.g., hide from hunting)
if config.secondary_output:
quantity = random.randint(config.secondary_min, config.secondary_max)
if quantity > 0:
resource = Resource(
type=config.secondary_output,
quantity=quantity,
created_turn=self.world.current_turn,
)
added = agent.add_to_inventory(resource)
if added > 0:
resources_gained.append(Resource(
type=config.secondary_output,
quantity=added,
created_turn=self.world.current_turn,
))
# 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,
message=message,
)
def _execute_trade(self, agent: Agent, decision: AIDecision) -> ActionResult:
"""Execute a trade action (buy, sell, or price adjustment). Supports multi-item trades."""
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(
buyer_id=agent.id,
order_id=decision.order_id,
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,
result.total_paid // result.quantity,
result.quantity,
self.world.current_turn,
)
# Deduct money from buyer
agent.money -= result.total_paid
# Add resources to buyer
resource = Resource(
type=result.resource_type,
quantity=result.quantity,
created_turn=self.world.current_turn,
)
agent.add_to_inventory(resource)
# Add money to seller
seller = self.world.get_agent(result.seller_id)
if seller:
seller.money += result.total_paid
agent.spend_energy(abs(config.energy_cost))
return ActionResult(
action_type=ActionType.TRADE,
success=True,
energy_spent=abs(config.energy_cost),
resources_gained=[resource],
message=f"Bought {result.quantity} {result.resource_type.value} for {result.total_paid}c",
)
else:
return ActionResult(
action_type=ActionType.TRADE,
success=False,
message=result.message,
)
elif decision.target_resource and decision.quantity > 0:
# Selling to market
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,
quantity=decision.quantity,
price_per_unit=decision.price,
current_turn=self.world.current_turn,
)
agent.spend_energy(abs(config.energy_cost))
return ActionResult(
action_type=ActionType.TRADE,
success=True,
energy_spent=abs(config.energy_cost),
message=f"Listed {decision.quantity} {decision.target_resource.value} @ {decision.price}c each",
)
else:
return ActionResult(
action_type=ActionType.TRADE,
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(
order_id=decision.adjust_order_id,
seller_id=agent.id,
new_price=decision.new_price,
current_turn=self.world.current_turn,
)
if success:
return ActionResult(
action_type=ActionType.TRADE,
success=True,
energy_spent=0, # Price adjustments are free
message=f"Adjusted {decision.target_resource.value} price to {decision.new_price}c",
)
else:
return ActionResult(
action_type=ActionType.TRADE,
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,
self.world.current_turn
)
resource = Resource(
type=result.resource_type,
quantity=result.quantity,
created_turn=self.world.current_turn,
)
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"
return ActionResult(
action_type=ActionType.TRADE,
success=True,
energy_spent=abs(config.energy_cost),
resources_gained=resources_gained,
message=message,
)
else:
return ActionResult(
action_type=ActionType.TRADE,
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 {
**self.world.get_state_snapshot(),
"market": self.market.get_state_snapshot(),
"mode": self.mode.value,
"is_running": self.is_running,
"recent_logs": [
log.to_dict() for log in self.turn_logs[-5:]
],
}
# Global engine instance
def get_engine() -> GameEngine:
"""Get the global game engine instance."""
return GameEngine()