Compare commits
1 Commits
master
...
war-and-pe
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cfd6c87f86 |
@ -28,10 +28,12 @@ class StatsSchema(BaseModel):
|
||||
hunger: int
|
||||
thirst: int
|
||||
heat: int
|
||||
faith: int = 50
|
||||
max_energy: int
|
||||
max_hunger: int
|
||||
max_thirst: int
|
||||
max_heat: int
|
||||
max_faith: int = 100
|
||||
|
||||
|
||||
class AgentActionSchema(BaseModel):
|
||||
@ -44,6 +46,28 @@ class AgentActionSchema(BaseModel):
|
||||
message: str
|
||||
|
||||
|
||||
class ReligionSchema(BaseModel):
|
||||
"""Schema for agent religion data."""
|
||||
religion: str
|
||||
faith: int
|
||||
is_zealot: bool = False
|
||||
times_converted: int = 0
|
||||
converts_made: int = 0
|
||||
description: str = ""
|
||||
|
||||
|
||||
class DiplomacySchema(BaseModel):
|
||||
"""Schema for agent diplomacy data."""
|
||||
faction: str
|
||||
faction_description: str = ""
|
||||
faction_color: str = "#808080"
|
||||
diplomacy_skill: float = 0.5
|
||||
aggression: float = 0.3
|
||||
negotiations_conducted: int = 0
|
||||
wars_declared: int = 0
|
||||
peace_treaties_made: int = 0
|
||||
|
||||
|
||||
class AgentResponse(BaseModel):
|
||||
"""Schema for agent data."""
|
||||
id: str
|
||||
@ -58,6 +82,9 @@ class AgentResponse(BaseModel):
|
||||
can_act: bool
|
||||
current_action: AgentActionSchema
|
||||
last_action_result: str
|
||||
# Religion and diplomacy
|
||||
religion: Optional[ReligionSchema] = None
|
||||
diplomacy: Optional[DiplomacySchema] = None
|
||||
|
||||
|
||||
# ============== Market Schemas ==============
|
||||
@ -121,12 +148,6 @@ 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):
|
||||
@ -143,18 +164,6 @@ 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):
|
||||
@ -170,7 +179,6 @@ class WorldStateResponse(BaseModel):
|
||||
mode: str
|
||||
is_running: bool
|
||||
recent_logs: list[TurnLogSchema]
|
||||
resource_stats: ResourceStatsSchema = ResourceStatsSchema()
|
||||
|
||||
|
||||
# ============== Control Schemas ==============
|
||||
|
||||
@ -12,41 +12,49 @@ class AgentStatsConfig:
|
||||
# Maximum values
|
||||
max_energy: int = 50
|
||||
max_hunger: int = 100
|
||||
max_thirst: int = 100 # Increased from 50 to give more buffer
|
||||
max_thirst: int = 100
|
||||
max_heat: int = 100
|
||||
max_faith: int = 100 # NEW: Religious faith level
|
||||
|
||||
# Starting values
|
||||
start_energy: int = 50
|
||||
start_hunger: int = 80
|
||||
start_thirst: int = 80 # Increased from 40 to start with more buffer
|
||||
start_thirst: int = 80
|
||||
start_heat: int = 100
|
||||
start_faith: int = 50 # NEW: Start with moderate faith
|
||||
|
||||
# Decay rates per turn
|
||||
energy_decay: int = 2
|
||||
hunger_decay: int = 2
|
||||
thirst_decay: int = 2 # Reduced from 3 to match hunger decay rate
|
||||
thirst_decay: int = 2
|
||||
heat_decay: int = 2
|
||||
faith_decay: int = 1 # NEW: Faith decays slowly without religious activity
|
||||
|
||||
# Thresholds
|
||||
critical_threshold: float = 0.25 # 25% triggers survival mode
|
||||
low_energy_threshold: int = 15 # Minimum energy to work
|
||||
critical_threshold: float = 0.25
|
||||
low_energy_threshold: int = 15
|
||||
|
||||
|
||||
@dataclass
|
||||
class ResourceConfig:
|
||||
"""Configuration for resource properties."""
|
||||
# Decay rates (turns until spoilage, 0 = infinite)
|
||||
meat_decay: int = 8 # Increased from 5 to give more time to use
|
||||
meat_decay: int = 8
|
||||
berries_decay: int = 25
|
||||
clothes_decay: int = 50
|
||||
oil_decay: int = 0 # NEW: Oil doesn't decay
|
||||
fuel_decay: int = 0 # NEW: Refined fuel doesn't decay
|
||||
|
||||
# Resource effects
|
||||
meat_hunger: int = 30
|
||||
meat_energy: int = 5
|
||||
berries_hunger: int = 8 # Increased from 5
|
||||
berries_thirst: int = 3 # Increased from 2
|
||||
water_thirst: int = 50 # Increased from 40 for better thirst recovery
|
||||
fire_heat: int = 15 # Increased from 10
|
||||
berries_hunger: int = 8
|
||||
berries_thirst: int = 3
|
||||
water_thirst: int = 50
|
||||
fire_heat: int = 15
|
||||
fuel_heat: int = 35 # NEW: Fuel provides more heat than wood
|
||||
oil_energy: int = 0 # NEW: Raw oil has no direct use
|
||||
fuel_energy: int = 8 # NEW: Refined fuel provides energy
|
||||
|
||||
|
||||
@dataclass
|
||||
@ -63,9 +71,23 @@ class ActionConfig:
|
||||
build_fire_energy: int = -5
|
||||
trade_energy: int = -1
|
||||
|
||||
# NEW: Oil industry actions
|
||||
drill_oil_energy: int = -10
|
||||
refine_energy: int = -8
|
||||
|
||||
# NEW: Religious actions
|
||||
pray_energy: int = -2
|
||||
preach_energy: int = -4
|
||||
|
||||
# NEW: Diplomatic actions
|
||||
negotiate_energy: int = -3
|
||||
declare_war_energy: int = -5
|
||||
make_peace_energy: int = -3
|
||||
|
||||
# Success chances (0.0 to 1.0)
|
||||
hunt_success: float = 0.7
|
||||
chop_wood_success: float = 0.9
|
||||
drill_oil_success: float = 0.6 # NEW: Harder to extract oil
|
||||
|
||||
# Output quantities
|
||||
hunt_meat_min: int = 1
|
||||
@ -77,6 +99,15 @@ class ActionConfig:
|
||||
chop_wood_min: int = 1
|
||||
chop_wood_max: int = 2
|
||||
|
||||
# NEW: Oil output
|
||||
drill_oil_min: int = 1
|
||||
drill_oil_max: int = 3
|
||||
|
||||
# NEW: Religious action effects
|
||||
pray_faith_gain: int = 25
|
||||
preach_faith_spread: int = 15
|
||||
preach_convert_chance: float = 0.15
|
||||
|
||||
|
||||
@dataclass
|
||||
class WorldConfig:
|
||||
@ -91,41 +122,58 @@ class WorldConfig:
|
||||
inventory_slots: int = 10
|
||||
starting_money: int = 100
|
||||
|
||||
# NEW: World features
|
||||
oil_fields_count: int = 3 # Number of oil field locations
|
||||
temple_count: int = 2 # Number of temple/religious locations
|
||||
|
||||
|
||||
@dataclass
|
||||
class MarketConfig:
|
||||
"""Configuration for market behavior."""
|
||||
turns_before_discount: int = 3
|
||||
discount_rate: float = 0.15 # 15% discount after waiting
|
||||
base_price_multiplier: float = 1.2 # Markup over production cost
|
||||
discount_rate: float = 0.15
|
||||
base_price_multiplier: float = 1.2
|
||||
|
||||
|
||||
@dataclass
|
||||
class EconomyConfig:
|
||||
"""Configuration for economic behavior and agent trading.
|
||||
|
||||
These values control how agents perceive the value of money and trading.
|
||||
Higher values make agents more trade-oriented.
|
||||
"""
|
||||
# How much agents value money vs energy
|
||||
# Higher = agents see money as more valuable (trade more)
|
||||
energy_to_money_ratio: float = 1.5 # 1 energy ≈ 1.5 coins
|
||||
|
||||
# How strongly agents desire wealth (0-1)
|
||||
# Higher = agents will prioritize building wealth
|
||||
"""Configuration for economic behavior and agent trading."""
|
||||
energy_to_money_ratio: float = 1.5
|
||||
wealth_desire: float = 0.3
|
||||
|
||||
# Buy efficiency threshold (0-1)
|
||||
# If market price < (threshold * fair_value), buy instead of gather
|
||||
# 0.7 means: buy if price is 70% or less of the fair value
|
||||
buy_efficiency_threshold: float = 0.7
|
||||
|
||||
# Minimum wealth target - agents want at least this much money
|
||||
min_wealth_target: int = 50
|
||||
max_price_markup: float = 2.0
|
||||
min_price_discount: float = 0.5
|
||||
|
||||
# Price adjustment limits
|
||||
max_price_markup: float = 2.0 # Maximum price = 2x base value
|
||||
min_price_discount: float = 0.5 # Minimum price = 50% of base value
|
||||
# NEW: Oil economy
|
||||
oil_base_price: int = 25 # Oil is valuable
|
||||
fuel_base_price: int = 40 # Refined fuel is more valuable
|
||||
|
||||
|
||||
@dataclass
|
||||
class ReligionConfig:
|
||||
"""Configuration for religion system."""
|
||||
num_religions: int = 3 # Number of different religions
|
||||
conversion_resistance: float = 0.5 # How hard to convert agents
|
||||
zealot_threshold: float = 0.80 # Faith level for zealot behavior
|
||||
faith_trade_bonus: float = 0.10 # Bonus when trading with same religion
|
||||
same_religion_bonus: float = 0.15 # General bonus with same religion
|
||||
different_religion_penalty: float = 0.10 # Penalty with different religion
|
||||
holy_war_threshold: float = 0.90 # Faith level to trigger religious conflict
|
||||
|
||||
|
||||
@dataclass
|
||||
class DiplomacyConfig:
|
||||
"""Configuration for diplomacy and faction system."""
|
||||
num_factions: int = 4 # Number of factions
|
||||
starting_relations: int = 50 # Neutral starting relations (0-100)
|
||||
alliance_threshold: int = 75 # Relations needed for alliance
|
||||
war_threshold: int = 25 # Relations below this = hostile
|
||||
relation_decay: int = 1 # Relations decay towards neutral
|
||||
trade_relation_boost: int = 2 # Trading improves relations
|
||||
war_damage_multiplier: float = 1.5 # Extra damage during war
|
||||
peace_treaty_duration: int = 20 # Turns peace treaty lasts
|
||||
war_exhaustion_rate: int = 2 # How fast war exhaustion builds
|
||||
|
||||
|
||||
@dataclass
|
||||
@ -137,9 +185,11 @@ class SimulationConfig:
|
||||
world: WorldConfig = field(default_factory=WorldConfig)
|
||||
market: MarketConfig = field(default_factory=MarketConfig)
|
||||
economy: EconomyConfig = field(default_factory=EconomyConfig)
|
||||
religion: ReligionConfig = field(default_factory=ReligionConfig) # NEW
|
||||
diplomacy: DiplomacyConfig = field(default_factory=DiplomacyConfig) # NEW
|
||||
|
||||
# Simulation control
|
||||
auto_step_interval: float = 1.0 # Seconds between auto steps
|
||||
auto_step_interval: float = 1.0
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
"""Convert to dictionary."""
|
||||
@ -150,6 +200,8 @@ class SimulationConfig:
|
||||
"world": asdict(self.world),
|
||||
"market": asdict(self.market),
|
||||
"economy": asdict(self.economy),
|
||||
"religion": asdict(self.religion),
|
||||
"diplomacy": asdict(self.diplomacy),
|
||||
"auto_step_interval": self.auto_step_interval,
|
||||
}
|
||||
|
||||
@ -163,6 +215,8 @@ class SimulationConfig:
|
||||
world=WorldConfig(**data.get("world", {})),
|
||||
market=MarketConfig(**data.get("market", {})),
|
||||
economy=EconomyConfig(**data.get("economy", {})),
|
||||
religion=ReligionConfig(**data.get("religion", {})),
|
||||
diplomacy=DiplomacyConfig(**data.get("diplomacy", {})),
|
||||
auto_step_interval=data.get("auto_step_interval", 1.0),
|
||||
)
|
||||
|
||||
@ -179,7 +233,7 @@ class SimulationConfig:
|
||||
data = json.load(f)
|
||||
return cls.from_dict(data)
|
||||
except FileNotFoundError:
|
||||
return cls() # Return defaults if file not found
|
||||
return cls()
|
||||
|
||||
|
||||
# Global configuration instance
|
||||
@ -187,10 +241,7 @@ _config: Optional[SimulationConfig] = None
|
||||
|
||||
|
||||
def get_config() -> SimulationConfig:
|
||||
"""Get the global configuration instance.
|
||||
|
||||
Loads from config.json if not already loaded.
|
||||
"""
|
||||
"""Get the global configuration instance."""
|
||||
global _config
|
||||
if _config is None:
|
||||
_config = load_config()
|
||||
@ -202,8 +253,6 @@ def load_config(path: str = "config.json") -> SimulationConfig:
|
||||
try:
|
||||
config_path = Path(path)
|
||||
if not config_path.is_absolute():
|
||||
# Try relative to workspace root (villsim/)
|
||||
# __file__ is backend/config.py, so .parent.parent is villsim/
|
||||
workspace_root = Path(__file__).parent.parent
|
||||
config_path = workspace_root / path
|
||||
|
||||
@ -214,7 +263,7 @@ def load_config(path: str = "config.json") -> SimulationConfig:
|
||||
except (FileNotFoundError, json.JSONDecodeError) as e:
|
||||
print(f"Warning: Could not load config from {path}: {e}")
|
||||
|
||||
return SimulationConfig() # Return defaults if file not found
|
||||
return SimulationConfig()
|
||||
|
||||
|
||||
def set_config(config: SimulationConfig) -> None:
|
||||
@ -252,4 +301,3 @@ def _reset_all_caches() -> None:
|
||||
reset_resource_cache()
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -1,4 +1,10 @@
|
||||
"""Game Engine for the Village Simulation."""
|
||||
"""Game Engine for the Village Simulation.
|
||||
|
||||
Now includes support for:
|
||||
- Oil industry (drill_oil, refine, burn_fuel)
|
||||
- Religion (pray, preach)
|
||||
- Diplomacy (negotiate, declare_war, make_peace)
|
||||
"""
|
||||
|
||||
import random
|
||||
import threading
|
||||
@ -9,8 +15,12 @@ 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.domain.resources import Resource, ResourceType, get_fire_heat, get_fuel_heat
|
||||
from backend.domain.personality import get_action_skill_modifier
|
||||
from backend.domain.religion import get_religion_action_bonus
|
||||
from backend.domain.diplomacy import (
|
||||
FactionType, get_faction_relations, reset_faction_relations
|
||||
)
|
||||
from backend.core.world import World, WorldConfig, TimeOfDay
|
||||
from backend.core.market import OrderBook
|
||||
from backend.core.ai import get_ai_decision, AIDecision
|
||||
@ -20,8 +30,8 @@ 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
|
||||
MANUAL = "manual"
|
||||
AUTO = "auto"
|
||||
|
||||
|
||||
@dataclass
|
||||
@ -31,10 +41,8 @@ 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)
|
||||
religious_events: list[dict] = field(default_factory=list) # NEW
|
||||
diplomatic_events: list[dict] = field(default_factory=list) # NEW
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
@ -42,9 +50,8 @@ class TurnLog:
|
||||
"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,
|
||||
"religious_events": self.religious_events,
|
||||
"diplomatic_events": self.diplomatic_events,
|
||||
}
|
||||
|
||||
|
||||
@ -67,30 +74,20 @@ class GameEngine:
|
||||
self.market = OrderBook()
|
||||
self.mode = SimulationMode.MANUAL
|
||||
self.is_running = False
|
||||
# Load auto_step_interval from config
|
||||
self.auto_step_interval = get_config().auto_step_interval
|
||||
self._auto_thread: Optional[threading.Thread] = None
|
||||
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()
|
||||
|
||||
# Reset faction relations
|
||||
reset_faction_relations()
|
||||
|
||||
if config:
|
||||
self.world = World(config=config)
|
||||
else:
|
||||
@ -98,17 +95,6 @@ class GameEngine:
|
||||
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())
|
||||
@ -117,18 +103,15 @@ class GameEngine:
|
||||
self.is_running = True
|
||||
|
||||
def initialize(self, num_agents: Optional[int] = None) -> None:
|
||||
"""Initialize the simulation with agents.
|
||||
"""Initialize the simulation with agents."""
|
||||
# Reset faction relations
|
||||
reset_faction_relations()
|
||||
|
||||
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())
|
||||
@ -143,7 +126,6 @@ class GameEngine:
|
||||
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,
|
||||
@ -151,16 +133,14 @@ class GameEngine:
|
||||
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)
|
||||
# Remove old corpses
|
||||
self._remove_old_corpses(current_turn)
|
||||
|
||||
# 1. Collect AI decisions for all living agents (not corpses)
|
||||
# Collect AI decisions
|
||||
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,
|
||||
@ -172,27 +152,24 @@ class GameEngine:
|
||||
)
|
||||
|
||||
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,
|
||||
world=self.world,
|
||||
)
|
||||
|
||||
decisions.append((agent, decision))
|
||||
|
||||
# Log decision
|
||||
self.logger.log_agent_decision(agent.id, decision.to_dict())
|
||||
|
||||
# 2. Calculate movement targets and move agents
|
||||
# Calculate movement
|
||||
for agent, decision in decisions:
|
||||
action_name = decision.action.value
|
||||
agent.set_action(
|
||||
@ -201,35 +178,16 @@ class GameEngine:
|
||||
world_height=self.world.config.height,
|
||||
message=decision.reason,
|
||||
target_resource=decision.target_resource.value if decision.target_resource else None,
|
||||
target_agent=decision.target_agent_id,
|
||||
)
|
||||
agent.update_movement()
|
||||
|
||||
# 3. Execute all actions and update action indicators with results
|
||||
# Execute actions
|
||||
for agent, decision in decisions:
|
||||
result = self._execute_action(agent, decision)
|
||||
result = self._execute_action(agent, decision, turn_log)
|
||||
|
||||
# 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,
|
||||
@ -238,7 +196,6 @@ class GameEngine:
|
||||
"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(),
|
||||
@ -248,45 +205,35 @@ class GameEngine:
|
||||
action_result=result.to_dict() if result else {},
|
||||
)
|
||||
|
||||
# 4. Resolve pending market orders (price updates)
|
||||
# Update market prices
|
||||
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
|
||||
# Apply passive decay
|
||||
for agent in self.world.get_living_agents():
|
||||
agent.apply_passive_decay()
|
||||
|
||||
# 6. Decay resources in inventories
|
||||
# Decay resources
|
||||
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)
|
||||
# Mark dead agents
|
||||
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
|
||||
# Advance time
|
||||
self.world.advance_time()
|
||||
|
||||
# 9. Check win/lose conditions (count only truly living agents, not corpses)
|
||||
# Check end conditions
|
||||
if len(self.world.get_living_agents()) == 0:
|
||||
self.is_running = False
|
||||
self.logger.close()
|
||||
@ -295,14 +242,12 @@ class GameEngine:
|
||||
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."""
|
||||
"""Mark agents who just died as corpses."""
|
||||
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)
|
||||
@ -313,7 +258,6 @@ class GameEngine:
|
||||
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:
|
||||
@ -322,12 +266,12 @@ class GameEngine:
|
||||
|
||||
return to_remove
|
||||
|
||||
def _execute_action(self, agent: Agent, decision: AIDecision) -> Optional[ActionResult]:
|
||||
def _execute_action(self, agent: Agent, decision: AIDecision, turn_log: TurnLog) -> Optional[ActionResult]:
|
||||
"""Execute an action for an agent."""
|
||||
action = decision.action
|
||||
config = ACTION_CONFIG[action]
|
||||
|
||||
# Handle different action types
|
||||
# Basic actions
|
||||
if action == ActionType.SLEEP:
|
||||
agent.restore_energy(config.energy_cost)
|
||||
return ActionResult(
|
||||
@ -349,17 +293,9 @@ class GameEngine:
|
||||
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")
|
||||
@ -369,8 +305,6 @@ class GameEngine:
|
||||
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(
|
||||
@ -378,30 +312,60 @@ 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.BURN_FUEL:
|
||||
if agent.has_resource(ResourceType.FUEL):
|
||||
agent.remove_from_inventory(ResourceType.FUEL, 1)
|
||||
if not agent.spend_energy(abs(config.energy_cost)):
|
||||
return ActionResult(action_type=action, success=False, message="Not enough energy")
|
||||
fuel_heat = get_fuel_heat()
|
||||
agent.apply_heat(fuel_heat)
|
||||
# Fuel also provides energy
|
||||
from backend.config import get_config
|
||||
fuel_energy = get_config().resources.fuel_energy
|
||||
agent.restore_energy(fuel_energy)
|
||||
return ActionResult(
|
||||
action_type=action,
|
||||
success=True,
|
||||
energy_spent=abs(config.energy_cost),
|
||||
heat_gained=fuel_heat,
|
||||
message=f"Burned fuel (+{fuel_heat} heat, +{fuel_energy} energy)",
|
||||
)
|
||||
return ActionResult(action_type=action, success=False, message="No fuel to burn")
|
||||
|
||||
elif action == ActionType.TRADE:
|
||||
return self._execute_trade(agent, decision)
|
||||
|
||||
# Religious actions
|
||||
elif action == ActionType.PRAY:
|
||||
return self._execute_pray(agent, config, turn_log)
|
||||
|
||||
elif action == ActionType.PREACH:
|
||||
return self._execute_preach(agent, config, turn_log)
|
||||
|
||||
# Diplomatic actions
|
||||
elif action == ActionType.NEGOTIATE:
|
||||
return self._execute_negotiate(agent, decision, config, turn_log)
|
||||
|
||||
elif action == ActionType.DECLARE_WAR:
|
||||
return self._execute_declare_war(agent, decision, config, turn_log)
|
||||
|
||||
elif action == ActionType.MAKE_PEACE:
|
||||
return self._execute_make_peace(agent, decision, config, turn_log)
|
||||
|
||||
# Production actions
|
||||
elif action in [ActionType.HUNT, ActionType.GATHER, ActionType.CHOP_WOOD,
|
||||
ActionType.GET_WATER, ActionType.WEAVE]:
|
||||
ActionType.GET_WATER, ActionType.WEAVE, ActionType.DRILL_OIL,
|
||||
ActionType.REFINE]:
|
||||
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
|
||||
- Woodcutting skill affects wood output
|
||||
- Skills improve with use
|
||||
"""
|
||||
# Check energy
|
||||
"""Execute a work action (hunting, gathering, drilling, etc.)."""
|
||||
energy_cost = abs(config.energy_cost)
|
||||
if not agent.spend_energy(energy_cost):
|
||||
return ActionResult(
|
||||
@ -410,36 +374,32 @@ class GameEngine:
|
||||
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
|
||||
agent.restore_energy(energy_cost)
|
||||
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)
|
||||
resources_consumed.append(Resource(
|
||||
type=config.requires_resource,
|
||||
quantity=config.requires_quantity,
|
||||
created_turn=self.world.current_turn,
|
||||
))
|
||||
|
||||
# Get relevant skill for this action
|
||||
# Get skill modifier
|
||||
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)
|
||||
# Get religion bonus
|
||||
religion_bonus = get_religion_action_bonus(agent.religion.religion, action.value)
|
||||
|
||||
# Combined modifier
|
||||
total_modifier = skill_modifier * religion_bonus
|
||||
|
||||
effective_success_chance = min(0.98, config.success_chance * total_modifier)
|
||||
if random.random() > effective_success_chance:
|
||||
# Record action attempt (skill still improves on failure, just less)
|
||||
agent.record_action(action.value)
|
||||
if skill_name:
|
||||
agent.skills.improve(skill_name, 0.005) # Small improvement on failure
|
||||
agent.skills.improve(skill_name, 0.005)
|
||||
return ActionResult(
|
||||
action_type=action,
|
||||
success=False,
|
||||
@ -447,13 +407,11 @@ class GameEngine:
|
||||
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))
|
||||
quantity = max(config.min_output, int(base_quantity * total_modifier))
|
||||
|
||||
if quantity > 0:
|
||||
resource = Resource(
|
||||
@ -469,10 +427,9 @@ class GameEngine:
|
||||
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)
|
||||
quantity = max(0, int(base_quantity * skill_modifier))
|
||||
quantity = max(0, int(base_quantity * total_modifier))
|
||||
if quantity > 0:
|
||||
resource = Resource(
|
||||
type=config.secondary_output,
|
||||
@ -487,12 +444,10 @@ class GameEngine:
|
||||
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
|
||||
agent.skills.improve(skill_name, 0.015)
|
||||
|
||||
# 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)"
|
||||
|
||||
@ -501,37 +456,243 @@ class GameEngine:
|
||||
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."""
|
||||
"""Get the skill name for an action."""
|
||||
skill_map = {
|
||||
ActionType.HUNT: "hunting",
|
||||
ActionType.GATHER: "gathering",
|
||||
ActionType.CHOP_WOOD: "woodcutting",
|
||||
ActionType.WEAVE: "crafting",
|
||||
ActionType.DRILL_OIL: "gathering", # Use gathering skill for now
|
||||
ActionType.REFINE: "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.
|
||||
def _execute_pray(self, agent: Agent, config, turn_log: TurnLog) -> ActionResult:
|
||||
"""Execute prayer action."""
|
||||
if not agent.spend_energy(abs(config.energy_cost)):
|
||||
return ActionResult(action_type=ActionType.PRAY, success=False, message="Not enough energy")
|
||||
|
||||
Trading skill improves with successful trades and affects prices slightly.
|
||||
"""
|
||||
faith_gain = config.faith_gain
|
||||
agent.gain_faith(faith_gain)
|
||||
agent.religion.record_prayer(self.world.current_turn)
|
||||
agent.record_action("pray")
|
||||
|
||||
turn_log.religious_events.append({
|
||||
"type": "prayer",
|
||||
"agent_id": agent.id,
|
||||
"agent_name": agent.name,
|
||||
"religion": agent.religion.religion.value,
|
||||
"faith_gained": faith_gain,
|
||||
"new_faith": agent.stats.faith,
|
||||
})
|
||||
|
||||
return ActionResult(
|
||||
action_type=ActionType.PRAY,
|
||||
success=True,
|
||||
energy_spent=abs(config.energy_cost),
|
||||
faith_gained=faith_gain,
|
||||
message=f"Prayed to {agent.religion.religion.value} (+{faith_gain} faith)",
|
||||
)
|
||||
|
||||
def _execute_preach(self, agent: Agent, config, turn_log: TurnLog) -> ActionResult:
|
||||
"""Execute preaching action to spread religion."""
|
||||
if not agent.spend_energy(abs(config.energy_cost)):
|
||||
return ActionResult(action_type=ActionType.PREACH, success=False, message="Not enough energy")
|
||||
|
||||
# Find nearby agents to potentially convert
|
||||
nearby = self.world.get_nearby_agents(agent, radius=4.0)
|
||||
conversions = 0
|
||||
|
||||
for target in nearby:
|
||||
if target.religion.religion == agent.religion.religion:
|
||||
# Same religion - boost their faith
|
||||
target.gain_faith(config.faith_spread // 2)
|
||||
else:
|
||||
# Different religion - try to convert
|
||||
if random.random() < config.success_chance:
|
||||
if target.religion.convert_to(agent.religion.religion, 40):
|
||||
conversions += 1
|
||||
agent.religion.record_conversion()
|
||||
self.world.total_conversions += 1
|
||||
|
||||
turn_log.religious_events.append({
|
||||
"type": "conversion",
|
||||
"preacher_id": agent.id,
|
||||
"convert_id": target.id,
|
||||
"convert_name": target.name,
|
||||
"new_religion": agent.religion.religion.value,
|
||||
})
|
||||
|
||||
agent.religion.record_sermon()
|
||||
agent.record_action("preach")
|
||||
|
||||
# Preaching also boosts own faith
|
||||
agent.gain_faith(config.faith_spread // 2)
|
||||
|
||||
if conversions > 0:
|
||||
message = f"Converted {conversions} to {agent.religion.religion.value}!"
|
||||
else:
|
||||
message = f"Preached the word of {agent.religion.religion.value}"
|
||||
|
||||
return ActionResult(
|
||||
action_type=ActionType.PREACH,
|
||||
success=True,
|
||||
energy_spent=abs(config.energy_cost),
|
||||
faith_gained=config.faith_spread // 2,
|
||||
message=message,
|
||||
)
|
||||
|
||||
def _execute_negotiate(self, agent: Agent, decision: AIDecision, config, turn_log: TurnLog) -> ActionResult:
|
||||
"""Execute diplomatic negotiation."""
|
||||
if not agent.spend_energy(abs(config.energy_cost)):
|
||||
return ActionResult(action_type=ActionType.NEGOTIATE, success=False, message="Not enough energy")
|
||||
|
||||
target_faction = decision.target_faction
|
||||
if not target_faction:
|
||||
return ActionResult(action_type=ActionType.NEGOTIATE, success=False, message="No target faction")
|
||||
|
||||
faction_relations = get_faction_relations()
|
||||
my_faction = agent.diplomacy.faction
|
||||
|
||||
# Attempt negotiation
|
||||
if random.random() < config.success_chance * agent.diplomacy.diplomacy_skill:
|
||||
# Successful negotiation improves relations
|
||||
from backend.config import get_config
|
||||
boost = get_config().diplomacy.trade_relation_boost * 2
|
||||
new_relation = faction_relations.modify_relation(my_faction, target_faction, int(boost))
|
||||
|
||||
agent.diplomacy.negotiations_conducted += 1
|
||||
agent.record_action("negotiate")
|
||||
|
||||
turn_log.diplomatic_events.append({
|
||||
"type": "negotiation",
|
||||
"agent_id": agent.id,
|
||||
"agent_faction": my_faction.value,
|
||||
"target_faction": target_faction.value,
|
||||
"success": True,
|
||||
"new_relation": new_relation,
|
||||
})
|
||||
|
||||
return ActionResult(
|
||||
action_type=ActionType.NEGOTIATE,
|
||||
success=True,
|
||||
energy_spent=abs(config.energy_cost),
|
||||
relation_change=int(boost),
|
||||
target_faction=target_faction.value,
|
||||
diplomatic_effect="improved",
|
||||
message=f"Improved relations with {target_faction.value} (+{int(boost)})",
|
||||
)
|
||||
else:
|
||||
agent.record_action("negotiate")
|
||||
return ActionResult(
|
||||
action_type=ActionType.NEGOTIATE,
|
||||
success=False,
|
||||
energy_spent=abs(config.energy_cost),
|
||||
target_faction=target_faction.value,
|
||||
message=f"Negotiations with {target_faction.value} failed",
|
||||
)
|
||||
|
||||
def _execute_declare_war(self, agent: Agent, decision: AIDecision, config, turn_log: TurnLog) -> ActionResult:
|
||||
"""Execute war declaration."""
|
||||
if not agent.spend_energy(abs(config.energy_cost)):
|
||||
return ActionResult(action_type=ActionType.DECLARE_WAR, success=False, message="Not enough energy")
|
||||
|
||||
target_faction = decision.target_faction
|
||||
if not target_faction:
|
||||
return ActionResult(action_type=ActionType.DECLARE_WAR, success=False, message="No target faction")
|
||||
|
||||
faction_relations = get_faction_relations()
|
||||
my_faction = agent.diplomacy.faction
|
||||
|
||||
success = faction_relations.declare_war(my_faction, target_faction, self.world.current_turn)
|
||||
|
||||
if success:
|
||||
self.world.total_wars += 1
|
||||
agent.diplomacy.wars_declared += 1
|
||||
agent.record_action("declare_war")
|
||||
|
||||
turn_log.diplomatic_events.append({
|
||||
"type": "war_declaration",
|
||||
"agent_id": agent.id,
|
||||
"aggressor_faction": my_faction.value,
|
||||
"defender_faction": target_faction.value,
|
||||
})
|
||||
|
||||
return ActionResult(
|
||||
action_type=ActionType.DECLARE_WAR,
|
||||
success=True,
|
||||
energy_spent=abs(config.energy_cost),
|
||||
target_faction=target_faction.value,
|
||||
diplomatic_effect="war",
|
||||
message=f"Declared WAR on {target_faction.value}!",
|
||||
)
|
||||
else:
|
||||
return ActionResult(
|
||||
action_type=ActionType.DECLARE_WAR,
|
||||
success=False,
|
||||
energy_spent=abs(config.energy_cost),
|
||||
message=f"Already at war with {target_faction.value}",
|
||||
)
|
||||
|
||||
def _execute_make_peace(self, agent: Agent, decision: AIDecision, config, turn_log: TurnLog) -> ActionResult:
|
||||
"""Execute peace treaty."""
|
||||
if not agent.spend_energy(abs(config.energy_cost)):
|
||||
return ActionResult(action_type=ActionType.MAKE_PEACE, success=False, message="Not enough energy")
|
||||
|
||||
target_faction = decision.target_faction
|
||||
if not target_faction:
|
||||
return ActionResult(action_type=ActionType.MAKE_PEACE, success=False, message="No target faction")
|
||||
|
||||
faction_relations = get_faction_relations()
|
||||
my_faction = agent.diplomacy.faction
|
||||
|
||||
# Peace is harder to achieve
|
||||
if random.random() < config.success_chance * agent.diplomacy.diplomacy_skill:
|
||||
success = faction_relations.make_peace(my_faction, target_faction, self.world.current_turn)
|
||||
|
||||
if success:
|
||||
self.world.total_peace_treaties += 1
|
||||
agent.diplomacy.peace_treaties_made += 1
|
||||
agent.record_action("make_peace")
|
||||
|
||||
turn_log.diplomatic_events.append({
|
||||
"type": "peace_treaty",
|
||||
"agent_id": agent.id,
|
||||
"faction1": my_faction.value,
|
||||
"faction2": target_faction.value,
|
||||
})
|
||||
|
||||
return ActionResult(
|
||||
action_type=ActionType.MAKE_PEACE,
|
||||
success=True,
|
||||
energy_spent=abs(config.energy_cost),
|
||||
target_faction=target_faction.value,
|
||||
diplomatic_effect="peace",
|
||||
message=f"Peace treaty signed with {target_faction.value}!",
|
||||
)
|
||||
|
||||
return ActionResult(
|
||||
action_type=ActionType.MAKE_PEACE,
|
||||
success=False,
|
||||
energy_spent=abs(config.energy_cost),
|
||||
message=f"Peace negotiations with {target_faction.value} failed",
|
||||
)
|
||||
|
||||
def _execute_trade(self, agent: Agent, decision: AIDecision) -> ActionResult:
|
||||
"""Execute a trade action."""
|
||||
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,
|
||||
@ -540,10 +701,8 @@ class GameEngine:
|
||||
)
|
||||
|
||||
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,
|
||||
@ -551,14 +710,8 @@ class GameEngine:
|
||||
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,
|
||||
quantity=result.quantity,
|
||||
@ -566,18 +719,25 @@ class GameEngine:
|
||||
)
|
||||
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
|
||||
seller.skills.improve("trading", 0.02)
|
||||
|
||||
# Improve faction relations from trade
|
||||
faction_relations = get_faction_relations()
|
||||
from backend.config import get_config
|
||||
boost = get_config().diplomacy.trade_relation_boost
|
||||
faction_relations.modify_relation(
|
||||
agent.diplomacy.faction,
|
||||
seller.diplomacy.faction,
|
||||
boost
|
||||
)
|
||||
|
||||
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
|
||||
agent.skills.improve("trading", 0.01)
|
||||
|
||||
return ActionResult(
|
||||
action_type=ActionType.TRADE,
|
||||
@ -594,7 +754,6 @@ class GameEngine:
|
||||
)
|
||||
|
||||
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)
|
||||
|
||||
@ -607,7 +766,7 @@ class GameEngine:
|
||||
)
|
||||
|
||||
agent.spend_energy(abs(config.energy_cost))
|
||||
agent.record_action("trade") # Track listing action
|
||||
agent.record_action("trade")
|
||||
|
||||
return ActionResult(
|
||||
action_type=ActionType.TRADE,
|
||||
@ -629,7 +788,7 @@ class GameEngine:
|
||||
)
|
||||
|
||||
def _execute_price_adjustment(self, agent: Agent, decision: AIDecision) -> ActionResult:
|
||||
"""Execute a price adjustment on an existing order (no energy cost)."""
|
||||
"""Execute a price adjustment."""
|
||||
success = self.market.adjust_order_price(
|
||||
order_id=decision.adjust_order_id,
|
||||
seller_id=agent.id,
|
||||
@ -641,8 +800,8 @@ class GameEngine:
|
||||
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",
|
||||
energy_spent=0,
|
||||
message=f"Adjusted price to {decision.new_price}c",
|
||||
)
|
||||
else:
|
||||
return ActionResult(
|
||||
@ -655,17 +814,13 @@ class GameEngine:
|
||||
"""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 = []
|
||||
@ -676,7 +831,6 @@ class GameEngine:
|
||||
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,
|
||||
@ -684,10 +838,6 @@ class GameEngine:
|
||||
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,
|
||||
@ -697,7 +847,6 @@ class GameEngine:
|
||||
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
|
||||
@ -760,35 +909,9 @@ 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,
|
||||
}
|
||||
|
||||
|
||||
# Global engine instance
|
||||
def get_engine() -> GameEngine:
|
||||
"""Get the global game engine instance."""
|
||||
return GameEngine()
|
||||
|
||||
@ -3,6 +3,9 @@
|
||||
The world spawns diverse agents with varied personality traits,
|
||||
skills, and starting conditions to create emergent professions
|
||||
and class inequality.
|
||||
|
||||
NEW: World now supports religion and faction systems for realistic
|
||||
social dynamics including religious diversity and geopolitical factions.
|
||||
"""
|
||||
|
||||
import random
|
||||
@ -15,6 +18,13 @@ from backend.domain.personality import (
|
||||
PersonalityTraits, Skills,
|
||||
generate_random_personality, generate_random_skills
|
||||
)
|
||||
from backend.domain.religion import (
|
||||
ReligiousBeliefs, ReligionType, generate_random_religion
|
||||
)
|
||||
from backend.domain.diplomacy import (
|
||||
AgentDiplomacy, FactionType, FactionRelations,
|
||||
generate_random_faction, reset_faction_relations, get_faction_relations
|
||||
)
|
||||
|
||||
|
||||
class TimeOfDay(Enum):
|
||||
@ -31,16 +41,14 @@ def _get_world_config_from_file():
|
||||
|
||||
@dataclass
|
||||
class WorldConfig:
|
||||
"""Configuration for the world.
|
||||
|
||||
Default values are loaded from config.json via create_world_config().
|
||||
These hardcoded defaults are only fallbacks.
|
||||
"""
|
||||
"""Configuration for the world."""
|
||||
width: int = 25
|
||||
height: int = 25
|
||||
initial_agents: int = 25
|
||||
day_steps: int = 10
|
||||
night_steps: int = 1
|
||||
oil_fields_count: int = 3 # NEW
|
||||
temple_count: int = 2 # NEW
|
||||
|
||||
|
||||
def create_world_config() -> WorldConfig:
|
||||
@ -52,9 +60,21 @@ def create_world_config() -> WorldConfig:
|
||||
initial_agents=cfg.initial_agents,
|
||||
day_steps=cfg.day_steps,
|
||||
night_steps=cfg.night_steps,
|
||||
oil_fields_count=getattr(cfg, 'oil_fields_count', 3),
|
||||
temple_count=getattr(cfg, 'temple_count', 2),
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class WorldLocation:
|
||||
"""A special location in the world."""
|
||||
name: str
|
||||
position: Position
|
||||
location_type: str # "oil_field", "temple", "market", etc.
|
||||
faction: Optional[FactionType] = None
|
||||
religion: Optional[ReligionType] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class World:
|
||||
"""Container for all entities in the simulation."""
|
||||
@ -65,9 +85,46 @@ class World:
|
||||
step_in_day: int = 0
|
||||
time_of_day: TimeOfDay = TimeOfDay.DAY
|
||||
|
||||
# Special locations
|
||||
oil_fields: list[WorldLocation] = field(default_factory=list)
|
||||
temples: list[WorldLocation] = field(default_factory=list)
|
||||
|
||||
# Faction relations
|
||||
faction_relations: FactionRelations = field(default_factory=FactionRelations)
|
||||
|
||||
# Statistics
|
||||
total_agents_spawned: int = 0
|
||||
total_agents_died: int = 0
|
||||
total_wars: int = 0
|
||||
total_peace_treaties: int = 0
|
||||
total_conversions: int = 0
|
||||
|
||||
def _generate_locations(self) -> None:
|
||||
"""Generate special locations in the world."""
|
||||
# Generate oil fields (right side of map - "resource-rich" area)
|
||||
self.oil_fields = []
|
||||
for i in range(self.config.oil_fields_count):
|
||||
x = self.config.width * random.uniform(0.75, 0.95)
|
||||
y = self.config.height * (i + 1) / (self.config.oil_fields_count + 1)
|
||||
self.oil_fields.append(WorldLocation(
|
||||
name=f"Oil Field {i + 1}",
|
||||
position=Position(x, y),
|
||||
location_type="oil_field",
|
||||
faction=random.choice([FactionType.MOUNTAINEER, FactionType.NORTHLANDS]),
|
||||
))
|
||||
|
||||
# Generate temples (scattered across map)
|
||||
self.temples = []
|
||||
religions = [r for r in ReligionType if r != ReligionType.ATHEIST]
|
||||
for i in range(self.config.temple_count):
|
||||
x = self.config.width * random.uniform(0.3, 0.7)
|
||||
y = self.config.height * (i + 1) / (self.config.temple_count + 1)
|
||||
self.temples.append(WorldLocation(
|
||||
name=f"Temple of {religions[i % len(religions)].value.title()}",
|
||||
position=Position(x, y),
|
||||
location_type="temple",
|
||||
religion=religions[i % len(religions)],
|
||||
))
|
||||
|
||||
def spawn_agent(
|
||||
self,
|
||||
@ -76,6 +133,8 @@ class World:
|
||||
position: Optional[Position] = None,
|
||||
archetype: Optional[str] = None,
|
||||
starting_money: Optional[int] = None,
|
||||
religion: Optional[ReligiousBeliefs] = None,
|
||||
faction: Optional[AgentDiplomacy] = None,
|
||||
) -> Agent:
|
||||
"""Spawn a new agent in the world with unique personality.
|
||||
|
||||
@ -85,6 +144,8 @@ class World:
|
||||
position: Starting position (random if None)
|
||||
archetype: Personality archetype ("hunter", "gatherer", "trader", etc.)
|
||||
starting_money: Starting money (random with inequality if None)
|
||||
religion: Religious beliefs (random if None)
|
||||
faction: Faction membership (random if None)
|
||||
"""
|
||||
if position is None:
|
||||
position = Position(
|
||||
@ -96,25 +157,38 @@ class World:
|
||||
personality = generate_random_personality(archetype)
|
||||
skills = generate_random_skills(personality)
|
||||
|
||||
# Generate religion if not provided
|
||||
if religion is None:
|
||||
religion = generate_random_religion(archetype)
|
||||
|
||||
# Generate faction if not provided
|
||||
if faction is None:
|
||||
faction = generate_random_faction(archetype)
|
||||
|
||||
# Variable starting money for class inequality
|
||||
# Some agents start with more, some with less
|
||||
if starting_money is None:
|
||||
from backend.config import get_config
|
||||
base_money = get_config().world.starting_money
|
||||
# Random multiplier: 0.3x to 2.0x base money
|
||||
# This creates natural class inequality
|
||||
money_multiplier = random.uniform(0.3, 2.0)
|
||||
|
||||
# Traders start with more money (their capital)
|
||||
if personality.trade_preference > 1.3:
|
||||
money_multiplier *= 1.5
|
||||
|
||||
# Oil-controlling factions have wealth bonus
|
||||
if faction.faction == FactionType.MOUNTAINEER:
|
||||
money_multiplier *= 1.3
|
||||
|
||||
starting_money = int(base_money * money_multiplier)
|
||||
|
||||
agent = Agent(
|
||||
name=name or f"Villager_{self.total_agents_spawned + 1}",
|
||||
profession=Profession.VILLAGER, # Will be updated based on personality
|
||||
profession=Profession.VILLAGER,
|
||||
position=position,
|
||||
personality=personality,
|
||||
skills=skills,
|
||||
religion=religion,
|
||||
diplomacy=faction,
|
||||
money=starting_money,
|
||||
)
|
||||
|
||||
@ -129,12 +203,35 @@ class World:
|
||||
return agent
|
||||
return None
|
||||
|
||||
def get_agents_by_faction(self, faction: FactionType) -> list[Agent]:
|
||||
"""Get all living agents in a faction."""
|
||||
return [
|
||||
a for a in self.agents
|
||||
if a.is_alive() and not a.is_corpse() and a.diplomacy.faction == faction
|
||||
]
|
||||
|
||||
def get_agents_by_religion(self, religion: ReligionType) -> list[Agent]:
|
||||
"""Get all living agents of a religion."""
|
||||
return [
|
||||
a for a in self.agents
|
||||
if a.is_alive() and not a.is_corpse() and a.religion.religion == religion
|
||||
]
|
||||
|
||||
def get_nearby_agents(self, agent: Agent, radius: float = 3.0) -> list[Agent]:
|
||||
"""Get living agents near a given agent."""
|
||||
nearby = []
|
||||
for other in self.agents:
|
||||
if other.id == agent.id:
|
||||
continue
|
||||
if not other.is_alive() or other.is_corpse():
|
||||
continue
|
||||
if agent.position.distance_to(other.position) <= radius:
|
||||
nearby.append(other)
|
||||
return nearby
|
||||
|
||||
def remove_dead_agents(self) -> list[Agent]:
|
||||
"""Remove all dead agents from the world. Returns list of removed agents.
|
||||
Note: This is now handled by the engine's corpse system for visualization.
|
||||
"""
|
||||
"""Remove all dead agents from the world."""
|
||||
dead_agents = [a for a in self.agents if not a.is_alive() and not a.is_corpse()]
|
||||
# Don't actually remove here - let the engine handle corpse visualization
|
||||
return dead_agents
|
||||
|
||||
def advance_time(self) -> None:
|
||||
@ -148,12 +245,14 @@ class World:
|
||||
self.step_in_day = 1
|
||||
self.current_day += 1
|
||||
|
||||
# Determine time of day
|
||||
if self.step_in_day <= self.config.day_steps:
|
||||
self.time_of_day = TimeOfDay.DAY
|
||||
else:
|
||||
self.time_of_day = TimeOfDay.NIGHT
|
||||
|
||||
# Update faction relations each turn
|
||||
self.faction_relations.update_turn(self.current_turn)
|
||||
|
||||
def is_night(self) -> bool:
|
||||
"""Check if it's currently night."""
|
||||
return self.time_of_day == TimeOfDay.NIGHT
|
||||
@ -167,13 +266,25 @@ class World:
|
||||
living = self.get_living_agents()
|
||||
total_money = sum(a.money for a in living)
|
||||
|
||||
# Count emergent professions (updated based on current skills)
|
||||
# Count emergent professions
|
||||
profession_counts = {}
|
||||
for agent in living:
|
||||
agent._update_profession() # Update based on current state
|
||||
agent._update_profession()
|
||||
prof = agent.profession.value
|
||||
profession_counts[prof] = profession_counts.get(prof, 0) + 1
|
||||
|
||||
# Count religions
|
||||
religion_counts = {}
|
||||
for agent in living:
|
||||
rel = agent.religion.religion.value
|
||||
religion_counts[rel] = religion_counts.get(rel, 0) + 1
|
||||
|
||||
# Count factions
|
||||
faction_counts = {}
|
||||
for agent in living:
|
||||
fac = agent.diplomacy.faction.value
|
||||
faction_counts[fac] = faction_counts.get(fac, 0) + 1
|
||||
|
||||
# Calculate wealth inequality metrics
|
||||
if living:
|
||||
moneys = sorted([a.money for a in living])
|
||||
@ -182,15 +293,21 @@ class World:
|
||||
richest = moneys[-1] if moneys else 0
|
||||
poorest = moneys[0] if moneys else 0
|
||||
|
||||
# Gini coefficient for inequality (0 = perfect equality, 1 = max inequality)
|
||||
# Gini coefficient
|
||||
n = len(moneys)
|
||||
if n > 1 and total_money > 0:
|
||||
sum_of_diffs = sum(abs(m1 - m2) for m1 in moneys for m2 in moneys)
|
||||
gini = sum_of_diffs / (2 * n * total_money)
|
||||
else:
|
||||
gini = 0
|
||||
|
||||
# Average faith
|
||||
avg_faith = sum(a.stats.faith for a in living) / len(living)
|
||||
else:
|
||||
avg_money = median_money = richest = poorest = gini = 0
|
||||
avg_money = median_money = richest = poorest = gini = avg_faith = 0
|
||||
|
||||
# War status
|
||||
active_wars = len(self.faction_relations.active_wars)
|
||||
|
||||
return {
|
||||
"current_turn": self.current_turn,
|
||||
@ -202,12 +319,20 @@ class World:
|
||||
"total_agents_died": self.total_agents_died,
|
||||
"total_money_in_circulation": total_money,
|
||||
"professions": profession_counts,
|
||||
# Wealth inequality metrics
|
||||
# Wealth metrics
|
||||
"avg_money": round(avg_money, 1),
|
||||
"median_money": median_money,
|
||||
"richest_agent": richest,
|
||||
"poorest_agent": poorest,
|
||||
"gini_coefficient": round(gini, 3),
|
||||
# NEW: Religion and diplomacy stats
|
||||
"religions": religion_counts,
|
||||
"factions": faction_counts,
|
||||
"active_wars": active_wars,
|
||||
"avg_faith": round(avg_faith, 1),
|
||||
"total_wars": self.total_wars,
|
||||
"total_peace_treaties": self.total_peace_treaties,
|
||||
"total_conversions": self.total_conversions,
|
||||
}
|
||||
|
||||
def get_state_snapshot(self) -> dict:
|
||||
@ -220,21 +345,34 @@ class World:
|
||||
"world_size": {"width": self.config.width, "height": self.config.height},
|
||||
"agents": [a.to_dict() for a in self.agents],
|
||||
"statistics": self.get_statistics(),
|
||||
# NEW: Special locations
|
||||
"oil_fields": [
|
||||
{"name": l.name, "position": l.position.to_dict(), "faction": l.faction.value if l.faction else None}
|
||||
for l in self.oil_fields
|
||||
],
|
||||
"temples": [
|
||||
{"name": l.name, "position": l.position.to_dict(), "religion": l.religion.value if l.religion else None}
|
||||
for l in self.temples
|
||||
],
|
||||
# NEW: Faction relations summary
|
||||
"faction_relations": self.faction_relations.to_dict(),
|
||||
}
|
||||
|
||||
def initialize(self) -> None:
|
||||
"""Initialize the world with diverse starting agents.
|
||||
|
||||
Creates a mix of agent archetypes to seed profession diversity:
|
||||
- Some hunters (risk-takers who hunt)
|
||||
- Some gatherers (cautious resource collectors)
|
||||
- Some traders (market-focused wealth builders)
|
||||
- Some generalists (balanced approach)
|
||||
Creates a mix of agent archetypes to seed profession diversity.
|
||||
Now also seeds religious and faction diversity.
|
||||
"""
|
||||
# Reset faction relations
|
||||
self.faction_relations = reset_faction_relations()
|
||||
|
||||
# Generate special locations
|
||||
self._generate_locations()
|
||||
|
||||
n = self.config.initial_agents
|
||||
|
||||
# Distribute archetypes for diversity
|
||||
# ~15% hunters, ~15% gatherers, ~15% traders, ~10% woodcutters, 45% random
|
||||
archetypes = (
|
||||
["hunter"] * max(1, n // 7) +
|
||||
["gatherer"] * max(1, n // 7) +
|
||||
@ -242,13 +380,33 @@ class World:
|
||||
["woodcutter"] * max(1, n // 10)
|
||||
)
|
||||
|
||||
# Fill remaining slots with random (no archetype)
|
||||
while len(archetypes) < n:
|
||||
archetypes.append(None)
|
||||
|
||||
# Shuffle to randomize positions
|
||||
random.shuffle(archetypes)
|
||||
|
||||
for archetype in archetypes:
|
||||
self.spawn_agent(archetype=archetype)
|
||||
|
||||
# Set up some initial faction tensions for drama
|
||||
self._create_initial_tensions()
|
||||
|
||||
def _create_initial_tensions(self) -> None:
|
||||
"""Create some initial diplomatic tensions for realistic starting conditions."""
|
||||
# Some factions have historical rivalries
|
||||
rivalries = [
|
||||
(FactionType.NORTHLANDS, FactionType.RIVERFOLK, -15),
|
||||
(FactionType.FORESTKIN, FactionType.MOUNTAINEER, -10),
|
||||
]
|
||||
|
||||
for faction1, faction2, modifier in rivalries:
|
||||
self.faction_relations.modify_relation(faction1, faction2, modifier)
|
||||
|
||||
# Some factions have good relations
|
||||
friendships = [
|
||||
(FactionType.RIVERFOLK, FactionType.PLAINSMEN, 10),
|
||||
(FactionType.PLAINSMEN, FactionType.FORESTKIN, 15),
|
||||
]
|
||||
|
||||
for faction1, faction2, modifier in friendships:
|
||||
self.faction_relations.modify_relation(faction1, faction2, modifier)
|
||||
|
||||
@ -3,6 +3,8 @@
|
||||
from .resources import ResourceType, Resource, RESOURCE_EFFECTS
|
||||
from .action import ActionType, ActionConfig, ActionResult, ACTION_CONFIG
|
||||
from .agent import Agent, AgentStats, Position
|
||||
from .religion import ReligionType, ReligiousBeliefs
|
||||
from .diplomacy import FactionType, AgentDiplomacy, FactionRelations
|
||||
|
||||
__all__ = [
|
||||
"ResourceType",
|
||||
@ -15,5 +17,11 @@ __all__ = [
|
||||
"Agent",
|
||||
"AgentStats",
|
||||
"Position",
|
||||
# Religion
|
||||
"ReligionType",
|
||||
"ReligiousBeliefs",
|
||||
# Diplomacy
|
||||
"FactionType",
|
||||
"AgentDiplomacy",
|
||||
"FactionRelations",
|
||||
]
|
||||
|
||||
|
||||
@ -15,6 +15,7 @@ if TYPE_CHECKING:
|
||||
|
||||
class ActionType(Enum):
|
||||
"""Types of actions an agent can perform."""
|
||||
# Basic survival actions
|
||||
SLEEP = "sleep" # Night action - restores energy
|
||||
REST = "rest" # Day action - restores some energy
|
||||
HUNT = "hunt" # Produces meat and hide
|
||||
@ -26,6 +27,20 @@ class ActionType(Enum):
|
||||
TRADE = "trade" # Market interaction
|
||||
CONSUME = "consume" # Consume resource from inventory
|
||||
|
||||
# NEW: Oil industry actions
|
||||
DRILL_OIL = "drill_oil" # Extract oil from oil fields
|
||||
REFINE = "refine" # Convert oil to fuel
|
||||
BURN_FUEL = "burn_fuel" # Use fuel for heat/energy
|
||||
|
||||
# NEW: Religious actions
|
||||
PRAY = "pray" # Increase faith, slight energy cost
|
||||
PREACH = "preach" # Spread religion, convert others
|
||||
|
||||
# NEW: Diplomatic actions
|
||||
NEGOTIATE = "negotiate" # Improve relations with another faction
|
||||
DECLARE_WAR = "declare_war" # Declare war on another faction
|
||||
MAKE_PEACE = "make_peace" # Propose peace treaty
|
||||
|
||||
|
||||
@dataclass
|
||||
class ActionConfig:
|
||||
@ -40,14 +55,13 @@ class ActionConfig:
|
||||
secondary_max: int = 0
|
||||
requires_resource: Optional[ResourceType] = None
|
||||
requires_quantity: int = 0
|
||||
# NEW: Faith effects
|
||||
faith_gain: int = 0
|
||||
faith_spread: int = 0
|
||||
|
||||
|
||||
def get_action_config() -> dict[ActionType, ActionConfig]:
|
||||
"""Get action configurations from the global config.
|
||||
|
||||
This function dynamically builds ACTION_CONFIG from config.json values.
|
||||
"""
|
||||
# Import here to avoid circular imports
|
||||
"""Get action configurations from the global config."""
|
||||
from backend.config import get_config
|
||||
|
||||
config = get_config()
|
||||
@ -55,10 +69,10 @@ def get_action_config() -> dict[ActionType, ActionConfig]:
|
||||
|
||||
return {
|
||||
ActionType.SLEEP: ActionConfig(
|
||||
energy_cost=actions.sleep_energy, # Restores energy
|
||||
energy_cost=actions.sleep_energy,
|
||||
),
|
||||
ActionType.REST: ActionConfig(
|
||||
energy_cost=actions.rest_energy, # Restores some energy
|
||||
energy_cost=actions.rest_energy,
|
||||
),
|
||||
ActionType.HUNT: ActionConfig(
|
||||
energy_cost=actions.hunt_energy,
|
||||
@ -112,6 +126,53 @@ def get_action_config() -> dict[ActionType, ActionConfig]:
|
||||
ActionType.CONSUME: ActionConfig(
|
||||
energy_cost=0,
|
||||
),
|
||||
# NEW: Oil industry actions
|
||||
ActionType.DRILL_OIL: ActionConfig(
|
||||
energy_cost=actions.drill_oil_energy,
|
||||
success_chance=actions.drill_oil_success,
|
||||
min_output=actions.drill_oil_min,
|
||||
max_output=actions.drill_oil_max,
|
||||
output_resource=ResourceType.OIL,
|
||||
),
|
||||
ActionType.REFINE: ActionConfig(
|
||||
energy_cost=actions.refine_energy,
|
||||
success_chance=1.0,
|
||||
min_output=1,
|
||||
max_output=1,
|
||||
output_resource=ResourceType.FUEL,
|
||||
requires_resource=ResourceType.OIL,
|
||||
requires_quantity=2, # 2 oil -> 1 fuel
|
||||
),
|
||||
ActionType.BURN_FUEL: ActionConfig(
|
||||
energy_cost=-1, # Minimal effort to burn fuel
|
||||
success_chance=1.0,
|
||||
requires_resource=ResourceType.FUEL,
|
||||
requires_quantity=1,
|
||||
),
|
||||
# NEW: Religious actions
|
||||
ActionType.PRAY: ActionConfig(
|
||||
energy_cost=actions.pray_energy,
|
||||
success_chance=1.0,
|
||||
faith_gain=actions.pray_faith_gain,
|
||||
),
|
||||
ActionType.PREACH: ActionConfig(
|
||||
energy_cost=actions.preach_energy,
|
||||
success_chance=actions.preach_convert_chance,
|
||||
faith_spread=actions.preach_faith_spread,
|
||||
),
|
||||
# NEW: Diplomatic actions
|
||||
ActionType.NEGOTIATE: ActionConfig(
|
||||
energy_cost=actions.negotiate_energy,
|
||||
success_chance=0.7, # Not always successful
|
||||
),
|
||||
ActionType.DECLARE_WAR: ActionConfig(
|
||||
energy_cost=actions.declare_war_energy,
|
||||
success_chance=1.0, # Always succeeds (but has consequences)
|
||||
),
|
||||
ActionType.MAKE_PEACE: ActionConfig(
|
||||
energy_cost=actions.make_peace_energy,
|
||||
success_chance=0.5, # Harder to make peace than war
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
@ -133,8 +194,6 @@ def reset_action_config_cache() -> None:
|
||||
_action_config_cache = None
|
||||
|
||||
|
||||
# For backwards compatibility - this is a property-like access
|
||||
# that returns fresh config each time (use get_cached_action_config for performance)
|
||||
class _ActionConfigAccessor:
|
||||
"""Accessor class that provides dict-like access to action configs."""
|
||||
|
||||
@ -161,6 +220,21 @@ class _ActionConfigAccessor:
|
||||
ACTION_CONFIG = _ActionConfigAccessor()
|
||||
|
||||
|
||||
# Action categories for AI decision making
|
||||
SURVIVAL_ACTIONS = {
|
||||
ActionType.HUNT, ActionType.GATHER, ActionType.CHOP_WOOD,
|
||||
ActionType.GET_WATER, ActionType.BUILD_FIRE, ActionType.CONSUME
|
||||
}
|
||||
PRODUCTION_ACTIONS = {
|
||||
ActionType.HUNT, ActionType.GATHER, ActionType.CHOP_WOOD,
|
||||
ActionType.GET_WATER, ActionType.DRILL_OIL
|
||||
}
|
||||
CRAFTING_ACTIONS = {ActionType.WEAVE, ActionType.REFINE}
|
||||
RELIGIOUS_ACTIONS = {ActionType.PRAY, ActionType.PREACH}
|
||||
DIPLOMATIC_ACTIONS = {ActionType.NEGOTIATE, ActionType.DECLARE_WAR, ActionType.MAKE_PEACE}
|
||||
ECONOMIC_ACTIONS = {ActionType.TRADE}
|
||||
|
||||
|
||||
@dataclass
|
||||
class ActionResult:
|
||||
"""Result of executing an action."""
|
||||
@ -170,8 +244,14 @@ class ActionResult:
|
||||
resources_gained: list = field(default_factory=list)
|
||||
resources_consumed: list = field(default_factory=list)
|
||||
heat_gained: int = 0
|
||||
faith_gained: int = 0 # NEW
|
||||
relation_change: int = 0 # NEW
|
||||
message: str = ""
|
||||
|
||||
# NEW: Diplomatic effects
|
||||
target_faction: Optional[str] = None
|
||||
diplomatic_effect: Optional[str] = None # "war", "peace", "improved", "degraded"
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
"""Convert to dictionary for API serialization."""
|
||||
return {
|
||||
@ -187,5 +267,9 @@ class ActionResult:
|
||||
for r in self.resources_consumed
|
||||
],
|
||||
"heat_gained": self.heat_gained,
|
||||
"faith_gained": self.faith_gained,
|
||||
"relation_change": self.relation_change,
|
||||
"target_faction": self.target_faction,
|
||||
"diplomatic_effect": self.diplomatic_effect,
|
||||
"message": self.message,
|
||||
}
|
||||
|
||||
@ -3,6 +3,9 @@
|
||||
Agent stats are loaded dynamically from the global config.
|
||||
Each agent now has unique personality traits and skills that create
|
||||
emergent professions and behavioral diversity.
|
||||
|
||||
NEW: Agents now have religion and faction membership for realistic
|
||||
social dynamics including religious beliefs and geopolitical allegiances.
|
||||
"""
|
||||
|
||||
import math
|
||||
@ -17,6 +20,8 @@ from .personality import (
|
||||
PersonalityTraits, Skills, ProfessionType,
|
||||
determine_profession
|
||||
)
|
||||
from .religion import ReligiousBeliefs
|
||||
from .diplomacy import AgentDiplomacy
|
||||
|
||||
|
||||
def _get_agent_stats_config():
|
||||
@ -33,6 +38,8 @@ class Profession(Enum):
|
||||
WOODCUTTER = "woodcutter"
|
||||
TRADER = "trader"
|
||||
CRAFTER = "crafter"
|
||||
OIL_WORKER = "oil_worker" # NEW: Oil industry worker
|
||||
PRIEST = "priest" # NEW: Religious leader
|
||||
|
||||
|
||||
@dataclass
|
||||
@ -80,18 +87,21 @@ class AgentStats:
|
||||
hunger: int = field(default=80)
|
||||
thirst: int = field(default=70)
|
||||
heat: int = field(default=100)
|
||||
faith: int = field(default=50) # NEW: Religious faith level
|
||||
|
||||
# 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)
|
||||
MAX_FAITH: int = field(default=100) # NEW
|
||||
|
||||
# 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)
|
||||
FAITH_DECAY: int = field(default=1) # NEW
|
||||
|
||||
# Critical threshold - loaded from config
|
||||
CRITICAL_THRESHOLD: float = field(default=0.25)
|
||||
@ -106,6 +116,9 @@ class AgentStats:
|
||||
heat_decay = self.HEAT_DECAY // 2 if has_clothes else self.HEAT_DECAY
|
||||
self.heat = max(0, self.heat - heat_decay)
|
||||
|
||||
# Faith decays slowly - praying restores it
|
||||
self.faith = max(0, self.faith - self.FAITH_DECAY)
|
||||
|
||||
def is_critical(self) -> bool:
|
||||
"""Check if any vital stat is below critical threshold."""
|
||||
threshold_hunger = int(self.MAX_HUNGER * self.CRITICAL_THRESHOLD)
|
||||
@ -135,16 +148,31 @@ class AgentStats:
|
||||
"""Check if agent has enough energy to perform an action."""
|
||||
return self.energy >= abs(energy_required)
|
||||
|
||||
def gain_faith(self, amount: int) -> None:
|
||||
"""Increase faith level."""
|
||||
self.faith = min(self.MAX_FAITH, self.faith + amount)
|
||||
|
||||
def lose_faith(self, amount: int) -> None:
|
||||
"""Decrease faith level."""
|
||||
self.faith = max(0, self.faith - amount)
|
||||
|
||||
@property
|
||||
def is_zealot(self) -> bool:
|
||||
"""Check if agent has zealot-level faith."""
|
||||
return self.faith >= int(self.MAX_FAITH * 0.80)
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
"energy": self.energy,
|
||||
"hunger": self.hunger,
|
||||
"thirst": self.thirst,
|
||||
"heat": self.heat,
|
||||
"faith": self.faith,
|
||||
"max_energy": self.MAX_ENERGY,
|
||||
"max_hunger": self.MAX_HUNGER,
|
||||
"max_thirst": self.MAX_THIRST,
|
||||
"max_heat": self.MAX_HEAT,
|
||||
"max_faith": self.MAX_FAITH,
|
||||
}
|
||||
|
||||
|
||||
@ -156,14 +184,17 @@ def create_agent_stats() -> AgentStats:
|
||||
hunger=config.start_hunger,
|
||||
thirst=config.start_thirst,
|
||||
heat=config.start_heat,
|
||||
faith=getattr(config, 'start_faith', 50),
|
||||
MAX_ENERGY=config.max_energy,
|
||||
MAX_HUNGER=config.max_hunger,
|
||||
MAX_THIRST=config.max_thirst,
|
||||
MAX_HEAT=config.max_heat,
|
||||
MAX_FAITH=getattr(config, 'max_faith', 100),
|
||||
ENERGY_DECAY=config.energy_decay,
|
||||
HUNGER_DECAY=config.hunger_decay,
|
||||
THIRST_DECAY=config.thirst_decay,
|
||||
HEAT_DECAY=config.heat_decay,
|
||||
FAITH_DECAY=getattr(config, 'faith_decay', 1),
|
||||
CRITICAL_THRESHOLD=config.critical_threshold,
|
||||
)
|
||||
|
||||
@ -171,9 +202,10 @@ def create_agent_stats() -> AgentStats:
|
||||
@dataclass
|
||||
class AgentAction:
|
||||
"""Current action being performed by an agent."""
|
||||
action_type: str = "" # e.g., "hunt", "gather", "trade", "rest"
|
||||
action_type: str = "" # e.g., "hunt", "gather", "trade", "rest", "pray"
|
||||
target_position: Optional[Position] = None
|
||||
target_resource: Optional[str] = None
|
||||
target_agent: Optional[str] = None # NEW: For diplomatic/religious actions
|
||||
progress: float = 0.0 # 0.0 to 1.0
|
||||
is_moving: bool = False
|
||||
message: str = ""
|
||||
@ -183,6 +215,7 @@ class AgentAction:
|
||||
"action_type": self.action_type,
|
||||
"target_position": self.target_position.to_dict() if self.target_position else None,
|
||||
"target_resource": self.target_resource,
|
||||
"target_agent": self.target_agent,
|
||||
"progress": round(self.progress, 2),
|
||||
"is_moving": self.is_moving,
|
||||
"message": self.message,
|
||||
@ -191,16 +224,27 @@ class AgentAction:
|
||||
|
||||
# Action location mappings (relative positions on the map for each action type)
|
||||
ACTION_LOCATIONS = {
|
||||
"hunt": {"zone": "forest", "offset_range": (0.6, 0.9)}, # Right side (forest)
|
||||
"gather": {"zone": "bushes", "offset_range": (0.1, 0.4)}, # Left side (bushes)
|
||||
"chop_wood": {"zone": "forest", "offset_range": (0.7, 0.95)}, # Far right (forest)
|
||||
"get_water": {"zone": "river", "offset_range": (0.0, 0.15)}, # Far left (river)
|
||||
"weave": {"zone": "village", "offset_range": (0.4, 0.6)}, # Center (village)
|
||||
"hunt": {"zone": "forest", "offset_range": (0.6, 0.9)},
|
||||
"gather": {"zone": "bushes", "offset_range": (0.1, 0.4)},
|
||||
"chop_wood": {"zone": "forest", "offset_range": (0.7, 0.95)},
|
||||
"get_water": {"zone": "river", "offset_range": (0.0, 0.15)},
|
||||
"weave": {"zone": "village", "offset_range": (0.4, 0.6)},
|
||||
"build_fire": {"zone": "village", "offset_range": (0.45, 0.55)},
|
||||
"trade": {"zone": "market", "offset_range": (0.5, 0.6)}, # Center (market)
|
||||
"burn_fuel": {"zone": "village", "offset_range": (0.45, 0.55)},
|
||||
"trade": {"zone": "market", "offset_range": (0.5, 0.6)},
|
||||
"rest": {"zone": "home", "offset_range": (0.4, 0.6)},
|
||||
"sleep": {"zone": "home", "offset_range": (0.4, 0.6)},
|
||||
"consume": {"zone": "current", "offset_range": (0, 0)}, # Stay in place
|
||||
"consume": {"zone": "current", "offset_range": (0, 0)},
|
||||
# NEW: Oil industry locations
|
||||
"drill_oil": {"zone": "oil_field", "offset_range": (0.8, 0.95)},
|
||||
"refine": {"zone": "refinery", "offset_range": (0.7, 0.85)},
|
||||
# NEW: Religious locations
|
||||
"pray": {"zone": "temple", "offset_range": (0.45, 0.55)},
|
||||
"preach": {"zone": "village", "offset_range": (0.4, 0.6)},
|
||||
# NEW: Diplomatic locations
|
||||
"negotiate": {"zone": "market", "offset_range": (0.5, 0.6)},
|
||||
"declare_war": {"zone": "village", "offset_range": (0.5, 0.5)},
|
||||
"make_peace": {"zone": "market", "offset_range": (0.5, 0.6)},
|
||||
}
|
||||
|
||||
|
||||
@ -217,61 +261,76 @@ class Agent:
|
||||
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.
|
||||
|
||||
NEW: Agents now have religious beliefs and faction membership.
|
||||
"""
|
||||
id: str = field(default_factory=lambda: str(uuid4())[:8])
|
||||
name: str = ""
|
||||
profession: Profession = Profession.VILLAGER # Now derived from personality/skills
|
||||
profession: Profession = Profession.VILLAGER
|
||||
position: Position = field(default_factory=Position)
|
||||
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
|
||||
money: int = field(default=-1)
|
||||
|
||||
# Personality and skills - create agent diversity
|
||||
# Personality and skills
|
||||
personality: PersonalityTraits = field(default_factory=PersonalityTraits)
|
||||
skills: Skills = field(default_factory=Skills)
|
||||
|
||||
# NEW: Religion and diplomacy
|
||||
religion: ReligiousBeliefs = field(default_factory=ReligiousBeliefs)
|
||||
diplomacy: AgentDiplomacy = field(default_factory=AgentDiplomacy)
|
||||
|
||||
# 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
|
||||
death_turn: int = -1
|
||||
death_reason: str = ""
|
||||
|
||||
# Statistics tracking for profession determination
|
||||
actions_performed: dict = field(default_factory=lambda: {
|
||||
"hunt": 0, "gather": 0, "chop_wood": 0, "trade": 0, "craft": 0
|
||||
"hunt": 0, "gather": 0, "chop_wood": 0, "trade": 0, "craft": 0,
|
||||
"drill_oil": 0, "refine": 0, "pray": 0, "preach": 0,
|
||||
"negotiate": 0, "declare_war": 0, "make_peace": 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
|
||||
INVENTORY_SLOTS: int = field(default=-1)
|
||||
MOVE_SPEED: float = 0.8
|
||||
|
||||
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."""
|
||||
"""Update profession based on personality, skills, and activities."""
|
||||
# Check for specialized professions first
|
||||
|
||||
# High religious activity = Priest
|
||||
if self.actions_performed.get("pray", 0) + self.actions_performed.get("preach", 0) > 10:
|
||||
if self.stats.faith > 70:
|
||||
self.profession = Profession.PRIEST
|
||||
return
|
||||
|
||||
# High oil activity = Oil Worker
|
||||
if self.actions_performed.get("drill_oil", 0) + self.actions_performed.get("refine", 0) > 10:
|
||||
self.profession = Profession.OIL_WORKER
|
||||
return
|
||||
|
||||
# Standard profession determination
|
||||
prof_type = determine_profession(self.personality, self.skills)
|
||||
profession_map = {
|
||||
ProfessionType.HUNTER: Profession.HUNTER,
|
||||
@ -287,19 +346,6 @@ class Agent:
|
||||
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
|
||||
@ -315,7 +361,7 @@ class Agent:
|
||||
)
|
||||
|
||||
def is_corpse(self) -> bool:
|
||||
"""Check if this agent is a corpse (died but still visible)."""
|
||||
"""Check if this agent is a corpse."""
|
||||
return self.death_turn >= 0
|
||||
|
||||
def can_act(self) -> bool:
|
||||
@ -326,6 +372,14 @@ class Agent:
|
||||
"""Check if agent has clothes equipped."""
|
||||
return any(r.type == ResourceType.CLOTHES for r in self.inventory)
|
||||
|
||||
def has_oil(self) -> bool:
|
||||
"""Check if agent has oil."""
|
||||
return any(r.type == ResourceType.OIL for r in self.inventory)
|
||||
|
||||
def has_fuel(self) -> bool:
|
||||
"""Check if agent has fuel."""
|
||||
return any(r.type == ResourceType.FUEL for r in self.inventory)
|
||||
|
||||
def inventory_space(self) -> int:
|
||||
"""Get remaining inventory slots."""
|
||||
total_items = sum(r.quantity for r in self.inventory)
|
||||
@ -342,22 +396,20 @@ class Agent:
|
||||
world_height: int,
|
||||
message: str = "",
|
||||
target_resource: Optional[str] = None,
|
||||
target_agent: Optional[str] = None,
|
||||
) -> 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()
|
||||
is_moving = False
|
||||
else:
|
||||
# Calculate target position based on action zone
|
||||
offset_range = location["offset_range"]
|
||||
offset_min = float(offset_range[0])
|
||||
offset_max = float(offset_range[1])
|
||||
offset_min = float(offset_range[0]) if offset_range else 0.0
|
||||
offset_max = float(offset_range[1]) if offset_range else 0.0
|
||||
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))
|
||||
|
||||
@ -368,6 +420,7 @@ class Agent:
|
||||
action_type=action_type,
|
||||
target_position=target,
|
||||
target_resource=target_resource,
|
||||
target_agent=target_agent,
|
||||
progress=0.0,
|
||||
is_moving=is_moving,
|
||||
message=message,
|
||||
@ -382,7 +435,7 @@ class Agent:
|
||||
)
|
||||
if reached:
|
||||
self.current_action.is_moving = False
|
||||
self.current_action.progress = 0.5 # At location, doing action
|
||||
self.current_action.progress = 0.5
|
||||
|
||||
def complete_action(self, success: bool, message: str) -> None:
|
||||
"""Mark current action as complete."""
|
||||
@ -399,13 +452,11 @@ class Agent:
|
||||
|
||||
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,
|
||||
quantity=quantity_to_add,
|
||||
@ -469,7 +520,7 @@ class Agent:
|
||||
return True
|
||||
|
||||
def apply_heat(self, amount: int) -> None:
|
||||
"""Apply heat from a fire."""
|
||||
"""Apply heat from a fire or fuel."""
|
||||
self.stats.heat = min(self.stats.MAX_HEAT, self.stats.heat + amount)
|
||||
|
||||
def restore_energy(self, amount: int) -> None:
|
||||
@ -483,8 +534,13 @@ class Agent:
|
||||
self.stats.energy -= amount
|
||||
return True
|
||||
|
||||
def gain_faith(self, amount: int) -> None:
|
||||
"""Increase faith from religious activity."""
|
||||
self.stats.gain_faith(amount)
|
||||
self.religion.gain_faith(amount)
|
||||
|
||||
def decay_inventory(self, current_turn: int) -> list[Resource]:
|
||||
"""Remove expired resources from inventory. Returns list of removed resources."""
|
||||
"""Remove expired resources from inventory."""
|
||||
expired = []
|
||||
for resource in self.inventory[:]:
|
||||
if resource.is_expired(current_turn):
|
||||
@ -495,15 +551,38 @@ class Agent:
|
||||
def apply_passive_decay(self) -> None:
|
||||
"""Apply passive stat decay for this turn."""
|
||||
self.stats.apply_passive_decay(has_clothes=self.has_clothes())
|
||||
self.religion.apply_decay()
|
||||
|
||||
def mark_dead(self, turn: int, reason: str) -> None:
|
||||
"""Mark this agent as dead."""
|
||||
self.death_turn = turn
|
||||
self.death_reason = reason
|
||||
|
||||
def shares_religion_with(self, other: "Agent") -> bool:
|
||||
"""Check if agent shares religion with another."""
|
||||
return self.religion.religion == other.religion.religion
|
||||
|
||||
def shares_faction_with(self, other: "Agent") -> bool:
|
||||
"""Check if agent shares faction with another."""
|
||||
return self.diplomacy.faction == other.diplomacy.faction
|
||||
|
||||
def get_trade_modifier_for(self, other: "Agent") -> float:
|
||||
"""Get combined trade modifier when trading with another agent."""
|
||||
# Religion modifier
|
||||
religion_mod = self.religion.get_trade_modifier(other.religion)
|
||||
|
||||
# Faction modifier (from global relations)
|
||||
from .diplomacy import get_faction_relations
|
||||
faction_relations = get_faction_relations()
|
||||
faction_mod = faction_relations.get_trade_modifier(
|
||||
self.diplomacy.faction,
|
||||
other.diplomacy.faction
|
||||
)
|
||||
|
||||
return religion_mod * faction_mod
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
"""Convert to dictionary for API serialization."""
|
||||
# Update profession before serializing
|
||||
self._update_profession()
|
||||
|
||||
return {
|
||||
@ -522,12 +601,13 @@ class Agent:
|
||||
"last_action_result": self.last_action_result,
|
||||
"death_turn": self.death_turn,
|
||||
"death_reason": self.death_reason,
|
||||
# New fields for agent diversity
|
||||
# Personality and skills
|
||||
"personality": self.personality.to_dict(),
|
||||
"skills": self.skills.to_dict(),
|
||||
"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(),
|
||||
# NEW: Religion and diplomacy
|
||||
"religion": self.religion.to_dict(),
|
||||
"diplomacy": self.diplomacy.to_dict(),
|
||||
}
|
||||
|
||||
515
backend/domain/diplomacy.py
Normal file
515
backend/domain/diplomacy.py
Normal file
@ -0,0 +1,515 @@
|
||||
"""Diplomacy system for the Village Simulation.
|
||||
|
||||
Creates faction-based politics with:
|
||||
- Multiple factions that agents belong to
|
||||
- Relations between factions (0-100)
|
||||
- War and peace mechanics
|
||||
- Trade agreements and alliances
|
||||
- Real-world style geopolitics
|
||||
"""
|
||||
|
||||
import random
|
||||
from dataclasses import dataclass, field
|
||||
from enum import Enum
|
||||
from typing import Optional, Dict, Set
|
||||
|
||||
|
||||
class FactionType(Enum):
|
||||
"""Types of factions in the simulation.
|
||||
|
||||
Like real-world nations/groups with distinct characteristics.
|
||||
"""
|
||||
NEUTRAL = "neutral" # Unaffiliated agents
|
||||
NORTHLANDS = "northlands" # Northern faction - hardy, value warmth
|
||||
RIVERFOLK = "riverfolk" # River faction - trade-focused, value water
|
||||
FORESTKIN = "forestkin" # Forest faction - hunters and gatherers
|
||||
MOUNTAINEER = "mountaineer" # Mountain faction - miners, value resources
|
||||
PLAINSMEN = "plainsmen" # Plains faction - farmers, balanced
|
||||
|
||||
|
||||
# Faction characteristics
|
||||
FACTION_TRAITS = {
|
||||
FactionType.NEUTRAL: {
|
||||
"description": "Unaffiliated individuals",
|
||||
"bonus_resource": None,
|
||||
"aggression": 0.0,
|
||||
"diplomacy_skill": 0.5,
|
||||
"trade_preference": 1.0,
|
||||
"color": "#808080",
|
||||
},
|
||||
FactionType.NORTHLANDS: {
|
||||
"description": "Hardy people of the North",
|
||||
"bonus_resource": "wood", # Wood for warmth
|
||||
"aggression": 0.4,
|
||||
"diplomacy_skill": 0.6,
|
||||
"trade_preference": 0.8,
|
||||
"color": "#4A90D9",
|
||||
},
|
||||
FactionType.RIVERFOLK: {
|
||||
"description": "Traders of the Rivers",
|
||||
"bonus_resource": "water",
|
||||
"aggression": 0.2,
|
||||
"diplomacy_skill": 0.9, # Best diplomats
|
||||
"trade_preference": 1.5, # Love trading
|
||||
"color": "#2E8B57",
|
||||
},
|
||||
FactionType.FORESTKIN: {
|
||||
"description": "Hunters of the Forest",
|
||||
"bonus_resource": "meat",
|
||||
"aggression": 0.5,
|
||||
"diplomacy_skill": 0.5,
|
||||
"trade_preference": 0.9,
|
||||
"color": "#228B22",
|
||||
},
|
||||
FactionType.MOUNTAINEER: {
|
||||
"description": "Miners of the Mountains",
|
||||
"bonus_resource": "oil", # Control oil fields
|
||||
"aggression": 0.3,
|
||||
"diplomacy_skill": 0.7,
|
||||
"trade_preference": 1.2,
|
||||
"color": "#8B4513",
|
||||
},
|
||||
FactionType.PLAINSMEN: {
|
||||
"description": "Farmers of the Plains",
|
||||
"bonus_resource": "berries",
|
||||
"aggression": 0.25,
|
||||
"diplomacy_skill": 0.6,
|
||||
"trade_preference": 1.0,
|
||||
"color": "#DAA520",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
class DiplomaticStatus(Enum):
|
||||
"""Current diplomatic status between factions."""
|
||||
WAR = "war" # Active conflict
|
||||
HOSTILE = "hostile" # Near-war tensions
|
||||
COLD = "cold" # Cool relations
|
||||
NEUTRAL = "neutral" # Default state
|
||||
FRIENDLY = "friendly" # Good relations
|
||||
ALLIED = "allied" # Full alliance
|
||||
|
||||
|
||||
@dataclass
|
||||
class Treaty:
|
||||
"""A diplomatic treaty between factions."""
|
||||
faction1: FactionType
|
||||
faction2: FactionType
|
||||
treaty_type: str # "peace", "trade", "alliance"
|
||||
start_turn: int
|
||||
duration: int
|
||||
terms: dict = field(default_factory=dict)
|
||||
|
||||
def is_active(self, current_turn: int) -> bool:
|
||||
"""Check if treaty is still active."""
|
||||
if self.duration <= 0: # Permanent
|
||||
return True
|
||||
return current_turn < self.start_turn + self.duration
|
||||
|
||||
def turns_remaining(self, current_turn: int) -> int:
|
||||
"""Get turns remaining in treaty."""
|
||||
if self.duration <= 0:
|
||||
return -1 # Permanent
|
||||
return max(0, (self.start_turn + self.duration) - current_turn)
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
"faction1": self.faction1.value,
|
||||
"faction2": self.faction2.value,
|
||||
"treaty_type": self.treaty_type,
|
||||
"start_turn": self.start_turn,
|
||||
"duration": self.duration,
|
||||
"terms": self.terms,
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class FactionRelations:
|
||||
"""Manages relations between all factions."""
|
||||
# Relations matrix (faction -> faction -> relation value 0-100)
|
||||
relations: Dict[FactionType, Dict[FactionType, int]] = field(default_factory=dict)
|
||||
|
||||
# Active wars
|
||||
active_wars: Set[tuple] = field(default_factory=set)
|
||||
|
||||
# Active treaties
|
||||
treaties: list = field(default_factory=list)
|
||||
|
||||
# War exhaustion per faction
|
||||
war_exhaustion: Dict[FactionType, int] = field(default_factory=dict)
|
||||
|
||||
def __post_init__(self):
|
||||
self._initialize_relations()
|
||||
|
||||
def _initialize_relations(self) -> None:
|
||||
"""Initialize default relations between all factions."""
|
||||
from backend.config import get_config
|
||||
config = get_config()
|
||||
starting = config.diplomacy.starting_relations
|
||||
|
||||
for faction1 in FactionType:
|
||||
if faction1 not in self.relations:
|
||||
self.relations[faction1] = {}
|
||||
if faction1 not in self.war_exhaustion:
|
||||
self.war_exhaustion[faction1] = 0
|
||||
|
||||
for faction2 in FactionType:
|
||||
if faction2 not in self.relations[faction1]:
|
||||
if faction1 == faction2:
|
||||
self.relations[faction1][faction2] = 100 # Perfect self-relations
|
||||
else:
|
||||
self.relations[faction1][faction2] = starting
|
||||
|
||||
def get_relation(self, faction1: FactionType, faction2: FactionType) -> int:
|
||||
"""Get relation value between two factions (0-100)."""
|
||||
if faction1 not in self.relations:
|
||||
self._initialize_relations()
|
||||
return self.relations.get(faction1, {}).get(faction2, 50)
|
||||
|
||||
def get_status(self, faction1: FactionType, faction2: FactionType) -> DiplomaticStatus:
|
||||
"""Get diplomatic status between factions."""
|
||||
if faction1 == faction2:
|
||||
return DiplomaticStatus.ALLIED
|
||||
|
||||
from backend.config import get_config
|
||||
config = get_config()
|
||||
|
||||
# Check for active war
|
||||
war_pair = tuple(sorted([faction1.value, faction2.value]))
|
||||
if war_pair in self.active_wars:
|
||||
return DiplomaticStatus.WAR
|
||||
|
||||
relation = self.get_relation(faction1, faction2)
|
||||
|
||||
if relation >= config.diplomacy.alliance_threshold:
|
||||
return DiplomaticStatus.ALLIED
|
||||
elif relation >= 65:
|
||||
return DiplomaticStatus.FRIENDLY
|
||||
elif relation >= 40:
|
||||
return DiplomaticStatus.NEUTRAL
|
||||
elif relation >= config.diplomacy.war_threshold:
|
||||
return DiplomaticStatus.COLD
|
||||
else:
|
||||
return DiplomaticStatus.HOSTILE
|
||||
|
||||
def modify_relation(self, faction1: FactionType, faction2: FactionType, amount: int) -> int:
|
||||
"""Modify relation between factions (symmetric)."""
|
||||
if faction1 == faction2:
|
||||
return 100
|
||||
|
||||
if faction1 not in self.relations:
|
||||
self._initialize_relations()
|
||||
|
||||
# Modify symmetrically
|
||||
current1 = self.relations[faction1].get(faction2, 50)
|
||||
current2 = self.relations[faction2].get(faction1, 50)
|
||||
|
||||
new_value1 = max(0, min(100, current1 + amount))
|
||||
new_value2 = max(0, min(100, current2 + amount))
|
||||
|
||||
self.relations[faction1][faction2] = new_value1
|
||||
self.relations[faction2][faction1] = new_value2
|
||||
|
||||
return new_value1
|
||||
|
||||
def declare_war(self, aggressor: FactionType, defender: FactionType, turn: int) -> bool:
|
||||
"""Declare war between factions."""
|
||||
if aggressor == defender:
|
||||
return False
|
||||
if aggressor == FactionType.NEUTRAL or defender == FactionType.NEUTRAL:
|
||||
return False
|
||||
|
||||
war_pair = tuple(sorted([aggressor.value, defender.value]))
|
||||
if war_pair in self.active_wars:
|
||||
return False # Already at war
|
||||
|
||||
self.active_wars.add(war_pair)
|
||||
|
||||
# Relations plummet
|
||||
self.modify_relation(aggressor, defender, -50)
|
||||
|
||||
# Cancel any treaties
|
||||
self.treaties = [
|
||||
t for t in self.treaties
|
||||
if not (t.faction1 in (aggressor, defender) and t.faction2 in (aggressor, defender))
|
||||
]
|
||||
|
||||
return True
|
||||
|
||||
def make_peace(self, faction1: FactionType, faction2: FactionType, turn: int) -> bool:
|
||||
"""Make peace between warring factions."""
|
||||
from backend.config import get_config
|
||||
config = get_config()
|
||||
|
||||
war_pair = tuple(sorted([faction1.value, faction2.value]))
|
||||
if war_pair not in self.active_wars:
|
||||
return False
|
||||
|
||||
self.active_wars.remove(war_pair)
|
||||
|
||||
# Create peace treaty
|
||||
treaty = Treaty(
|
||||
faction1=faction1,
|
||||
faction2=faction2,
|
||||
treaty_type="peace",
|
||||
start_turn=turn,
|
||||
duration=config.diplomacy.peace_treaty_duration,
|
||||
)
|
||||
self.treaties.append(treaty)
|
||||
|
||||
# Improve relations slightly
|
||||
self.modify_relation(faction1, faction2, 15)
|
||||
|
||||
# Reset war exhaustion
|
||||
self.war_exhaustion[faction1] = 0
|
||||
self.war_exhaustion[faction2] = 0
|
||||
|
||||
return True
|
||||
|
||||
def form_alliance(self, faction1: FactionType, faction2: FactionType, turn: int) -> bool:
|
||||
"""Form an alliance between factions."""
|
||||
from backend.config import get_config
|
||||
config = get_config()
|
||||
|
||||
if faction1 == faction2:
|
||||
return False
|
||||
|
||||
relation = self.get_relation(faction1, faction2)
|
||||
if relation < config.diplomacy.alliance_threshold:
|
||||
return False
|
||||
|
||||
# Check not already allied
|
||||
for treaty in self.treaties:
|
||||
if treaty.treaty_type == "alliance":
|
||||
if faction1 in (treaty.faction1, treaty.faction2) and faction2 in (treaty.faction1, treaty.faction2):
|
||||
return False
|
||||
|
||||
treaty = Treaty(
|
||||
faction1=faction1,
|
||||
faction2=faction2,
|
||||
treaty_type="alliance",
|
||||
start_turn=turn,
|
||||
duration=0, # Permanent until broken
|
||||
)
|
||||
self.treaties.append(treaty)
|
||||
|
||||
return True
|
||||
|
||||
def update_turn(self, current_turn: int) -> None:
|
||||
"""Update diplomacy state each turn."""
|
||||
from backend.config import get_config
|
||||
config = get_config()
|
||||
|
||||
# Remove expired treaties
|
||||
self.treaties = [t for t in self.treaties if t.is_active(current_turn)]
|
||||
|
||||
# Relations naturally decay over time (things get worse without diplomacy)
|
||||
# This makes active diplomacy necessary to maintain peace
|
||||
for faction1 in FactionType:
|
||||
for faction2 in FactionType:
|
||||
if faction1 != faction2 and faction1 != FactionType.NEUTRAL and faction2 != FactionType.NEUTRAL:
|
||||
current = self.get_relation(faction1, faction2)
|
||||
# Relations decay down towards hostility
|
||||
# Only decay if above minimum (0) to avoid negative values
|
||||
if current > 0:
|
||||
self.relations[faction1][faction2] = max(0, current - config.diplomacy.relation_decay)
|
||||
|
||||
# Increase war exhaustion for factions at war
|
||||
for war_pair in self.active_wars:
|
||||
faction1_name, faction2_name = war_pair
|
||||
for faction in FactionType:
|
||||
if faction.value in (faction1_name, faction2_name):
|
||||
self.war_exhaustion[faction] = self.war_exhaustion.get(faction, 0) + config.diplomacy.war_exhaustion_rate
|
||||
|
||||
def is_at_war(self, faction1: FactionType, faction2: FactionType) -> bool:
|
||||
"""Check if two factions are at war."""
|
||||
if faction1 == faction2:
|
||||
return False
|
||||
war_pair = tuple(sorted([faction1.value, faction2.value]))
|
||||
return war_pair in self.active_wars
|
||||
|
||||
def is_allied(self, faction1: FactionType, faction2: FactionType) -> bool:
|
||||
"""Check if two factions are allied."""
|
||||
if faction1 == faction2:
|
||||
return True
|
||||
for treaty in self.treaties:
|
||||
if treaty.treaty_type == "alliance":
|
||||
if faction1 in (treaty.faction1, treaty.faction2) and faction2 in (treaty.faction1, treaty.faction2):
|
||||
return True
|
||||
return False
|
||||
|
||||
def get_trade_modifier(self, faction1: FactionType, faction2: FactionType) -> float:
|
||||
"""Get trade modifier between factions based on relations."""
|
||||
if faction1 == faction2:
|
||||
return 1.2 # Same faction bonus
|
||||
|
||||
status = self.get_status(faction1, faction2)
|
||||
|
||||
modifiers = {
|
||||
DiplomaticStatus.WAR: 0.0, # No trade during war
|
||||
DiplomaticStatus.HOSTILE: 0.5,
|
||||
DiplomaticStatus.COLD: 0.8,
|
||||
DiplomaticStatus.NEUTRAL: 1.0,
|
||||
DiplomaticStatus.FRIENDLY: 1.1,
|
||||
DiplomaticStatus.ALLIED: 1.3,
|
||||
}
|
||||
|
||||
return modifiers.get(status, 1.0)
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
"""Convert to dictionary for serialization."""
|
||||
return {
|
||||
"relations": {
|
||||
f1.value: {f2.value: v for f2, v in inner.items()}
|
||||
for f1, inner in self.relations.items()
|
||||
},
|
||||
"active_wars": list(self.active_wars),
|
||||
"treaties": [t.to_dict() for t in self.treaties],
|
||||
"war_exhaustion": {f.value: e for f, e in self.war_exhaustion.items()},
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class AgentDiplomacy:
|
||||
"""An agent's diplomatic state and faction membership."""
|
||||
faction: FactionType = FactionType.NEUTRAL
|
||||
|
||||
# Personal relations with other agents (agent_id -> relation value)
|
||||
personal_relations: Dict[str, int] = field(default_factory=dict)
|
||||
|
||||
# Diplomatic actions taken
|
||||
negotiations_conducted: int = 0
|
||||
wars_declared: int = 0
|
||||
peace_treaties_made: int = 0
|
||||
|
||||
@property
|
||||
def traits(self) -> dict:
|
||||
"""Get faction traits."""
|
||||
return FACTION_TRAITS.get(self.faction, FACTION_TRAITS[FactionType.NEUTRAL])
|
||||
|
||||
@property
|
||||
def diplomacy_skill(self) -> float:
|
||||
"""Get base diplomacy skill from faction."""
|
||||
return self.traits.get("diplomacy_skill", 0.5)
|
||||
|
||||
@property
|
||||
def aggression(self) -> float:
|
||||
"""Get faction aggression level."""
|
||||
return self.traits.get("aggression", 0.0)
|
||||
|
||||
@property
|
||||
def trade_preference(self) -> float:
|
||||
"""Get faction trade preference."""
|
||||
return self.traits.get("trade_preference", 1.0)
|
||||
|
||||
def get_personal_relation(self, other_id: str) -> int:
|
||||
"""Get personal relation with another agent."""
|
||||
return self.personal_relations.get(other_id, 50)
|
||||
|
||||
def modify_personal_relation(self, other_id: str, amount: int) -> int:
|
||||
"""Modify personal relation with another agent."""
|
||||
current = self.personal_relations.get(other_id, 50)
|
||||
new_value = max(0, min(100, current + amount))
|
||||
self.personal_relations[other_id] = new_value
|
||||
return new_value
|
||||
|
||||
def should_negotiate(self, other: "AgentDiplomacy", faction_relations: FactionRelations) -> bool:
|
||||
"""Check if agent should try to negotiate with another."""
|
||||
if self.faction == FactionType.NEUTRAL:
|
||||
return False
|
||||
|
||||
# Check if at war - high motivation to negotiate peace if exhausted
|
||||
if faction_relations.is_at_war(self.faction, other.faction):
|
||||
exhaustion = faction_relations.war_exhaustion.get(self.faction, 0)
|
||||
return exhaustion > 20 and random.random() < self.diplomacy_skill
|
||||
|
||||
# Try to improve relations if not allied
|
||||
if not faction_relations.is_allied(self.faction, other.faction):
|
||||
return random.random() < self.diplomacy_skill * 0.3
|
||||
|
||||
return False
|
||||
|
||||
def should_declare_war(self, other: "AgentDiplomacy", faction_relations: FactionRelations) -> bool:
|
||||
"""Check if agent should try to declare war."""
|
||||
if self.faction == FactionType.NEUTRAL or other.faction == FactionType.NEUTRAL:
|
||||
return False
|
||||
if self.faction == other.faction:
|
||||
return False
|
||||
if faction_relations.is_at_war(self.faction, other.faction):
|
||||
return False # Already at war
|
||||
|
||||
relation = faction_relations.get_relation(self.faction, other.faction)
|
||||
|
||||
# War is more likely with low relations and high aggression
|
||||
war_probability = (self.aggression * (1 - relation / 100)) * 0.2
|
||||
|
||||
return random.random() < war_probability
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
"""Convert to dictionary for serialization."""
|
||||
return {
|
||||
"faction": self.faction.value,
|
||||
"faction_description": self.traits.get("description", ""),
|
||||
"faction_color": self.traits.get("color", "#808080"),
|
||||
"diplomacy_skill": self.diplomacy_skill,
|
||||
"aggression": self.aggression,
|
||||
"negotiations_conducted": self.negotiations_conducted,
|
||||
"wars_declared": self.wars_declared,
|
||||
"peace_treaties_made": self.peace_treaties_made,
|
||||
}
|
||||
|
||||
|
||||
def generate_random_faction(archetype: Optional[str] = None) -> AgentDiplomacy:
|
||||
"""Generate random faction membership for an agent."""
|
||||
factions = list(FactionType)
|
||||
weights = [1.0] * len(factions)
|
||||
|
||||
# Lower weight for neutral
|
||||
weights[factions.index(FactionType.NEUTRAL)] = 0.3
|
||||
|
||||
# Archetype influences faction choice
|
||||
if archetype == "hunter":
|
||||
weights[factions.index(FactionType.FORESTKIN)] = 3.0
|
||||
weights[factions.index(FactionType.MOUNTAINEER)] = 2.0
|
||||
elif archetype == "gatherer":
|
||||
weights[factions.index(FactionType.PLAINSMEN)] = 3.0
|
||||
weights[factions.index(FactionType.RIVERFOLK)] = 2.0
|
||||
elif archetype == "trader":
|
||||
weights[factions.index(FactionType.RIVERFOLK)] = 3.0
|
||||
elif archetype == "woodcutter":
|
||||
weights[factions.index(FactionType.NORTHLANDS)] = 3.0
|
||||
weights[factions.index(FactionType.FORESTKIN)] = 2.0
|
||||
|
||||
# Weighted random selection
|
||||
total = sum(weights)
|
||||
r = random.random() * total
|
||||
cumulative = 0
|
||||
chosen_faction = FactionType.NEUTRAL
|
||||
|
||||
for faction, weight in zip(factions, weights):
|
||||
cumulative += weight
|
||||
if r <= cumulative:
|
||||
chosen_faction = faction
|
||||
break
|
||||
|
||||
return AgentDiplomacy(faction=chosen_faction)
|
||||
|
||||
|
||||
# Global faction relations (shared across all agents)
|
||||
_global_faction_relations: Optional[FactionRelations] = None
|
||||
|
||||
|
||||
def get_faction_relations() -> FactionRelations:
|
||||
"""Get the global faction relations instance."""
|
||||
global _global_faction_relations
|
||||
if _global_faction_relations is None:
|
||||
_global_faction_relations = FactionRelations()
|
||||
return _global_faction_relations
|
||||
|
||||
|
||||
def reset_faction_relations() -> FactionRelations:
|
||||
"""Reset faction relations to default state."""
|
||||
global _global_faction_relations
|
||||
_global_faction_relations = FactionRelations()
|
||||
return _global_faction_relations
|
||||
|
||||
337
backend/domain/religion.py
Normal file
337
backend/domain/religion.py
Normal file
@ -0,0 +1,337 @@
|
||||
"""Religion system for the Village Simulation.
|
||||
|
||||
Creates diverse religious beliefs that affect agent behavior:
|
||||
- Each agent has a religion (or atheist)
|
||||
- Faith level affects actions and decisions
|
||||
- Same-religion agents cooperate better
|
||||
- Different religions can create conflict
|
||||
- High faith agents become zealots
|
||||
"""
|
||||
|
||||
import random
|
||||
from dataclasses import dataclass, field
|
||||
from enum import Enum
|
||||
from typing import Optional
|
||||
|
||||
|
||||
class ReligionType(Enum):
|
||||
"""Types of religions in the simulation.
|
||||
|
||||
These represent different belief systems with unique characteristics.
|
||||
"""
|
||||
ATHEIST = "atheist" # No religion - neutral
|
||||
SOLARIS = "solaris" # Sun worshippers - value energy and activity
|
||||
AQUARIUS = "aquarius" # Water worshippers - value water and peace
|
||||
TERRANUS = "terranus" # Earth worshippers - value resources and hoarding
|
||||
IGNIS = "ignis" # Fire worshippers - value heat and trade
|
||||
NATURIS = "naturis" # Nature worshippers - value gathering and sustainability
|
||||
|
||||
|
||||
# Religion characteristics
|
||||
RELIGION_TRAITS = {
|
||||
ReligionType.ATHEIST: {
|
||||
"description": "No religious belief",
|
||||
"bonus_action": None,
|
||||
"preferred_resource": None,
|
||||
"aggression": 0.0,
|
||||
"conversion_resistance": 0.3,
|
||||
},
|
||||
ReligionType.SOLARIS: {
|
||||
"description": "Worshippers of the Sun",
|
||||
"bonus_action": "hunt", # Sun gives strength to hunt
|
||||
"preferred_resource": "meat",
|
||||
"aggression": 0.4, # Moderate aggression
|
||||
"conversion_resistance": 0.6,
|
||||
},
|
||||
ReligionType.AQUARIUS: {
|
||||
"description": "Worshippers of Water",
|
||||
"bonus_action": "get_water",
|
||||
"preferred_resource": "water",
|
||||
"aggression": 0.1, # Peaceful religion
|
||||
"conversion_resistance": 0.7,
|
||||
},
|
||||
ReligionType.TERRANUS: {
|
||||
"description": "Worshippers of the Earth",
|
||||
"bonus_action": "gather",
|
||||
"preferred_resource": "berries",
|
||||
"aggression": 0.2,
|
||||
"conversion_resistance": 0.8,
|
||||
},
|
||||
ReligionType.IGNIS: {
|
||||
"description": "Worshippers of Fire",
|
||||
"bonus_action": "trade", # Fire of commerce
|
||||
"preferred_resource": "wood",
|
||||
"aggression": 0.5, # Hot-tempered
|
||||
"conversion_resistance": 0.5,
|
||||
},
|
||||
ReligionType.NATURIS: {
|
||||
"description": "Worshippers of Nature",
|
||||
"bonus_action": "gather",
|
||||
"preferred_resource": "berries",
|
||||
"aggression": 0.15, # Peaceful
|
||||
"conversion_resistance": 0.75,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class ReligiousBeliefs:
|
||||
"""An agent's religious beliefs and faith state."""
|
||||
religion: ReligionType = ReligionType.ATHEIST
|
||||
faith: int = 50 # 0-100, loaded from config
|
||||
|
||||
# Historical conversion tracking
|
||||
times_converted: int = 0
|
||||
last_prayer_turn: int = -1
|
||||
|
||||
# Zealot state
|
||||
is_zealot: bool = False
|
||||
|
||||
# Religious influence
|
||||
converts_made: int = 0
|
||||
sermons_given: int = 0
|
||||
|
||||
def __post_init__(self):
|
||||
self._update_zealot_status()
|
||||
|
||||
def _update_zealot_status(self) -> None:
|
||||
"""Update zealot status based on faith level."""
|
||||
from backend.config import get_config
|
||||
config = get_config()
|
||||
threshold = int(config.religion.zealot_threshold * 100)
|
||||
self.is_zealot = self.faith >= threshold
|
||||
|
||||
@property
|
||||
def traits(self) -> dict:
|
||||
"""Get traits for current religion."""
|
||||
return RELIGION_TRAITS.get(self.religion, RELIGION_TRAITS[ReligionType.ATHEIST])
|
||||
|
||||
@property
|
||||
def description(self) -> str:
|
||||
"""Get religion description."""
|
||||
return self.traits["description"]
|
||||
|
||||
@property
|
||||
def is_religious(self) -> bool:
|
||||
"""Check if agent has a religion."""
|
||||
return self.religion != ReligionType.ATHEIST
|
||||
|
||||
@property
|
||||
def conversion_resistance(self) -> float:
|
||||
"""Get resistance to conversion."""
|
||||
base = self.traits.get("conversion_resistance", 0.5)
|
||||
# Higher faith = harder to convert
|
||||
faith_modifier = self.faith / 100 * 0.3
|
||||
return min(0.95, base + faith_modifier)
|
||||
|
||||
def gain_faith(self, amount: int) -> None:
|
||||
"""Increase faith level."""
|
||||
self.faith = min(100, self.faith + amount)
|
||||
self._update_zealot_status()
|
||||
|
||||
def lose_faith(self, amount: int) -> None:
|
||||
"""Decrease faith level."""
|
||||
self.faith = max(0, self.faith - amount)
|
||||
self._update_zealot_status()
|
||||
|
||||
def apply_decay(self) -> None:
|
||||
"""Apply faith decay per turn (if not recently prayed)."""
|
||||
from backend.config import get_config
|
||||
decay = get_config().agent_stats.faith_decay
|
||||
self.faith = max(0, self.faith - decay)
|
||||
self._update_zealot_status()
|
||||
|
||||
def convert_to(self, new_religion: ReligionType, faith_level: int = 30) -> bool:
|
||||
"""Attempt to convert to a new religion."""
|
||||
if new_religion == self.religion:
|
||||
return False
|
||||
|
||||
# Check conversion resistance
|
||||
if random.random() < self.conversion_resistance:
|
||||
return False
|
||||
|
||||
self.religion = new_religion
|
||||
self.faith = faith_level
|
||||
self.times_converted += 1
|
||||
self._update_zealot_status()
|
||||
return True
|
||||
|
||||
def record_prayer(self, turn: int) -> None:
|
||||
"""Record that prayer was performed."""
|
||||
self.last_prayer_turn = turn
|
||||
|
||||
def record_conversion(self) -> None:
|
||||
"""Record a successful conversion made."""
|
||||
self.converts_made += 1
|
||||
|
||||
def record_sermon(self) -> None:
|
||||
"""Record a sermon given."""
|
||||
self.sermons_given += 1
|
||||
|
||||
def get_trade_modifier(self, other: "ReligiousBeliefs") -> float:
|
||||
"""Get trade modifier when dealing with another agent's religion."""
|
||||
from backend.config import get_config
|
||||
config = get_config()
|
||||
|
||||
if self.religion == ReligionType.ATHEIST or other.religion == ReligionType.ATHEIST:
|
||||
return 1.0 # No modifier for atheists
|
||||
|
||||
if self.religion == other.religion:
|
||||
# Same religion bonus
|
||||
bonus = config.religion.same_religion_bonus
|
||||
# Zealots get extra bonus
|
||||
if self.is_zealot and other.is_zealot:
|
||||
bonus *= 1.5
|
||||
return 1.0 + bonus
|
||||
else:
|
||||
# Different religion penalty
|
||||
penalty = config.religion.different_religion_penalty
|
||||
# Zealots are more hostile to other religions
|
||||
if self.is_zealot:
|
||||
penalty *= 1.5
|
||||
return 1.0 - penalty
|
||||
|
||||
def should_convert_other(self, other: "ReligiousBeliefs") -> bool:
|
||||
"""Check if agent should try to convert another agent."""
|
||||
if not self.is_religious:
|
||||
return False
|
||||
if self.religion == other.religion:
|
||||
return False
|
||||
# Zealots always want to convert
|
||||
if self.is_zealot:
|
||||
return True
|
||||
# High faith agents sometimes want to convert
|
||||
return random.random() < (self.faith / 100) * 0.5
|
||||
|
||||
def is_hostile_to(self, other: "ReligiousBeliefs") -> bool:
|
||||
"""Check if religiously hostile to another agent."""
|
||||
if not self.is_religious or not other.is_religious:
|
||||
return False
|
||||
if self.religion == other.religion:
|
||||
return False
|
||||
|
||||
from backend.config import get_config
|
||||
config = get_config()
|
||||
|
||||
# Only zealots are hostile
|
||||
if not self.is_zealot:
|
||||
return False
|
||||
|
||||
# Check if faith is high enough for holy war
|
||||
if self.faith >= config.religion.holy_war_threshold * 100:
|
||||
return True
|
||||
|
||||
return random.random() < self.traits.get("aggression", 0.0)
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
"""Convert to dictionary for serialization."""
|
||||
return {
|
||||
"religion": self.religion.value,
|
||||
"faith": self.faith,
|
||||
"is_zealot": self.is_zealot,
|
||||
"times_converted": self.times_converted,
|
||||
"converts_made": self.converts_made,
|
||||
"description": self.description,
|
||||
}
|
||||
|
||||
|
||||
def generate_random_religion(archetype: Optional[str] = None) -> ReligiousBeliefs:
|
||||
"""Generate random religious beliefs for an agent.
|
||||
|
||||
Args:
|
||||
archetype: Optional personality archetype that influences religion
|
||||
"""
|
||||
from backend.config import get_config
|
||||
config = get_config()
|
||||
|
||||
# Get available religions
|
||||
religions = list(ReligionType)
|
||||
|
||||
# Weight by archetype
|
||||
weights = [1.0] * len(religions)
|
||||
|
||||
if archetype == "hunter":
|
||||
# Hunters favor Solaris (sun/strength)
|
||||
weights[religions.index(ReligionType.SOLARIS)] = 3.0
|
||||
weights[religions.index(ReligionType.IGNIS)] = 2.0
|
||||
elif archetype == "gatherer":
|
||||
# Gatherers favor Naturis/Terranus
|
||||
weights[religions.index(ReligionType.NATURIS)] = 3.0
|
||||
weights[religions.index(ReligionType.TERRANUS)] = 2.0
|
||||
elif archetype == "trader":
|
||||
# Traders favor Ignis (commerce)
|
||||
weights[religions.index(ReligionType.IGNIS)] = 3.0
|
||||
weights[religions.index(ReligionType.AQUARIUS)] = 2.0 # Water trade routes
|
||||
elif archetype == "woodcutter":
|
||||
weights[religions.index(ReligionType.TERRANUS)] = 2.0
|
||||
weights[religions.index(ReligionType.NATURIS)] = 1.5
|
||||
|
||||
# Atheists are uncommon - lower base weight
|
||||
weights[religions.index(ReligionType.ATHEIST)] = 0.2
|
||||
|
||||
# Weighted random selection
|
||||
total = sum(weights)
|
||||
r = random.random() * total
|
||||
cumulative = 0
|
||||
chosen_religion = ReligionType.ATHEIST
|
||||
|
||||
for i, (religion, weight) in enumerate(zip(religions, weights)):
|
||||
cumulative += weight
|
||||
if r <= cumulative:
|
||||
chosen_religion = religion
|
||||
break
|
||||
|
||||
# Starting faith varies
|
||||
if chosen_religion == ReligionType.ATHEIST:
|
||||
starting_faith = random.randint(0, 20)
|
||||
else:
|
||||
starting_faith = random.randint(30, 70)
|
||||
|
||||
return ReligiousBeliefs(
|
||||
religion=chosen_religion,
|
||||
faith=starting_faith,
|
||||
)
|
||||
|
||||
|
||||
def get_religion_compatibility(religion1: ReligionType, religion2: ReligionType) -> float:
|
||||
"""Get compatibility score between two religions (0-1)."""
|
||||
if religion1 == religion2:
|
||||
return 1.0
|
||||
|
||||
if religion1 == ReligionType.ATHEIST or religion2 == ReligionType.ATHEIST:
|
||||
return 0.7 # Atheists are neutral
|
||||
|
||||
# Compatible pairs
|
||||
compatible_pairs = [
|
||||
(ReligionType.NATURIS, ReligionType.AQUARIUS), # Nature and water
|
||||
(ReligionType.TERRANUS, ReligionType.NATURIS), # Earth and nature
|
||||
(ReligionType.SOLARIS, ReligionType.IGNIS), # Sun and fire
|
||||
]
|
||||
|
||||
# Hostile pairs
|
||||
hostile_pairs = [
|
||||
(ReligionType.AQUARIUS, ReligionType.IGNIS), # Water vs fire
|
||||
(ReligionType.SOLARIS, ReligionType.AQUARIUS), # Sun vs water
|
||||
]
|
||||
|
||||
pair = (religion1, religion2)
|
||||
reverse_pair = (religion2, religion1)
|
||||
|
||||
if pair in compatible_pairs or reverse_pair in compatible_pairs:
|
||||
return 0.8
|
||||
if pair in hostile_pairs or reverse_pair in hostile_pairs:
|
||||
return 0.3
|
||||
|
||||
return 0.5 # Neutral
|
||||
|
||||
|
||||
def get_religion_action_bonus(religion: ReligionType, action_type: str) -> float:
|
||||
"""Get action bonus/penalty for a religion performing an action."""
|
||||
traits = RELIGION_TRAITS.get(religion, {})
|
||||
bonus_action = traits.get("bonus_action")
|
||||
|
||||
if bonus_action == action_type:
|
||||
return 1.15 # 15% bonus for favored action
|
||||
|
||||
return 1.0
|
||||
|
||||
@ -19,6 +19,9 @@ class ResourceType(Enum):
|
||||
WOOD = "wood"
|
||||
HIDE = "hide"
|
||||
CLOTHES = "clothes"
|
||||
# NEW: Oil industry resources
|
||||
OIL = "oil" # Raw crude oil - must be refined
|
||||
FUEL = "fuel" # Refined fuel - provides heat and energy
|
||||
|
||||
|
||||
@dataclass
|
||||
@ -32,7 +35,6 @@ class ResourceEffect:
|
||||
|
||||
def get_resource_effects() -> dict[ResourceType, ResourceEffect]:
|
||||
"""Get resource effects from the global config."""
|
||||
# Import here to avoid circular imports
|
||||
from backend.config import get_config
|
||||
|
||||
config = get_config()
|
||||
@ -53,12 +55,19 @@ def get_resource_effects() -> dict[ResourceType, ResourceEffect]:
|
||||
ResourceType.WOOD: ResourceEffect(), # Used as fuel, not consumed directly
|
||||
ResourceType.HIDE: ResourceEffect(), # Used for crafting
|
||||
ResourceType.CLOTHES: ResourceEffect(), # Passive heat reduction effect
|
||||
# NEW: Oil resources
|
||||
ResourceType.OIL: ResourceEffect(
|
||||
energy=resources.oil_energy, # Raw oil has no direct use
|
||||
),
|
||||
ResourceType.FUEL: ResourceEffect(
|
||||
energy=resources.fuel_energy, # Refined fuel provides energy
|
||||
heat=resources.fuel_heat, # And significant heat
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def get_resource_decay_rates() -> dict[ResourceType, Optional[int]]:
|
||||
"""Get resource decay rates from the global config."""
|
||||
# Import here to avoid circular imports
|
||||
from backend.config import get_config
|
||||
|
||||
config = get_config()
|
||||
@ -71,6 +80,9 @@ def get_resource_decay_rates() -> dict[ResourceType, Optional[int]]:
|
||||
ResourceType.WOOD: None, # Infinite
|
||||
ResourceType.HIDE: None, # Infinite
|
||||
ResourceType.CLOTHES: resources.clothes_decay if resources.clothes_decay > 0 else None,
|
||||
# NEW: Oil resources don't decay
|
||||
ResourceType.OIL: resources.oil_decay if resources.oil_decay > 0 else None,
|
||||
ResourceType.FUEL: resources.fuel_decay if resources.fuel_decay > 0 else None,
|
||||
}
|
||||
|
||||
|
||||
@ -80,6 +92,12 @@ def get_fire_heat() -> int:
|
||||
return get_config().resources.fire_heat
|
||||
|
||||
|
||||
def get_fuel_heat() -> int:
|
||||
"""Get heat provided by burning fuel."""
|
||||
from backend.config import get_config
|
||||
return get_config().resources.fuel_heat
|
||||
|
||||
|
||||
# Cached values for performance
|
||||
_resource_effects_cache: Optional[dict[ResourceType, ResourceEffect]] = None
|
||||
_resource_decay_cache: Optional[dict[ResourceType, Optional[int]]] = None
|
||||
@ -140,6 +158,37 @@ RESOURCE_EFFECTS = _ResourceEffectsAccessor()
|
||||
RESOURCE_DECAY_RATES = _ResourceDecayAccessor()
|
||||
|
||||
|
||||
# Resource categories for AI and display
|
||||
FOOD_RESOURCES = {ResourceType.MEAT, ResourceType.BERRIES}
|
||||
DRINK_RESOURCES = {ResourceType.WATER}
|
||||
HEAT_RESOURCES = {ResourceType.WOOD, ResourceType.FUEL}
|
||||
CRAFTING_MATERIALS = {ResourceType.HIDE, ResourceType.OIL}
|
||||
VALUABLE_RESOURCES = {ResourceType.OIL, ResourceType.FUEL, ResourceType.CLOTHES}
|
||||
|
||||
|
||||
def get_resource_base_value(resource_type: ResourceType) -> int:
|
||||
"""Get the base economic value of a resource."""
|
||||
from backend.config import get_config
|
||||
config = get_config()
|
||||
|
||||
# Oil and fuel have special pricing
|
||||
if resource_type == ResourceType.OIL:
|
||||
return config.economy.oil_base_price
|
||||
elif resource_type == ResourceType.FUEL:
|
||||
return config.economy.fuel_base_price
|
||||
|
||||
# Other resources based on production cost
|
||||
base_values = {
|
||||
ResourceType.MEAT: 15,
|
||||
ResourceType.BERRIES: 5,
|
||||
ResourceType.WATER: 3,
|
||||
ResourceType.WOOD: 8,
|
||||
ResourceType.HIDE: 10,
|
||||
ResourceType.CLOTHES: 20,
|
||||
}
|
||||
return base_values.get(resource_type, 10)
|
||||
|
||||
|
||||
@dataclass
|
||||
class Resource:
|
||||
"""A resource instance in the simulation."""
|
||||
@ -157,6 +206,16 @@ class Resource:
|
||||
"""Get the effect of consuming this resource."""
|
||||
return get_cached_resource_effects()[self.type]
|
||||
|
||||
@property
|
||||
def is_valuable(self) -> bool:
|
||||
"""Check if this is a high-value resource."""
|
||||
return self.type in VALUABLE_RESOURCES
|
||||
|
||||
@property
|
||||
def base_value(self) -> int:
|
||||
"""Get the base economic value."""
|
||||
return get_resource_base_value(self.type)
|
||||
|
||||
def is_expired(self, current_turn: int) -> bool:
|
||||
"""Check if the resource has decayed."""
|
||||
if self.decay_rate is None:
|
||||
@ -177,4 +236,5 @@ class Resource:
|
||||
"quantity": self.quantity,
|
||||
"created_turn": self.created_turn,
|
||||
"decay_rate": self.decay_rate,
|
||||
"base_value": self.base_value,
|
||||
}
|
||||
|
||||
@ -1,17 +1,12 @@
|
||||
"""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",
|
||||
@ -53,7 +48,6 @@ def root():
|
||||
"name": "Village Simulation API",
|
||||
"version": "1.0.0",
|
||||
"docs": "/docs",
|
||||
"web_frontend": "/web/",
|
||||
"status": "running",
|
||||
}
|
||||
|
||||
@ -69,14 +63,6 @@ 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(
|
||||
|
||||
135
config.json
135
config.json
@ -1,73 +1,118 @@
|
||||
{
|
||||
"agent_stats": {
|
||||
"max_energy": 50,
|
||||
"max_energy": 60,
|
||||
"max_hunger": 100,
|
||||
"max_thirst": 100,
|
||||
"max_heat": 100,
|
||||
"start_energy": 50,
|
||||
"start_hunger": 70,
|
||||
"start_thirst": 75,
|
||||
"max_faith": 100,
|
||||
"start_energy": 60,
|
||||
"start_hunger": 90,
|
||||
"start_thirst": 90,
|
||||
"start_heat": 100,
|
||||
"start_faith": 45,
|
||||
"energy_decay": 1,
|
||||
"hunger_decay": 2,
|
||||
"thirst_decay": 3,
|
||||
"heat_decay": 3,
|
||||
"critical_threshold": 0.25,
|
||||
"hunger_decay": 1,
|
||||
"thirst_decay": 2,
|
||||
"heat_decay": 2,
|
||||
"faith_decay": 1,
|
||||
"critical_threshold": 0.18,
|
||||
"low_energy_threshold": 12
|
||||
},
|
||||
"resources": {
|
||||
"meat_decay": 10,
|
||||
"berries_decay": 6,
|
||||
"clothes_decay": 20,
|
||||
"meat_hunger": 35,
|
||||
"meat_energy": 12,
|
||||
"berries_hunger": 10,
|
||||
"berries_thirst": 4,
|
||||
"water_thirst": 50,
|
||||
"fire_heat": 20
|
||||
"meat_decay": 15,
|
||||
"berries_decay": 10,
|
||||
"clothes_decay": 30,
|
||||
"oil_decay": 0,
|
||||
"fuel_decay": 0,
|
||||
"meat_hunger": 45,
|
||||
"meat_energy": 15,
|
||||
"berries_hunger": 15,
|
||||
"berries_thirst": 6,
|
||||
"water_thirst": 60,
|
||||
"fire_heat": 30,
|
||||
"fuel_heat": 45,
|
||||
"oil_energy": 0,
|
||||
"fuel_energy": 12
|
||||
},
|
||||
"actions": {
|
||||
"sleep_energy": 55,
|
||||
"rest_energy": 12,
|
||||
"hunt_energy": -7,
|
||||
"gather_energy": -3,
|
||||
"chop_wood_energy": -6,
|
||||
"rest_energy": 15,
|
||||
"hunt_energy": -5,
|
||||
"gather_energy": -2,
|
||||
"chop_wood_energy": -4,
|
||||
"get_water_energy": -2,
|
||||
"weave_energy": -6,
|
||||
"build_fire_energy": -4,
|
||||
"weave_energy": -4,
|
||||
"build_fire_energy": -3,
|
||||
"trade_energy": -1,
|
||||
"hunt_success": 0.70,
|
||||
"drill_oil_energy": -7,
|
||||
"refine_energy": -5,
|
||||
"pray_energy": -2,
|
||||
"preach_energy": -3,
|
||||
"negotiate_energy": -2,
|
||||
"declare_war_energy": -3,
|
||||
"make_peace_energy": -2,
|
||||
"hunt_success": 0.80,
|
||||
"chop_wood_success": 0.90,
|
||||
"hunt_meat_min": 2,
|
||||
"hunt_meat_max": 5,
|
||||
"drill_oil_success": 0.70,
|
||||
"hunt_meat_min": 3,
|
||||
"hunt_meat_max": 6,
|
||||
"hunt_hide_min": 0,
|
||||
"hunt_hide_max": 2,
|
||||
"gather_min": 2,
|
||||
"gather_max": 4,
|
||||
"chop_wood_min": 1,
|
||||
"chop_wood_max": 3
|
||||
"gather_min": 3,
|
||||
"gather_max": 6,
|
||||
"chop_wood_min": 2,
|
||||
"chop_wood_max": 4,
|
||||
"drill_oil_min": 2,
|
||||
"drill_oil_max": 5,
|
||||
"pray_faith_gain": 18,
|
||||
"preach_faith_spread": 10,
|
||||
"preach_convert_chance": 0.10
|
||||
},
|
||||
"world": {
|
||||
"width": 25,
|
||||
"height": 25,
|
||||
"initial_agents": 25,
|
||||
"width": 50,
|
||||
"height": 50,
|
||||
"initial_agents": 100,
|
||||
"day_steps": 10,
|
||||
"night_steps": 1,
|
||||
"inventory_slots": 12,
|
||||
"starting_money": 80
|
||||
"inventory_slots": 14,
|
||||
"starting_money": 100,
|
||||
"oil_fields_count": 5,
|
||||
"temple_count": 5
|
||||
},
|
||||
"market": {
|
||||
"turns_before_discount": 15,
|
||||
"discount_rate": 0.12,
|
||||
"base_price_multiplier": 1.3
|
||||
"turns_before_discount": 10,
|
||||
"discount_rate": 0.08,
|
||||
"base_price_multiplier": 1.15
|
||||
},
|
||||
"economy": {
|
||||
"energy_to_money_ratio": 1.5,
|
||||
"wealth_desire": 0.35,
|
||||
"buy_efficiency_threshold": 0.75,
|
||||
"min_wealth_target": 50,
|
||||
"max_price_markup": 2.5,
|
||||
"min_price_discount": 0.4
|
||||
"energy_to_money_ratio": 1.2,
|
||||
"wealth_desire": 0.25,
|
||||
"buy_efficiency_threshold": 0.85,
|
||||
"min_wealth_target": 30,
|
||||
"max_price_markup": 1.8,
|
||||
"min_price_discount": 0.5,
|
||||
"oil_base_price": 18,
|
||||
"fuel_base_price": 30
|
||||
},
|
||||
"auto_step_interval": 0.15
|
||||
"religion": {
|
||||
"num_religions": 5,
|
||||
"conversion_resistance": 0.55,
|
||||
"zealot_threshold": 0.75,
|
||||
"faith_trade_bonus": 0.10,
|
||||
"same_religion_bonus": 0.12,
|
||||
"different_religion_penalty": 0.06,
|
||||
"holy_war_threshold": 0.85
|
||||
},
|
||||
"diplomacy": {
|
||||
"num_factions": 5,
|
||||
"starting_relations": 50,
|
||||
"alliance_threshold": 75,
|
||||
"war_threshold": 20,
|
||||
"relation_decay": 2,
|
||||
"trade_relation_boost": 5,
|
||||
"war_damage_multiplier": 1.2,
|
||||
"peace_treaty_duration": 15,
|
||||
"war_exhaustion_rate": 5
|
||||
},
|
||||
"auto_step_interval": 0.10
|
||||
}
|
||||
@ -1,7 +1,10 @@
|
||||
"""HTTP client for communicating with the Village Simulation backend."""
|
||||
"""HTTP client for communicating with the Village Simulation backend.
|
||||
|
||||
Handles state including religion, factions, diplomacy, and oil economy.
|
||||
"""
|
||||
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Optional, Any
|
||||
|
||||
import requests
|
||||
@ -25,6 +28,15 @@ class SimulationState:
|
||||
is_running: bool
|
||||
recent_logs: list[dict]
|
||||
|
||||
# New fields for religion, factions, diplomacy
|
||||
oil_fields: list[dict] = field(default_factory=list)
|
||||
temples: list[dict] = field(default_factory=list)
|
||||
faction_relations: dict = field(default_factory=dict)
|
||||
diplomatic_events: list[dict] = field(default_factory=list)
|
||||
religious_events: list[dict] = field(default_factory=list)
|
||||
active_wars: list[dict] = field(default_factory=list)
|
||||
peace_treaties: list[dict] = field(default_factory=list)
|
||||
|
||||
@classmethod
|
||||
def from_api_response(cls, data: dict) -> "SimulationState":
|
||||
"""Create from API response data."""
|
||||
@ -42,12 +54,75 @@ class SimulationState:
|
||||
mode=data.get("mode", "manual"),
|
||||
is_running=data.get("is_running", False),
|
||||
recent_logs=data.get("recent_logs", []),
|
||||
# New fields
|
||||
oil_fields=data.get("oil_fields", []),
|
||||
temples=data.get("temples", []),
|
||||
faction_relations=data.get("faction_relations", {}),
|
||||
diplomatic_events=data.get("diplomatic_events", []),
|
||||
religious_events=data.get("religious_events", []),
|
||||
active_wars=data.get("active_wars", []),
|
||||
peace_treaties=data.get("peace_treaties", []),
|
||||
)
|
||||
|
||||
def get_living_agents(self) -> list[dict]:
|
||||
"""Get only living agents."""
|
||||
return [a for a in self.agents if a.get("is_alive", False)]
|
||||
|
||||
def get_agents_by_faction(self) -> dict[str, list[dict]]:
|
||||
"""Group living agents by faction."""
|
||||
result: dict[str, list[dict]] = {}
|
||||
for agent in self.get_living_agents():
|
||||
# Faction is under diplomacy.faction (not faction.type)
|
||||
diplomacy = agent.get("diplomacy", {})
|
||||
faction = diplomacy.get("faction", "neutral")
|
||||
if faction not in result:
|
||||
result[faction] = []
|
||||
result[faction].append(agent)
|
||||
return result
|
||||
|
||||
def get_agents_by_religion(self) -> dict[str, list[dict]]:
|
||||
"""Group living agents by religion."""
|
||||
result: dict[str, list[dict]] = {}
|
||||
for agent in self.get_living_agents():
|
||||
# Religion type is under religion.religion (not religion.type)
|
||||
religion_data = agent.get("religion", {})
|
||||
religion = religion_data.get("religion", "atheist")
|
||||
if religion not in result:
|
||||
result[religion] = []
|
||||
result[religion].append(agent)
|
||||
return result
|
||||
|
||||
def get_faction_stats(self) -> dict:
|
||||
"""Get faction statistics."""
|
||||
stats = self.statistics.get("factions", {})
|
||||
if not stats:
|
||||
# Compute from agents if not in statistics
|
||||
by_faction = self.get_agents_by_faction()
|
||||
stats = {f: len(agents) for f, agents in by_faction.items()}
|
||||
return stats
|
||||
|
||||
def get_religion_stats(self) -> dict:
|
||||
"""Get religion statistics."""
|
||||
stats = self.statistics.get("religions", {})
|
||||
if not stats:
|
||||
# Compute from agents if not in statistics
|
||||
by_religion = self.get_agents_by_religion()
|
||||
stats = {r: len(agents) for r, agents in by_religion.items()}
|
||||
return stats
|
||||
|
||||
def get_avg_faith(self) -> float:
|
||||
"""Get average faith level."""
|
||||
avg = self.statistics.get("avg_faith", 0)
|
||||
if not avg:
|
||||
agents = self.get_living_agents()
|
||||
if agents:
|
||||
# Faith is under religion.faith
|
||||
total_faith = sum(
|
||||
a.get("religion", {}).get("faith", 50) for a in agents
|
||||
)
|
||||
avg = total_faith / len(agents)
|
||||
return avg
|
||||
|
||||
|
||||
class SimulationClient:
|
||||
"""HTTP client for the Village Simulation backend."""
|
||||
@ -82,7 +157,7 @@ class SimulationClient:
|
||||
self.connected = True
|
||||
self._retry_count = 0
|
||||
return response.json()
|
||||
except RequestException as e:
|
||||
except RequestException:
|
||||
self._retry_count += 1
|
||||
if self._retry_count >= self._max_retries:
|
||||
self.connected = False
|
||||
@ -107,7 +182,7 @@ class SimulationClient:
|
||||
if data:
|
||||
self.last_state = SimulationState.from_api_response(data)
|
||||
return self.last_state
|
||||
return self.last_state # Return cached state if request failed
|
||||
return self.last_state
|
||||
|
||||
def advance_turn(self) -> bool:
|
||||
"""Advance the simulation by one step."""
|
||||
@ -121,9 +196,9 @@ class SimulationClient:
|
||||
|
||||
def initialize(
|
||||
self,
|
||||
num_agents: int = 8,
|
||||
world_width: int = 20,
|
||||
world_height: int = 20,
|
||||
num_agents: int = 100,
|
||||
world_width: int = 30,
|
||||
world_height: int = 30,
|
||||
) -> bool:
|
||||
"""Initialize or reset the simulation."""
|
||||
result = self._request("POST", "/control/initialize", json={
|
||||
@ -177,4 +252,3 @@ class SimulationClient:
|
||||
"""Reset configuration to defaults."""
|
||||
result = self._request("POST", "/config/reset")
|
||||
return result is not None and result.get("success", False)
|
||||
|
||||
|
||||
122
frontend/main.py
122
frontend/main.py
@ -1,4 +1,7 @@
|
||||
"""Main Pygame application for the Village Simulation frontend."""
|
||||
"""Main Pygame application for the Village Simulation frontend.
|
||||
|
||||
Redesigned for fullscreen, 100+ agents, with religion, factions, and diplomacy.
|
||||
"""
|
||||
|
||||
import sys
|
||||
import pygame
|
||||
@ -12,14 +15,13 @@ from frontend.renderer.stats_renderer import StatsRenderer
|
||||
|
||||
|
||||
# Window configuration
|
||||
WINDOW_WIDTH = 1200
|
||||
WINDOW_HEIGHT = 800
|
||||
WINDOW_TITLE = "Village Economy Simulation"
|
||||
FPS = 30
|
||||
WINDOW_TITLE = "Village Simulation - Economy, Religion & Diplomacy"
|
||||
FPS = 60
|
||||
|
||||
# Layout configuration
|
||||
TOP_PANEL_HEIGHT = 50
|
||||
RIGHT_PANEL_WIDTH = 200
|
||||
# Layout ratios (will scale with screen)
|
||||
TOP_PANEL_HEIGHT_RATIO = 0.06
|
||||
RIGHT_PANEL_WIDTH_RATIO = 0.22
|
||||
BOTTOM_PANEL_HEIGHT_RATIO = 0.08
|
||||
|
||||
|
||||
class VillageSimulationApp:
|
||||
@ -30,31 +32,60 @@ class VillageSimulationApp:
|
||||
pygame.init()
|
||||
pygame.font.init()
|
||||
|
||||
# Create window
|
||||
self.screen = pygame.display.set_mode((WINDOW_WIDTH, WINDOW_HEIGHT))
|
||||
# Get display info for fullscreen
|
||||
display_info = pygame.display.Info()
|
||||
self.screen_width = display_info.current_w
|
||||
self.screen_height = display_info.current_h
|
||||
|
||||
# Create fullscreen window
|
||||
self.screen = pygame.display.set_mode(
|
||||
(self.screen_width, self.screen_height),
|
||||
pygame.FULLSCREEN | pygame.DOUBLEBUF | pygame.HWSURFACE
|
||||
)
|
||||
pygame.display.set_caption(WINDOW_TITLE)
|
||||
|
||||
# Hide mouse cursor briefly on startup
|
||||
pygame.mouse.set_visible(True)
|
||||
|
||||
# Clock for FPS control
|
||||
self.clock = pygame.time.Clock()
|
||||
|
||||
# Fonts
|
||||
self.font = pygame.font.Font(None, 24)
|
||||
# Calculate layout dimensions
|
||||
self.top_panel_height = int(self.screen_height * TOP_PANEL_HEIGHT_RATIO)
|
||||
self.right_panel_width = int(self.screen_width * RIGHT_PANEL_WIDTH_RATIO)
|
||||
self.bottom_panel_height = int(self.screen_height * BOTTOM_PANEL_HEIGHT_RATIO)
|
||||
|
||||
# Fonts - scale with screen
|
||||
font_scale = min(self.screen_width / 1920, self.screen_height / 1080)
|
||||
self.font_size_small = max(14, int(16 * font_scale))
|
||||
self.font_size_medium = max(18, int(22 * font_scale))
|
||||
self.font_size_large = max(24, int(28 * font_scale))
|
||||
|
||||
self.font = pygame.font.Font(None, self.font_size_medium)
|
||||
self.font_small = pygame.font.Font(None, self.font_size_small)
|
||||
self.font_large = pygame.font.Font(None, self.font_size_large)
|
||||
|
||||
# Network client
|
||||
self.client = SimulationClient(server_url)
|
||||
|
||||
# Calculate map area
|
||||
# Calculate map area (left side, between top and bottom panels)
|
||||
self.map_rect = pygame.Rect(
|
||||
0,
|
||||
TOP_PANEL_HEIGHT,
|
||||
WINDOW_WIDTH - RIGHT_PANEL_WIDTH,
|
||||
WINDOW_HEIGHT - TOP_PANEL_HEIGHT,
|
||||
self.top_panel_height,
|
||||
self.screen_width - self.right_panel_width,
|
||||
self.screen_height - self.top_panel_height - self.bottom_panel_height,
|
||||
)
|
||||
|
||||
# Initialize renderers
|
||||
# Initialize renderers with screen dimensions
|
||||
self.map_renderer = MapRenderer(self.screen, self.map_rect)
|
||||
self.agent_renderer = AgentRenderer(self.screen, self.map_renderer, self.font)
|
||||
self.ui_renderer = UIRenderer(self.screen, self.font)
|
||||
self.agent_renderer = AgentRenderer(self.screen, self.map_renderer, self.font_small)
|
||||
self.ui_renderer = UIRenderer(
|
||||
self.screen,
|
||||
self.font,
|
||||
self.top_panel_height,
|
||||
self.right_panel_width,
|
||||
self.bottom_panel_height
|
||||
)
|
||||
self.settings_renderer = SettingsRenderer(self.screen)
|
||||
self.stats_renderer = StatsRenderer(self.screen)
|
||||
|
||||
@ -62,26 +93,25 @@ class VillageSimulationApp:
|
||||
self.state: SimulationState | None = None
|
||||
self.running = True
|
||||
self.hovered_agent: dict | None = None
|
||||
self._last_turn: int = -1 # Track turn changes for stats update
|
||||
self._last_turn: int = -1
|
||||
|
||||
# Polling interval (ms)
|
||||
self.last_poll_time = 0
|
||||
self.poll_interval = 100 # Poll every 100ms for smoother updates
|
||||
self.poll_interval = 50 # Poll every 50ms for smoother updates
|
||||
|
||||
# Setup settings callbacks
|
||||
self._setup_settings_callbacks()
|
||||
|
||||
def _setup_settings_callbacks(self) -> None:
|
||||
"""Set up callbacks for the settings panel."""
|
||||
# Override the apply and reset callbacks
|
||||
original_apply = self.settings_renderer._apply_config
|
||||
original_reset = self.settings_renderer._reset_config
|
||||
|
||||
def apply_config():
|
||||
config = self.settings_renderer.get_config()
|
||||
if self.client.update_config(config):
|
||||
# Restart simulation with new config
|
||||
if self.client.initialize():
|
||||
num_agents = config.get("world", {}).get("initial_agents", 100)
|
||||
if self.client.initialize(num_agents=num_agents):
|
||||
self.state = self.client.get_state()
|
||||
self.settings_renderer.status_message = "Config applied & simulation restarted!"
|
||||
self.settings_renderer.status_color = (80, 180, 100)
|
||||
@ -94,7 +124,6 @@ class VillageSimulationApp:
|
||||
|
||||
def reset_config():
|
||||
if self.client.reset_config():
|
||||
# Reload config from server
|
||||
config = self.client.get_config()
|
||||
if config:
|
||||
self.settings_renderer.set_config(config)
|
||||
@ -147,13 +176,12 @@ class VillageSimulationApp:
|
||||
# Advance one turn
|
||||
if self.client.connected and not self.settings_renderer.visible and not self.stats_renderer.visible:
|
||||
if self.client.advance_turn():
|
||||
# Immediately fetch new state
|
||||
self.state = self.client.get_state()
|
||||
|
||||
elif event.key == pygame.K_r:
|
||||
# Reset simulation
|
||||
if self.client.connected and not self.settings_renderer.visible and not self.stats_renderer.visible:
|
||||
if self.client.initialize():
|
||||
if self.client.initialize(num_agents=100):
|
||||
self.state = self.client.get_state()
|
||||
self.stats_renderer.clear_history()
|
||||
self._last_turn = -1
|
||||
@ -177,6 +205,10 @@ class VillageSimulationApp:
|
||||
self._load_config()
|
||||
self.settings_renderer.toggle()
|
||||
|
||||
elif event.key == pygame.K_f:
|
||||
# Toggle fullscreen (alternative)
|
||||
pygame.display.toggle_fullscreen()
|
||||
|
||||
def _handle_mouse_motion(self, event: pygame.event.Event) -> None:
|
||||
"""Handle mouse motion for agent hover detection."""
|
||||
if not self.state or self.settings_renderer.visible:
|
||||
@ -190,7 +222,7 @@ class VillageSimulationApp:
|
||||
if not self.map_rect.collidepoint(mouse_pos):
|
||||
return
|
||||
|
||||
# Check each agent
|
||||
# Check each agent (only check visible ones for performance)
|
||||
for agent in self.state.agents:
|
||||
if not agent.get("is_alive", False):
|
||||
continue
|
||||
@ -242,8 +274,8 @@ class VillageSimulationApp:
|
||||
|
||||
def draw(self) -> None:
|
||||
"""Draw all elements."""
|
||||
# Clear screen
|
||||
self.screen.fill((30, 35, 45))
|
||||
# Clear screen with dark background
|
||||
self.screen.fill((15, 17, 23))
|
||||
|
||||
if self.state:
|
||||
# Draw map
|
||||
@ -252,7 +284,7 @@ class VillageSimulationApp:
|
||||
# Draw agents
|
||||
self.agent_renderer.draw(self.state)
|
||||
|
||||
# Draw UI
|
||||
# Draw UI panels
|
||||
self.ui_renderer.draw(self.state)
|
||||
|
||||
# Draw agent tooltip if hovering
|
||||
@ -272,16 +304,22 @@ class VillageSimulationApp:
|
||||
|
||||
# Draw hints at bottom
|
||||
if not self.settings_renderer.visible and not self.stats_renderer.visible:
|
||||
hint_font = pygame.font.Font(None, 18)
|
||||
hint = hint_font.render("S: Settings | G: Statistics & Graphs", True, (100, 100, 120))
|
||||
self.screen.blit(hint, (5, self.screen.get_height() - 20))
|
||||
hint_font = pygame.font.Font(None, 16)
|
||||
hint = hint_font.render(
|
||||
"SPACE: Next Turn | R: Reset | M: Mode | S: Settings | G: Graphs | ESC: Quit",
|
||||
True, (80, 85, 100)
|
||||
)
|
||||
self.screen.blit(hint, (10, self.screen_height - 22))
|
||||
|
||||
# Update display
|
||||
pygame.display.flip()
|
||||
|
||||
def run(self) -> None:
|
||||
"""Main game loop."""
|
||||
print("Starting Village Simulation Frontend...")
|
||||
print("=" * 60)
|
||||
print(" VILLAGE SIMULATION - Economy, Religion & Diplomacy")
|
||||
print("=" * 60)
|
||||
print(f"\nScreen: {self.screen_width}x{self.screen_height} (Fullscreen)")
|
||||
print("Connecting to backend at http://localhost:8000...")
|
||||
|
||||
# Try to connect initially
|
||||
@ -292,12 +330,13 @@ class VillageSimulationApp:
|
||||
self.state = self.client.get_state()
|
||||
|
||||
print("\nControls:")
|
||||
print(" SPACE - Advance turn")
|
||||
print(" R - Reset simulation")
|
||||
print(" M - Toggle auto/manual mode")
|
||||
print(" S - Open settings")
|
||||
print(" G - Open statistics & graphs")
|
||||
print(" ESC - Close panel / Quit")
|
||||
print(" SPACE - Advance turn")
|
||||
print(" R - Reset simulation (100 agents)")
|
||||
print(" M - Toggle auto/manual mode")
|
||||
print(" S - Open settings")
|
||||
print(" G - Open statistics & graphs")
|
||||
print(" F - Toggle fullscreen")
|
||||
print(" ESC - Close panel / Quit")
|
||||
print()
|
||||
|
||||
while self.running:
|
||||
@ -311,7 +350,6 @@ class VillageSimulationApp:
|
||||
|
||||
def main():
|
||||
"""Entry point for the frontend application."""
|
||||
# Get server URL from command line if provided
|
||||
server_url = "http://localhost:8000"
|
||||
if len(sys.argv) > 1:
|
||||
server_url = sys.argv[1]
|
||||
|
||||
@ -4,6 +4,13 @@ from .map_renderer import MapRenderer
|
||||
from .agent_renderer import AgentRenderer
|
||||
from .ui_renderer import UIRenderer
|
||||
from .settings_renderer import SettingsRenderer
|
||||
from .stats_renderer import StatsRenderer
|
||||
|
||||
__all__ = ["MapRenderer", "AgentRenderer", "UIRenderer", "SettingsRenderer"]
|
||||
__all__ = [
|
||||
"MapRenderer",
|
||||
"AgentRenderer",
|
||||
"UIRenderer",
|
||||
"SettingsRenderer",
|
||||
"StatsRenderer",
|
||||
]
|
||||
|
||||
|
||||
@ -1,4 +1,7 @@
|
||||
"""Agent renderer for the Village Simulation."""
|
||||
"""Agent renderer for the Village Simulation.
|
||||
|
||||
Optimized for 100+ agents with faction/religion color coding.
|
||||
"""
|
||||
|
||||
import math
|
||||
import pygame
|
||||
@ -9,42 +12,53 @@ if TYPE_CHECKING:
|
||||
from frontend.renderer.map_renderer import MapRenderer
|
||||
|
||||
|
||||
# Profession colors (villager is the default now)
|
||||
PROFESSION_COLORS = {
|
||||
"villager": (100, 140, 180), # Blue-gray for generic villager
|
||||
"hunter": (180, 80, 80), # Red
|
||||
"gatherer": (80, 160, 80), # Green
|
||||
"woodcutter": (139, 90, 43), # Brown
|
||||
"crafter": (160, 120, 200), # Purple
|
||||
# Faction colors - matches backend FactionType
|
||||
FACTION_COLORS = {
|
||||
"northlands": (100, 160, 220), # Ice blue
|
||||
"riverfolk": (70, 160, 180), # River teal
|
||||
"forestkin": (90, 160, 80), # Forest green
|
||||
"mountaineer": (150, 120, 90), # Mountain brown
|
||||
"plainsmen": (200, 180, 100), # Plains gold
|
||||
"neutral": (120, 120, 120), # Gray
|
||||
}
|
||||
|
||||
# Religion colors
|
||||
RELIGION_COLORS = {
|
||||
"solaris": (255, 200, 80), # Golden sun
|
||||
"aquarius": (80, 170, 240), # Ocean blue
|
||||
"terranus": (160, 120, 70), # Earth brown
|
||||
"ignis": (240, 100, 50), # Fire red
|
||||
"naturis": (100, 200, 100), # Forest green
|
||||
"atheist": (140, 140, 140), # Gray
|
||||
}
|
||||
|
||||
# Corpse color
|
||||
CORPSE_COLOR = (60, 60, 60) # Dark gray
|
||||
CORPSE_COLOR = (50, 50, 55)
|
||||
|
||||
# Status bar colors
|
||||
BAR_COLORS = {
|
||||
"energy": (255, 220, 80), # Yellow
|
||||
"hunger": (220, 140, 80), # Orange
|
||||
"thirst": (80, 160, 220), # Blue
|
||||
"heat": (220, 80, 80), # Red
|
||||
}
|
||||
|
||||
# Action icons/symbols
|
||||
# Action symbols (simplified for performance)
|
||||
ACTION_SYMBOLS = {
|
||||
"hunt": "🏹",
|
||||
"gather": "🍇",
|
||||
"chop_wood": "🪓",
|
||||
"get_water": "💧",
|
||||
"weave": "🧵",
|
||||
"build_fire": "🔥",
|
||||
"trade": "💰",
|
||||
"rest": "💤",
|
||||
"sleep": "😴",
|
||||
"consume": "🍖",
|
||||
"dead": "💀",
|
||||
"hunt": "⚔",
|
||||
"gather": "◆",
|
||||
"chop_wood": "▲",
|
||||
"get_water": "◎",
|
||||
"weave": "⊕",
|
||||
"build_fire": "◈",
|
||||
"trade": "$",
|
||||
"rest": "○",
|
||||
"sleep": "◐",
|
||||
"consume": "●",
|
||||
"drill_oil": "⛏",
|
||||
"refine": "⚙",
|
||||
"pray": "✦",
|
||||
"preach": "✧",
|
||||
"negotiate": "⚖",
|
||||
"declare_war": "⚔",
|
||||
"make_peace": "☮",
|
||||
"burn_fuel": "◈",
|
||||
"dead": "✖",
|
||||
}
|
||||
|
||||
# Fallback ASCII symbols for systems without emoji support
|
||||
# Fallback ASCII
|
||||
ACTION_LETTERS = {
|
||||
"hunt": "H",
|
||||
"gather": "G",
|
||||
@ -56,12 +70,20 @@ ACTION_LETTERS = {
|
||||
"rest": "R",
|
||||
"sleep": "Z",
|
||||
"consume": "E",
|
||||
"drill_oil": "O",
|
||||
"refine": "U",
|
||||
"pray": "P",
|
||||
"preach": "!",
|
||||
"negotiate": "N",
|
||||
"declare_war": "!",
|
||||
"make_peace": "+",
|
||||
"burn_fuel": "B",
|
||||
"dead": "X",
|
||||
}
|
||||
|
||||
|
||||
class AgentRenderer:
|
||||
"""Renders agents on the map with movement and action indicators."""
|
||||
"""Renders agents on the map with faction/religion indicators."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
@ -72,89 +94,116 @@ class AgentRenderer:
|
||||
self.screen = screen
|
||||
self.map_renderer = map_renderer
|
||||
self.font = font
|
||||
self.small_font = pygame.font.Font(None, 16)
|
||||
self.action_font = pygame.font.Font(None, 20)
|
||||
self.small_font = pygame.font.Font(None, 14)
|
||||
self.action_font = pygame.font.Font(None, 16)
|
||||
self.tooltip_font = pygame.font.Font(None, 18)
|
||||
|
||||
# Animation state
|
||||
self.animation_tick = 0
|
||||
|
||||
# Performance: limit detail level based on agent count
|
||||
self.detail_level = 2 # 0=minimal, 1=basic, 2=full
|
||||
|
||||
def _get_faction_color(self, agent: dict) -> tuple[int, int, int]:
|
||||
"""Get agent's faction color."""
|
||||
# Faction is under diplomacy.faction (not faction.type)
|
||||
diplomacy = agent.get("diplomacy", {})
|
||||
faction = diplomacy.get("faction", "neutral")
|
||||
return FACTION_COLORS.get(faction, FACTION_COLORS["neutral"])
|
||||
|
||||
def _get_religion_color(self, agent: dict) -> tuple[int, int, int]:
|
||||
"""Get agent's religion color."""
|
||||
# Religion type is under religion.religion (not religion.type)
|
||||
religion_data = agent.get("religion", {})
|
||||
religion = religion_data.get("religion", "atheist")
|
||||
return RELIGION_COLORS.get(religion, RELIGION_COLORS["atheist"])
|
||||
|
||||
def _get_agent_color(self, agent: dict) -> tuple[int, int, int]:
|
||||
"""Get the color for an agent based on state."""
|
||||
# Corpses are dark gray
|
||||
"""Get the main color for an agent (faction-based)."""
|
||||
if agent.get("is_corpse", False) or not agent.get("is_alive", True):
|
||||
return CORPSE_COLOR
|
||||
|
||||
profession = agent.get("profession", "villager")
|
||||
base_color = PROFESSION_COLORS.get(profession, (100, 140, 180))
|
||||
base_color = self._get_faction_color(agent)
|
||||
|
||||
if not agent.get("can_act", True):
|
||||
# Slightly dimmed for exhausted agents
|
||||
return tuple(int(c * 0.7) for c in base_color)
|
||||
# Dimmed for exhausted agents
|
||||
return tuple(int(c * 0.6) for c in base_color)
|
||||
|
||||
return base_color
|
||||
|
||||
def _draw_status_bar(
|
||||
def _draw_mini_bar(
|
||||
self,
|
||||
x: int,
|
||||
y: int,
|
||||
width: int,
|
||||
height: int,
|
||||
value: int,
|
||||
max_value: int,
|
||||
value: float,
|
||||
max_value: float,
|
||||
color: tuple[int, int, int],
|
||||
) -> None:
|
||||
"""Draw a single status bar."""
|
||||
"""Draw a tiny status bar."""
|
||||
if max_value <= 0:
|
||||
return
|
||||
|
||||
# Background
|
||||
pygame.draw.rect(self.screen, (40, 40, 40), (x, y, width, height))
|
||||
pygame.draw.rect(self.screen, (25, 25, 30), (x, y, width, height))
|
||||
|
||||
# Fill
|
||||
fill_width = int((value / max_value) * width) if max_value > 0 else 0
|
||||
fill_width = int((value / max_value) * width)
|
||||
if fill_width > 0:
|
||||
pygame.draw.rect(self.screen, color, (x, y, fill_width, height))
|
||||
# Color gradient based on value
|
||||
ratio = value / max_value
|
||||
if ratio < 0.25:
|
||||
bar_color = (200, 60, 60) # Critical - red
|
||||
elif ratio < 0.5:
|
||||
bar_color = (200, 150, 60) # Low - orange
|
||||
else:
|
||||
bar_color = color
|
||||
pygame.draw.rect(self.screen, bar_color, (x, y, fill_width, height))
|
||||
|
||||
# Border
|
||||
pygame.draw.rect(self.screen, (80, 80, 80), (x, y, width, height), 1)
|
||||
|
||||
def _draw_status_bars(self, agent: dict, center_x: int, center_y: int, size: int) -> None:
|
||||
"""Draw status bars below the agent."""
|
||||
def _draw_status_bars(
|
||||
self,
|
||||
agent: dict,
|
||||
center_x: int,
|
||||
center_y: int,
|
||||
size: int
|
||||
) -> None:
|
||||
"""Draw compact status bars below the agent."""
|
||||
stats = agent.get("stats", {})
|
||||
|
||||
bar_width = size + 10
|
||||
bar_height = 3
|
||||
bar_spacing = 4
|
||||
start_y = center_y + size // 2 + 4
|
||||
bar_width = size + 6
|
||||
bar_height = 2
|
||||
bar_spacing = 3
|
||||
start_y = center_y + size // 2 + 3
|
||||
|
||||
bars = [
|
||||
("energy", stats.get("energy", 0), stats.get("max_energy", 100)),
|
||||
("hunger", stats.get("hunger", 0), stats.get("max_hunger", 100)),
|
||||
("thirst", stats.get("thirst", 0), stats.get("max_thirst", 50)),
|
||||
("heat", stats.get("heat", 0), stats.get("max_heat", 100)),
|
||||
(stats.get("energy", 0), stats.get("max_energy", 100), (220, 200, 80)),
|
||||
(stats.get("hunger", 0), stats.get("max_hunger", 100), (200, 130, 80)),
|
||||
(stats.get("thirst", 0), stats.get("max_thirst", 100), (80, 160, 200)),
|
||||
]
|
||||
|
||||
for i, (stat_name, value, max_value) in enumerate(bars):
|
||||
for i, (value, max_value, color) in enumerate(bars):
|
||||
bar_y = start_y + i * bar_spacing
|
||||
self._draw_status_bar(
|
||||
self._draw_mini_bar(
|
||||
center_x - bar_width // 2,
|
||||
bar_y,
|
||||
bar_width,
|
||||
bar_height,
|
||||
value,
|
||||
max_value,
|
||||
BAR_COLORS[stat_name],
|
||||
color,
|
||||
)
|
||||
|
||||
def _draw_action_indicator(
|
||||
def _draw_action_bubble(
|
||||
self,
|
||||
agent: dict,
|
||||
center_x: int,
|
||||
center_y: int,
|
||||
agent_size: int,
|
||||
) -> None:
|
||||
"""Draw action indicator above the agent."""
|
||||
"""Draw action indicator bubble above agent."""
|
||||
current_action = agent.get("current_action", {})
|
||||
action_type = current_action.get("action_type", "")
|
||||
is_moving = current_action.get("is_moving", False)
|
||||
message = current_action.get("message", "")
|
||||
|
||||
if not action_type:
|
||||
return
|
||||
@ -162,170 +211,191 @@ class AgentRenderer:
|
||||
# Get action symbol
|
||||
symbol = ACTION_LETTERS.get(action_type, "?")
|
||||
|
||||
# Draw action bubble above agent
|
||||
bubble_y = center_y - agent_size // 2 - 20
|
||||
# Position above agent
|
||||
bubble_y = center_y - agent_size // 2 - 12
|
||||
|
||||
# Animate if moving
|
||||
is_moving = current_action.get("is_moving", False)
|
||||
if is_moving:
|
||||
# Bouncing animation
|
||||
offset = int(3 * math.sin(self.animation_tick * 0.3))
|
||||
offset = int(2 * math.sin(self.animation_tick * 0.3))
|
||||
bubble_y += offset
|
||||
|
||||
# Draw bubble background
|
||||
bubble_width = 22
|
||||
bubble_height = 18
|
||||
# Draw small bubble
|
||||
bubble_w, bubble_h = 14, 12
|
||||
bubble_rect = pygame.Rect(
|
||||
center_x - bubble_width // 2,
|
||||
bubble_y - bubble_height // 2,
|
||||
bubble_width,
|
||||
bubble_height,
|
||||
center_x - bubble_w // 2,
|
||||
bubble_y - bubble_h // 2,
|
||||
bubble_w,
|
||||
bubble_h,
|
||||
)
|
||||
|
||||
# Color based on action success/failure
|
||||
if "Failed" in message:
|
||||
bg_color = (120, 60, 60)
|
||||
# Color based on action type
|
||||
if action_type in ["pray", "preach"]:
|
||||
bg_color = (60, 50, 80)
|
||||
border_color = (120, 100, 160)
|
||||
elif action_type in ["negotiate", "make_peace"]:
|
||||
bg_color = (50, 70, 80)
|
||||
border_color = (100, 160, 180)
|
||||
elif action_type in ["declare_war"]:
|
||||
bg_color = (80, 40, 40)
|
||||
border_color = (180, 80, 80)
|
||||
elif action_type in ["drill_oil", "refine", "burn_fuel"]:
|
||||
bg_color = (60, 55, 40)
|
||||
border_color = (140, 120, 80)
|
||||
elif is_moving:
|
||||
bg_color = (60, 80, 120)
|
||||
bg_color = (50, 60, 80)
|
||||
border_color = (100, 140, 200)
|
||||
else:
|
||||
bg_color = (50, 70, 50)
|
||||
border_color = (80, 140, 80)
|
||||
bg_color = (40, 55, 45)
|
||||
border_color = (80, 130, 90)
|
||||
|
||||
pygame.draw.rect(self.screen, bg_color, bubble_rect, border_radius=4)
|
||||
pygame.draw.rect(self.screen, border_color, bubble_rect, 1, border_radius=4)
|
||||
pygame.draw.rect(self.screen, bg_color, bubble_rect, border_radius=3)
|
||||
pygame.draw.rect(self.screen, border_color, bubble_rect, 1, border_radius=3)
|
||||
|
||||
# Draw action letter
|
||||
text = self.action_font.render(symbol, True, (255, 255, 255))
|
||||
# Draw symbol
|
||||
text = self.action_font.render(symbol, True, (230, 230, 230))
|
||||
text_rect = text.get_rect(center=(center_x, bubble_y))
|
||||
self.screen.blit(text, text_rect)
|
||||
|
||||
# Draw movement trail if moving
|
||||
if is_moving:
|
||||
target_pos = current_action.get("target_position")
|
||||
if target_pos:
|
||||
target_x, target_y = self.map_renderer.grid_to_screen(
|
||||
target_pos.get("x", 0),
|
||||
target_pos.get("y", 0),
|
||||
)
|
||||
# Draw dotted line to target
|
||||
self._draw_dotted_line(
|
||||
(center_x, center_y),
|
||||
(target_x, target_y),
|
||||
(100, 100, 100),
|
||||
4,
|
||||
)
|
||||
|
||||
def _draw_dotted_line(
|
||||
self,
|
||||
start: tuple[int, int],
|
||||
end: tuple[int, int],
|
||||
color: tuple[int, int, int],
|
||||
dot_spacing: int = 5,
|
||||
) -> None:
|
||||
"""Draw a dotted line between two points."""
|
||||
dx = end[0] - start[0]
|
||||
dy = end[1] - start[1]
|
||||
distance = max(1, int((dx ** 2 + dy ** 2) ** 0.5))
|
||||
|
||||
for i in range(0, distance, dot_spacing * 2):
|
||||
t = i / distance
|
||||
x = int(start[0] + dx * t)
|
||||
y = int(start[1] + dy * t)
|
||||
pygame.draw.circle(self.screen, color, (x, y), 1)
|
||||
|
||||
def _draw_last_action_result(
|
||||
def _draw_religion_indicator(
|
||||
self,
|
||||
agent: dict,
|
||||
center_x: int,
|
||||
center_y: int,
|
||||
agent_size: int,
|
||||
) -> None:
|
||||
"""Draw the last action result as floating text."""
|
||||
result = agent.get("last_action_result", "")
|
||||
if not result:
|
||||
return
|
||||
"""Draw a small religion indicator (faith glow)."""
|
||||
faith = agent.get("faith", 50)
|
||||
religion_color = self._get_religion_color(agent)
|
||||
|
||||
# Truncate long messages
|
||||
if len(result) > 25:
|
||||
result = result[:22] + "..."
|
||||
# Only show for agents with significant faith
|
||||
if faith > 70:
|
||||
# Divine glow effect
|
||||
glow_alpha = int((faith / 100) * 60)
|
||||
glow_surface = pygame.Surface(
|
||||
(agent_size * 2, agent_size * 2),
|
||||
pygame.SRCALPHA
|
||||
)
|
||||
pygame.draw.circle(
|
||||
glow_surface,
|
||||
(*religion_color, glow_alpha),
|
||||
(agent_size, agent_size),
|
||||
agent_size,
|
||||
)
|
||||
self.screen.blit(
|
||||
glow_surface,
|
||||
(center_x - agent_size, center_y - agent_size),
|
||||
)
|
||||
|
||||
# Draw text below status bars
|
||||
text_y = center_y + agent_size // 2 + 22
|
||||
# Small religion dot indicator
|
||||
dot_x = center_x + agent_size // 2 - 2
|
||||
dot_y = center_y - agent_size // 2 + 2
|
||||
pygame.draw.circle(self.screen, religion_color, (dot_x, dot_y), 3)
|
||||
pygame.draw.circle(self.screen, (30, 30, 35), (dot_x, dot_y), 3, 1)
|
||||
|
||||
text = self.small_font.render(result, True, (180, 180, 180))
|
||||
text_rect = text.get_rect(center=(center_x, text_y))
|
||||
def _draw_war_indicator(self, agent: dict, center_x: int, center_y: int) -> None:
|
||||
"""Draw indicator if agent's faction is at war."""
|
||||
diplomacy = agent.get("diplomacy", {})
|
||||
faction = diplomacy.get("faction", "neutral")
|
||||
at_war = agent.get("at_war", False)
|
||||
|
||||
# Background for readability
|
||||
bg_rect = text_rect.inflate(4, 2)
|
||||
pygame.draw.rect(self.screen, (30, 30, 40, 180), bg_rect)
|
||||
|
||||
self.screen.blit(text, text_rect)
|
||||
if at_war:
|
||||
# Red war indicator
|
||||
pulse = 0.5 + 0.5 * math.sin(self.animation_tick * 0.15)
|
||||
war_color = (int(200 * pulse), int(50 * pulse), int(50 * pulse))
|
||||
pygame.draw.circle(
|
||||
self.screen, war_color,
|
||||
(center_x - 6, center_y - 6),
|
||||
3,
|
||||
)
|
||||
|
||||
def draw(self, state: "SimulationState") -> None:
|
||||
"""Draw all agents (including corpses for one turn)."""
|
||||
"""Draw all agents (optimized for many agents)."""
|
||||
self.animation_tick += 1
|
||||
|
||||
cell_w, cell_h = self.map_renderer.get_cell_size()
|
||||
agent_size = min(cell_w, cell_h) - 8
|
||||
agent_size = max(10, min(agent_size, 30)) # Clamp size
|
||||
agent_size = min(cell_w, cell_h) - 6
|
||||
agent_size = max(8, min(agent_size, 24))
|
||||
|
||||
# Adjust detail level based on agent count
|
||||
living_count = len(state.get_living_agents())
|
||||
if living_count > 150:
|
||||
self.detail_level = 0
|
||||
elif living_count > 80:
|
||||
self.detail_level = 1
|
||||
else:
|
||||
self.detail_level = 2
|
||||
|
||||
# Separate corpses and living agents
|
||||
corpses = []
|
||||
living = []
|
||||
|
||||
for agent in state.agents:
|
||||
is_corpse = agent.get("is_corpse", False)
|
||||
is_alive = agent.get("is_alive", True)
|
||||
if agent.get("is_corpse", False):
|
||||
corpses.append(agent)
|
||||
elif agent.get("is_alive", True):
|
||||
living.append(agent)
|
||||
|
||||
# Get screen position from agent's current position
|
||||
# Draw corpses first (behind living agents)
|
||||
for agent in corpses:
|
||||
pos = agent.get("position", {"x": 0, "y": 0})
|
||||
screen_x, screen_y = self.map_renderer.grid_to_screen(pos["x"], pos["y"])
|
||||
self._draw_corpse(agent, screen_x, screen_y, agent_size)
|
||||
|
||||
# Draw living agents
|
||||
for agent in living:
|
||||
pos = agent.get("position", {"x": 0, "y": 0})
|
||||
screen_x, screen_y = self.map_renderer.grid_to_screen(pos["x"], pos["y"])
|
||||
|
||||
if is_corpse:
|
||||
# Draw corpse with death indicator
|
||||
self._draw_corpse(agent, screen_x, screen_y, agent_size)
|
||||
continue
|
||||
# Religion glow (full detail only)
|
||||
if self.detail_level >= 2:
|
||||
self._draw_religion_indicator(agent, screen_x, screen_y, agent_size)
|
||||
|
||||
if not is_alive:
|
||||
continue
|
||||
# Action bubble (basic+ detail)
|
||||
if self.detail_level >= 1:
|
||||
self._draw_action_bubble(agent, screen_x, screen_y, agent_size)
|
||||
|
||||
# Draw movement trail/line to target first (behind agent)
|
||||
self._draw_action_indicator(agent, screen_x, screen_y, agent_size)
|
||||
|
||||
# Draw agent circle
|
||||
# Main agent circle with faction color
|
||||
color = self._get_agent_color(agent)
|
||||
pygame.draw.circle(self.screen, color, (screen_x, screen_y), agent_size // 2)
|
||||
pygame.draw.circle(
|
||||
self.screen, color,
|
||||
(screen_x, screen_y),
|
||||
agent_size // 2,
|
||||
)
|
||||
|
||||
# Draw border - animated if moving
|
||||
# Border - based on state
|
||||
current_action = agent.get("current_action", {})
|
||||
is_moving = current_action.get("is_moving", False)
|
||||
|
||||
if is_moving:
|
||||
# Pulsing border when moving
|
||||
pulse = int(127 + 127 * math.sin(self.animation_tick * 0.2))
|
||||
pulse = int(180 + 75 * math.sin(self.animation_tick * 0.2))
|
||||
border_color = (pulse, pulse, 255)
|
||||
elif agent.get("can_act"):
|
||||
border_color = (255, 255, 255)
|
||||
border_color = (200, 200, 210)
|
||||
else:
|
||||
border_color = (100, 100, 100)
|
||||
border_color = (80, 80, 85)
|
||||
|
||||
pygame.draw.circle(self.screen, border_color, (screen_x, screen_y), agent_size // 2, 2)
|
||||
pygame.draw.circle(
|
||||
self.screen, border_color,
|
||||
(screen_x, screen_y),
|
||||
agent_size // 2,
|
||||
1,
|
||||
)
|
||||
|
||||
# Draw money indicator (small coin icon)
|
||||
# Money indicator
|
||||
money = agent.get("money", 0)
|
||||
if money > 0:
|
||||
coin_x = screen_x + agent_size // 2 - 4
|
||||
coin_y = screen_y - agent_size // 2 - 4
|
||||
pygame.draw.circle(self.screen, (255, 215, 0), (coin_x, coin_y), 4)
|
||||
pygame.draw.circle(self.screen, (200, 160, 0), (coin_x, coin_y), 4, 1)
|
||||
if money > 50:
|
||||
coin_x = screen_x + agent_size // 2 - 2
|
||||
coin_y = screen_y - agent_size // 2 - 2
|
||||
pygame.draw.circle(self.screen, (255, 215, 0), (coin_x, coin_y), 3)
|
||||
|
||||
# Draw "V" for villager
|
||||
text = self.small_font.render("V", True, (255, 255, 255))
|
||||
text_rect = text.get_rect(center=(screen_x, screen_y))
|
||||
self.screen.blit(text, text_rect)
|
||||
# War indicator
|
||||
if self.detail_level >= 1:
|
||||
self._draw_war_indicator(agent, screen_x, screen_y)
|
||||
|
||||
# Draw status bars
|
||||
self._draw_status_bars(agent, screen_x, screen_y, agent_size)
|
||||
|
||||
# Draw last action result
|
||||
self._draw_last_action_result(agent, screen_x, screen_y, agent_size)
|
||||
# Status bars (basic+ detail)
|
||||
if self.detail_level >= 1:
|
||||
self._draw_status_bars(agent, screen_x, screen_y, agent_size)
|
||||
|
||||
def _draw_corpse(
|
||||
self,
|
||||
@ -334,97 +404,117 @@ class AgentRenderer:
|
||||
center_y: int,
|
||||
agent_size: int,
|
||||
) -> None:
|
||||
"""Draw a corpse with death reason displayed."""
|
||||
# Draw corpse circle (dark gray)
|
||||
pygame.draw.circle(self.screen, CORPSE_COLOR, (center_x, center_y), agent_size // 2)
|
||||
"""Draw a corpse marker."""
|
||||
# Simple X marker
|
||||
pygame.draw.circle(
|
||||
self.screen, CORPSE_COLOR,
|
||||
(center_x, center_y),
|
||||
agent_size // 3,
|
||||
)
|
||||
pygame.draw.circle(
|
||||
self.screen, (100, 50, 50),
|
||||
(center_x, center_y),
|
||||
agent_size // 3,
|
||||
1,
|
||||
)
|
||||
|
||||
# Draw red X border
|
||||
pygame.draw.circle(self.screen, (150, 50, 50), (center_x, center_y), agent_size // 2, 2)
|
||||
|
||||
# Draw skull symbol
|
||||
text = self.action_font.render("X", True, (180, 80, 80))
|
||||
text_rect = text.get_rect(center=(center_x, center_y))
|
||||
self.screen.blit(text, text_rect)
|
||||
|
||||
# Draw death reason above corpse
|
||||
death_reason = agent.get("death_reason", "unknown")
|
||||
name = agent.get("name", "Unknown")
|
||||
|
||||
# Death indicator bubble
|
||||
bubble_y = center_y - agent_size // 2 - 20
|
||||
bubble_text = f"💀 {death_reason}"
|
||||
|
||||
text = self.small_font.render(bubble_text, True, (255, 100, 100))
|
||||
text_rect = text.get_rect(center=(center_x, bubble_y))
|
||||
|
||||
# Background for readability
|
||||
bg_rect = text_rect.inflate(8, 4)
|
||||
pygame.draw.rect(self.screen, (40, 20, 20), bg_rect, border_radius=3)
|
||||
pygame.draw.rect(self.screen, (120, 50, 50), bg_rect, 1, border_radius=3)
|
||||
|
||||
self.screen.blit(text, text_rect)
|
||||
|
||||
# Draw name below
|
||||
name_y = center_y + agent_size // 2 + 8
|
||||
name_text = self.small_font.render(name, True, (150, 150, 150))
|
||||
name_rect = name_text.get_rect(center=(center_x, name_y))
|
||||
self.screen.blit(name_text, name_rect)
|
||||
# X symbol
|
||||
half = agent_size // 4
|
||||
pygame.draw.line(
|
||||
self.screen, (120, 60, 60),
|
||||
(center_x - half, center_y - half),
|
||||
(center_x + half, center_y + half),
|
||||
1,
|
||||
)
|
||||
pygame.draw.line(
|
||||
self.screen, (120, 60, 60),
|
||||
(center_x + half, center_y - half),
|
||||
(center_x - half, center_y + half),
|
||||
1,
|
||||
)
|
||||
|
||||
def draw_agent_tooltip(self, agent: dict, mouse_pos: tuple[int, int]) -> None:
|
||||
"""Draw a tooltip for an agent when hovered."""
|
||||
# Build tooltip text
|
||||
lines = [
|
||||
agent.get("name", "Unknown"),
|
||||
f"Profession: {agent.get('profession', '?').capitalize()}",
|
||||
f"Money: {agent.get('money', 0)} coins",
|
||||
"",
|
||||
]
|
||||
"""Draw a detailed tooltip for hovered agent."""
|
||||
lines = []
|
||||
|
||||
# Name and faction
|
||||
name = agent.get("name", "Unknown")
|
||||
diplomacy = agent.get("diplomacy", {})
|
||||
faction = diplomacy.get("faction", "neutral").title()
|
||||
lines.append(f"{name}")
|
||||
lines.append(f"Faction: {faction}")
|
||||
|
||||
# Religion and faith
|
||||
religion_data = agent.get("religion", {})
|
||||
religion = religion_data.get("religion", "atheist").title()
|
||||
faith = religion_data.get("faith", 50)
|
||||
lines.append(f"Religion: {religion} ({faith}% faith)")
|
||||
|
||||
# Money
|
||||
money = agent.get("money", 0)
|
||||
lines.append(f"Money: {money} coins")
|
||||
|
||||
lines.append("")
|
||||
|
||||
# Stats
|
||||
stats = agent.get("stats", {})
|
||||
lines.append(f"Energy: {stats.get('energy', 0)}/{stats.get('max_energy', 100)}")
|
||||
lines.append(f"Hunger: {stats.get('hunger', 0)}/{stats.get('max_hunger', 100)}")
|
||||
lines.append(f"Thirst: {stats.get('thirst', 0)}/{stats.get('max_thirst', 100)}")
|
||||
lines.append(f"Heat: {stats.get('heat', 0)}/{stats.get('max_heat', 100)}")
|
||||
|
||||
# Current action
|
||||
current_action = agent.get("current_action", {})
|
||||
action_type = current_action.get("action_type", "")
|
||||
if action_type:
|
||||
action_msg = current_action.get("message", action_type)
|
||||
lines.append(f"Action: {action_msg[:40]}")
|
||||
if current_action.get("is_moving"):
|
||||
lines.append(" (moving to location)")
|
||||
lines.append("")
|
||||
lines.append(f"Action: {action_type.replace('_', ' ').title()}")
|
||||
if current_action.get("is_moving"):
|
||||
lines.append(" (moving)")
|
||||
|
||||
lines.append("Stats:")
|
||||
stats = agent.get("stats", {})
|
||||
lines.append(f" Energy: {stats.get('energy', 0)}/{stats.get('max_energy', 100)}")
|
||||
lines.append(f" Hunger: {stats.get('hunger', 0)}/{stats.get('max_hunger', 100)}")
|
||||
lines.append(f" Thirst: {stats.get('thirst', 0)}/{stats.get('max_thirst', 50)}")
|
||||
lines.append(f" Heat: {stats.get('heat', 0)}/{stats.get('max_heat', 100)}")
|
||||
|
||||
# Inventory summary
|
||||
inventory = agent.get("inventory", [])
|
||||
if inventory:
|
||||
lines.append("")
|
||||
lines.append("Inventory:")
|
||||
for item in inventory[:5]:
|
||||
lines.append(f" {item.get('type', '?')}: {item.get('quantity', 0)}")
|
||||
for item in inventory[:4]:
|
||||
item_type = item.get("type", "?")
|
||||
qty = item.get("quantity", 0)
|
||||
lines.append(f" {item_type}: {qty}")
|
||||
if len(inventory) > 4:
|
||||
lines.append(f" ...+{len(inventory) - 4} more")
|
||||
|
||||
# Last action result
|
||||
last_result = agent.get("last_action_result", "")
|
||||
if last_result:
|
||||
lines.append("")
|
||||
lines.append(f"Last: {last_result[:35]}")
|
||||
|
||||
# Calculate tooltip size
|
||||
# Calculate size
|
||||
line_height = 16
|
||||
max_width = max(self.small_font.size(line)[0] for line in lines) + 20
|
||||
height = len(lines) * line_height + 10
|
||||
max_width = max(self.tooltip_font.size(line)[0] for line in lines if line) + 24
|
||||
height = len(lines) * line_height + 16
|
||||
|
||||
# Position tooltip near mouse but not off screen
|
||||
x = min(mouse_pos[0] + 15, self.screen.get_width() - max_width - 5)
|
||||
y = min(mouse_pos[1] + 15, self.screen.get_height() - height - 5)
|
||||
# Position
|
||||
x = min(mouse_pos[0] + 15, self.screen.get_width() - max_width - 10)
|
||||
y = min(mouse_pos[1] + 15, self.screen.get_height() - height - 10)
|
||||
|
||||
# Draw background
|
||||
# Background with faction color accent
|
||||
tooltip_rect = pygame.Rect(x, y, max_width, height)
|
||||
pygame.draw.rect(self.screen, (30, 30, 40), tooltip_rect)
|
||||
pygame.draw.rect(self.screen, (100, 100, 120), tooltip_rect, 1)
|
||||
pygame.draw.rect(self.screen, (25, 28, 35), tooltip_rect, border_radius=6)
|
||||
|
||||
# Faction color accent bar
|
||||
faction_color = self._get_faction_color(agent)
|
||||
pygame.draw.rect(
|
||||
self.screen, faction_color,
|
||||
(x, y, 4, height),
|
||||
border_top_left_radius=6,
|
||||
border_bottom_left_radius=6,
|
||||
)
|
||||
|
||||
pygame.draw.rect(
|
||||
self.screen, (60, 70, 85),
|
||||
tooltip_rect, 1, border_radius=6,
|
||||
)
|
||||
|
||||
# Draw text
|
||||
for i, line in enumerate(lines):
|
||||
text = self.small_font.render(line, True, (220, 220, 220))
|
||||
self.screen.blit(text, (x + 10, y + 5 + i * line_height))
|
||||
if not line:
|
||||
continue
|
||||
color = (220, 220, 225) if i == 0 else (170, 175, 185)
|
||||
text = self.tooltip_font.render(line, True, color)
|
||||
self.screen.blit(text, (x + 12, y + 8 + i * line_height))
|
||||
|
||||
@ -1,5 +1,10 @@
|
||||
"""Map renderer for the Village Simulation."""
|
||||
"""Map renderer for the Village Simulation.
|
||||
|
||||
Beautiful dark theme with oil fields, temples, and terrain features.
|
||||
"""
|
||||
|
||||
import math
|
||||
import random
|
||||
import pygame
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
@ -7,29 +12,58 @@ if TYPE_CHECKING:
|
||||
from frontend.client import SimulationState
|
||||
|
||||
|
||||
# Color palette
|
||||
# Color palette - Cyberpunk dark theme
|
||||
class Colors:
|
||||
# Background colors
|
||||
DAY_BG = (180, 200, 160) # Soft green for day
|
||||
NIGHT_BG = (40, 45, 60) # Dark blue for night
|
||||
GRID_LINE = (120, 140, 110) # Subtle grid lines
|
||||
GRID_LINE_NIGHT = (60, 65, 80)
|
||||
DAY_BG = (28, 35, 42)
|
||||
NIGHT_BG = (12, 14, 20)
|
||||
|
||||
# Terrain features (for visual variety)
|
||||
GRASS_LIGHT = (160, 190, 140)
|
||||
GRASS_DARK = (140, 170, 120)
|
||||
WATER_SPOT = (100, 140, 180)
|
||||
# Terrain
|
||||
GRASS_LIGHT = (32, 45, 38)
|
||||
GRASS_DARK = (26, 38, 32)
|
||||
GRASS_ACCENT = (38, 52, 44)
|
||||
WATER_SPOT = (25, 45, 65)
|
||||
WATER_DEEP = (18, 35, 55)
|
||||
|
||||
# Grid
|
||||
GRID_LINE = (45, 55, 60)
|
||||
GRID_LINE_NIGHT = (25, 30, 38)
|
||||
|
||||
# Special locations
|
||||
OIL_FIELD = (35, 35, 35)
|
||||
OIL_GLOW = (80, 70, 45)
|
||||
TEMPLE_GLOW = (100, 80, 140)
|
||||
|
||||
# Religion colors
|
||||
RELIGIONS = {
|
||||
"solaris": (255, 180, 50), # Golden sun
|
||||
"aquarius": (50, 150, 220), # Ocean blue
|
||||
"terranus": (140, 100, 60), # Earth brown
|
||||
"ignis": (220, 80, 40), # Fire red
|
||||
"naturis": (80, 180, 80), # Forest green
|
||||
"atheist": (100, 100, 100), # Gray
|
||||
}
|
||||
|
||||
# Faction colors
|
||||
FACTIONS = {
|
||||
"northlands": (100, 150, 200), # Ice blue
|
||||
"riverfolk": (60, 140, 170), # River teal
|
||||
"forestkin": (80, 140, 70), # Forest green
|
||||
"mountaineer": (130, 110, 90), # Mountain brown
|
||||
"plainsmen": (180, 160, 100), # Plains gold
|
||||
"neutral": (100, 100, 100), # Gray
|
||||
}
|
||||
|
||||
|
||||
class MapRenderer:
|
||||
"""Renders the map/terrain background."""
|
||||
"""Renders the map/terrain background with special locations."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
screen: pygame.Surface,
|
||||
map_rect: pygame.Rect,
|
||||
world_width: int = 20,
|
||||
world_height: int = 20,
|
||||
world_width: int = 30,
|
||||
world_height: int = 30,
|
||||
):
|
||||
self.screen = screen
|
||||
self.map_rect = map_rect
|
||||
@ -38,24 +72,40 @@ class MapRenderer:
|
||||
self._cell_width = map_rect.width / world_width
|
||||
self._cell_height = map_rect.height / world_height
|
||||
|
||||
# Pre-generate some terrain variation
|
||||
# Animation state
|
||||
self.animation_tick = 0
|
||||
|
||||
# Pre-generate terrain
|
||||
self._terrain_cache = self._generate_terrain()
|
||||
|
||||
# Surface cache for static elements
|
||||
self._terrain_surface: pygame.Surface | None = None
|
||||
self._cached_dimensions = (world_width, world_height, map_rect.width, map_rect.height)
|
||||
|
||||
def _generate_terrain(self) -> list[list[int]]:
|
||||
"""Generate simple terrain variation (0 = light, 1 = dark, 2 = water)."""
|
||||
import random
|
||||
"""Generate terrain variation using noise-like pattern."""
|
||||
random.seed(42) # Consistent terrain
|
||||
terrain = []
|
||||
|
||||
for y in range(self.world_height):
|
||||
row = []
|
||||
for x in range(self.world_width):
|
||||
# Simple pattern: mostly grass with occasional water spots
|
||||
if random.random() < 0.05:
|
||||
row.append(2) # Water spot
|
||||
elif (x + y) % 3 == 0:
|
||||
# Create organic-looking patterns
|
||||
noise = (
|
||||
math.sin(x * 0.3) * math.cos(y * 0.3) +
|
||||
math.sin(x * 0.7 + y * 0.5) * 0.5
|
||||
)
|
||||
|
||||
if noise > 0.8:
|
||||
row.append(2) # Water
|
||||
elif noise > 0.3:
|
||||
row.append(1) # Dark grass
|
||||
elif noise < -0.5:
|
||||
row.append(3) # Accent grass
|
||||
else:
|
||||
row.append(0) # Light grass
|
||||
terrain.append(row)
|
||||
|
||||
return terrain
|
||||
|
||||
def update_dimensions(self, world_width: int, world_height: int) -> None:
|
||||
@ -66,6 +116,7 @@ class MapRenderer:
|
||||
self._cell_width = self.map_rect.width / world_width
|
||||
self._cell_height = self.map_rect.height / world_height
|
||||
self._terrain_cache = self._generate_terrain()
|
||||
self._terrain_surface = None # Invalidate cache
|
||||
|
||||
def grid_to_screen(self, grid_x: int, grid_y: int) -> tuple[int, int]:
|
||||
"""Convert grid coordinates to screen coordinates (center of cell)."""
|
||||
@ -77,70 +128,212 @@ class MapRenderer:
|
||||
"""Get the size of a single cell."""
|
||||
return int(self._cell_width), int(self._cell_height)
|
||||
|
||||
def draw(self, state: "SimulationState") -> None:
|
||||
"""Draw the map background."""
|
||||
is_night = state.time_of_day == "night"
|
||||
def _render_terrain_surface(self, is_night: bool) -> pygame.Surface:
|
||||
"""Render terrain to a cached surface."""
|
||||
surface = pygame.Surface((self.map_rect.width, self.map_rect.height))
|
||||
|
||||
# Fill background
|
||||
bg_color = Colors.NIGHT_BG if is_night else Colors.DAY_BG
|
||||
pygame.draw.rect(self.screen, bg_color, self.map_rect)
|
||||
surface.fill(bg_color)
|
||||
|
||||
# Draw terrain cells
|
||||
for y in range(self.world_height):
|
||||
for x in range(self.world_width):
|
||||
cell_rect = pygame.Rect(
|
||||
self.map_rect.left + x * self._cell_width,
|
||||
self.map_rect.top + y * self._cell_height,
|
||||
self._cell_width + 1, # +1 to avoid gaps
|
||||
x * self._cell_width,
|
||||
y * self._cell_height,
|
||||
self._cell_width + 1,
|
||||
self._cell_height + 1,
|
||||
)
|
||||
|
||||
terrain_type = self._terrain_cache[y][x]
|
||||
|
||||
if is_night:
|
||||
# Darker colors at night
|
||||
if terrain_type == 2:
|
||||
color = (60, 80, 110)
|
||||
color = (15, 25, 40)
|
||||
elif terrain_type == 1:
|
||||
color = (35, 40, 55)
|
||||
color = (18, 25, 22)
|
||||
elif terrain_type == 3:
|
||||
color = (22, 30, 26)
|
||||
else:
|
||||
color = (45, 50, 65)
|
||||
color = (20, 28, 24)
|
||||
else:
|
||||
if terrain_type == 2:
|
||||
color = Colors.WATER_SPOT
|
||||
elif terrain_type == 1:
|
||||
color = Colors.GRASS_DARK
|
||||
elif terrain_type == 3:
|
||||
color = Colors.GRASS_ACCENT
|
||||
else:
|
||||
color = Colors.GRASS_LIGHT
|
||||
|
||||
pygame.draw.rect(self.screen, color, cell_rect)
|
||||
pygame.draw.rect(surface, color, cell_rect)
|
||||
|
||||
# Draw grid lines
|
||||
# Draw subtle grid
|
||||
grid_color = Colors.GRID_LINE_NIGHT if is_night else Colors.GRID_LINE
|
||||
|
||||
# Vertical lines
|
||||
for x in range(self.world_width + 1):
|
||||
start_x = self.map_rect.left + x * self._cell_width
|
||||
start_x = x * self._cell_width
|
||||
pygame.draw.line(
|
||||
self.screen,
|
||||
surface,
|
||||
grid_color,
|
||||
(start_x, self.map_rect.top),
|
||||
(start_x, self.map_rect.bottom),
|
||||
(start_x, 0),
|
||||
(start_x, self.map_rect.height),
|
||||
1,
|
||||
)
|
||||
|
||||
# Horizontal lines
|
||||
for y in range(self.world_height + 1):
|
||||
start_y = self.map_rect.top + y * self._cell_height
|
||||
start_y = y * self._cell_height
|
||||
pygame.draw.line(
|
||||
self.screen,
|
||||
surface,
|
||||
grid_color,
|
||||
(self.map_rect.left, start_y),
|
||||
(self.map_rect.right, start_y),
|
||||
(0, start_y),
|
||||
(self.map_rect.width, start_y),
|
||||
1,
|
||||
)
|
||||
|
||||
# Draw border
|
||||
border_color = (80, 90, 70) if not is_night else (80, 85, 100)
|
||||
return surface
|
||||
|
||||
def _draw_oil_field(self, oil_field: dict, is_night: bool) -> None:
|
||||
"""Draw an oil field with pulsing glow effect."""
|
||||
pos = oil_field.get("position", {"x": 0, "y": 0})
|
||||
screen_x, screen_y = self.grid_to_screen(pos["x"], pos["y"])
|
||||
|
||||
cell_w, cell_h = self.get_cell_size()
|
||||
radius = min(cell_w, cell_h) // 2 - 2
|
||||
|
||||
# Pulsing glow
|
||||
pulse = 0.7 + 0.3 * math.sin(self.animation_tick * 0.05)
|
||||
glow_color = tuple(int(c * pulse) for c in Colors.OIL_GLOW)
|
||||
|
||||
# Outer glow
|
||||
for i in range(3, 0, -1):
|
||||
alpha = int(30 * pulse / i)
|
||||
glow_surface = pygame.Surface((radius * 4, radius * 4), pygame.SRCALPHA)
|
||||
pygame.draw.circle(
|
||||
glow_surface,
|
||||
(*glow_color, alpha),
|
||||
(radius * 2, radius * 2),
|
||||
radius + i * 3,
|
||||
)
|
||||
self.screen.blit(
|
||||
glow_surface,
|
||||
(screen_x - radius * 2, screen_y - radius * 2),
|
||||
)
|
||||
|
||||
# Oil derrick shape
|
||||
pygame.draw.circle(self.screen, Colors.OIL_FIELD, (screen_x, screen_y), radius)
|
||||
pygame.draw.circle(self.screen, glow_color, (screen_x, screen_y), radius, 2)
|
||||
|
||||
# Derrick icon (triangle)
|
||||
points = [
|
||||
(screen_x, screen_y - radius + 2),
|
||||
(screen_x - radius // 2, screen_y + radius // 2),
|
||||
(screen_x + radius // 2, screen_y + radius // 2),
|
||||
]
|
||||
pygame.draw.polygon(self.screen, glow_color, points)
|
||||
pygame.draw.polygon(self.screen, (40, 40, 40), points, 1)
|
||||
|
||||
# Oil remaining indicator
|
||||
oil_remaining = oil_field.get("oil_remaining", 1000)
|
||||
if oil_remaining < 500:
|
||||
# Low oil warning
|
||||
warning_pulse = 0.5 + 0.5 * math.sin(self.animation_tick * 0.1)
|
||||
warning_color = (int(200 * warning_pulse), int(60 * warning_pulse), 0)
|
||||
pygame.draw.circle(
|
||||
self.screen, warning_color,
|
||||
(screen_x + radius, screen_y - radius),
|
||||
4,
|
||||
)
|
||||
|
||||
def _draw_temple(self, temple: dict, is_night: bool) -> None:
|
||||
"""Draw a temple with religion-colored glow."""
|
||||
pos = temple.get("position", {"x": 0, "y": 0})
|
||||
religion_type = temple.get("religion_type", "atheist")
|
||||
screen_x, screen_y = self.grid_to_screen(pos["x"], pos["y"])
|
||||
|
||||
cell_w, cell_h = self.get_cell_size()
|
||||
radius = min(cell_w, cell_h) // 2 - 2
|
||||
|
||||
# Get religion color
|
||||
religion_color = Colors.RELIGIONS.get(religion_type, Colors.RELIGIONS["atheist"])
|
||||
|
||||
# Pulsing glow
|
||||
pulse = 0.6 + 0.4 * math.sin(self.animation_tick * 0.03 + hash(religion_type) % 10)
|
||||
glow_color = tuple(int(c * pulse) for c in religion_color)
|
||||
|
||||
# Outer divine glow
|
||||
for i in range(4, 0, -1):
|
||||
alpha = int(40 * pulse / i)
|
||||
glow_surface = pygame.Surface((radius * 5, radius * 5), pygame.SRCALPHA)
|
||||
pygame.draw.circle(
|
||||
glow_surface,
|
||||
(*glow_color, alpha),
|
||||
(radius * 2.5, radius * 2.5),
|
||||
int(radius + i * 4),
|
||||
)
|
||||
self.screen.blit(
|
||||
glow_surface,
|
||||
(screen_x - radius * 2.5, screen_y - radius * 2.5),
|
||||
)
|
||||
|
||||
# Temple base
|
||||
pygame.draw.circle(self.screen, (40, 35, 50), (screen_x, screen_y), radius)
|
||||
pygame.draw.circle(self.screen, glow_color, (screen_x, screen_y), radius, 2)
|
||||
|
||||
# Temple icon (cross/star pattern)
|
||||
half = radius // 2
|
||||
pygame.draw.line(self.screen, glow_color,
|
||||
(screen_x, screen_y - half),
|
||||
(screen_x, screen_y + half), 2)
|
||||
pygame.draw.line(self.screen, glow_color,
|
||||
(screen_x - half, screen_y),
|
||||
(screen_x + half, screen_y), 2)
|
||||
|
||||
# Religion initial
|
||||
font = pygame.font.Font(None, max(10, radius))
|
||||
initial = religion_type[0].upper() if religion_type else "?"
|
||||
text = font.render(initial, True, (255, 255, 255))
|
||||
text_rect = text.get_rect(center=(screen_x, screen_y))
|
||||
self.screen.blit(text, text_rect)
|
||||
|
||||
def draw(self, state: "SimulationState") -> None:
|
||||
"""Draw the map background with all features."""
|
||||
self.animation_tick += 1
|
||||
|
||||
is_night = state.time_of_day == "night"
|
||||
|
||||
# Draw terrain (cached for performance)
|
||||
current_dims = (self.world_width, self.world_height,
|
||||
self.map_rect.width, self.map_rect.height)
|
||||
|
||||
if self._terrain_surface is None or self._cached_dimensions != current_dims:
|
||||
self._terrain_surface = self._render_terrain_surface(is_night)
|
||||
self._cached_dimensions = current_dims
|
||||
|
||||
self.screen.blit(self._terrain_surface, self.map_rect.topleft)
|
||||
|
||||
# Draw oil fields
|
||||
for oil_field in state.oil_fields:
|
||||
self._draw_oil_field(oil_field, is_night)
|
||||
|
||||
# Draw temples
|
||||
for temple in state.temples:
|
||||
self._draw_temple(temple, is_night)
|
||||
|
||||
# Draw border with glow effect
|
||||
border_color = (50, 55, 70) if not is_night else (35, 40, 55)
|
||||
pygame.draw.rect(self.screen, border_color, self.map_rect, 2)
|
||||
|
||||
# Corner accents
|
||||
corner_size = 15
|
||||
accent_color = (80, 100, 130) if not is_night else (60, 75, 100)
|
||||
corners = [
|
||||
(self.map_rect.left, self.map_rect.top),
|
||||
(self.map_rect.right - corner_size, self.map_rect.top),
|
||||
(self.map_rect.left, self.map_rect.bottom - corner_size),
|
||||
(self.map_rect.right - corner_size, self.map_rect.bottom - corner_size),
|
||||
]
|
||||
for cx, cy in corners:
|
||||
pygame.draw.rect(self.screen, accent_color,
|
||||
(cx, cy, corner_size, corner_size), 1)
|
||||
|
||||
@ -1,4 +1,7 @@
|
||||
"""Settings UI renderer with sliders for the Village Simulation."""
|
||||
"""Settings UI renderer with sliders for the Village Simulation.
|
||||
|
||||
Includes settings for economy, religion, diplomacy, and oil.
|
||||
"""
|
||||
|
||||
import pygame
|
||||
from dataclasses import dataclass
|
||||
@ -7,76 +10,132 @@ from typing import Optional, Callable, Any
|
||||
|
||||
class Colors:
|
||||
"""Color palette for settings UI."""
|
||||
BG = (25, 28, 35)
|
||||
PANEL_BG = (35, 40, 50)
|
||||
PANEL_BORDER = (70, 80, 95)
|
||||
TEXT_PRIMARY = (230, 230, 235)
|
||||
TEXT_SECONDARY = (160, 165, 175)
|
||||
TEXT_HIGHLIGHT = (100, 180, 255)
|
||||
SLIDER_BG = (50, 55, 65)
|
||||
SLIDER_FILL = (80, 140, 200)
|
||||
BG = (15, 17, 23)
|
||||
PANEL_BG = (22, 26, 35)
|
||||
PANEL_HEADER = (28, 33, 45)
|
||||
PANEL_BORDER = (50, 60, 80)
|
||||
TEXT_PRIMARY = (225, 228, 235)
|
||||
TEXT_SECONDARY = (140, 150, 165)
|
||||
TEXT_HIGHLIGHT = (100, 200, 255)
|
||||
SLIDER_BG = (40, 45, 55)
|
||||
SLIDER_FILL = (70, 130, 200)
|
||||
SLIDER_HANDLE = (220, 220, 230)
|
||||
BUTTON_BG = (60, 100, 160)
|
||||
BUTTON_HOVER = (80, 120, 180)
|
||||
BUTTON_BG = (50, 90, 150)
|
||||
BUTTON_HOVER = (70, 110, 170)
|
||||
BUTTON_TEXT = (255, 255, 255)
|
||||
SUCCESS = (80, 180, 100)
|
||||
WARNING = (200, 160, 80)
|
||||
|
||||
# Section colors
|
||||
SECTION_ECONOMY = (100, 200, 255)
|
||||
SECTION_WORLD = (100, 220, 150)
|
||||
SECTION_RELIGION = (200, 150, 255)
|
||||
SECTION_DIPLOMACY = (255, 180, 100)
|
||||
SECTION_OIL = (180, 160, 100)
|
||||
|
||||
|
||||
@dataclass
|
||||
class SliderConfig:
|
||||
"""Configuration for a slider widget."""
|
||||
name: str
|
||||
key: str # Dot-separated path like "agent_stats.max_energy"
|
||||
key: str
|
||||
min_val: float
|
||||
max_val: float
|
||||
step: float = 1.0
|
||||
is_int: bool = True
|
||||
description: str = ""
|
||||
section: str = "General"
|
||||
|
||||
|
||||
# Define all configurable parameters with sliders
|
||||
# Organized slider configs by section
|
||||
SLIDER_CONFIGS = [
|
||||
# Agent Stats Section
|
||||
SliderConfig("Max Energy", "agent_stats.max_energy", 50, 200, 10, True, "Maximum energy capacity"),
|
||||
SliderConfig("Max Hunger", "agent_stats.max_hunger", 50, 200, 10, True, "Maximum hunger capacity"),
|
||||
SliderConfig("Max Thirst", "agent_stats.max_thirst", 25, 100, 5, True, "Maximum thirst capacity"),
|
||||
SliderConfig("Max Heat", "agent_stats.max_heat", 50, 200, 10, True, "Maximum heat capacity"),
|
||||
SliderConfig("Energy Decay", "agent_stats.energy_decay", 1, 10, 1, True, "Energy lost per turn"),
|
||||
SliderConfig("Hunger Decay", "agent_stats.hunger_decay", 1, 10, 1, True, "Hunger lost per turn"),
|
||||
SliderConfig("Thirst Decay", "agent_stats.thirst_decay", 1, 10, 1, True, "Thirst lost per turn"),
|
||||
SliderConfig("Heat Decay", "agent_stats.heat_decay", 1, 10, 1, True, "Heat lost per turn"),
|
||||
SliderConfig("Critical %", "agent_stats.critical_threshold", 0.1, 0.5, 0.05, False, "Threshold for survival mode"),
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
# WORLD SETTINGS
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
SliderConfig("World Width", "world.width", 15, 60, 5, True, "Grid width", "World"),
|
||||
SliderConfig("World Height", "world.height", 15, 60, 5, True, "Grid height", "World"),
|
||||
SliderConfig("Initial Agents", "world.initial_agents", 10, 200, 10, True, "Starting population", "World"),
|
||||
SliderConfig("Day Steps", "world.day_steps", 5, 20, 1, True, "Steps per day", "World"),
|
||||
SliderConfig("Starting Money", "world.starting_money", 50, 300, 25, True, "Initial coins", "World"),
|
||||
SliderConfig("Inventory Slots", "world.inventory_slots", 6, 20, 2, True, "Max items", "World"),
|
||||
|
||||
# World Section
|
||||
SliderConfig("World Width", "world.width", 10, 50, 5, True, "World grid width"),
|
||||
SliderConfig("World Height", "world.height", 10, 50, 5, True, "World grid height"),
|
||||
SliderConfig("Initial Agents", "world.initial_agents", 2, 20, 1, True, "Starting agent count"),
|
||||
SliderConfig("Day Steps", "world.day_steps", 5, 20, 1, True, "Steps per day"),
|
||||
SliderConfig("Inventory Slots", "world.inventory_slots", 5, 20, 1, True, "Agent inventory size"),
|
||||
SliderConfig("Starting Money", "world.starting_money", 50, 500, 50, True, "Initial coins per agent"),
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
# AGENT STATS
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
SliderConfig("Max Energy", "agent_stats.max_energy", 30, 150, 10, True, "Energy capacity", "Stats"),
|
||||
SliderConfig("Max Hunger", "agent_stats.max_hunger", 50, 200, 10, True, "Hunger capacity", "Stats"),
|
||||
SliderConfig("Max Thirst", "agent_stats.max_thirst", 50, 200, 10, True, "Thirst capacity", "Stats"),
|
||||
SliderConfig("Energy Decay", "agent_stats.energy_decay", 1, 5, 1, True, "Energy lost/turn", "Stats"),
|
||||
SliderConfig("Hunger Decay", "agent_stats.hunger_decay", 1, 8, 1, True, "Hunger lost/turn", "Stats"),
|
||||
SliderConfig("Thirst Decay", "agent_stats.thirst_decay", 1, 8, 1, True, "Thirst lost/turn", "Stats"),
|
||||
SliderConfig("Critical %", "agent_stats.critical_threshold", 0.1, 0.4, 0.05, False, "Survival mode threshold", "Stats"),
|
||||
|
||||
# Actions Section
|
||||
SliderConfig("Hunt Energy Cost", "actions.hunt_energy", -30, -5, 5, True, "Energy spent hunting"),
|
||||
SliderConfig("Gather Energy Cost", "actions.gather_energy", -20, -1, 1, True, "Energy spent gathering"),
|
||||
SliderConfig("Hunt Success %", "actions.hunt_success", 0.3, 1.0, 0.1, False, "Hunting success chance"),
|
||||
SliderConfig("Sleep Restore", "actions.sleep_energy", 30, 100, 10, True, "Energy restored by sleep"),
|
||||
SliderConfig("Rest Restore", "actions.rest_energy", 5, 30, 5, True, "Energy restored by rest"),
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
# ACTIONS
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
SliderConfig("Hunt Energy", "actions.hunt_energy", -15, -3, 1, True, "Energy cost", "Actions"),
|
||||
SliderConfig("Gather Energy", "actions.gather_energy", -8, -1, 1, True, "Energy cost", "Actions"),
|
||||
SliderConfig("Hunt Success %", "actions.hunt_success", 0.4, 1.0, 0.1, False, "Success chance", "Actions"),
|
||||
SliderConfig("Sleep Restore", "actions.sleep_energy", 30, 80, 10, True, "Energy gained", "Actions"),
|
||||
SliderConfig("Rest Restore", "actions.rest_energy", 5, 25, 5, True, "Energy gained", "Actions"),
|
||||
|
||||
# Resources Section
|
||||
SliderConfig("Meat Decay", "resources.meat_decay", 2, 20, 1, True, "Turns until meat spoils"),
|
||||
SliderConfig("Berries Decay", "resources.berries_decay", 10, 50, 5, True, "Turns until berries spoil"),
|
||||
SliderConfig("Meat Hunger +", "resources.meat_hunger", 10, 60, 5, True, "Hunger restored by meat"),
|
||||
SliderConfig("Water Thirst +", "resources.water_thirst", 20, 60, 5, True, "Thirst restored by water"),
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
# RELIGION
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
SliderConfig("Max Faith", "agent_stats.max_faith", 50, 150, 10, True, "Faith capacity", "Religion"),
|
||||
SliderConfig("Faith Decay", "agent_stats.faith_decay", 0, 5, 1, True, "Faith lost/turn", "Religion"),
|
||||
SliderConfig("Pray Faith Gain", "actions.pray_faith_gain", 10, 50, 5, True, "Faith from prayer", "Religion"),
|
||||
SliderConfig("Convert Chance", "actions.preach_convert_chance", 0.05, 0.4, 0.05, False, "Conversion rate", "Religion"),
|
||||
SliderConfig("Zealot Threshold", "religion.zealot_threshold", 0.6, 0.95, 0.05, False, "Zealot faith %", "Religion"),
|
||||
SliderConfig("Same Religion Bonus", "religion.same_religion_bonus", 0.0, 0.3, 0.05, False, "Trade bonus", "Religion"),
|
||||
|
||||
# Market Section
|
||||
SliderConfig("Discount Turns", "market.turns_before_discount", 1, 10, 1, True, "Turns before price drop"),
|
||||
SliderConfig("Discount Rate %", "market.discount_rate", 0.05, 0.30, 0.05, False, "Price reduction per period"),
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
# DIPLOMACY
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
SliderConfig("Num Factions", "diplomacy.num_factions", 2, 8, 1, True, "Active factions", "Diplomacy"),
|
||||
SliderConfig("Starting Relations", "diplomacy.starting_relations", 30, 70, 5, True, "Initial relation", "Diplomacy"),
|
||||
SliderConfig("Alliance Threshold", "diplomacy.alliance_threshold", 60, 90, 5, True, "For alliance", "Diplomacy"),
|
||||
SliderConfig("War Threshold", "diplomacy.war_threshold", 10, 40, 5, True, "For war", "Diplomacy"),
|
||||
SliderConfig("Relation Decay", "diplomacy.relation_decay", 0, 5, 1, True, "Decay per turn", "Diplomacy"),
|
||||
SliderConfig("War Exhaustion", "diplomacy.war_exhaustion_rate", 1, 10, 1, True, "Exhaustion/turn", "Diplomacy"),
|
||||
SliderConfig("Peace Duration", "diplomacy.peace_treaty_duration", 10, 50, 5, True, "Treaty turns", "Diplomacy"),
|
||||
|
||||
# Simulation Section
|
||||
SliderConfig("Auto Step (s)", "auto_step_interval", 0.2, 3.0, 0.2, False, "Seconds between auto steps"),
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
# OIL & RESOURCES
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
SliderConfig("Oil Fields", "world.oil_fields_count", 1, 10, 1, True, "Number of fields", "Oil"),
|
||||
SliderConfig("Drill Energy", "actions.drill_oil_energy", -20, -5, 1, True, "Drill cost", "Oil"),
|
||||
SliderConfig("Drill Success %", "actions.drill_oil_success", 0.3, 1.0, 0.1, False, "Success chance", "Oil"),
|
||||
SliderConfig("Oil Base Price", "economy.oil_base_price", 10, 50, 5, True, "Market price", "Oil"),
|
||||
SliderConfig("Fuel Base Price", "economy.fuel_base_price", 20, 80, 5, True, "Market price", "Oil"),
|
||||
SliderConfig("Fuel Heat", "resources.fuel_heat", 20, 60, 5, True, "Heat provided", "Oil"),
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
# MARKET
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
SliderConfig("Discount Turns", "market.turns_before_discount", 5, 30, 5, True, "Before price drop", "Market"),
|
||||
SliderConfig("Discount Rate %", "market.discount_rate", 0.05, 0.25, 0.05, False, "Per period", "Market"),
|
||||
SliderConfig("Max Markup", "economy.max_price_markup", 1.5, 4.0, 0.5, False, "Price ceiling", "Market"),
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
# SIMULATION
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
SliderConfig("Auto Step (s)", "auto_step_interval", 0.1, 2.0, 0.1, False, "Seconds/step", "Simulation"),
|
||||
]
|
||||
|
||||
# Section order and colors
|
||||
SECTION_ORDER = ["World", "Stats", "Actions", "Religion", "Diplomacy", "Oil", "Market", "Simulation"]
|
||||
SECTION_COLORS = {
|
||||
"World": Colors.SECTION_WORLD,
|
||||
"Stats": Colors.SECTION_ECONOMY,
|
||||
"Actions": Colors.SECTION_ECONOMY,
|
||||
"Religion": Colors.SECTION_RELIGION,
|
||||
"Diplomacy": Colors.SECTION_DIPLOMACY,
|
||||
"Oil": Colors.SECTION_OIL,
|
||||
"Market": Colors.SECTION_ECONOMY,
|
||||
"Simulation": Colors.TEXT_SECONDARY,
|
||||
}
|
||||
|
||||
|
||||
class Slider:
|
||||
"""A slider widget for adjusting numeric values."""
|
||||
@ -97,17 +156,17 @@ class Slider:
|
||||
self.hovered = False
|
||||
|
||||
def set_value(self, value: float) -> None:
|
||||
"""Set the slider value."""
|
||||
"""Set slider value."""
|
||||
self.value = max(self.config.min_val, min(self.config.max_val, value))
|
||||
if self.config.is_int:
|
||||
self.value = int(round(self.value))
|
||||
|
||||
def get_value(self) -> Any:
|
||||
"""Get the current value."""
|
||||
"""Get current value."""
|
||||
return int(self.value) if self.config.is_int else round(self.value, 2)
|
||||
|
||||
def handle_event(self, event: pygame.event.Event) -> bool:
|
||||
"""Handle input events. Returns True if value changed."""
|
||||
"""Handle events."""
|
||||
if event.type == pygame.MOUSEBUTTONDOWN:
|
||||
if self._slider_area().collidepoint(event.pos):
|
||||
self.dragging = True
|
||||
@ -124,27 +183,23 @@ class Slider:
|
||||
return False
|
||||
|
||||
def _slider_area(self) -> pygame.Rect:
|
||||
"""Get the actual slider track area."""
|
||||
"""Get slider track area."""
|
||||
return pygame.Rect(
|
||||
self.rect.x + 120, # Leave space for label
|
||||
self.rect.y + 15,
|
||||
self.rect.width - 180, # Leave space for value display
|
||||
20,
|
||||
self.rect.x + 130,
|
||||
self.rect.y + 12,
|
||||
self.rect.width - 200,
|
||||
16,
|
||||
)
|
||||
|
||||
def _update_from_mouse(self, mouse_x: int) -> bool:
|
||||
"""Update value based on mouse position."""
|
||||
"""Update value from mouse."""
|
||||
slider_area = self._slider_area()
|
||||
|
||||
# Calculate position as 0-1
|
||||
rel_x = mouse_x - slider_area.x
|
||||
ratio = max(0, min(1, rel_x / slider_area.width))
|
||||
|
||||
# Calculate value
|
||||
range_val = self.config.max_val - self.config.min_val
|
||||
new_value = self.config.min_val + ratio * range_val
|
||||
|
||||
# Apply step
|
||||
if self.config.step > 0:
|
||||
new_value = round(new_value / self.config.step) * self.config.step
|
||||
|
||||
@ -152,45 +207,44 @@ class Slider:
|
||||
self.set_value(new_value)
|
||||
return abs(old_value - self.value) > 0.001
|
||||
|
||||
def draw(self, screen: pygame.Surface) -> None:
|
||||
def draw(self, screen: pygame.Surface, section_color: tuple) -> None:
|
||||
"""Draw the slider."""
|
||||
# Background
|
||||
# Hover highlight
|
||||
if self.hovered:
|
||||
pygame.draw.rect(screen, (45, 50, 60), self.rect)
|
||||
pygame.draw.rect(screen, (35, 40, 50), self.rect, border_radius=4)
|
||||
|
||||
# Label
|
||||
label = self.small_font.render(self.config.name, True, Colors.TEXT_PRIMARY)
|
||||
screen.blit(label, (self.rect.x + 5, self.rect.y + 5))
|
||||
screen.blit(label, (self.rect.x + 8, self.rect.y + 6))
|
||||
|
||||
# Slider track
|
||||
slider_area = self._slider_area()
|
||||
pygame.draw.rect(screen, Colors.SLIDER_BG, slider_area, border_radius=3)
|
||||
pygame.draw.rect(screen, Colors.SLIDER_BG, slider_area, border_radius=4)
|
||||
|
||||
# Slider fill
|
||||
ratio = (self.value - self.config.min_val) / (self.config.max_val - self.config.min_val)
|
||||
fill_width = int(ratio * slider_area.width)
|
||||
fill_rect = pygame.Rect(slider_area.x, slider_area.y, fill_width, slider_area.height)
|
||||
pygame.draw.rect(screen, Colors.SLIDER_FILL, fill_rect, border_radius=3)
|
||||
pygame.draw.rect(screen, section_color, fill_rect, border_radius=4)
|
||||
|
||||
# Handle
|
||||
handle_x = slider_area.x + fill_width
|
||||
handle_rect = pygame.Rect(handle_x - 4, slider_area.y - 2, 8, slider_area.height + 4)
|
||||
pygame.draw.rect(screen, Colors.SLIDER_HANDLE, handle_rect, border_radius=2)
|
||||
handle_rect = pygame.Rect(handle_x - 5, slider_area.y - 2, 10, slider_area.height + 4)
|
||||
pygame.draw.rect(screen, Colors.SLIDER_HANDLE, handle_rect, border_radius=3)
|
||||
|
||||
# Value display
|
||||
value_str = str(self.get_value())
|
||||
value_text = self.small_font.render(value_str, True, Colors.TEXT_HIGHLIGHT)
|
||||
value_x = self.rect.right - 50
|
||||
screen.blit(value_text, (value_x, self.rect.y + 5))
|
||||
screen.blit(value_text, (self.rect.right - 55, self.rect.y + 6))
|
||||
|
||||
# Description on hover
|
||||
if self.hovered and self.config.description:
|
||||
desc = self.small_font.render(self.config.description, True, Colors.TEXT_SECONDARY)
|
||||
screen.blit(desc, (self.rect.x + 5, self.rect.y + 25))
|
||||
screen.blit(desc, (self.rect.x + 8, self.rect.y + 24))
|
||||
|
||||
|
||||
class Button:
|
||||
"""A simple button widget."""
|
||||
"""Button widget."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
@ -208,7 +262,7 @@ class Button:
|
||||
self.hovered = False
|
||||
|
||||
def handle_event(self, event: pygame.event.Event) -> bool:
|
||||
"""Handle input events. Returns True if clicked."""
|
||||
"""Handle events."""
|
||||
if event.type == pygame.MOUSEMOTION:
|
||||
self.hovered = self.rect.collidepoint(event.pos)
|
||||
|
||||
@ -221,10 +275,10 @@ class Button:
|
||||
return False
|
||||
|
||||
def draw(self, screen: pygame.Surface) -> None:
|
||||
"""Draw the button."""
|
||||
"""Draw button."""
|
||||
color = Colors.BUTTON_HOVER if self.hovered else self.color
|
||||
pygame.draw.rect(screen, color, self.rect, border_radius=5)
|
||||
pygame.draw.rect(screen, Colors.PANEL_BORDER, self.rect, 1, border_radius=5)
|
||||
pygame.draw.rect(screen, color, self.rect, border_radius=6)
|
||||
pygame.draw.rect(screen, Colors.PANEL_BORDER, self.rect, 1, border_radius=6)
|
||||
|
||||
text = self.font.render(self.text, True, Colors.BUTTON_TEXT)
|
||||
text_rect = text.get_rect(center=self.rect.center)
|
||||
@ -232,21 +286,23 @@ class Button:
|
||||
|
||||
|
||||
class SettingsRenderer:
|
||||
"""Renders the settings UI panel with sliders."""
|
||||
"""Settings panel with organized sections and sliders."""
|
||||
|
||||
def __init__(self, screen: pygame.Surface):
|
||||
self.screen = screen
|
||||
self.font = pygame.font.Font(None, 24)
|
||||
self.small_font = pygame.font.Font(None, 18)
|
||||
self.title_font = pygame.font.Font(None, 32)
|
||||
self.font = pygame.font.Font(None, 22)
|
||||
self.small_font = pygame.font.Font(None, 16)
|
||||
self.title_font = pygame.font.Font(None, 28)
|
||||
self.section_font = pygame.font.Font(None, 20)
|
||||
|
||||
self.visible = False
|
||||
self.scroll_offset = 0
|
||||
self.max_scroll = 0
|
||||
self.current_section = 0
|
||||
|
||||
# Create sliders
|
||||
self.sliders: list[Slider] = []
|
||||
self.buttons: list[Button] = []
|
||||
self.section_tabs: list[pygame.Rect] = []
|
||||
self.config_data: dict = {}
|
||||
|
||||
self._create_widgets()
|
||||
@ -254,32 +310,44 @@ class SettingsRenderer:
|
||||
self.status_color = Colors.TEXT_SECONDARY
|
||||
|
||||
def _create_widgets(self) -> None:
|
||||
"""Create slider widgets."""
|
||||
panel_width = 400
|
||||
slider_height = 45
|
||||
start_y = 80
|
||||
"""Create widgets."""
|
||||
screen_w, screen_h = self.screen.get_size()
|
||||
|
||||
panel_x = (self.screen.get_width() - panel_width) // 2
|
||||
# Panel dimensions - wider for better readability
|
||||
panel_width = min(600, screen_w - 100)
|
||||
panel_height = screen_h - 80
|
||||
panel_x = (screen_w - panel_width) // 2
|
||||
panel_y = 40
|
||||
|
||||
for i, config in enumerate(SLIDER_CONFIGS):
|
||||
rect = pygame.Rect(
|
||||
panel_x + 10,
|
||||
start_y + i * slider_height,
|
||||
panel_width - 20,
|
||||
slider_height,
|
||||
)
|
||||
self.panel_rect = pygame.Rect(panel_x, panel_y, panel_width, panel_height)
|
||||
|
||||
# Tab bar for sections
|
||||
tab_height = 30
|
||||
self.tab_rect = pygame.Rect(panel_x, panel_y, panel_width, tab_height)
|
||||
|
||||
# Content area
|
||||
content_start_y = panel_y + tab_height + 10
|
||||
slider_height = 38
|
||||
|
||||
# Group sliders by section
|
||||
self.sliders_by_section: dict[str, list[Slider]] = {s: [] for s in SECTION_ORDER}
|
||||
|
||||
slider_width = panel_width - 40
|
||||
|
||||
for config in SLIDER_CONFIGS:
|
||||
rect = pygame.Rect(panel_x + 20, 0, slider_width, slider_height)
|
||||
slider = Slider(rect, config, self.font, self.small_font)
|
||||
self.sliders.append(slider)
|
||||
self.sliders_by_section[config.section].append(slider)
|
||||
|
||||
# Calculate max scroll
|
||||
total_height = len(SLIDER_CONFIGS) * slider_height + 150
|
||||
visible_height = self.screen.get_height() - 150
|
||||
self.max_scroll = max(0, total_height - visible_height)
|
||||
# Calculate positions for current section
|
||||
self._layout_current_section()
|
||||
|
||||
# Create buttons at the bottom
|
||||
button_y = self.screen.get_height() - 60
|
||||
button_width = 100
|
||||
# Buttons at bottom
|
||||
button_y = panel_y + panel_height - 50
|
||||
button_width = 120
|
||||
button_height = 35
|
||||
button_spacing = 15
|
||||
|
||||
buttons_data = [
|
||||
("Apply & Restart", self._apply_config, Colors.SUCCESS),
|
||||
@ -287,26 +355,43 @@ class SettingsRenderer:
|
||||
("Close", self.toggle, Colors.PANEL_BORDER),
|
||||
]
|
||||
|
||||
total_button_width = len(buttons_data) * button_width + (len(buttons_data) - 1) * 10
|
||||
start_x = (self.screen.get_width() - total_button_width) // 2
|
||||
total_w = len(buttons_data) * button_width + (len(buttons_data) - 1) * button_spacing
|
||||
start_x = panel_x + (panel_width - total_w) // 2
|
||||
|
||||
for i, (text, callback, color) in enumerate(buttons_data):
|
||||
rect = pygame.Rect(
|
||||
start_x + i * (button_width + 10),
|
||||
start_x + i * (button_width + button_spacing),
|
||||
button_y,
|
||||
button_width,
|
||||
button_height,
|
||||
)
|
||||
self.buttons.append(Button(rect, text, self.small_font, callback, color))
|
||||
|
||||
def _layout_current_section(self) -> None:
|
||||
"""Layout sliders for current section."""
|
||||
section = SECTION_ORDER[self.current_section]
|
||||
sliders = self.sliders_by_section[section]
|
||||
|
||||
content_y = self.panel_rect.y + 50
|
||||
slider_height = 38
|
||||
|
||||
for i, slider in enumerate(sliders):
|
||||
slider.rect.y = content_y + i * slider_height - self.scroll_offset
|
||||
|
||||
# Calculate max scroll
|
||||
total_height = len(sliders) * slider_height
|
||||
visible_height = self.panel_rect.height - 120
|
||||
self.max_scroll = max(0, total_height - visible_height)
|
||||
|
||||
def toggle(self) -> None:
|
||||
"""Toggle settings visibility."""
|
||||
"""Toggle visibility."""
|
||||
self.visible = not self.visible
|
||||
if self.visible:
|
||||
self.scroll_offset = 0
|
||||
self._layout_current_section()
|
||||
|
||||
def set_config(self, config_data: dict) -> None:
|
||||
"""Set slider values from config data."""
|
||||
"""Set slider values from config."""
|
||||
self.config_data = config_data
|
||||
|
||||
for slider in self.sliders:
|
||||
@ -315,16 +400,14 @@ class SettingsRenderer:
|
||||
slider.set_value(value)
|
||||
|
||||
def get_config(self) -> dict:
|
||||
"""Get current config from slider values."""
|
||||
"""Get config from sliders."""
|
||||
result = {}
|
||||
|
||||
for slider in self.sliders:
|
||||
self._set_nested_value(result, slider.config.key, slider.get_value())
|
||||
|
||||
return result
|
||||
|
||||
def _get_nested_value(self, data: dict, key: str) -> Any:
|
||||
"""Get a value from nested dict using dot notation."""
|
||||
"""Get nested dict value."""
|
||||
parts = key.split(".")
|
||||
current = data
|
||||
for part in parts:
|
||||
@ -335,7 +418,7 @@ class SettingsRenderer:
|
||||
return current
|
||||
|
||||
def _set_nested_value(self, data: dict, key: str, value: Any) -> None:
|
||||
"""Set a value in nested dict using dot notation."""
|
||||
"""Set nested dict value."""
|
||||
parts = key.split(".")
|
||||
current = data
|
||||
for part in parts[:-1]:
|
||||
@ -345,104 +428,138 @@ class SettingsRenderer:
|
||||
current[parts[-1]] = value
|
||||
|
||||
def _apply_config(self) -> None:
|
||||
"""Apply configuration callback (to be set externally)."""
|
||||
"""Apply config callback."""
|
||||
self.status_message = "Config applied - restart to see changes"
|
||||
self.status_color = Colors.SUCCESS
|
||||
|
||||
def _reset_config(self) -> None:
|
||||
"""Reset configuration callback (to be set externally)."""
|
||||
"""Reset config callback."""
|
||||
self.status_message = "Config reset to defaults"
|
||||
self.status_color = Colors.WARNING
|
||||
|
||||
def handle_event(self, event: pygame.event.Event) -> bool:
|
||||
"""Handle input events. Returns True if event was consumed."""
|
||||
"""Handle events."""
|
||||
if not self.visible:
|
||||
return False
|
||||
|
||||
# Handle scrolling
|
||||
# Tab clicks
|
||||
if event.type == pygame.MOUSEBUTTONDOWN:
|
||||
if self.tab_rect.collidepoint(event.pos):
|
||||
tab_width = self.panel_rect.width // len(SECTION_ORDER)
|
||||
rel_x = event.pos[0] - self.tab_rect.x
|
||||
tab_idx = rel_x // tab_width
|
||||
if 0 <= tab_idx < len(SECTION_ORDER) and tab_idx != self.current_section:
|
||||
self.current_section = tab_idx
|
||||
self.scroll_offset = 0
|
||||
self._layout_current_section()
|
||||
return True
|
||||
|
||||
# Scrolling
|
||||
if event.type == pygame.MOUSEWHEEL:
|
||||
self.scroll_offset -= event.y * 30
|
||||
self.scroll_offset = max(0, min(self.max_scroll, self.scroll_offset))
|
||||
self._layout_current_section()
|
||||
return True
|
||||
|
||||
# Handle sliders
|
||||
for slider in self.sliders:
|
||||
# Adjust slider position for scroll
|
||||
original_y = slider.rect.y
|
||||
slider.rect.y -= self.scroll_offset
|
||||
# Sliders for current section
|
||||
section = SECTION_ORDER[self.current_section]
|
||||
for slider in self.sliders_by_section[section]:
|
||||
adjusted_rect = slider.rect.copy()
|
||||
|
||||
if slider.handle_event(event):
|
||||
slider.rect.y = original_y
|
||||
return True
|
||||
|
||||
slider.rect.y = original_y
|
||||
|
||||
# Handle buttons
|
||||
# Buttons
|
||||
for button in self.buttons:
|
||||
if button.handle_event(event):
|
||||
return True
|
||||
|
||||
# Consume all clicks when settings are visible
|
||||
# Consume clicks
|
||||
if event.type == pygame.MOUSEBUTTONDOWN:
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
def draw(self) -> None:
|
||||
"""Draw the settings panel."""
|
||||
"""Draw settings panel."""
|
||||
if not self.visible:
|
||||
return
|
||||
|
||||
# Dim background
|
||||
overlay = pygame.Surface(self.screen.get_size(), pygame.SRCALPHA)
|
||||
overlay.fill((0, 0, 0, 200))
|
||||
overlay.fill((0, 0, 0, 220))
|
||||
self.screen.blit(overlay, (0, 0))
|
||||
|
||||
# Panel background
|
||||
panel_width = 420
|
||||
panel_height = self.screen.get_height() - 40
|
||||
panel_x = (self.screen.get_width() - panel_width) // 2
|
||||
panel_rect = pygame.Rect(panel_x, 20, panel_width, panel_height)
|
||||
pygame.draw.rect(self.screen, Colors.PANEL_BG, panel_rect, border_radius=10)
|
||||
pygame.draw.rect(self.screen, Colors.PANEL_BORDER, panel_rect, 2, border_radius=10)
|
||||
# Panel
|
||||
pygame.draw.rect(self.screen, Colors.PANEL_BG, self.panel_rect, border_radius=10)
|
||||
pygame.draw.rect(self.screen, Colors.PANEL_BORDER, self.panel_rect, 2, border_radius=10)
|
||||
|
||||
# Title
|
||||
title = self.title_font.render("Simulation Settings", True, Colors.TEXT_PRIMARY)
|
||||
title_rect = title.get_rect(centerx=self.screen.get_width() // 2, y=35)
|
||||
title_rect = title.get_rect(centerx=self.panel_rect.centerx, y=self.panel_rect.y + 8)
|
||||
self.screen.blit(title, title_rect)
|
||||
|
||||
# Create clipping region for scrollable area
|
||||
clip_rect = pygame.Rect(panel_x, 70, panel_width, panel_height - 130)
|
||||
# Section tabs
|
||||
self._draw_section_tabs()
|
||||
|
||||
# Draw sliders with scroll offset
|
||||
for slider in self.sliders:
|
||||
# Adjust position for scroll
|
||||
adjusted_rect = slider.rect.copy()
|
||||
adjusted_rect.y -= self.scroll_offset
|
||||
# Clipping for sliders
|
||||
clip_rect = pygame.Rect(
|
||||
self.panel_rect.x + 10,
|
||||
self.panel_rect.y + 45,
|
||||
self.panel_rect.width - 20,
|
||||
self.panel_rect.height - 110,
|
||||
)
|
||||
|
||||
# Only draw if visible
|
||||
if clip_rect.colliderect(adjusted_rect):
|
||||
# Temporarily move slider for drawing
|
||||
original_y = slider.rect.y
|
||||
slider.rect.y = adjusted_rect.y
|
||||
slider.draw(self.screen)
|
||||
slider.rect.y = original_y
|
||||
# Draw sliders for current section
|
||||
section = SECTION_ORDER[self.current_section]
|
||||
section_color = SECTION_COLORS.get(section, Colors.TEXT_SECONDARY)
|
||||
|
||||
# Draw scroll indicator
|
||||
for slider in self.sliders_by_section[section]:
|
||||
if clip_rect.colliderect(slider.rect):
|
||||
slider.draw(self.screen, section_color)
|
||||
|
||||
# Scroll indicator
|
||||
if self.max_scroll > 0:
|
||||
scroll_ratio = self.scroll_offset / self.max_scroll
|
||||
scroll_height = max(30, int((clip_rect.height / (clip_rect.height + self.max_scroll)) * clip_rect.height))
|
||||
scroll_y = clip_rect.y + int(scroll_ratio * (clip_rect.height - scroll_height))
|
||||
scroll_rect = pygame.Rect(panel_rect.right - 8, scroll_y, 4, scroll_height)
|
||||
pygame.draw.rect(self.screen, Colors.SLIDER_FILL, scroll_rect, border_radius=2)
|
||||
bar_height = max(30, int((clip_rect.height / (clip_rect.height + self.max_scroll)) * clip_rect.height))
|
||||
bar_y = clip_rect.y + int(scroll_ratio * (clip_rect.height - bar_height))
|
||||
bar_rect = pygame.Rect(self.panel_rect.right - 12, bar_y, 5, bar_height)
|
||||
pygame.draw.rect(self.screen, Colors.SLIDER_FILL, bar_rect, border_radius=2)
|
||||
|
||||
# Draw buttons
|
||||
# Buttons
|
||||
for button in self.buttons:
|
||||
button.draw(self.screen)
|
||||
|
||||
# Status message
|
||||
if self.status_message:
|
||||
status = self.small_font.render(self.status_message, True, self.status_color)
|
||||
status_rect = status.get_rect(centerx=self.screen.get_width() // 2, y=self.screen.get_height() - 90)
|
||||
status_rect = status.get_rect(
|
||||
centerx=self.panel_rect.centerx,
|
||||
y=self.panel_rect.bottom - 80
|
||||
)
|
||||
self.screen.blit(status, status_rect)
|
||||
|
||||
def _draw_section_tabs(self) -> None:
|
||||
"""Draw section tabs."""
|
||||
tab_width = self.panel_rect.width // len(SECTION_ORDER)
|
||||
tab_y = self.panel_rect.y + 32
|
||||
tab_height = 20
|
||||
|
||||
for i, section in enumerate(SECTION_ORDER):
|
||||
tab_x = self.panel_rect.x + i * tab_width
|
||||
tab_rect = pygame.Rect(tab_x, tab_y, tab_width, tab_height)
|
||||
|
||||
color = SECTION_COLORS.get(section, Colors.TEXT_SECONDARY)
|
||||
|
||||
if i == self.current_section:
|
||||
pygame.draw.rect(self.screen, color, tab_rect, border_radius=3)
|
||||
text_color = Colors.BG
|
||||
else:
|
||||
pygame.draw.rect(self.screen, (40, 45, 55), tab_rect, border_radius=3)
|
||||
text_color = color
|
||||
|
||||
# Section name (abbreviated)
|
||||
name = section[:5] if len(section) > 5 else section
|
||||
text = self.small_font.render(name, True, text_color)
|
||||
text_rect = text.get_rect(center=tab_rect.center)
|
||||
self.screen.blit(text, text_rect)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -1,217 +1,591 @@
|
||||
"""UI renderer for the Village Simulation."""
|
||||
"""UI renderer for the Village Simulation.
|
||||
|
||||
Beautiful dark theme with panels for statistics, factions, religion, and diplomacy.
|
||||
"""
|
||||
|
||||
import math
|
||||
import pygame
|
||||
from typing import TYPE_CHECKING, Optional
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from frontend.client import SimulationState
|
||||
|
||||
|
||||
class Colors:
|
||||
# UI colors
|
||||
PANEL_BG = (35, 40, 50)
|
||||
PANEL_BORDER = (70, 80, 95)
|
||||
TEXT_PRIMARY = (230, 230, 235)
|
||||
TEXT_SECONDARY = (160, 165, 175)
|
||||
TEXT_HIGHLIGHT = (100, 180, 255)
|
||||
# Base UI colors - dark cyberpunk theme
|
||||
BG = (15, 17, 23)
|
||||
PANEL_BG = (22, 26, 35)
|
||||
PANEL_HEADER = (28, 33, 45)
|
||||
PANEL_BORDER = (45, 55, 70)
|
||||
PANEL_ACCENT = (60, 80, 110)
|
||||
|
||||
# Text
|
||||
TEXT_PRIMARY = (225, 228, 235)
|
||||
TEXT_SECONDARY = (140, 150, 165)
|
||||
TEXT_HIGHLIGHT = (100, 200, 255)
|
||||
TEXT_WARNING = (255, 180, 80)
|
||||
TEXT_DANGER = (255, 100, 100)
|
||||
TEXT_SUCCESS = (100, 220, 140)
|
||||
|
||||
# Day/Night indicator
|
||||
# Day/Night
|
||||
DAY_COLOR = (255, 220, 100)
|
||||
NIGHT_COLOR = (100, 120, 180)
|
||||
NIGHT_COLOR = (100, 140, 200)
|
||||
|
||||
# Faction colors
|
||||
FACTIONS = {
|
||||
"northlands": (100, 160, 220),
|
||||
"riverfolk": (70, 160, 180),
|
||||
"forestkin": (90, 160, 80),
|
||||
"mountaineer": (150, 120, 90),
|
||||
"plainsmen": (200, 180, 100),
|
||||
"neutral": (120, 120, 120),
|
||||
}
|
||||
|
||||
# Religion colors
|
||||
RELIGIONS = {
|
||||
"solaris": (255, 200, 80),
|
||||
"aquarius": (80, 170, 240),
|
||||
"terranus": (160, 120, 70),
|
||||
"ignis": (240, 100, 50),
|
||||
"naturis": (100, 200, 100),
|
||||
"atheist": (140, 140, 140),
|
||||
}
|
||||
|
||||
# Scrollbar
|
||||
SCROLLBAR_BG = (35, 40, 50)
|
||||
SCROLLBAR_HANDLE = (70, 90, 120)
|
||||
|
||||
|
||||
class UIRenderer:
|
||||
"""Renders UI elements (HUD, panels, text info)."""
|
||||
|
||||
def __init__(self, screen: pygame.Surface, font: pygame.font.Font):
|
||||
def __init__(
|
||||
self,
|
||||
screen: pygame.Surface,
|
||||
font: pygame.font.Font,
|
||||
top_panel_height: int = 50,
|
||||
right_panel_width: int = 280,
|
||||
bottom_panel_height: int = 60,
|
||||
):
|
||||
self.screen = screen
|
||||
self.font = font
|
||||
self.small_font = pygame.font.Font(None, 20)
|
||||
self.title_font = pygame.font.Font(None, 28)
|
||||
self.top_panel_height = top_panel_height
|
||||
self.right_panel_width = right_panel_width
|
||||
self.bottom_panel_height = bottom_panel_height
|
||||
|
||||
# Panel dimensions
|
||||
self.top_panel_height = 50
|
||||
self.right_panel_width = 200
|
||||
# Fonts
|
||||
self.small_font = pygame.font.Font(None, 16)
|
||||
self.medium_font = pygame.font.Font(None, 20)
|
||||
self.title_font = pygame.font.Font(None, 24)
|
||||
self.large_font = pygame.font.Font(None, 28)
|
||||
|
||||
def _draw_panel(self, rect: pygame.Rect, title: Optional[str] = None) -> None:
|
||||
"""Draw a panel background."""
|
||||
pygame.draw.rect(self.screen, Colors.PANEL_BG, rect)
|
||||
pygame.draw.rect(self.screen, Colors.PANEL_BORDER, rect, 1)
|
||||
# Scrolling state for right panel
|
||||
self.scroll_offset = 0
|
||||
self.max_scroll = 0
|
||||
self.scroll_dragging = False
|
||||
|
||||
# Animation
|
||||
self.animation_tick = 0
|
||||
|
||||
def _draw_panel_bg(
|
||||
self,
|
||||
rect: pygame.Rect,
|
||||
title: str = None,
|
||||
accent_color: tuple = None,
|
||||
) -> int:
|
||||
"""Draw a panel background. Returns Y position after header."""
|
||||
# Main background
|
||||
pygame.draw.rect(self.screen, Colors.PANEL_BG, rect, border_radius=4)
|
||||
pygame.draw.rect(self.screen, Colors.PANEL_BORDER, rect, 1, border_radius=4)
|
||||
|
||||
y = rect.y + 6
|
||||
|
||||
if title:
|
||||
title_text = self.small_font.render(title, True, Colors.TEXT_SECONDARY)
|
||||
self.screen.blit(title_text, (rect.x + 8, rect.y + 4))
|
||||
# Header area
|
||||
header_rect = pygame.Rect(rect.x, rect.y, rect.width, 24)
|
||||
pygame.draw.rect(
|
||||
self.screen, Colors.PANEL_HEADER, header_rect,
|
||||
border_top_left_radius=4, border_top_right_radius=4,
|
||||
)
|
||||
|
||||
# Accent line
|
||||
if accent_color:
|
||||
pygame.draw.line(
|
||||
self.screen, accent_color,
|
||||
(rect.x + 2, rect.y + 24),
|
||||
(rect.x + rect.width - 2, rect.y + 24),
|
||||
2,
|
||||
)
|
||||
|
||||
# Title
|
||||
text = self.medium_font.render(title, True, Colors.TEXT_PRIMARY)
|
||||
self.screen.blit(text, (rect.x + 10, rect.y + 5))
|
||||
y = rect.y + 30
|
||||
|
||||
return y
|
||||
|
||||
def _draw_progress_bar(
|
||||
self,
|
||||
x: int,
|
||||
y: int,
|
||||
width: int,
|
||||
height: int,
|
||||
value: float,
|
||||
max_value: float,
|
||||
color: tuple,
|
||||
bg_color: tuple = (35, 40, 50),
|
||||
show_label: bool = False,
|
||||
label: str = "",
|
||||
) -> None:
|
||||
"""Draw a styled progress bar."""
|
||||
# Background
|
||||
pygame.draw.rect(self.screen, bg_color, (x, y, width, height), border_radius=2)
|
||||
|
||||
# Fill
|
||||
if max_value > 0:
|
||||
ratio = min(1.0, value / max_value)
|
||||
fill_width = int(ratio * width)
|
||||
if fill_width > 0:
|
||||
pygame.draw.rect(
|
||||
self.screen, color,
|
||||
(x, y, fill_width, height),
|
||||
border_radius=2,
|
||||
)
|
||||
|
||||
# Label
|
||||
if show_label and label:
|
||||
text = self.small_font.render(label, True, Colors.TEXT_PRIMARY)
|
||||
text_rect = text.get_rect(midleft=(x + 4, y + height // 2))
|
||||
self.screen.blit(text, text_rect)
|
||||
|
||||
def draw_top_bar(self, state: "SimulationState") -> None:
|
||||
"""Draw the top information bar."""
|
||||
self.animation_tick += 1
|
||||
|
||||
rect = pygame.Rect(0, 0, self.screen.get_width(), self.top_panel_height)
|
||||
pygame.draw.rect(self.screen, Colors.PANEL_BG, rect)
|
||||
pygame.draw.line(
|
||||
self.screen,
|
||||
Colors.PANEL_BORDER,
|
||||
(0, self.top_panel_height),
|
||||
(self.screen.get_width(), self.top_panel_height),
|
||||
self.screen, Colors.PANEL_BORDER,
|
||||
(0, self.top_panel_height - 1),
|
||||
(self.screen.get_width(), self.top_panel_height - 1),
|
||||
)
|
||||
|
||||
# Day/Night and Turn info
|
||||
# Day/Night indicator with animated glow
|
||||
is_night = state.time_of_day == "night"
|
||||
time_color = Colors.NIGHT_COLOR if is_night else Colors.DAY_COLOR
|
||||
time_text = "NIGHT" if is_night else "DAY"
|
||||
|
||||
# Draw time indicator circle
|
||||
pygame.draw.circle(self.screen, time_color, (25, 25), 12)
|
||||
pygame.draw.circle(self.screen, Colors.PANEL_BORDER, (25, 25), 12, 1)
|
||||
# Glow effect
|
||||
glow_alpha = int(100 + 50 * math.sin(self.animation_tick * 0.05))
|
||||
glow_surface = pygame.Surface((40, 40), pygame.SRCALPHA)
|
||||
pygame.draw.circle(glow_surface, (*time_color, glow_alpha), (20, 20), 18)
|
||||
self.screen.blit(glow_surface, (10, 5))
|
||||
|
||||
# Time/day text
|
||||
# Time circle
|
||||
pygame.draw.circle(self.screen, time_color, (30, 25), 12)
|
||||
pygame.draw.circle(self.screen, Colors.PANEL_BORDER, (30, 25), 12, 1)
|
||||
|
||||
# Time/turn info
|
||||
info_text = f"{time_text} | Day {state.day}, Step {state.step_in_day} | Turn {state.turn}"
|
||||
text = self.font.render(info_text, True, Colors.TEXT_PRIMARY)
|
||||
self.screen.blit(text, (50, 15))
|
||||
self.screen.blit(text, (55, 14))
|
||||
|
||||
# Agent count
|
||||
living = len(state.get_living_agents())
|
||||
total = len(state.agents)
|
||||
agent_text = f"Population: {living}/{total}"
|
||||
color = Colors.TEXT_SUCCESS if living > total * 0.5 else Colors.TEXT_WARNING
|
||||
if living < total * 0.25:
|
||||
color = Colors.TEXT_DANGER
|
||||
text = self.medium_font.render(agent_text, True, color)
|
||||
self.screen.blit(text, (300, 16))
|
||||
|
||||
# Active wars indicator
|
||||
active_wars = len(state.active_wars)
|
||||
if active_wars > 0:
|
||||
war_pulse = 0.5 + 0.5 * math.sin(self.animation_tick * 0.1)
|
||||
war_color = (int(200 * war_pulse), int(60 * war_pulse), int(60 * war_pulse))
|
||||
war_text = f"⚔ {active_wars} WAR{'S' if active_wars > 1 else ''}"
|
||||
text = self.medium_font.render(war_text, True, war_color)
|
||||
self.screen.blit(text, (450, 16))
|
||||
|
||||
# Mode and status (right side)
|
||||
right_x = self.screen.get_width() - 180
|
||||
|
||||
# Mode indicator
|
||||
mode_color = Colors.TEXT_HIGHLIGHT if state.mode == "auto" else Colors.TEXT_SECONDARY
|
||||
mode_text = f"Mode: {state.mode.upper()}"
|
||||
text = self.small_font.render(mode_text, True, mode_color)
|
||||
self.screen.blit(text, (self.screen.get_width() - 120, 8))
|
||||
text = self.medium_font.render(mode_text, True, mode_color)
|
||||
self.screen.blit(text, (right_x, 10))
|
||||
|
||||
# Running indicator
|
||||
# Running status
|
||||
if state.is_running:
|
||||
status_text = "RUNNING"
|
||||
status_color = (100, 200, 100)
|
||||
status_text = "● RUNNING"
|
||||
status_color = Colors.TEXT_SUCCESS
|
||||
else:
|
||||
status_text = "STOPPED"
|
||||
status_color = Colors.TEXT_DANGER
|
||||
status_text = "○ STOPPED"
|
||||
status_color = Colors.TEXT_SECONDARY
|
||||
|
||||
text = self.small_font.render(status_text, True, status_color)
|
||||
self.screen.blit(text, (self.screen.get_width() - 120, 28))
|
||||
text = self.medium_font.render(status_text, True, status_color)
|
||||
self.screen.blit(text, (right_x, 28))
|
||||
|
||||
def draw_right_panel(self, state: "SimulationState") -> None:
|
||||
"""Draw the right information panel."""
|
||||
"""Draw the right information panel with scrollable content."""
|
||||
panel_x = self.screen.get_width() - self.right_panel_width
|
||||
panel_height = self.screen.get_height() - self.top_panel_height - self.bottom_panel_height
|
||||
|
||||
# Main panel background
|
||||
rect = pygame.Rect(
|
||||
panel_x,
|
||||
self.top_panel_height,
|
||||
self.right_panel_width,
|
||||
self.screen.get_height() - self.top_panel_height,
|
||||
panel_x, self.top_panel_height,
|
||||
self.right_panel_width, panel_height,
|
||||
)
|
||||
pygame.draw.rect(self.screen, Colors.PANEL_BG, rect)
|
||||
pygame.draw.line(
|
||||
self.screen,
|
||||
Colors.PANEL_BORDER,
|
||||
self.screen, Colors.PANEL_BORDER,
|
||||
(panel_x, self.top_panel_height),
|
||||
(panel_x, self.screen.get_height()),
|
||||
(panel_x, self.screen.get_height() - self.bottom_panel_height),
|
||||
)
|
||||
|
||||
# Content area with padding
|
||||
content_x = panel_x + 12
|
||||
content_width = self.right_panel_width - 24
|
||||
y = self.top_panel_height + 10
|
||||
|
||||
# Statistics section
|
||||
y = self._draw_statistics_section(state, panel_x + 10, y)
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
# STATISTICS SECTION
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
y = self._draw_stats_section(state, content_x, y, content_width)
|
||||
y += 15
|
||||
|
||||
# Market section
|
||||
y = self._draw_market_section(state, panel_x + 10, y + 20)
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
# FACTIONS SECTION
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
y = self._draw_factions_section(state, content_x, y, content_width)
|
||||
y += 15
|
||||
|
||||
# Controls help section
|
||||
self._draw_controls_help(panel_x + 10, self.screen.get_height() - 100)
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
# RELIGION SECTION
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
y = self._draw_religion_section(state, content_x, y, content_width)
|
||||
y += 15
|
||||
|
||||
def _draw_statistics_section(self, state: "SimulationState", x: int, y: int) -> int:
|
||||
"""Draw the statistics section."""
|
||||
# Title
|
||||
title = self.title_font.render("Statistics", True, Colors.TEXT_PRIMARY)
|
||||
self.screen.blit(title, (x, y))
|
||||
y += 30
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
# DIPLOMACY SECTION
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
y = self._draw_diplomacy_section(state, content_x, y, content_width)
|
||||
y += 15
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
# MARKET SECTION
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
y = self._draw_market_section(state, content_x, y, content_width)
|
||||
|
||||
def _draw_stats_section(
|
||||
self, state: "SimulationState", x: int, y: int, width: int
|
||||
) -> int:
|
||||
"""Draw statistics section."""
|
||||
# Section header
|
||||
text = self.title_font.render("📊 Statistics", True, Colors.TEXT_HIGHLIGHT)
|
||||
self.screen.blit(text, (x, y))
|
||||
y += 24
|
||||
|
||||
stats = state.statistics
|
||||
|
||||
# Population bar
|
||||
living = len(state.get_living_agents())
|
||||
total = len(state.agents)
|
||||
pop_color = Colors.TEXT_SUCCESS if living > total * 0.5 else Colors.TEXT_WARNING
|
||||
|
||||
# Population
|
||||
pop_color = Colors.TEXT_PRIMARY if living > 2 else Colors.TEXT_DANGER
|
||||
text = self.small_font.render(f"Population: {living}", True, pop_color)
|
||||
self.screen.blit(text, (x, y))
|
||||
y += 18
|
||||
pygame.draw.rect(
|
||||
self.screen, Colors.SCROLLBAR_BG,
|
||||
(x, y, width, 14), border_radius=2,
|
||||
)
|
||||
if total > 0:
|
||||
ratio = living / total
|
||||
pygame.draw.rect(
|
||||
self.screen, pop_color,
|
||||
(x, y, int(width * ratio), 14), border_radius=2,
|
||||
)
|
||||
pop_text = self.small_font.render(f"Pop: {living}/{total}", True, Colors.TEXT_PRIMARY)
|
||||
self.screen.blit(pop_text, (x + 4, y + 1))
|
||||
y += 20
|
||||
|
||||
# Deaths
|
||||
# Deaths and money
|
||||
deaths = stats.get("total_agents_died", 0)
|
||||
if deaths > 0:
|
||||
text = self.small_font.render(f"Deaths: {deaths}", True, Colors.TEXT_WARNING)
|
||||
self.screen.blit(text, (x, y))
|
||||
y += 18
|
||||
|
||||
# Total money
|
||||
total_money = stats.get("total_money_in_circulation", 0)
|
||||
text = self.small_font.render(f"Total Coins: {total_money}", True, Colors.TEXT_SECONDARY)
|
||||
|
||||
text = self.small_font.render(f"Deaths: {deaths}", True, Colors.TEXT_DANGER)
|
||||
self.screen.blit(text, (x, y))
|
||||
y += 18
|
||||
|
||||
# Professions
|
||||
professions = stats.get("professions", {})
|
||||
if professions:
|
||||
y += 5
|
||||
text = self.small_font.render("Professions:", True, Colors.TEXT_SECONDARY)
|
||||
self.screen.blit(text, (x, y))
|
||||
y += 16
|
||||
text = self.small_font.render(f"💰 {total_money}", True, (255, 215, 0))
|
||||
self.screen.blit(text, (x + width // 2, y))
|
||||
y += 16
|
||||
|
||||
for prof, count in professions.items():
|
||||
text = self.small_font.render(f" {prof}: {count}", True, Colors.TEXT_SECONDARY)
|
||||
self.screen.blit(text, (x, y))
|
||||
y += 14
|
||||
# Average faith
|
||||
avg_faith = state.get_avg_faith()
|
||||
text = self.small_font.render(f"Avg Faith: {avg_faith:.0f}%", True, Colors.TEXT_SECONDARY)
|
||||
self.screen.blit(text, (x, y))
|
||||
y += 16
|
||||
|
||||
return y
|
||||
|
||||
def _draw_market_section(self, state: "SimulationState", x: int, y: int) -> int:
|
||||
"""Draw the market section."""
|
||||
# Title
|
||||
title = self.title_font.render("Market", True, Colors.TEXT_PRIMARY)
|
||||
self.screen.blit(title, (x, y))
|
||||
y += 30
|
||||
def _draw_factions_section(
|
||||
self, state: "SimulationState", x: int, y: int, width: int
|
||||
) -> int:
|
||||
"""Draw factions section with distribution bars."""
|
||||
# Section header
|
||||
text = self.title_font.render("⚔ Factions", True, (180, 160, 120))
|
||||
self.screen.blit(text, (x, y))
|
||||
y += 22
|
||||
|
||||
faction_stats = state.get_faction_stats()
|
||||
total = sum(faction_stats.values()) or 1
|
||||
|
||||
# Sort by count
|
||||
sorted_factions = sorted(
|
||||
faction_stats.items(),
|
||||
key=lambda x: x[1],
|
||||
reverse=True
|
||||
)
|
||||
|
||||
for faction, count in sorted_factions[:5]: # Top 5
|
||||
color = Colors.FACTIONS.get(faction, Colors.FACTIONS["neutral"])
|
||||
ratio = count / total
|
||||
|
||||
# Faction bar
|
||||
bar_width = int(width * 0.6 * ratio * (total / max(1, sorted_factions[0][1])))
|
||||
bar_width = max(4, min(bar_width, int(width * 0.6)))
|
||||
|
||||
pygame.draw.rect(
|
||||
self.screen, (*color, 180),
|
||||
(x, y, bar_width, 10), border_radius=2,
|
||||
)
|
||||
|
||||
# Faction name and count
|
||||
label = f"{faction[:8]}: {count}"
|
||||
text = self.small_font.render(label, True, color)
|
||||
self.screen.blit(text, (x + bar_width + 8, y - 1))
|
||||
y += 14
|
||||
|
||||
return y
|
||||
|
||||
def _draw_religion_section(
|
||||
self, state: "SimulationState", x: int, y: int, width: int
|
||||
) -> int:
|
||||
"""Draw religion section with distribution."""
|
||||
# Section header
|
||||
text = self.title_font.render("✦ Religions", True, (200, 180, 220))
|
||||
self.screen.blit(text, (x, y))
|
||||
y += 22
|
||||
|
||||
religion_stats = state.get_religion_stats()
|
||||
total = sum(religion_stats.values()) or 1
|
||||
|
||||
# Sort by count
|
||||
sorted_religions = sorted(
|
||||
religion_stats.items(),
|
||||
key=lambda x: x[1],
|
||||
reverse=True
|
||||
)
|
||||
|
||||
for religion, count in sorted_religions[:5]: # Top 5
|
||||
color = Colors.RELIGIONS.get(religion, Colors.RELIGIONS["atheist"])
|
||||
ratio = count / total
|
||||
|
||||
# Religion color dot
|
||||
pygame.draw.circle(self.screen, color, (x + 5, y + 5), 4)
|
||||
|
||||
# Religion name, count, and percentage
|
||||
pct = ratio * 100
|
||||
label = f"{religion[:8]}: {count} ({pct:.0f}%)"
|
||||
text = self.small_font.render(label, True, Colors.TEXT_SECONDARY)
|
||||
self.screen.blit(text, (x + 14, y))
|
||||
y += 14
|
||||
|
||||
return y
|
||||
|
||||
def _draw_diplomacy_section(
|
||||
self, state: "SimulationState", x: int, y: int, width: int
|
||||
) -> int:
|
||||
"""Draw diplomacy section with wars and treaties."""
|
||||
# Section header
|
||||
text = self.title_font.render("🏛 Diplomacy", True, (120, 180, 200))
|
||||
self.screen.blit(text, (x, y))
|
||||
y += 22
|
||||
|
||||
# Active wars
|
||||
active_wars = state.active_wars
|
||||
if active_wars:
|
||||
text = self.small_font.render("Active Wars:", True, Colors.TEXT_DANGER)
|
||||
self.screen.blit(text, (x, y))
|
||||
y += 14
|
||||
|
||||
for war in active_wars[:3]: # Show up to 3 wars
|
||||
f1 = war.get("faction1", "?")[:6]
|
||||
f2 = war.get("faction2", "?")[:6]
|
||||
c1 = Colors.FACTIONS.get(war.get("faction1", "neutral"), (150, 150, 150))
|
||||
c2 = Colors.FACTIONS.get(war.get("faction2", "neutral"), (150, 150, 150))
|
||||
|
||||
# War indicator
|
||||
pygame.draw.circle(self.screen, c1, (x + 5, y + 5), 4)
|
||||
text = self.small_font.render(" ⚔ ", True, (200, 80, 80))
|
||||
self.screen.blit(text, (x + 12, y - 1))
|
||||
pygame.draw.circle(self.screen, c2, (x + 35, y + 5), 4)
|
||||
|
||||
war_text = f"{f1} vs {f2}"
|
||||
text = self.small_font.render(war_text, True, Colors.TEXT_SECONDARY)
|
||||
self.screen.blit(text, (x + 45, y))
|
||||
y += 14
|
||||
else:
|
||||
text = self.small_font.render("☮ No active wars", True, Colors.TEXT_SUCCESS)
|
||||
self.screen.blit(text, (x, y))
|
||||
y += 14
|
||||
|
||||
# Peace treaties
|
||||
peace_treaties = state.peace_treaties
|
||||
if peace_treaties:
|
||||
text = self.small_font.render(
|
||||
f"Peace Treaties: {len(peace_treaties)}",
|
||||
True, Colors.TEXT_SUCCESS
|
||||
)
|
||||
self.screen.blit(text, (x, y))
|
||||
y += 14
|
||||
|
||||
# Recent diplomatic events
|
||||
recent_events = state.diplomatic_events[:2]
|
||||
if recent_events:
|
||||
y += 4
|
||||
for event in recent_events:
|
||||
event_type = event.get("type", "unknown")
|
||||
if event_type == "war_declared":
|
||||
color = Colors.TEXT_DANGER
|
||||
icon = "⚔"
|
||||
elif event_type == "peace_made":
|
||||
color = Colors.TEXT_SUCCESS
|
||||
icon = "☮"
|
||||
else:
|
||||
color = Colors.TEXT_SECONDARY
|
||||
icon = "•"
|
||||
|
||||
desc = event.get("description", event_type)[:25]
|
||||
text = self.small_font.render(f"{icon} {desc}", True, color)
|
||||
self.screen.blit(text, (x, y))
|
||||
y += 12
|
||||
|
||||
return y
|
||||
|
||||
def _draw_market_section(
|
||||
self, state: "SimulationState", x: int, y: int, width: int
|
||||
) -> int:
|
||||
"""Draw market section with prices."""
|
||||
# Section header
|
||||
text = self.title_font.render("💹 Market", True, (100, 200, 150))
|
||||
self.screen.blit(text, (x, y))
|
||||
y += 22
|
||||
|
||||
# Order count
|
||||
order_count = len(state.market_orders)
|
||||
text = self.small_font.render(f"Active Orders: {order_count}", True, Colors.TEXT_SECONDARY)
|
||||
text = self.small_font.render(
|
||||
f"Active Orders: {order_count}",
|
||||
True, Colors.TEXT_SECONDARY
|
||||
)
|
||||
self.screen.blit(text, (x, y))
|
||||
y += 20
|
||||
y += 16
|
||||
|
||||
# Price summary for each resource with available stock
|
||||
# Price summary (show resources with stock)
|
||||
prices = state.market_prices
|
||||
for resource, data in prices.items():
|
||||
if data.get("total_available", 0) > 0:
|
||||
shown = 0
|
||||
for resource, data in sorted(prices.items()):
|
||||
if shown >= 6: # Limit display
|
||||
break
|
||||
|
||||
total_available = data.get("total_available", 0)
|
||||
if total_available > 0:
|
||||
price = data.get("lowest_price", "?")
|
||||
qty = data.get("total_available", 0)
|
||||
text = self.small_font.render(
|
||||
f"{resource}: {qty}x @ {price}c",
|
||||
True,
|
||||
Colors.TEXT_SECONDARY,
|
||||
)
|
||||
|
||||
# Resource color coding
|
||||
if "oil" in resource.lower() or "fuel" in resource.lower():
|
||||
res_color = (180, 160, 100)
|
||||
elif "meat" in resource.lower():
|
||||
res_color = (200, 120, 100)
|
||||
elif "water" in resource.lower():
|
||||
res_color = (100, 160, 200)
|
||||
else:
|
||||
res_color = Colors.TEXT_SECONDARY
|
||||
|
||||
res_text = f"{resource[:6]}: {total_available}x @ {price}c"
|
||||
text = self.small_font.render(res_text, True, res_color)
|
||||
self.screen.blit(text, (x, y))
|
||||
y += 16
|
||||
y += 14
|
||||
shown += 1
|
||||
|
||||
if shown == 0:
|
||||
text = self.small_font.render("No items for sale", True, Colors.TEXT_SECONDARY)
|
||||
self.screen.blit(text, (x, y))
|
||||
y += 14
|
||||
|
||||
return y
|
||||
|
||||
def _draw_controls_help(self, x: int, y: int) -> None:
|
||||
"""Draw controls help at bottom of panel."""
|
||||
def draw_bottom_bar(self, state: "SimulationState") -> None:
|
||||
"""Draw bottom information bar with event log."""
|
||||
bar_y = self.screen.get_height() - self.bottom_panel_height
|
||||
rect = pygame.Rect(0, bar_y, self.screen.get_width(), self.bottom_panel_height)
|
||||
|
||||
pygame.draw.rect(self.screen, Colors.PANEL_BG, rect)
|
||||
pygame.draw.line(
|
||||
self.screen,
|
||||
Colors.PANEL_BORDER,
|
||||
(x - 5, y - 10),
|
||||
(self.screen.get_width() - 5, y - 10),
|
||||
self.screen, Colors.PANEL_BORDER,
|
||||
(0, bar_y), (self.screen.get_width(), bar_y),
|
||||
)
|
||||
|
||||
title = self.small_font.render("Controls", True, Colors.TEXT_PRIMARY)
|
||||
self.screen.blit(title, (x, y))
|
||||
y += 20
|
||||
# Recent events (religious + diplomatic)
|
||||
x = 15
|
||||
y = bar_y + 8
|
||||
|
||||
controls = [
|
||||
"SPACE - Next Turn",
|
||||
"R - Reset Simulation",
|
||||
"M - Toggle Mode",
|
||||
"S - Settings",
|
||||
"ESC - Quit",
|
||||
]
|
||||
text = self.medium_font.render("Recent Events:", True, Colors.TEXT_SECONDARY)
|
||||
self.screen.blit(text, (x, y))
|
||||
x += 120
|
||||
|
||||
for control in controls:
|
||||
text = self.small_font.render(control, True, Colors.TEXT_SECONDARY)
|
||||
# Show recent religious events
|
||||
for event in state.religious_events[:2]:
|
||||
event_type = event.get("type", "")
|
||||
desc = event.get("description", event_type)[:30]
|
||||
|
||||
if event_type == "conversion":
|
||||
color = Colors.RELIGIONS.get(event.get("to_religion", "atheist"), (150, 150, 150))
|
||||
elif event_type == "prayer":
|
||||
color = (180, 160, 220)
|
||||
else:
|
||||
color = Colors.TEXT_SECONDARY
|
||||
|
||||
text = self.small_font.render(f"✦ {desc}", True, color)
|
||||
self.screen.blit(text, (x, y))
|
||||
x += text.get_width() + 20
|
||||
|
||||
# Show recent diplomatic events
|
||||
for event in state.diplomatic_events[:2]:
|
||||
event_type = event.get("type", "")
|
||||
desc = event.get("description", event_type)[:30]
|
||||
|
||||
if "war" in event_type.lower():
|
||||
color = Colors.TEXT_DANGER
|
||||
icon = "⚔"
|
||||
elif "peace" in event_type.lower():
|
||||
color = Colors.TEXT_SUCCESS
|
||||
icon = "☮"
|
||||
else:
|
||||
color = Colors.TEXT_SECONDARY
|
||||
icon = "🏛"
|
||||
|
||||
text = self.small_font.render(f"{icon} {desc}", True, color)
|
||||
self.screen.blit(text, (x, y))
|
||||
x += text.get_width() + 20
|
||||
|
||||
# If no events, show placeholder
|
||||
if not state.religious_events and not state.diplomatic_events:
|
||||
text = self.small_font.render(
|
||||
"No recent events",
|
||||
True, Colors.TEXT_SECONDARY
|
||||
)
|
||||
self.screen.blit(text, (x, y))
|
||||
y += 16
|
||||
|
||||
def draw_connection_status(self, connected: bool) -> None:
|
||||
"""Draw connection status overlay when disconnected."""
|
||||
@ -220,20 +594,52 @@ class UIRenderer:
|
||||
|
||||
# Semi-transparent overlay
|
||||
overlay = pygame.Surface(self.screen.get_size(), pygame.SRCALPHA)
|
||||
overlay.fill((0, 0, 0, 180))
|
||||
overlay.fill((0, 0, 0, 200))
|
||||
self.screen.blit(overlay, (0, 0))
|
||||
|
||||
# Connection message
|
||||
text = self.title_font.render("Connecting to server...", True, Colors.TEXT_WARNING)
|
||||
text_rect = text.get_rect(center=(self.screen.get_width() // 2, self.screen.get_height() // 2))
|
||||
# Connection box
|
||||
box_w, box_h = 400, 150
|
||||
box_x = (self.screen.get_width() - box_w) // 2
|
||||
box_y = (self.screen.get_height() - box_h) // 2
|
||||
|
||||
pygame.draw.rect(
|
||||
self.screen, Colors.PANEL_BG,
|
||||
(box_x, box_y, box_w, box_h), border_radius=10,
|
||||
)
|
||||
pygame.draw.rect(
|
||||
self.screen, Colors.PANEL_ACCENT,
|
||||
(box_x, box_y, box_w, box_h), 2, border_radius=10,
|
||||
)
|
||||
|
||||
# Pulsing dot
|
||||
pulse = 0.5 + 0.5 * math.sin(self.animation_tick * 0.1)
|
||||
dot_color = (int(255 * pulse), int(180 * pulse), int(80 * pulse))
|
||||
pygame.draw.circle(
|
||||
self.screen, dot_color,
|
||||
(box_x + 30, box_y + 40), 8,
|
||||
)
|
||||
|
||||
# Text
|
||||
text = self.large_font.render("Connecting to server...", True, Colors.TEXT_WARNING)
|
||||
text_rect = text.get_rect(center=(self.screen.get_width() // 2, box_y + 40))
|
||||
self.screen.blit(text, text_rect)
|
||||
|
||||
hint = self.small_font.render("Make sure the backend is running on localhost:8000", True, Colors.TEXT_SECONDARY)
|
||||
hint_rect = hint.get_rect(center=(self.screen.get_width() // 2, self.screen.get_height() // 2 + 30))
|
||||
hint = self.medium_font.render(
|
||||
"Make sure the backend is running on localhost:8000",
|
||||
True, Colors.TEXT_SECONDARY
|
||||
)
|
||||
hint_rect = hint.get_rect(center=(self.screen.get_width() // 2, box_y + 80))
|
||||
self.screen.blit(hint, hint_rect)
|
||||
|
||||
cmd = self.small_font.render(
|
||||
"Run: python -m backend.main",
|
||||
True, Colors.TEXT_HIGHLIGHT
|
||||
)
|
||||
cmd_rect = cmd.get_rect(center=(self.screen.get_width() // 2, box_y + 110))
|
||||
self.screen.blit(cmd, cmd_rect)
|
||||
|
||||
def draw(self, state: "SimulationState") -> None:
|
||||
"""Draw all UI elements."""
|
||||
self.draw_top_bar(state)
|
||||
self.draw_right_panel(state)
|
||||
|
||||
self.draw_bottom_bar(state)
|
||||
|
||||
83
tools/debug_diplomacy.py
Normal file
83
tools/debug_diplomacy.py
Normal file
@ -0,0 +1,83 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Debug script for diplomacy relations."""
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||
|
||||
from backend.core.engine import GameEngine
|
||||
from backend.domain.diplomacy import FactionType, get_faction_relations, DiplomaticStatus
|
||||
|
||||
def main():
|
||||
print("Debugging diplomacy relations...\n")
|
||||
|
||||
engine = GameEngine()
|
||||
engine.initialize(50)
|
||||
|
||||
faction_relations = get_faction_relations()
|
||||
|
||||
print("Initial Relations:")
|
||||
factions = [f for f in FactionType if f != FactionType.NEUTRAL]
|
||||
for f1 in factions:
|
||||
for f2 in factions:
|
||||
if f1 != f2:
|
||||
rel = faction_relations.get_relation(f1, f2)
|
||||
status = faction_relations.get_status(f1, f2)
|
||||
print(f" {f1.value:12s} -> {f2.value:12s}: {rel:3d} ({status.value})")
|
||||
|
||||
# Run 50 turns and check relations
|
||||
print("\n\nRunning 50 turns...")
|
||||
for step in range(50):
|
||||
engine.next_step()
|
||||
|
||||
print("\nAfter 50 turns:")
|
||||
hostile_pairs = []
|
||||
for f1 in factions:
|
||||
for f2 in factions:
|
||||
if f1.value < f2.value: # Avoid duplicates
|
||||
rel = faction_relations.get_relation(f1, f2)
|
||||
status = faction_relations.get_status(f1, f2)
|
||||
marker = "⚔️" if status == DiplomaticStatus.HOSTILE else ""
|
||||
print(f" {f1.value:12s} <-> {f2.value:12s}: {rel:3d} ({status.value:8s}) {marker}")
|
||||
if status == DiplomaticStatus.HOSTILE:
|
||||
hostile_pairs.append((f1, f2, rel))
|
||||
|
||||
print(f"\nHostile pairs: {len(hostile_pairs)}")
|
||||
for f1, f2, rel in hostile_pairs:
|
||||
print(f" {f1.value} vs {f2.value}: {rel}")
|
||||
|
||||
# Run 50 more turns
|
||||
print("\n\nRunning 50 more turns...")
|
||||
for step in range(50):
|
||||
engine.next_step()
|
||||
|
||||
print("\nAfter 100 turns:")
|
||||
hostile_pairs = []
|
||||
war_pairs = []
|
||||
for f1 in factions:
|
||||
for f2 in factions:
|
||||
if f1.value < f2.value:
|
||||
rel = faction_relations.get_relation(f1, f2)
|
||||
status = faction_relations.get_status(f1, f2)
|
||||
if status == DiplomaticStatus.HOSTILE:
|
||||
hostile_pairs.append((f1, f2, rel))
|
||||
elif status == DiplomaticStatus.WAR:
|
||||
war_pairs.append((f1, f2, rel))
|
||||
print(f" {f1.value:12s} <-> {f2.value:12s}: {rel:3d} ({status.value:8s})")
|
||||
|
||||
print(f"\nHostile pairs: {len(hostile_pairs)}")
|
||||
print(f"War pairs: {len(war_pairs)}")
|
||||
|
||||
if war_pairs:
|
||||
print("\n🔥 WARS ACTIVE:")
|
||||
for f1, f2, rel in war_pairs:
|
||||
print(f" {f1.value} vs {f2.value}")
|
||||
|
||||
stats = engine.world.get_statistics()
|
||||
print(f"\nTotal wars declared: {stats.get('total_wars', 0)}")
|
||||
print(f"Active wars: {faction_relations.active_wars}")
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
||||
738
tools/optimize_balance.py
Normal file
738
tools/optimize_balance.py
Normal file
@ -0,0 +1,738 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Comprehensive Balance Optimizer for Village Simulation
|
||||
|
||||
This script runs simulations and optimizes config values for:
|
||||
- High survival rate (target: >50% at end)
|
||||
- Religion diversity (no single religion >60%)
|
||||
- Faction survival (all factions have living members)
|
||||
- Active market (trades happening, money circulating)
|
||||
- Oil industry activity (drilling and refining)
|
||||
|
||||
Usage:
|
||||
python tools/optimize_balance.py [--iterations 20] [--steps 1000]
|
||||
python tools/optimize_balance.py --quick-test
|
||||
python tools/optimize_balance.py --analyze-current
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import random
|
||||
import re
|
||||
import sys
|
||||
from collections import defaultdict
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
# Add parent directory for imports
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||
|
||||
from backend.config import get_config, reload_config
|
||||
from backend.core.engine import GameEngine
|
||||
from backend.core.logger import reset_simulation_logger
|
||||
from backend.domain.action import reset_action_config_cache
|
||||
from backend.domain.resources import reset_resource_cache
|
||||
|
||||
|
||||
@dataclass
|
||||
class BalanceMetrics:
|
||||
"""Comprehensive metrics for simulation balance."""
|
||||
total_turns: int = 0
|
||||
initial_population: int = 0
|
||||
final_population: int = 0
|
||||
|
||||
# Survival tracking
|
||||
deaths_by_cause: dict = field(default_factory=lambda: defaultdict(int))
|
||||
population_over_time: list = field(default_factory=list)
|
||||
|
||||
# Religion tracking
|
||||
religion_counts: dict = field(default_factory=lambda: defaultdict(int))
|
||||
conversions: int = 0
|
||||
|
||||
# Faction tracking
|
||||
faction_counts: dict = field(default_factory=lambda: defaultdict(int))
|
||||
wars_declared: int = 0
|
||||
peace_treaties: int = 0
|
||||
|
||||
# Market tracking
|
||||
total_listings: int = 0
|
||||
total_trades: int = 0
|
||||
trade_volume: int = 0
|
||||
trade_value: int = 0
|
||||
trades_by_resource: dict = field(default_factory=lambda: defaultdict(int))
|
||||
|
||||
# Action diversity
|
||||
action_counts: dict = field(default_factory=lambda: defaultdict(int))
|
||||
|
||||
# Oil industry
|
||||
oil_drilled: int = 0
|
||||
fuel_refined: int = 0
|
||||
|
||||
# Economy
|
||||
money_circulation: list = field(default_factory=list)
|
||||
avg_wealth: list = field(default_factory=list)
|
||||
wealth_gini: list = field(default_factory=list)
|
||||
|
||||
@property
|
||||
def survival_rate(self) -> float:
|
||||
"""Final survival rate."""
|
||||
if self.initial_population == 0:
|
||||
return 0
|
||||
return self.final_population / self.initial_population
|
||||
|
||||
@property
|
||||
def religion_diversity(self) -> float:
|
||||
"""Religion diversity score (0-1, higher = more diverse)."""
|
||||
if not self.religion_counts:
|
||||
return 0
|
||||
total = sum(self.religion_counts.values())
|
||||
if total == 0:
|
||||
return 0
|
||||
max_count = max(self.religion_counts.values())
|
||||
# Perfect diversity = 20% each (5 religions), worst = 100% one religion
|
||||
return 1.0 - (max_count / total)
|
||||
|
||||
@property
|
||||
def dominant_religion_pct(self) -> float:
|
||||
"""Percentage held by dominant religion."""
|
||||
if not self.religion_counts:
|
||||
return 0
|
||||
total = sum(self.religion_counts.values())
|
||||
if total == 0:
|
||||
return 0
|
||||
return max(self.religion_counts.values()) / total
|
||||
|
||||
@property
|
||||
def factions_alive(self) -> int:
|
||||
"""Number of factions with living members."""
|
||||
return len([f for f, c in self.faction_counts.items() if c > 0])
|
||||
|
||||
@property
|
||||
def faction_diversity(self) -> float:
|
||||
"""Faction diversity (0-1)."""
|
||||
if not self.faction_counts:
|
||||
return 0
|
||||
alive = self.factions_alive
|
||||
# We have 5 non-neutral factions
|
||||
return alive / 5.0
|
||||
|
||||
@property
|
||||
def market_activity(self) -> float:
|
||||
"""Market activity score."""
|
||||
if self.total_turns == 0:
|
||||
return 0
|
||||
trades_per_turn = self.total_trades / self.total_turns
|
||||
# Target: 0.3 trades per turn per 10 agents
|
||||
return min(1.0, trades_per_turn / 0.3)
|
||||
|
||||
@property
|
||||
def trade_diversity(self) -> float:
|
||||
"""How many different resources are being traded."""
|
||||
resources_traded = len([r for r, c in self.trades_by_resource.items() if c > 0])
|
||||
return resources_traded / 6.0 # 6 tradeable resources
|
||||
|
||||
@property
|
||||
def oil_industry_activity(self) -> float:
|
||||
"""Oil industry health score."""
|
||||
total_oil_ops = self.oil_drilled + self.fuel_refined
|
||||
# Target: 5% of actions should be oil-related
|
||||
total_actions = sum(self.action_counts.values())
|
||||
if total_actions == 0:
|
||||
return 0
|
||||
return min(1.0, (total_oil_ops / total_actions) / 0.05)
|
||||
|
||||
@property
|
||||
def economy_health(self) -> float:
|
||||
"""Overall economy health."""
|
||||
if not self.avg_wealth:
|
||||
return 0
|
||||
final_wealth = self.avg_wealth[-1]
|
||||
# Target: average wealth should stay above 50
|
||||
return min(1.0, final_wealth / 50)
|
||||
|
||||
def score(self) -> float:
|
||||
"""Calculate overall balance score (0-100)."""
|
||||
score = 0
|
||||
|
||||
# Survival rate (0-30 points) - CRITICAL
|
||||
# Target: at least 30% survival
|
||||
survival_score = min(30, self.survival_rate * 100)
|
||||
score += survival_score
|
||||
|
||||
# Religion diversity (0-15 points)
|
||||
# Target: no single religion > 50%
|
||||
religion_score = self.religion_diversity * 15
|
||||
score += religion_score
|
||||
|
||||
# Faction survival (0-15 points)
|
||||
# Target: at least 4 of 5 factions alive
|
||||
faction_score = self.faction_diversity * 15
|
||||
score += faction_score
|
||||
|
||||
# Market activity (0-15 points)
|
||||
market_score = self.market_activity * 15
|
||||
score += market_score
|
||||
|
||||
# Trade diversity (0-10 points)
|
||||
trade_div_score = self.trade_diversity * 10
|
||||
score += trade_div_score
|
||||
|
||||
# Oil industry (0-10 points)
|
||||
oil_score = self.oil_industry_activity * 10
|
||||
score += oil_score
|
||||
|
||||
# Economy health (0-5 points)
|
||||
econ_score = self.economy_health * 5
|
||||
score += econ_score
|
||||
|
||||
return score
|
||||
|
||||
|
||||
def run_simulation(config_overrides: dict, num_steps: int = 1000, num_agents: int = 100) -> BalanceMetrics:
|
||||
"""Run a simulation with custom config and return metrics."""
|
||||
# Apply config overrides
|
||||
config_path = Path("config.json")
|
||||
with open(config_path) as f:
|
||||
config = json.load(f)
|
||||
|
||||
# Deep merge overrides
|
||||
for section, values in config_overrides.items():
|
||||
if section in config:
|
||||
config[section].update(values)
|
||||
else:
|
||||
config[section] = values
|
||||
|
||||
# Save temp config
|
||||
temp_config = Path("config_temp.json")
|
||||
with open(temp_config, 'w') as f:
|
||||
json.dump(config, f, indent=2)
|
||||
|
||||
# Reload config
|
||||
reload_config(str(temp_config))
|
||||
reset_action_config_cache()
|
||||
reset_resource_cache()
|
||||
|
||||
# Initialize engine - need to set initial_agents BEFORE reset() calls initialize()
|
||||
GameEngine._instance = None # Reset singleton
|
||||
engine = GameEngine()
|
||||
# Note: reset() already calls world.initialize(), so we must set initial_agents first
|
||||
# Get the config and modify it before reset
|
||||
sim_config = get_config()
|
||||
engine.world.config.initial_agents = num_agents
|
||||
# Reset creates a new world and initializes it
|
||||
from backend.core.world import World, WorldConfig
|
||||
world_config = WorldConfig(initial_agents=num_agents)
|
||||
engine.reset(config=world_config)
|
||||
|
||||
# Suppress logging
|
||||
import logging
|
||||
logging.getLogger("simulation").setLevel(logging.ERROR)
|
||||
|
||||
metrics = BalanceMetrics()
|
||||
metrics.initial_population = num_agents
|
||||
|
||||
# Run simulation
|
||||
for step in range(num_steps):
|
||||
if not engine.is_running:
|
||||
break
|
||||
|
||||
turn_log = engine.next_step()
|
||||
metrics.total_turns += 1
|
||||
|
||||
# Track population
|
||||
living = len(engine.world.get_living_agents())
|
||||
metrics.population_over_time.append(living)
|
||||
|
||||
# Track money
|
||||
agents = engine.world.get_living_agents()
|
||||
if agents:
|
||||
total_money = sum(a.money for a in agents)
|
||||
avg_money = total_money / len(agents)
|
||||
metrics.money_circulation.append(total_money)
|
||||
metrics.avg_wealth.append(avg_money)
|
||||
|
||||
# Gini coefficient
|
||||
moneys = sorted([a.money for a in agents])
|
||||
n = len(moneys)
|
||||
if n > 1 and total_money > 0:
|
||||
sum_of_diffs = sum(abs(m1 - m2) for m1 in moneys for m2 in moneys)
|
||||
gini = sum_of_diffs / (2 * n * total_money)
|
||||
else:
|
||||
gini = 0
|
||||
metrics.wealth_gini.append(gini)
|
||||
|
||||
# Process actions
|
||||
for action_data in turn_log.agent_actions:
|
||||
decision = action_data.get("decision", {})
|
||||
result = action_data.get("result", {})
|
||||
action_type = decision.get("action", "unknown")
|
||||
|
||||
metrics.action_counts[action_type] += 1
|
||||
|
||||
# Track specific actions
|
||||
if action_type == "drill_oil" and result.get("success"):
|
||||
for res in result.get("resources_gained", []):
|
||||
if res.get("type") == "oil":
|
||||
metrics.oil_drilled += res.get("quantity", 0)
|
||||
|
||||
elif action_type == "refine" and result.get("success"):
|
||||
for res in result.get("resources_gained", []):
|
||||
if res.get("type") == "fuel":
|
||||
metrics.fuel_refined += res.get("quantity", 0)
|
||||
|
||||
elif action_type == "preach" and result.get("success"):
|
||||
if "converted" in result.get("message", "").lower():
|
||||
metrics.conversions += 1
|
||||
|
||||
elif action_type == "declare_war" and result.get("success"):
|
||||
metrics.wars_declared += 1
|
||||
|
||||
elif action_type == "make_peace" and result.get("success"):
|
||||
metrics.peace_treaties += 1
|
||||
|
||||
elif action_type == "trade" and result.get("success"):
|
||||
message = result.get("message", "")
|
||||
if "Listed" in message:
|
||||
metrics.total_listings += 1
|
||||
elif "Bought" in message:
|
||||
match = re.search(r"Bought (\d+) (\w+) for (\d+)c", message)
|
||||
if match:
|
||||
qty = int(match.group(1))
|
||||
res = match.group(2)
|
||||
value = int(match.group(3))
|
||||
metrics.total_trades += 1
|
||||
metrics.trade_volume += qty
|
||||
metrics.trade_value += value
|
||||
metrics.trades_by_resource[res] += 1
|
||||
|
||||
# Process deaths
|
||||
for death_name in turn_log.deaths:
|
||||
for agent in engine.world.agents:
|
||||
if agent.name == death_name and agent.death_reason:
|
||||
metrics.deaths_by_cause[agent.death_reason] += 1
|
||||
break
|
||||
|
||||
# Collect final stats
|
||||
living_agents = engine.world.get_living_agents()
|
||||
metrics.final_population = len(living_agents)
|
||||
|
||||
# Count religions and factions
|
||||
for agent in living_agents:
|
||||
metrics.religion_counts[agent.religion.religion.value] += 1
|
||||
metrics.faction_counts[agent.diplomacy.faction.value] += 1
|
||||
|
||||
# Cleanup
|
||||
engine.logger.close()
|
||||
temp_config.unlink(missing_ok=True)
|
||||
|
||||
return metrics
|
||||
|
||||
|
||||
def generate_balanced_config() -> dict:
|
||||
"""Generate a config focused on balance."""
|
||||
return {
|
||||
"agent_stats": {
|
||||
"start_hunger": random.randint(85, 95),
|
||||
"start_thirst": random.randint(85, 95),
|
||||
"hunger_decay": random.randint(1, 2),
|
||||
"thirst_decay": random.randint(1, 3),
|
||||
"heat_decay": random.randint(1, 2),
|
||||
"faith_decay": random.randint(1, 2),
|
||||
"critical_threshold": round(random.uniform(0.12, 0.20), 2),
|
||||
},
|
||||
"resources": {
|
||||
"meat_hunger": random.randint(40, 55),
|
||||
"berries_hunger": random.randint(12, 18),
|
||||
"water_thirst": random.randint(50, 70),
|
||||
"fire_heat": random.randint(25, 40),
|
||||
},
|
||||
"actions": {
|
||||
"hunt_success": round(random.uniform(0.75, 0.90), 2),
|
||||
"drill_oil_success": round(random.uniform(0.70, 0.85), 2),
|
||||
"hunt_meat_min": random.randint(3, 4),
|
||||
"hunt_meat_max": random.randint(5, 7),
|
||||
"gather_min": random.randint(4, 5),
|
||||
"gather_max": random.randint(6, 8),
|
||||
# preach_convert_chance is in actions, not religion
|
||||
"preach_convert_chance": round(random.uniform(0.03, 0.08), 2),
|
||||
},
|
||||
"religion": {
|
||||
"conversion_resistance": round(random.uniform(0.65, 0.85), 2),
|
||||
"zealot_threshold": round(random.uniform(0.80, 0.92), 2),
|
||||
"same_religion_bonus": round(random.uniform(0.08, 0.15), 2),
|
||||
"different_religion_penalty": round(random.uniform(0.02, 0.06), 2),
|
||||
},
|
||||
"diplomacy": {
|
||||
"starting_relations": random.randint(55, 70),
|
||||
"relation_decay": random.randint(0, 1),
|
||||
"trade_relation_boost": random.randint(6, 10),
|
||||
"war_exhaustion_rate": random.randint(8, 15),
|
||||
"war_threshold": random.randint(15, 25),
|
||||
},
|
||||
"economy": {
|
||||
"buy_efficiency_threshold": round(random.uniform(0.80, 0.95), 2),
|
||||
"min_wealth_target": random.randint(25, 50),
|
||||
"max_price_markup": round(random.uniform(1.4, 1.8), 1),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def mutate_config(config: dict, mutation_rate: float = 0.3) -> dict:
|
||||
"""Mutate a configuration."""
|
||||
new_config = json.loads(json.dumps(config))
|
||||
|
||||
for section, values in new_config.items():
|
||||
for key, value in values.items():
|
||||
if random.random() < mutation_rate:
|
||||
if isinstance(value, int):
|
||||
delta = max(1, abs(value) // 4)
|
||||
new_config[section][key] = max(0, value + random.randint(-delta, delta))
|
||||
elif isinstance(value, float):
|
||||
delta = abs(value) * 0.15
|
||||
new_val = value + random.uniform(-delta, delta)
|
||||
new_config[section][key] = round(max(0.01, min(0.99, new_val)), 2)
|
||||
|
||||
return new_config
|
||||
|
||||
|
||||
def crossover_configs(config1: dict, config2: dict) -> dict:
|
||||
"""Crossover two configurations."""
|
||||
new_config = {}
|
||||
for section in set(config1.keys()) | set(config2.keys()):
|
||||
if section in config1 and section in config2:
|
||||
new_config[section] = {}
|
||||
for key in set(config1[section].keys()) | set(config2[section].keys()):
|
||||
if random.random() < 0.5 and key in config1[section]:
|
||||
new_config[section][key] = config1[section][key]
|
||||
elif key in config2[section]:
|
||||
new_config[section][key] = config2[section][key]
|
||||
elif section in config1:
|
||||
new_config[section] = config1[section].copy()
|
||||
else:
|
||||
new_config[section] = config2[section].copy()
|
||||
return new_config
|
||||
|
||||
|
||||
def print_metrics(metrics: BalanceMetrics, detailed: bool = True):
|
||||
"""Print metrics in a readable format."""
|
||||
print(f"\n 📊 Balance Score: {metrics.score():.1f}/100")
|
||||
print(f" ├─ Survival: {metrics.survival_rate*100:.0f}% ({metrics.final_population}/{metrics.initial_population})")
|
||||
print(f" ├─ Religion: {metrics.religion_diversity*100:.0f}% diversity (dominant: {metrics.dominant_religion_pct*100:.0f}%)")
|
||||
print(f" ├─ Factions: {metrics.factions_alive}/5 alive ({metrics.faction_diversity*100:.0f}%)")
|
||||
print(f" ├─ Market: {metrics.total_trades} trades, {metrics.total_listings} listings")
|
||||
print(f" ├─ Trade diversity: {metrics.trade_diversity*100:.0f}%")
|
||||
print(f" ├─ Oil industry: {metrics.oil_drilled} oil, {metrics.fuel_refined} fuel")
|
||||
print(f" └─ Economy: avg wealth ${metrics.avg_wealth[-1]:.0f}" if metrics.avg_wealth else " └─ Economy: N/A")
|
||||
|
||||
if detailed:
|
||||
print(f"\n 📋 Death causes:")
|
||||
for cause, count in sorted(metrics.deaths_by_cause.items(), key=lambda x: -x[1])[:5]:
|
||||
print(f" - {cause}: {count}")
|
||||
|
||||
print(f"\n 🏛️ Religions:")
|
||||
for religion, count in sorted(metrics.religion_counts.items(), key=lambda x: -x[1]):
|
||||
print(f" - {religion}: {count}")
|
||||
|
||||
print(f"\n ⚔️ Factions:")
|
||||
for faction, count in sorted(metrics.faction_counts.items(), key=lambda x: -x[1]):
|
||||
print(f" - {faction}: {count}")
|
||||
|
||||
|
||||
def optimize_balance(iterations: int = 20, steps_per_sim: int = 1000, population_size: int = 8):
|
||||
"""Run genetic optimization for balance."""
|
||||
print("\n" + "=" * 70)
|
||||
print("🧬 BALANCE OPTIMIZER - Finding Optimal Configuration")
|
||||
print("=" * 70)
|
||||
print(f" Iterations: {iterations}")
|
||||
print(f" Steps per simulation: {steps_per_sim}")
|
||||
print(f" Population size: {population_size}")
|
||||
print(f" Agents per simulation: 100")
|
||||
print("=" * 70)
|
||||
|
||||
# Create initial population
|
||||
population = []
|
||||
|
||||
# Start with a well-balanced baseline
|
||||
baseline = {
|
||||
"agent_stats": {
|
||||
"start_hunger": 92,
|
||||
"start_thirst": 92,
|
||||
"hunger_decay": 1,
|
||||
"thirst_decay": 2,
|
||||
"heat_decay": 1,
|
||||
"faith_decay": 1,
|
||||
"critical_threshold": 0.15,
|
||||
},
|
||||
"resources": {
|
||||
"meat_hunger": 50,
|
||||
"berries_hunger": 15,
|
||||
"water_thirst": 65,
|
||||
"fire_heat": 35,
|
||||
},
|
||||
"actions": {
|
||||
"hunt_success": 0.85,
|
||||
"drill_oil_success": 0.80,
|
||||
"hunt_meat_min": 4,
|
||||
"hunt_meat_max": 6,
|
||||
"gather_min": 4,
|
||||
"gather_max": 7,
|
||||
"preach_convert_chance": 0.05,
|
||||
},
|
||||
"religion": {
|
||||
"conversion_resistance": 0.75,
|
||||
"zealot_threshold": 0.88,
|
||||
"same_religion_bonus": 0.10,
|
||||
"different_religion_penalty": 0.03,
|
||||
},
|
||||
"diplomacy": {
|
||||
"starting_relations": 65,
|
||||
"relation_decay": 0,
|
||||
"trade_relation_boost": 8,
|
||||
"war_exhaustion_rate": 12,
|
||||
"war_threshold": 18,
|
||||
},
|
||||
"economy": {
|
||||
"buy_efficiency_threshold": 0.88,
|
||||
"min_wealth_target": 35,
|
||||
"max_price_markup": 1.5,
|
||||
},
|
||||
}
|
||||
population.append(baseline)
|
||||
|
||||
# Add survival-focused variant
|
||||
survival_focused = json.loads(json.dumps(baseline))
|
||||
survival_focused["agent_stats"]["hunger_decay"] = 1
|
||||
survival_focused["agent_stats"]["thirst_decay"] = 1
|
||||
survival_focused["resources"]["meat_hunger"] = 55
|
||||
survival_focused["resources"]["berries_hunger"] = 18
|
||||
survival_focused["resources"]["water_thirst"] = 70
|
||||
population.append(survival_focused)
|
||||
|
||||
# Add religion-balanced variant
|
||||
religion_balanced = json.loads(json.dumps(baseline))
|
||||
religion_balanced["religion"]["conversion_resistance"] = 0.82
|
||||
religion_balanced["actions"]["preach_convert_chance"] = 0.03
|
||||
religion_balanced["religion"]["zealot_threshold"] = 0.92
|
||||
population.append(religion_balanced)
|
||||
|
||||
# Add diplomacy-stable variant
|
||||
diplomacy_stable = json.loads(json.dumps(baseline))
|
||||
diplomacy_stable["diplomacy"]["relation_decay"] = 0
|
||||
diplomacy_stable["diplomacy"]["starting_relations"] = 70
|
||||
diplomacy_stable["diplomacy"]["war_exhaustion_rate"] = 15
|
||||
population.append(diplomacy_stable)
|
||||
|
||||
# Fill rest with random
|
||||
while len(population) < population_size:
|
||||
population.append(generate_balanced_config())
|
||||
|
||||
best_config = None
|
||||
best_score = 0
|
||||
best_metrics = None
|
||||
|
||||
for gen in range(iterations):
|
||||
print(f"\n📍 Generation {gen + 1}/{iterations}")
|
||||
print("-" * 50)
|
||||
|
||||
scored_population = []
|
||||
for i, config in enumerate(population):
|
||||
sys.stdout.write(f"\r Evaluating config {i + 1}/{len(population)}...")
|
||||
sys.stdout.flush()
|
||||
|
||||
metrics = run_simulation(config, steps_per_sim, num_agents=100)
|
||||
score = metrics.score()
|
||||
scored_population.append((config, metrics, score))
|
||||
|
||||
# Sort by score
|
||||
scored_population.sort(key=lambda x: x[2], reverse=True)
|
||||
|
||||
# Print top results
|
||||
print(f"\r Top configs this generation:")
|
||||
for i, (config, metrics, score) in enumerate(scored_population[:3]):
|
||||
print(f"\n #{i + 1}: Score {score:.1f}")
|
||||
print_metrics(metrics, detailed=False)
|
||||
|
||||
# Track best overall
|
||||
if scored_population[0][2] > best_score:
|
||||
best_config = scored_population[0][0]
|
||||
best_score = scored_population[0][2]
|
||||
best_metrics = scored_population[0][1]
|
||||
print(f"\n ⭐ New best score: {best_score:.1f}")
|
||||
|
||||
# Create next generation
|
||||
new_population = []
|
||||
|
||||
# Keep top 2 (elitism)
|
||||
new_population.append(scored_population[0][0])
|
||||
new_population.append(scored_population[1][0])
|
||||
|
||||
# Crossover and mutate
|
||||
while len(new_population) < population_size:
|
||||
parent1 = random.choice(scored_population[:4])[0]
|
||||
parent2 = random.choice(scored_population[:4])[0]
|
||||
child = crossover_configs(parent1, parent2)
|
||||
child = mutate_config(child, mutation_rate=0.25)
|
||||
new_population.append(child)
|
||||
|
||||
population = new_population
|
||||
|
||||
print("\n" + "=" * 70)
|
||||
print("🏆 OPTIMIZATION COMPLETE")
|
||||
print("=" * 70)
|
||||
|
||||
print(f"\n Best Score: {best_score:.1f}/100")
|
||||
print_metrics(best_metrics, detailed=True)
|
||||
|
||||
print("\n 📝 Best Configuration:")
|
||||
print("-" * 50)
|
||||
print(json.dumps(best_config, indent=2))
|
||||
|
||||
# Save optimized config
|
||||
output_path = Path("config_balanced.json")
|
||||
|
||||
with open("config.json") as f:
|
||||
full_config = json.load(f)
|
||||
|
||||
for section, values in best_config.items():
|
||||
if section in full_config:
|
||||
full_config[section].update(values)
|
||||
else:
|
||||
full_config[section] = values
|
||||
|
||||
with open(output_path, 'w') as f:
|
||||
json.dump(full_config, f, indent=2)
|
||||
|
||||
print(f"\n ✅ Saved optimized config to: {output_path}")
|
||||
print(" To apply: cp config_balanced.json config.json")
|
||||
|
||||
return best_config, best_metrics
|
||||
|
||||
|
||||
def analyze_current_config(steps: int = 500):
|
||||
"""Analyze the current configuration."""
|
||||
print("\n" + "=" * 70)
|
||||
print("📊 ANALYZING CURRENT CONFIGURATION")
|
||||
print("=" * 70)
|
||||
|
||||
metrics = run_simulation({}, steps, num_agents=100)
|
||||
print_metrics(metrics, detailed=True)
|
||||
|
||||
# Provide recommendations
|
||||
print("\n" + "=" * 70)
|
||||
print("💡 RECOMMENDATIONS")
|
||||
print("=" * 70)
|
||||
|
||||
if metrics.survival_rate < 0.3:
|
||||
print("\n ⚠️ LOW SURVIVAL RATE")
|
||||
print(" - Reduce hunger_decay and thirst_decay")
|
||||
print(" - Increase food resource values (meat_hunger, berries_hunger)")
|
||||
print(" - Lower critical_threshold")
|
||||
|
||||
if metrics.dominant_religion_pct > 0.6:
|
||||
print("\n ⚠️ RELIGION DOMINANCE")
|
||||
print(" - Increase conversion_resistance (try 0.75+)")
|
||||
print(" - Reduce preach_convert_chance (try 0.05)")
|
||||
print(" - Increase zealot_threshold (try 0.88+)")
|
||||
|
||||
if metrics.factions_alive < 4:
|
||||
print("\n ⚠️ FACTIONS DYING OUT")
|
||||
print(" - Set relation_decay to 0 or 1")
|
||||
print(" - Increase starting_relations (try 65+)")
|
||||
print(" - Increase war_exhaustion_rate (try 10+)")
|
||||
|
||||
if metrics.total_trades < metrics.total_turns * 0.1:
|
||||
print("\n ⚠️ LOW MARKET ACTIVITY")
|
||||
print(" - Increase buy_efficiency_threshold (try 0.9)")
|
||||
print(" - Lower min_wealth_target")
|
||||
print(" - Reduce max_price_markup")
|
||||
|
||||
if metrics.oil_drilled + metrics.fuel_refined < 50:
|
||||
print("\n ⚠️ LOW OIL INDUSTRY")
|
||||
print(" - Increase drill_oil_success (try 0.80)")
|
||||
print(" - Check that factions with oil bonus survive")
|
||||
|
||||
return metrics
|
||||
|
||||
|
||||
def quick_test(steps: int = 500):
|
||||
"""Quick test with a balanced preset."""
|
||||
print("\n🧪 Quick Test with Balanced Preset")
|
||||
print("-" * 50)
|
||||
|
||||
test_config = {
|
||||
"agent_stats": {
|
||||
"start_hunger": 92,
|
||||
"start_thirst": 92,
|
||||
"hunger_decay": 1,
|
||||
"thirst_decay": 2,
|
||||
"heat_decay": 1,
|
||||
"faith_decay": 1,
|
||||
"critical_threshold": 0.15,
|
||||
},
|
||||
"resources": {
|
||||
"meat_hunger": 50,
|
||||
"berries_hunger": 16,
|
||||
"water_thirst": 65,
|
||||
"fire_heat": 35,
|
||||
},
|
||||
"actions": {
|
||||
"hunt_success": 0.85,
|
||||
"drill_oil_success": 0.80,
|
||||
"hunt_meat_min": 4,
|
||||
"hunt_meat_max": 6,
|
||||
"gather_min": 4,
|
||||
"gather_max": 7,
|
||||
"preach_convert_chance": 0.04,
|
||||
},
|
||||
"religion": {
|
||||
"conversion_resistance": 0.78,
|
||||
"zealot_threshold": 0.90,
|
||||
"same_religion_bonus": 0.10,
|
||||
"different_religion_penalty": 0.03,
|
||||
},
|
||||
"diplomacy": {
|
||||
"starting_relations": 65,
|
||||
"relation_decay": 0,
|
||||
"trade_relation_boost": 8,
|
||||
"war_exhaustion_rate": 12,
|
||||
"war_threshold": 18,
|
||||
},
|
||||
"economy": {
|
||||
"buy_efficiency_threshold": 0.90,
|
||||
"min_wealth_target": 30,
|
||||
"max_price_markup": 1.5,
|
||||
},
|
||||
}
|
||||
|
||||
print("\n Testing config:")
|
||||
print(json.dumps(test_config, indent=2))
|
||||
|
||||
metrics = run_simulation(test_config, steps, num_agents=100)
|
||||
print_metrics(metrics, detailed=True)
|
||||
|
||||
return metrics
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="Optimize Village Simulation balance")
|
||||
parser.add_argument("--iterations", "-i", type=int, default=15, help="Optimization iterations")
|
||||
parser.add_argument("--steps", "-s", type=int, default=800, help="Steps per simulation")
|
||||
parser.add_argument("--population", "-p", type=int, default=8, help="Population size for GA")
|
||||
parser.add_argument("--quick-test", "-q", action="store_true", help="Quick test balanced preset")
|
||||
parser.add_argument("--analyze-current", "-a", action="store_true", help="Analyze current config")
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.quick_test:
|
||||
quick_test(args.steps)
|
||||
elif args.analyze_current:
|
||||
analyze_current_config(args.steps)
|
||||
else:
|
||||
optimize_balance(args.iterations, args.steps, args.population)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
||||
238
tools/test_religion_diplomacy.py
Normal file
238
tools/test_religion_diplomacy.py
Normal file
@ -0,0 +1,238 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Test script for Religion and Diplomacy features.
|
||||
|
||||
Verifies that agents are spawned with diverse religions and factions,
|
||||
and that the systems work correctly.
|
||||
|
||||
Usage:
|
||||
python tools/test_religion_diplomacy.py [--steps 100]
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import sys
|
||||
from collections import defaultdict
|
||||
from pathlib import Path
|
||||
|
||||
# Add parent directory for imports
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||
|
||||
from backend.config import get_config
|
||||
from backend.core.engine import GameEngine
|
||||
|
||||
|
||||
def test_agent_diversity(num_agents: int = 50, num_steps: int = 100):
|
||||
"""Test that agents have diverse religions and factions."""
|
||||
print("\n" + "=" * 70)
|
||||
print(" RELIGION & DIPLOMACY SYSTEM TEST")
|
||||
print("=" * 70)
|
||||
|
||||
# Initialize engine
|
||||
engine = GameEngine()
|
||||
engine.initialize(num_agents)
|
||||
|
||||
# Analyze agent distribution
|
||||
religion_counts = defaultdict(int)
|
||||
faction_counts = defaultdict(int)
|
||||
faith_levels = []
|
||||
|
||||
agents = engine.world.agents
|
||||
|
||||
print(f"\n📊 Initial Agent Distribution ({len(agents)} agents):")
|
||||
print("-" * 50)
|
||||
|
||||
for agent in agents:
|
||||
religion = agent.religion.religion.value
|
||||
faction = agent.diplomacy.faction.value
|
||||
religion_counts[religion] += 1
|
||||
faction_counts[faction] += 1
|
||||
faith_levels.append(agent.religion.faith)
|
||||
|
||||
print("\n🕯️ RELIGIONS:")
|
||||
for religion, count in sorted(religion_counts.items(), key=lambda x: -x[1]):
|
||||
pct = count / len(agents) * 100
|
||||
bar = "█" * int(pct / 5)
|
||||
print(f" {religion:12s}: {count:3d} ({pct:5.1f}%) {bar}")
|
||||
|
||||
print("\n⚔️ FACTIONS:")
|
||||
for faction, count in sorted(faction_counts.items(), key=lambda x: -x[1]):
|
||||
pct = count / len(agents) * 100
|
||||
bar = "█" * int(pct / 5)
|
||||
print(f" {faction:12s}: {count:3d} ({pct:5.1f}%) {bar}")
|
||||
|
||||
avg_faith = sum(faith_levels) / len(faith_levels) if faith_levels else 0
|
||||
print(f"\n✨ Average Faith: {avg_faith:.1f}")
|
||||
|
||||
# Check for issues
|
||||
issues = []
|
||||
|
||||
atheist_pct = religion_counts.get("atheist", 0) / len(agents) * 100
|
||||
if atheist_pct > 50:
|
||||
issues.append(f"⚠️ Too many atheists: {atheist_pct:.1f}% (expected < 50%)")
|
||||
|
||||
neutral_pct = faction_counts.get("neutral", 0) / len(agents) * 100
|
||||
if neutral_pct > 30:
|
||||
issues.append(f"⚠️ Too many neutral faction: {neutral_pct:.1f}% (expected < 30%)")
|
||||
|
||||
if len(religion_counts) < 3:
|
||||
issues.append(f"⚠️ Low religion diversity: only {len(religion_counts)} religions")
|
||||
|
||||
if len(faction_counts) < 3:
|
||||
issues.append(f"⚠️ Low faction diversity: only {len(faction_counts)} factions")
|
||||
|
||||
if issues:
|
||||
print("\n⚠️ ISSUES FOUND:")
|
||||
for issue in issues:
|
||||
print(f" {issue}")
|
||||
else:
|
||||
print("\n✅ Distribution looks good!")
|
||||
|
||||
# Run simulation to test mechanics
|
||||
print("\n" + "=" * 70)
|
||||
print(f" Running {num_steps} step simulation...")
|
||||
print("=" * 70)
|
||||
|
||||
# Track events
|
||||
religious_events = []
|
||||
diplomatic_events = []
|
||||
faith_changes = []
|
||||
|
||||
initial_faith = {a.id: a.religion.faith for a in agents}
|
||||
|
||||
for step in range(num_steps):
|
||||
turn_log = engine.next_step()
|
||||
|
||||
# Collect events
|
||||
religious_events.extend(turn_log.religious_events)
|
||||
diplomatic_events.extend(turn_log.diplomatic_events)
|
||||
|
||||
if step % 20 == 19:
|
||||
sys.stdout.write(f"\r Step {step + 1}/{num_steps}...")
|
||||
sys.stdout.flush()
|
||||
|
||||
print(f"\r Completed {num_steps} steps! ")
|
||||
|
||||
# Final analysis
|
||||
print("\n📈 SIMULATION RESULTS:")
|
||||
print("-" * 50)
|
||||
|
||||
living_agents = engine.world.get_living_agents()
|
||||
print(f" Living agents: {len(living_agents)}/{len(agents)}")
|
||||
|
||||
# Final faith levels
|
||||
final_faith = [a.religion.faith for a in living_agents]
|
||||
avg_final_faith = sum(final_faith) / len(final_faith) if final_faith else 0
|
||||
print(f" Average faith: {avg_final_faith:.1f} (started: {avg_faith:.1f})")
|
||||
|
||||
# Events summary
|
||||
print(f"\n Religious events: {len(religious_events)}")
|
||||
if religious_events:
|
||||
event_types = defaultdict(int)
|
||||
for event in religious_events:
|
||||
event_types[event.get("type", "unknown")] += 1
|
||||
for event_type, count in sorted(event_types.items(), key=lambda x: -x[1])[:5]:
|
||||
print(f" - {event_type}: {count}")
|
||||
|
||||
print(f"\n Diplomatic events: {len(diplomatic_events)}")
|
||||
if diplomatic_events:
|
||||
event_types = defaultdict(int)
|
||||
for event in diplomatic_events:
|
||||
event_types[event.get("type", "unknown")] += 1
|
||||
for event_type, count in sorted(event_types.items(), key=lambda x: -x[1])[:5]:
|
||||
print(f" - {event_type}: {count}")
|
||||
|
||||
# Check world state
|
||||
stats = engine.world.get_statistics()
|
||||
print(f"\n Total wars declared: {stats.get('total_wars', 0)}")
|
||||
print(f" Total peace treaties: {stats.get('total_peace_treaties', 0)}")
|
||||
print(f" Active wars: {stats.get('active_wars', [])}")
|
||||
|
||||
# Final religion/faction distribution
|
||||
print("\n📊 Final Religion Distribution:")
|
||||
final_religions = defaultdict(int)
|
||||
for agent in living_agents:
|
||||
final_religions[agent.religion.religion.value] += 1
|
||||
|
||||
for religion, count in sorted(final_religions.items(), key=lambda x: -x[1]):
|
||||
pct = count / len(living_agents) * 100 if living_agents else 0
|
||||
print(f" {religion:12s}: {count:3d} ({pct:5.1f}%)")
|
||||
|
||||
# Return metrics for optimization
|
||||
return {
|
||||
"initial_atheist_pct": atheist_pct,
|
||||
"initial_neutral_pct": neutral_pct,
|
||||
"religion_diversity": len(religion_counts),
|
||||
"faction_diversity": len(faction_counts),
|
||||
"avg_initial_faith": avg_faith,
|
||||
"avg_final_faith": avg_final_faith,
|
||||
"religious_events": len(religious_events),
|
||||
"diplomatic_events": len(diplomatic_events),
|
||||
"survival_rate": len(living_agents) / len(agents) if agents else 0,
|
||||
}
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="Test Religion & Diplomacy systems")
|
||||
parser.add_argument("--agents", "-a", type=int, default=50, help="Number of agents")
|
||||
parser.add_argument("--steps", "-s", type=int, default=100, help="Simulation steps")
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
metrics = test_agent_diversity(args.agents, args.steps)
|
||||
|
||||
print("\n" + "=" * 70)
|
||||
print(" TEST COMPLETE")
|
||||
print("=" * 70)
|
||||
|
||||
# Final verdict
|
||||
score = 0
|
||||
max_score = 6
|
||||
|
||||
if metrics["initial_atheist_pct"] < 30:
|
||||
score += 1
|
||||
print("✅ Atheist percentage is reasonable")
|
||||
else:
|
||||
print(f"❌ Too many atheists: {metrics['initial_atheist_pct']:.1f}%")
|
||||
|
||||
if metrics["initial_neutral_pct"] < 20:
|
||||
score += 1
|
||||
print("✅ Neutral faction percentage is reasonable")
|
||||
else:
|
||||
print(f"❌ Too many neutrals: {metrics['initial_neutral_pct']:.1f}%")
|
||||
|
||||
if metrics["religion_diversity"] >= 4:
|
||||
score += 1
|
||||
print(f"✅ Good religion diversity: {metrics['religion_diversity']} religions")
|
||||
else:
|
||||
print(f"❌ Low religion diversity: {metrics['religion_diversity']} religions")
|
||||
|
||||
if metrics["faction_diversity"] >= 4:
|
||||
score += 1
|
||||
print(f"✅ Good faction diversity: {metrics['faction_diversity']} factions")
|
||||
else:
|
||||
print(f"❌ Low faction diversity: {metrics['faction_diversity']} factions")
|
||||
|
||||
if metrics["religious_events"] > 0:
|
||||
score += 1
|
||||
print(f"✅ Religious events occurring: {metrics['religious_events']}")
|
||||
else:
|
||||
print("❌ No religious events")
|
||||
|
||||
if metrics["diplomatic_events"] > 0 or metrics["religious_events"] > 0:
|
||||
score += 1
|
||||
print(f"✅ Social dynamics active")
|
||||
else:
|
||||
print("❌ No social dynamics")
|
||||
|
||||
print(f"\n📊 Score: {score}/{max_score}")
|
||||
|
||||
if score < max_score:
|
||||
print("\n💡 Consider adjusting config.json parameters for better diversity")
|
||||
|
||||
return score == max_score
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
success = main()
|
||||
sys.exit(0 if success else 1)
|
||||
|
||||
@ -1,279 +0,0 @@
|
||||
<!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>
|
||||
|
||||
@ -1,132 +0,0 @@
|
||||
/**
|
||||
* 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;
|
||||
|
||||
@ -1,71 +0,0 @@
|
||||
/**
|
||||
* 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,
|
||||
};
|
||||
|
||||
@ -1,73 +0,0 @@
|
||||
/**
|
||||
* 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 };
|
||||
|
||||
@ -1,141 +0,0 @@
|
||||
/**
|
||||
* 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
@ -1,7 +0,0 @@
|
||||
/**
|
||||
* Scene exports
|
||||
*/
|
||||
|
||||
export { default as BootScene } from './BootScene.js';
|
||||
export { default as GameScene } from './GameScene.js';
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Loading…
x
Reference in New Issue
Block a user