Compare commits
1 Commits
master
...
war-and-pe
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cfd6c87f86 |
@ -28,10 +28,12 @@ class StatsSchema(BaseModel):
|
|||||||
hunger: int
|
hunger: int
|
||||||
thirst: int
|
thirst: int
|
||||||
heat: int
|
heat: int
|
||||||
|
faith: int = 50
|
||||||
max_energy: int
|
max_energy: int
|
||||||
max_hunger: int
|
max_hunger: int
|
||||||
max_thirst: int
|
max_thirst: int
|
||||||
max_heat: int
|
max_heat: int
|
||||||
|
max_faith: int = 100
|
||||||
|
|
||||||
|
|
||||||
class AgentActionSchema(BaseModel):
|
class AgentActionSchema(BaseModel):
|
||||||
@ -44,6 +46,28 @@ class AgentActionSchema(BaseModel):
|
|||||||
message: str
|
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):
|
class AgentResponse(BaseModel):
|
||||||
"""Schema for agent data."""
|
"""Schema for agent data."""
|
||||||
id: str
|
id: str
|
||||||
@ -58,6 +82,9 @@ class AgentResponse(BaseModel):
|
|||||||
can_act: bool
|
can_act: bool
|
||||||
current_action: AgentActionSchema
|
current_action: AgentActionSchema
|
||||||
last_action_result: str
|
last_action_result: str
|
||||||
|
# Religion and diplomacy
|
||||||
|
religion: Optional[ReligionSchema] = None
|
||||||
|
diplomacy: Optional[DiplomacySchema] = None
|
||||||
|
|
||||||
|
|
||||||
# ============== Market Schemas ==============
|
# ============== Market Schemas ==============
|
||||||
@ -121,12 +148,6 @@ class StatisticsSchema(BaseModel):
|
|||||||
total_agents_died: int
|
total_agents_died: int
|
||||||
total_money_in_circulation: int
|
total_money_in_circulation: int
|
||||||
professions: dict[str, 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):
|
class ActionLogSchema(BaseModel):
|
||||||
@ -143,18 +164,6 @@ class TurnLogSchema(BaseModel):
|
|||||||
agent_actions: list[ActionLogSchema]
|
agent_actions: list[ActionLogSchema]
|
||||||
deaths: list[str]
|
deaths: list[str]
|
||||||
trades: list[dict]
|
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):
|
class WorldStateResponse(BaseModel):
|
||||||
@ -170,7 +179,6 @@ class WorldStateResponse(BaseModel):
|
|||||||
mode: str
|
mode: str
|
||||||
is_running: bool
|
is_running: bool
|
||||||
recent_logs: list[TurnLogSchema]
|
recent_logs: list[TurnLogSchema]
|
||||||
resource_stats: ResourceStatsSchema = ResourceStatsSchema()
|
|
||||||
|
|
||||||
|
|
||||||
# ============== Control Schemas ==============
|
# ============== Control Schemas ==============
|
||||||
|
|||||||
@ -12,41 +12,49 @@ class AgentStatsConfig:
|
|||||||
# Maximum values
|
# Maximum values
|
||||||
max_energy: int = 50
|
max_energy: int = 50
|
||||||
max_hunger: int = 100
|
max_hunger: int = 100
|
||||||
max_thirst: int = 100 # Increased from 50 to give more buffer
|
max_thirst: int = 100
|
||||||
max_heat: int = 100
|
max_heat: int = 100
|
||||||
|
max_faith: int = 100 # NEW: Religious faith level
|
||||||
|
|
||||||
# Starting values
|
# Starting values
|
||||||
start_energy: int = 50
|
start_energy: int = 50
|
||||||
start_hunger: int = 80
|
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_heat: int = 100
|
||||||
|
start_faith: int = 50 # NEW: Start with moderate faith
|
||||||
|
|
||||||
# Decay rates per turn
|
# Decay rates per turn
|
||||||
energy_decay: int = 2
|
energy_decay: int = 2
|
||||||
hunger_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
|
heat_decay: int = 2
|
||||||
|
faith_decay: int = 1 # NEW: Faith decays slowly without religious activity
|
||||||
|
|
||||||
# Thresholds
|
# Thresholds
|
||||||
critical_threshold: float = 0.25 # 25% triggers survival mode
|
critical_threshold: float = 0.25
|
||||||
low_energy_threshold: int = 15 # Minimum energy to work
|
low_energy_threshold: int = 15
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class ResourceConfig:
|
class ResourceConfig:
|
||||||
"""Configuration for resource properties."""
|
"""Configuration for resource properties."""
|
||||||
# Decay rates (turns until spoilage, 0 = infinite)
|
# 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
|
berries_decay: int = 25
|
||||||
clothes_decay: int = 50
|
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
|
# Resource effects
|
||||||
meat_hunger: int = 30
|
meat_hunger: int = 30
|
||||||
meat_energy: int = 5
|
meat_energy: int = 5
|
||||||
berries_hunger: int = 8 # Increased from 5
|
berries_hunger: int = 8
|
||||||
berries_thirst: int = 3 # Increased from 2
|
berries_thirst: int = 3
|
||||||
water_thirst: int = 50 # Increased from 40 for better thirst recovery
|
water_thirst: int = 50
|
||||||
fire_heat: int = 15 # Increased from 10
|
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
|
@dataclass
|
||||||
@ -63,9 +71,23 @@ class ActionConfig:
|
|||||||
build_fire_energy: int = -5
|
build_fire_energy: int = -5
|
||||||
trade_energy: int = -1
|
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)
|
# Success chances (0.0 to 1.0)
|
||||||
hunt_success: float = 0.7
|
hunt_success: float = 0.7
|
||||||
chop_wood_success: float = 0.9
|
chop_wood_success: float = 0.9
|
||||||
|
drill_oil_success: float = 0.6 # NEW: Harder to extract oil
|
||||||
|
|
||||||
# Output quantities
|
# Output quantities
|
||||||
hunt_meat_min: int = 1
|
hunt_meat_min: int = 1
|
||||||
@ -77,6 +99,15 @@ class ActionConfig:
|
|||||||
chop_wood_min: int = 1
|
chop_wood_min: int = 1
|
||||||
chop_wood_max: int = 2
|
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
|
@dataclass
|
||||||
class WorldConfig:
|
class WorldConfig:
|
||||||
@ -91,41 +122,58 @@ class WorldConfig:
|
|||||||
inventory_slots: int = 10
|
inventory_slots: int = 10
|
||||||
starting_money: int = 100
|
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
|
@dataclass
|
||||||
class MarketConfig:
|
class MarketConfig:
|
||||||
"""Configuration for market behavior."""
|
"""Configuration for market behavior."""
|
||||||
turns_before_discount: int = 3
|
turns_before_discount: int = 3
|
||||||
discount_rate: float = 0.15 # 15% discount after waiting
|
discount_rate: float = 0.15
|
||||||
base_price_multiplier: float = 1.2 # Markup over production cost
|
base_price_multiplier: float = 1.2
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class EconomyConfig:
|
class EconomyConfig:
|
||||||
"""Configuration for economic behavior and agent trading.
|
"""Configuration for economic behavior and agent trading."""
|
||||||
|
energy_to_money_ratio: float = 1.5
|
||||||
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
|
|
||||||
wealth_desire: float = 0.3
|
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
|
buy_efficiency_threshold: float = 0.7
|
||||||
|
|
||||||
# Minimum wealth target - agents want at least this much money
|
|
||||||
min_wealth_target: int = 50
|
min_wealth_target: int = 50
|
||||||
|
max_price_markup: float = 2.0
|
||||||
|
min_price_discount: float = 0.5
|
||||||
|
|
||||||
# Price adjustment limits
|
# NEW: Oil economy
|
||||||
max_price_markup: float = 2.0 # Maximum price = 2x base value
|
oil_base_price: int = 25 # Oil is valuable
|
||||||
min_price_discount: float = 0.5 # Minimum price = 50% of base value
|
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
|
@dataclass
|
||||||
@ -137,9 +185,11 @@ class SimulationConfig:
|
|||||||
world: WorldConfig = field(default_factory=WorldConfig)
|
world: WorldConfig = field(default_factory=WorldConfig)
|
||||||
market: MarketConfig = field(default_factory=MarketConfig)
|
market: MarketConfig = field(default_factory=MarketConfig)
|
||||||
economy: EconomyConfig = field(default_factory=EconomyConfig)
|
economy: EconomyConfig = field(default_factory=EconomyConfig)
|
||||||
|
religion: ReligionConfig = field(default_factory=ReligionConfig) # NEW
|
||||||
|
diplomacy: DiplomacyConfig = field(default_factory=DiplomacyConfig) # NEW
|
||||||
|
|
||||||
# Simulation control
|
# Simulation control
|
||||||
auto_step_interval: float = 1.0 # Seconds between auto steps
|
auto_step_interval: float = 1.0
|
||||||
|
|
||||||
def to_dict(self) -> dict:
|
def to_dict(self) -> dict:
|
||||||
"""Convert to dictionary."""
|
"""Convert to dictionary."""
|
||||||
@ -150,6 +200,8 @@ class SimulationConfig:
|
|||||||
"world": asdict(self.world),
|
"world": asdict(self.world),
|
||||||
"market": asdict(self.market),
|
"market": asdict(self.market),
|
||||||
"economy": asdict(self.economy),
|
"economy": asdict(self.economy),
|
||||||
|
"religion": asdict(self.religion),
|
||||||
|
"diplomacy": asdict(self.diplomacy),
|
||||||
"auto_step_interval": self.auto_step_interval,
|
"auto_step_interval": self.auto_step_interval,
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -163,6 +215,8 @@ class SimulationConfig:
|
|||||||
world=WorldConfig(**data.get("world", {})),
|
world=WorldConfig(**data.get("world", {})),
|
||||||
market=MarketConfig(**data.get("market", {})),
|
market=MarketConfig(**data.get("market", {})),
|
||||||
economy=EconomyConfig(**data.get("economy", {})),
|
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),
|
auto_step_interval=data.get("auto_step_interval", 1.0),
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -179,7 +233,7 @@ class SimulationConfig:
|
|||||||
data = json.load(f)
|
data = json.load(f)
|
||||||
return cls.from_dict(data)
|
return cls.from_dict(data)
|
||||||
except FileNotFoundError:
|
except FileNotFoundError:
|
||||||
return cls() # Return defaults if file not found
|
return cls()
|
||||||
|
|
||||||
|
|
||||||
# Global configuration instance
|
# Global configuration instance
|
||||||
@ -187,10 +241,7 @@ _config: Optional[SimulationConfig] = None
|
|||||||
|
|
||||||
|
|
||||||
def get_config() -> SimulationConfig:
|
def get_config() -> SimulationConfig:
|
||||||
"""Get the global configuration instance.
|
"""Get the global configuration instance."""
|
||||||
|
|
||||||
Loads from config.json if not already loaded.
|
|
||||||
"""
|
|
||||||
global _config
|
global _config
|
||||||
if _config is None:
|
if _config is None:
|
||||||
_config = load_config()
|
_config = load_config()
|
||||||
@ -202,8 +253,6 @@ def load_config(path: str = "config.json") -> SimulationConfig:
|
|||||||
try:
|
try:
|
||||||
config_path = Path(path)
|
config_path = Path(path)
|
||||||
if not config_path.is_absolute():
|
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
|
workspace_root = Path(__file__).parent.parent
|
||||||
config_path = workspace_root / path
|
config_path = workspace_root / path
|
||||||
|
|
||||||
@ -214,7 +263,7 @@ def load_config(path: str = "config.json") -> SimulationConfig:
|
|||||||
except (FileNotFoundError, json.JSONDecodeError) as e:
|
except (FileNotFoundError, json.JSONDecodeError) as e:
|
||||||
print(f"Warning: Could not load config from {path}: {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:
|
def set_config(config: SimulationConfig) -> None:
|
||||||
@ -252,4 +301,3 @@ def _reset_all_caches() -> None:
|
|||||||
reset_resource_cache()
|
reset_resource_cache()
|
||||||
except ImportError:
|
except ImportError:
|
||||||
pass
|
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 random
|
||||||
import threading
|
import threading
|
||||||
@ -9,8 +15,12 @@ from typing import Optional
|
|||||||
|
|
||||||
from backend.domain.agent import Agent
|
from backend.domain.agent import Agent
|
||||||
from backend.domain.action import ActionType, ActionResult, ACTION_CONFIG
|
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.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.world import World, WorldConfig, TimeOfDay
|
||||||
from backend.core.market import OrderBook
|
from backend.core.market import OrderBook
|
||||||
from backend.core.ai import get_ai_decision, AIDecision
|
from backend.core.ai import get_ai_decision, AIDecision
|
||||||
@ -20,8 +30,8 @@ from backend.config import get_config
|
|||||||
|
|
||||||
class SimulationMode(Enum):
|
class SimulationMode(Enum):
|
||||||
"""Simulation run mode."""
|
"""Simulation run mode."""
|
||||||
MANUAL = "manual" # Wait for explicit next_step call
|
MANUAL = "manual"
|
||||||
AUTO = "auto" # Run automatically with timer
|
AUTO = "auto"
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
@ -31,10 +41,8 @@ class TurnLog:
|
|||||||
agent_actions: list[dict] = field(default_factory=list)
|
agent_actions: list[dict] = field(default_factory=list)
|
||||||
deaths: list[str] = field(default_factory=list)
|
deaths: list[str] = field(default_factory=list)
|
||||||
trades: list[dict] = field(default_factory=list)
|
trades: list[dict] = field(default_factory=list)
|
||||||
# Resource tracking for this turn
|
religious_events: list[dict] = field(default_factory=list) # NEW
|
||||||
resources_produced: dict = field(default_factory=dict)
|
diplomatic_events: list[dict] = field(default_factory=list) # NEW
|
||||||
resources_consumed: dict = field(default_factory=dict)
|
|
||||||
resources_spoiled: dict = field(default_factory=dict)
|
|
||||||
|
|
||||||
def to_dict(self) -> dict:
|
def to_dict(self) -> dict:
|
||||||
return {
|
return {
|
||||||
@ -42,9 +50,8 @@ class TurnLog:
|
|||||||
"agent_actions": self.agent_actions,
|
"agent_actions": self.agent_actions,
|
||||||
"deaths": self.deaths,
|
"deaths": self.deaths,
|
||||||
"trades": self.trades,
|
"trades": self.trades,
|
||||||
"resources_produced": self.resources_produced,
|
"religious_events": self.religious_events,
|
||||||
"resources_consumed": self.resources_consumed,
|
"diplomatic_events": self.diplomatic_events,
|
||||||
"resources_spoiled": self.resources_spoiled,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@ -67,30 +74,20 @@ class GameEngine:
|
|||||||
self.market = OrderBook()
|
self.market = OrderBook()
|
||||||
self.mode = SimulationMode.MANUAL
|
self.mode = SimulationMode.MANUAL
|
||||||
self.is_running = False
|
self.is_running = False
|
||||||
# Load auto_step_interval from config
|
|
||||||
self.auto_step_interval = get_config().auto_step_interval
|
self.auto_step_interval = get_config().auto_step_interval
|
||||||
self._auto_thread: Optional[threading.Thread] = None
|
self._auto_thread: Optional[threading.Thread] = None
|
||||||
self._stop_event = threading.Event()
|
self._stop_event = threading.Event()
|
||||||
self.turn_logs: list[TurnLog] = []
|
self.turn_logs: list[TurnLog] = []
|
||||||
self.logger = get_simulation_logger()
|
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
|
self._initialized = True
|
||||||
|
|
||||||
def reset(self, config: Optional[WorldConfig] = None) -> None:
|
def reset(self, config: Optional[WorldConfig] = None) -> None:
|
||||||
"""Reset the simulation to initial state."""
|
"""Reset the simulation to initial state."""
|
||||||
# Stop auto mode if running
|
|
||||||
self._stop_auto_mode()
|
self._stop_auto_mode()
|
||||||
|
|
||||||
|
# Reset faction relations
|
||||||
|
reset_faction_relations()
|
||||||
|
|
||||||
if config:
|
if config:
|
||||||
self.world = World(config=config)
|
self.world = World(config=config)
|
||||||
else:
|
else:
|
||||||
@ -98,17 +95,6 @@ class GameEngine:
|
|||||||
self.market = OrderBook()
|
self.market = OrderBook()
|
||||||
self.turn_logs = []
|
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()
|
self.logger = reset_simulation_logger()
|
||||||
sim_config = get_config()
|
sim_config = get_config()
|
||||||
self.logger.start_session(sim_config.to_dict())
|
self.logger.start_session(sim_config.to_dict())
|
||||||
@ -117,18 +103,15 @@ class GameEngine:
|
|||||||
self.is_running = True
|
self.is_running = True
|
||||||
|
|
||||||
def initialize(self, num_agents: Optional[int] = None) -> None:
|
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:
|
if num_agents is not None:
|
||||||
self.world.config.initial_agents = num_agents
|
self.world.config.initial_agents = num_agents
|
||||||
# Otherwise use the value already loaded from config.json
|
|
||||||
|
|
||||||
self.world.initialize()
|
self.world.initialize()
|
||||||
|
|
||||||
# Start logging session
|
|
||||||
self.logger = reset_simulation_logger()
|
self.logger = reset_simulation_logger()
|
||||||
sim_config = get_config()
|
sim_config = get_config()
|
||||||
self.logger.start_session(sim_config.to_dict())
|
self.logger.start_session(sim_config.to_dict())
|
||||||
@ -143,7 +126,6 @@ class GameEngine:
|
|||||||
turn_log = TurnLog(turn=self.world.current_turn + 1)
|
turn_log = TurnLog(turn=self.world.current_turn + 1)
|
||||||
current_turn = self.world.current_turn + 1
|
current_turn = self.world.current_turn + 1
|
||||||
|
|
||||||
# Start logging this turn
|
|
||||||
self.logger.start_turn(
|
self.logger.start_turn(
|
||||||
turn=current_turn,
|
turn=current_turn,
|
||||||
day=self.world.current_day,
|
day=self.world.current_day,
|
||||||
@ -151,16 +133,14 @@ class GameEngine:
|
|||||||
time_of_day=self.world.time_of_day.value,
|
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()]
|
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)
|
self._remove_old_corpses(current_turn)
|
||||||
|
|
||||||
# 1. Collect AI decisions for all living agents (not corpses)
|
# Collect AI decisions
|
||||||
decisions: list[tuple[Agent, AIDecision]] = []
|
decisions: list[tuple[Agent, AIDecision]] = []
|
||||||
for agent in self.world.get_living_agents():
|
for agent in self.world.get_living_agents():
|
||||||
# Log agent state before
|
|
||||||
self.logger.log_agent_before(
|
self.logger.log_agent_before(
|
||||||
agent_id=agent.id,
|
agent_id=agent.id,
|
||||||
agent_name=agent.name,
|
agent_name=agent.name,
|
||||||
@ -172,27 +152,24 @@ class GameEngine:
|
|||||||
)
|
)
|
||||||
|
|
||||||
if self.world.is_night():
|
if self.world.is_night():
|
||||||
# Force sleep at night
|
|
||||||
decision = AIDecision(
|
decision = AIDecision(
|
||||||
action=ActionType.SLEEP,
|
action=ActionType.SLEEP,
|
||||||
reason="Night time: sleeping",
|
reason="Night time: sleeping",
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
# Pass time info so AI can prepare for night
|
|
||||||
decision = get_ai_decision(
|
decision = get_ai_decision(
|
||||||
agent,
|
agent,
|
||||||
self.market,
|
self.market,
|
||||||
step_in_day=self.world.step_in_day,
|
step_in_day=self.world.step_in_day,
|
||||||
day_steps=self.world.config.day_steps,
|
day_steps=self.world.config.day_steps,
|
||||||
current_turn=current_turn,
|
current_turn=current_turn,
|
||||||
|
world=self.world,
|
||||||
)
|
)
|
||||||
|
|
||||||
decisions.append((agent, decision))
|
decisions.append((agent, decision))
|
||||||
|
|
||||||
# Log decision
|
|
||||||
self.logger.log_agent_decision(agent.id, decision.to_dict())
|
self.logger.log_agent_decision(agent.id, decision.to_dict())
|
||||||
|
|
||||||
# 2. Calculate movement targets and move agents
|
# Calculate movement
|
||||||
for agent, decision in decisions:
|
for agent, decision in decisions:
|
||||||
action_name = decision.action.value
|
action_name = decision.action.value
|
||||||
agent.set_action(
|
agent.set_action(
|
||||||
@ -201,35 +178,16 @@ class GameEngine:
|
|||||||
world_height=self.world.config.height,
|
world_height=self.world.config.height,
|
||||||
message=decision.reason,
|
message=decision.reason,
|
||||||
target_resource=decision.target_resource.value if decision.target_resource else None,
|
target_resource=decision.target_resource.value if decision.target_resource else None,
|
||||||
|
target_agent=decision.target_agent_id,
|
||||||
)
|
)
|
||||||
agent.update_movement()
|
agent.update_movement()
|
||||||
|
|
||||||
# 3. Execute all actions and update action indicators with results
|
# Execute actions
|
||||||
for agent, decision in decisions:
|
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:
|
if result:
|
||||||
agent.complete_action(result.success, result.message)
|
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({
|
turn_log.agent_actions.append({
|
||||||
"agent_id": agent.id,
|
"agent_id": agent.id,
|
||||||
@ -238,7 +196,6 @@ class GameEngine:
|
|||||||
"result": result.to_dict() if result else None,
|
"result": result.to_dict() if result else None,
|
||||||
})
|
})
|
||||||
|
|
||||||
# Log agent state after action
|
|
||||||
self.logger.log_agent_after(
|
self.logger.log_agent_after(
|
||||||
agent_id=agent.id,
|
agent_id=agent.id,
|
||||||
stats=agent.stats.to_dict(),
|
stats=agent.stats.to_dict(),
|
||||||
@ -248,45 +205,35 @@ class GameEngine:
|
|||||||
action_result=result.to_dict() if result else {},
|
action_result=result.to_dict() if result else {},
|
||||||
)
|
)
|
||||||
|
|
||||||
# 4. Resolve pending market orders (price updates)
|
# Update market prices
|
||||||
self.market.update_prices(current_turn)
|
self.market.update_prices(current_turn)
|
||||||
|
|
||||||
# Log market state after
|
|
||||||
market_orders_after = [o.to_dict() for o in self.market.get_active_orders()]
|
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)
|
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():
|
for agent in self.world.get_living_agents():
|
||||||
agent.apply_passive_decay()
|
agent.apply_passive_decay()
|
||||||
|
|
||||||
# 6. Decay resources in inventories
|
# Decay resources
|
||||||
for agent in self.world.get_living_agents():
|
for agent in self.world.get_living_agents():
|
||||||
expired = agent.decay_inventory(current_turn)
|
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)
|
newly_dead = self._mark_dead_agents(current_turn)
|
||||||
for dead_agent in newly_dead:
|
for dead_agent in newly_dead:
|
||||||
cause = dead_agent.death_reason
|
cause = dead_agent.death_reason
|
||||||
self.logger.log_death(dead_agent.name, cause)
|
self.logger.log_death(dead_agent.name, cause)
|
||||||
# Cancel their market orders immediately
|
|
||||||
self.market.cancel_seller_orders(dead_agent.id)
|
self.market.cancel_seller_orders(dead_agent.id)
|
||||||
turn_log.deaths = [a.name for a in newly_dead]
|
turn_log.deaths = [a.name for a in newly_dead]
|
||||||
|
|
||||||
# Log statistics
|
|
||||||
self.logger.log_statistics(self.world.get_statistics())
|
self.logger.log_statistics(self.world.get_statistics())
|
||||||
|
|
||||||
# End turn logging
|
|
||||||
self.logger.end_turn()
|
self.logger.end_turn()
|
||||||
|
|
||||||
# 8. Advance time
|
# Advance time
|
||||||
self.world.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:
|
if len(self.world.get_living_agents()) == 0:
|
||||||
self.is_running = False
|
self.is_running = False
|
||||||
self.logger.close()
|
self.logger.close()
|
||||||
@ -295,14 +242,12 @@ class GameEngine:
|
|||||||
return turn_log
|
return turn_log
|
||||||
|
|
||||||
def _mark_dead_agents(self, current_turn: int) -> list[Agent]:
|
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 = []
|
newly_dead = []
|
||||||
for agent in self.world.agents:
|
for agent in self.world.agents:
|
||||||
if not agent.is_alive() and not agent.is_corpse():
|
if not agent.is_alive() and not agent.is_corpse():
|
||||||
# Agent just died this turn
|
|
||||||
cause = agent.stats.get_critical_stat() or "unknown"
|
cause = agent.stats.get_critical_stat() or "unknown"
|
||||||
agent.mark_dead(current_turn, cause)
|
agent.mark_dead(current_turn, cause)
|
||||||
# Clear their action to show death state
|
|
||||||
agent.current_action.action_type = "dead"
|
agent.current_action.action_type = "dead"
|
||||||
agent.current_action.message = f"Died: {cause}"
|
agent.current_action.message = f"Died: {cause}"
|
||||||
newly_dead.append(agent)
|
newly_dead.append(agent)
|
||||||
@ -313,7 +258,6 @@ class GameEngine:
|
|||||||
to_remove = []
|
to_remove = []
|
||||||
for agent in self.world.agents:
|
for agent in self.world.agents:
|
||||||
if agent.is_corpse() and agent.death_turn < current_turn:
|
if agent.is_corpse() and agent.death_turn < current_turn:
|
||||||
# Corpse has been visible for one turn, remove it
|
|
||||||
to_remove.append(agent)
|
to_remove.append(agent)
|
||||||
|
|
||||||
for agent in to_remove:
|
for agent in to_remove:
|
||||||
@ -322,12 +266,12 @@ class GameEngine:
|
|||||||
|
|
||||||
return to_remove
|
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."""
|
"""Execute an action for an agent."""
|
||||||
action = decision.action
|
action = decision.action
|
||||||
config = ACTION_CONFIG[action]
|
config = ACTION_CONFIG[action]
|
||||||
|
|
||||||
# Handle different action types
|
# Basic actions
|
||||||
if action == ActionType.SLEEP:
|
if action == ActionType.SLEEP:
|
||||||
agent.restore_energy(config.energy_cost)
|
agent.restore_energy(config.energy_cost)
|
||||||
return ActionResult(
|
return ActionResult(
|
||||||
@ -349,17 +293,9 @@ class GameEngine:
|
|||||||
elif action == ActionType.CONSUME:
|
elif action == ActionType.CONSUME:
|
||||||
if decision.target_resource:
|
if decision.target_resource:
|
||||||
success = agent.consume(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(
|
return ActionResult(
|
||||||
action_type=action,
|
action_type=action,
|
||||||
success=success,
|
success=success,
|
||||||
resources_consumed=consumed_list,
|
|
||||||
message=f"Consumed {decision.target_resource.value}" if success else "Nothing to consume",
|
message=f"Consumed {decision.target_resource.value}" if success else "Nothing to consume",
|
||||||
)
|
)
|
||||||
return ActionResult(action_type=action, success=False, message="No resource specified")
|
return ActionResult(action_type=action, success=False, message="No resource specified")
|
||||||
@ -369,8 +305,6 @@ class GameEngine:
|
|||||||
agent.remove_from_inventory(ResourceType.WOOD, 1)
|
agent.remove_from_inventory(ResourceType.WOOD, 1)
|
||||||
if not agent.spend_energy(abs(config.energy_cost)):
|
if not agent.spend_energy(abs(config.energy_cost)):
|
||||||
return ActionResult(action_type=action, success=False, message="Not enough energy")
|
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()
|
fire_heat = get_fire_heat()
|
||||||
agent.apply_heat(fire_heat)
|
agent.apply_heat(fire_heat)
|
||||||
return ActionResult(
|
return ActionResult(
|
||||||
@ -378,30 +312,60 @@ class GameEngine:
|
|||||||
success=True,
|
success=True,
|
||||||
energy_spent=abs(config.energy_cost),
|
energy_spent=abs(config.energy_cost),
|
||||||
heat_gained=fire_heat,
|
heat_gained=fire_heat,
|
||||||
resources_consumed=[Resource(type=ResourceType.WOOD, quantity=1, created_turn=self.world.current_turn)],
|
|
||||||
message="Built a warm fire",
|
message="Built a warm fire",
|
||||||
)
|
)
|
||||||
return ActionResult(action_type=action, success=False, message="No wood for 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:
|
elif action == ActionType.TRADE:
|
||||||
return self._execute_trade(agent, decision)
|
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,
|
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 self._execute_work(agent, action, config)
|
||||||
|
|
||||||
return ActionResult(action_type=action, success=False, message="Unknown action")
|
return ActionResult(action_type=action, success=False, message="Unknown action")
|
||||||
|
|
||||||
def _execute_work(self, agent: Agent, action: ActionType, config) -> ActionResult:
|
def _execute_work(self, agent: Agent, action: ActionType, config) -> ActionResult:
|
||||||
"""Execute a work action (hunting, gathering, etc.).
|
"""Execute a work action (hunting, gathering, drilling, 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
|
|
||||||
energy_cost = abs(config.energy_cost)
|
energy_cost = abs(config.energy_cost)
|
||||||
if not agent.spend_energy(energy_cost):
|
if not agent.spend_energy(energy_cost):
|
||||||
return ActionResult(
|
return ActionResult(
|
||||||
@ -410,36 +374,32 @@ class GameEngine:
|
|||||||
message="Not enough energy",
|
message="Not enough energy",
|
||||||
)
|
)
|
||||||
|
|
||||||
# Check required materials
|
|
||||||
resources_consumed = []
|
|
||||||
if config.requires_resource:
|
if config.requires_resource:
|
||||||
if not agent.has_resource(config.requires_resource, config.requires_quantity):
|
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(
|
return ActionResult(
|
||||||
action_type=action,
|
action_type=action,
|
||||||
success=False,
|
success=False,
|
||||||
message=f"Missing required {config.requires_resource.value}",
|
message=f"Missing required {config.requires_resource.value}",
|
||||||
)
|
)
|
||||||
agent.remove_from_inventory(config.requires_resource, config.requires_quantity)
|
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_name = self._get_skill_for_action(action)
|
||||||
skill_value = getattr(agent.skills, skill_name, 1.0) if skill_name else 1.0
|
skill_value = getattr(agent.skills, skill_name, 1.0) if skill_name else 1.0
|
||||||
skill_modifier = get_action_skill_modifier(skill_value)
|
skill_modifier = get_action_skill_modifier(skill_value)
|
||||||
|
|
||||||
# Check success chance (modified by skill)
|
# Get religion bonus
|
||||||
# Higher skill = higher effective success chance
|
religion_bonus = get_religion_action_bonus(agent.religion.religion, action.value)
|
||||||
effective_success_chance = min(0.98, config.success_chance * skill_modifier)
|
|
||||||
|
# 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:
|
if random.random() > effective_success_chance:
|
||||||
# Record action attempt (skill still improves on failure, just less)
|
|
||||||
agent.record_action(action.value)
|
agent.record_action(action.value)
|
||||||
if skill_name:
|
if skill_name:
|
||||||
agent.skills.improve(skill_name, 0.005) # Small improvement on failure
|
agent.skills.improve(skill_name, 0.005)
|
||||||
return ActionResult(
|
return ActionResult(
|
||||||
action_type=action,
|
action_type=action,
|
||||||
success=False,
|
success=False,
|
||||||
@ -447,13 +407,11 @@ class GameEngine:
|
|||||||
message="Action failed",
|
message="Action failed",
|
||||||
)
|
)
|
||||||
|
|
||||||
# Generate output (modified by skill for quantity)
|
|
||||||
resources_gained = []
|
resources_gained = []
|
||||||
|
|
||||||
if config.output_resource:
|
if config.output_resource:
|
||||||
# Skill affects output quantity
|
|
||||||
base_quantity = random.randint(config.min_output, config.max_output)
|
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:
|
if quantity > 0:
|
||||||
resource = Resource(
|
resource = Resource(
|
||||||
@ -469,10 +427,9 @@ class GameEngine:
|
|||||||
created_turn=self.world.current_turn,
|
created_turn=self.world.current_turn,
|
||||||
))
|
))
|
||||||
|
|
||||||
# Secondary output (e.g., hide from hunting) - also affected by skill
|
|
||||||
if config.secondary_output:
|
if config.secondary_output:
|
||||||
base_quantity = random.randint(config.secondary_min, config.secondary_max)
|
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:
|
if quantity > 0:
|
||||||
resource = Resource(
|
resource = Resource(
|
||||||
type=config.secondary_output,
|
type=config.secondary_output,
|
||||||
@ -487,12 +444,10 @@ class GameEngine:
|
|||||||
created_turn=self.world.current_turn,
|
created_turn=self.world.current_turn,
|
||||||
))
|
))
|
||||||
|
|
||||||
# Record action and improve skill
|
|
||||||
agent.record_action(action.value)
|
agent.record_action(action.value)
|
||||||
if skill_name:
|
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)
|
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)"
|
message = f"{action.value}: {gained_str}" if gained_str else f"{action.value} (nothing gained)"
|
||||||
|
|
||||||
@ -501,37 +456,243 @@ class GameEngine:
|
|||||||
success=True,
|
success=True,
|
||||||
energy_spent=energy_cost,
|
energy_spent=energy_cost,
|
||||||
resources_gained=resources_gained,
|
resources_gained=resources_gained,
|
||||||
resources_consumed=resources_consumed,
|
|
||||||
message=message,
|
message=message,
|
||||||
)
|
)
|
||||||
|
|
||||||
def _get_skill_for_action(self, action: ActionType) -> Optional[str]:
|
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 = {
|
skill_map = {
|
||||||
ActionType.HUNT: "hunting",
|
ActionType.HUNT: "hunting",
|
||||||
ActionType.GATHER: "gathering",
|
ActionType.GATHER: "gathering",
|
||||||
ActionType.CHOP_WOOD: "woodcutting",
|
ActionType.CHOP_WOOD: "woodcutting",
|
||||||
ActionType.WEAVE: "crafting",
|
ActionType.WEAVE: "crafting",
|
||||||
|
ActionType.DRILL_OIL: "gathering", # Use gathering skill for now
|
||||||
|
ActionType.REFINE: "crafting",
|
||||||
}
|
}
|
||||||
return skill_map.get(action)
|
return skill_map.get(action)
|
||||||
|
|
||||||
def _execute_trade(self, agent: Agent, decision: AIDecision) -> ActionResult:
|
def _execute_pray(self, agent: Agent, config, turn_log: TurnLog) -> ActionResult:
|
||||||
"""Execute a trade action (buy, sell, or price adjustment). Supports multi-item trades.
|
"""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]
|
config = ACTION_CONFIG[ActionType.TRADE]
|
||||||
|
|
||||||
# Handle price adjustments (no energy cost)
|
|
||||||
if decision.adjust_order_id and decision.new_price is not None:
|
if decision.adjust_order_id and decision.new_price is not None:
|
||||||
return self._execute_price_adjustment(agent, decision)
|
return self._execute_price_adjustment(agent, decision)
|
||||||
|
|
||||||
# Handle multi-item trades
|
|
||||||
if decision.trade_items:
|
if decision.trade_items:
|
||||||
return self._execute_multi_buy(agent, decision)
|
return self._execute_multi_buy(agent, decision)
|
||||||
|
|
||||||
if decision.order_id:
|
if decision.order_id:
|
||||||
# Buying single item from market
|
|
||||||
result = self.market.execute_buy(
|
result = self.market.execute_buy(
|
||||||
buyer_id=agent.id,
|
buyer_id=agent.id,
|
||||||
order_id=decision.order_id,
|
order_id=decision.order_id,
|
||||||
@ -540,10 +701,8 @@ class GameEngine:
|
|||||||
)
|
)
|
||||||
|
|
||||||
if result.success:
|
if result.success:
|
||||||
# Log the trade
|
|
||||||
self.logger.log_trade(result.to_dict())
|
self.logger.log_trade(result.to_dict())
|
||||||
|
|
||||||
# Record sale for price history tracking
|
|
||||||
self.market._record_sale(
|
self.market._record_sale(
|
||||||
result.resource_type,
|
result.resource_type,
|
||||||
result.total_paid // result.quantity,
|
result.total_paid // result.quantity,
|
||||||
@ -551,14 +710,8 @@ class GameEngine:
|
|||||||
self.world.current_turn,
|
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
|
agent.money -= result.total_paid
|
||||||
|
|
||||||
# Add resources to buyer
|
|
||||||
resource = Resource(
|
resource = Resource(
|
||||||
type=result.resource_type,
|
type=result.resource_type,
|
||||||
quantity=result.quantity,
|
quantity=result.quantity,
|
||||||
@ -566,18 +719,25 @@ class GameEngine:
|
|||||||
)
|
)
|
||||||
agent.add_to_inventory(resource)
|
agent.add_to_inventory(resource)
|
||||||
|
|
||||||
# Add money to seller and record their trade
|
|
||||||
seller = self.world.get_agent(result.seller_id)
|
seller = self.world.get_agent(result.seller_id)
|
||||||
if seller:
|
if seller:
|
||||||
seller.money += result.total_paid
|
seller.money += result.total_paid
|
||||||
seller.record_trade(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))
|
agent.spend_energy(abs(config.energy_cost))
|
||||||
|
|
||||||
# Record buyer's trade and improve skill
|
|
||||||
agent.record_action("trade")
|
agent.record_action("trade")
|
||||||
agent.skills.improve("trading", 0.01) # Buyer skill improves less
|
agent.skills.improve("trading", 0.01)
|
||||||
|
|
||||||
return ActionResult(
|
return ActionResult(
|
||||||
action_type=ActionType.TRADE,
|
action_type=ActionType.TRADE,
|
||||||
@ -594,7 +754,6 @@ class GameEngine:
|
|||||||
)
|
)
|
||||||
|
|
||||||
elif decision.target_resource and decision.quantity > 0:
|
elif decision.target_resource and decision.quantity > 0:
|
||||||
# Selling to market (listing)
|
|
||||||
if agent.has_resource(decision.target_resource, decision.quantity):
|
if agent.has_resource(decision.target_resource, decision.quantity):
|
||||||
agent.remove_from_inventory(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.spend_energy(abs(config.energy_cost))
|
||||||
agent.record_action("trade") # Track listing action
|
agent.record_action("trade")
|
||||||
|
|
||||||
return ActionResult(
|
return ActionResult(
|
||||||
action_type=ActionType.TRADE,
|
action_type=ActionType.TRADE,
|
||||||
@ -629,7 +788,7 @@ class GameEngine:
|
|||||||
)
|
)
|
||||||
|
|
||||||
def _execute_price_adjustment(self, agent: Agent, decision: AIDecision) -> ActionResult:
|
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(
|
success = self.market.adjust_order_price(
|
||||||
order_id=decision.adjust_order_id,
|
order_id=decision.adjust_order_id,
|
||||||
seller_id=agent.id,
|
seller_id=agent.id,
|
||||||
@ -641,8 +800,8 @@ class GameEngine:
|
|||||||
return ActionResult(
|
return ActionResult(
|
||||||
action_type=ActionType.TRADE,
|
action_type=ActionType.TRADE,
|
||||||
success=True,
|
success=True,
|
||||||
energy_spent=0, # Price adjustments are free
|
energy_spent=0,
|
||||||
message=f"Adjusted {decision.target_resource.value} price to {decision.new_price}c",
|
message=f"Adjusted price to {decision.new_price}c",
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
return ActionResult(
|
return ActionResult(
|
||||||
@ -655,17 +814,13 @@ class GameEngine:
|
|||||||
"""Execute a multi-item buy trade."""
|
"""Execute a multi-item buy trade."""
|
||||||
config = ACTION_CONFIG[ActionType.TRADE]
|
config = ACTION_CONFIG[ActionType.TRADE]
|
||||||
|
|
||||||
# Build list of purchases
|
|
||||||
purchases = [(item.order_id, item.quantity) for item in decision.trade_items]
|
purchases = [(item.order_id, item.quantity) for item in decision.trade_items]
|
||||||
|
|
||||||
# Execute all purchases
|
|
||||||
results = self.market.execute_multi_buy(
|
results = self.market.execute_multi_buy(
|
||||||
buyer_id=agent.id,
|
buyer_id=agent.id,
|
||||||
purchases=purchases,
|
purchases=purchases,
|
||||||
buyer_money=agent.money,
|
buyer_money=agent.money,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Process results
|
|
||||||
total_paid = 0
|
total_paid = 0
|
||||||
resources_gained = []
|
resources_gained = []
|
||||||
items_bought = []
|
items_bought = []
|
||||||
@ -676,7 +831,6 @@ class GameEngine:
|
|||||||
agent.money -= result.total_paid
|
agent.money -= result.total_paid
|
||||||
total_paid += result.total_paid
|
total_paid += result.total_paid
|
||||||
|
|
||||||
# Record sale for price history
|
|
||||||
self.market._record_sale(
|
self.market._record_sale(
|
||||||
result.resource_type,
|
result.resource_type,
|
||||||
result.total_paid // result.quantity,
|
result.total_paid // result.quantity,
|
||||||
@ -684,10 +838,6 @@ class GameEngine:
|
|||||||
self.world.current_turn
|
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(
|
resource = Resource(
|
||||||
type=result.resource_type,
|
type=result.resource_type,
|
||||||
quantity=result.quantity,
|
quantity=result.quantity,
|
||||||
@ -697,7 +847,6 @@ class GameEngine:
|
|||||||
resources_gained.append(resource)
|
resources_gained.append(resource)
|
||||||
items_bought.append(f"{result.quantity} {result.resource_type.value}")
|
items_bought.append(f"{result.quantity} {result.resource_type.value}")
|
||||||
|
|
||||||
# Add money to seller
|
|
||||||
seller = self.world.get_agent(result.seller_id)
|
seller = self.world.get_agent(result.seller_id)
|
||||||
if seller:
|
if seller:
|
||||||
seller.money += result.total_paid
|
seller.money += result.total_paid
|
||||||
@ -760,35 +909,9 @@ class GameEngine:
|
|||||||
"recent_logs": [
|
"recent_logs": [
|
||||||
log.to_dict() for log in self.turn_logs[-5:]
|
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:
|
def get_engine() -> GameEngine:
|
||||||
"""Get the global game engine instance."""
|
"""Get the global game engine instance."""
|
||||||
return GameEngine()
|
return GameEngine()
|
||||||
|
|||||||
@ -3,6 +3,9 @@
|
|||||||
The world spawns diverse agents with varied personality traits,
|
The world spawns diverse agents with varied personality traits,
|
||||||
skills, and starting conditions to create emergent professions
|
skills, and starting conditions to create emergent professions
|
||||||
and class inequality.
|
and class inequality.
|
||||||
|
|
||||||
|
NEW: World now supports religion and faction systems for realistic
|
||||||
|
social dynamics including religious diversity and geopolitical factions.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import random
|
import random
|
||||||
@ -15,6 +18,13 @@ from backend.domain.personality import (
|
|||||||
PersonalityTraits, Skills,
|
PersonalityTraits, Skills,
|
||||||
generate_random_personality, generate_random_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):
|
class TimeOfDay(Enum):
|
||||||
@ -31,16 +41,14 @@ def _get_world_config_from_file():
|
|||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class WorldConfig:
|
class WorldConfig:
|
||||||
"""Configuration for the world.
|
"""Configuration for the world."""
|
||||||
|
|
||||||
Default values are loaded from config.json via create_world_config().
|
|
||||||
These hardcoded defaults are only fallbacks.
|
|
||||||
"""
|
|
||||||
width: int = 25
|
width: int = 25
|
||||||
height: int = 25
|
height: int = 25
|
||||||
initial_agents: int = 25
|
initial_agents: int = 25
|
||||||
day_steps: int = 10
|
day_steps: int = 10
|
||||||
night_steps: int = 1
|
night_steps: int = 1
|
||||||
|
oil_fields_count: int = 3 # NEW
|
||||||
|
temple_count: int = 2 # NEW
|
||||||
|
|
||||||
|
|
||||||
def create_world_config() -> WorldConfig:
|
def create_world_config() -> WorldConfig:
|
||||||
@ -52,9 +60,21 @@ def create_world_config() -> WorldConfig:
|
|||||||
initial_agents=cfg.initial_agents,
|
initial_agents=cfg.initial_agents,
|
||||||
day_steps=cfg.day_steps,
|
day_steps=cfg.day_steps,
|
||||||
night_steps=cfg.night_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
|
@dataclass
|
||||||
class World:
|
class World:
|
||||||
"""Container for all entities in the simulation."""
|
"""Container for all entities in the simulation."""
|
||||||
@ -65,9 +85,46 @@ class World:
|
|||||||
step_in_day: int = 0
|
step_in_day: int = 0
|
||||||
time_of_day: TimeOfDay = TimeOfDay.DAY
|
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
|
# Statistics
|
||||||
total_agents_spawned: int = 0
|
total_agents_spawned: int = 0
|
||||||
total_agents_died: 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(
|
def spawn_agent(
|
||||||
self,
|
self,
|
||||||
@ -76,6 +133,8 @@ class World:
|
|||||||
position: Optional[Position] = None,
|
position: Optional[Position] = None,
|
||||||
archetype: Optional[str] = None,
|
archetype: Optional[str] = None,
|
||||||
starting_money: Optional[int] = None,
|
starting_money: Optional[int] = None,
|
||||||
|
religion: Optional[ReligiousBeliefs] = None,
|
||||||
|
faction: Optional[AgentDiplomacy] = None,
|
||||||
) -> Agent:
|
) -> Agent:
|
||||||
"""Spawn a new agent in the world with unique personality.
|
"""Spawn a new agent in the world with unique personality.
|
||||||
|
|
||||||
@ -85,6 +144,8 @@ class World:
|
|||||||
position: Starting position (random if None)
|
position: Starting position (random if None)
|
||||||
archetype: Personality archetype ("hunter", "gatherer", "trader", etc.)
|
archetype: Personality archetype ("hunter", "gatherer", "trader", etc.)
|
||||||
starting_money: Starting money (random with inequality if None)
|
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:
|
if position is None:
|
||||||
position = Position(
|
position = Position(
|
||||||
@ -96,25 +157,38 @@ class World:
|
|||||||
personality = generate_random_personality(archetype)
|
personality = generate_random_personality(archetype)
|
||||||
skills = generate_random_skills(personality)
|
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
|
# Variable starting money for class inequality
|
||||||
# Some agents start with more, some with less
|
|
||||||
if starting_money is None:
|
if starting_money is None:
|
||||||
from backend.config import get_config
|
from backend.config import get_config
|
||||||
base_money = get_config().world.starting_money
|
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)
|
money_multiplier = random.uniform(0.3, 2.0)
|
||||||
|
|
||||||
# Traders start with more money (their capital)
|
# Traders start with more money (their capital)
|
||||||
if personality.trade_preference > 1.3:
|
if personality.trade_preference > 1.3:
|
||||||
money_multiplier *= 1.5
|
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)
|
starting_money = int(base_money * money_multiplier)
|
||||||
|
|
||||||
agent = Agent(
|
agent = Agent(
|
||||||
name=name or f"Villager_{self.total_agents_spawned + 1}",
|
name=name or f"Villager_{self.total_agents_spawned + 1}",
|
||||||
profession=Profession.VILLAGER, # Will be updated based on personality
|
profession=Profession.VILLAGER,
|
||||||
position=position,
|
position=position,
|
||||||
personality=personality,
|
personality=personality,
|
||||||
skills=skills,
|
skills=skills,
|
||||||
|
religion=religion,
|
||||||
|
diplomacy=faction,
|
||||||
money=starting_money,
|
money=starting_money,
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -129,12 +203,35 @@ class World:
|
|||||||
return agent
|
return agent
|
||||||
return None
|
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]:
|
def remove_dead_agents(self) -> list[Agent]:
|
||||||
"""Remove all dead agents from the world. Returns list of removed agents.
|
"""Remove all dead agents from the world."""
|
||||||
Note: This is now handled by the engine's corpse system for visualization.
|
|
||||||
"""
|
|
||||||
dead_agents = [a for a in self.agents if not a.is_alive() and not a.is_corpse()]
|
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
|
return dead_agents
|
||||||
|
|
||||||
def advance_time(self) -> None:
|
def advance_time(self) -> None:
|
||||||
@ -148,12 +245,14 @@ class World:
|
|||||||
self.step_in_day = 1
|
self.step_in_day = 1
|
||||||
self.current_day += 1
|
self.current_day += 1
|
||||||
|
|
||||||
# Determine time of day
|
|
||||||
if self.step_in_day <= self.config.day_steps:
|
if self.step_in_day <= self.config.day_steps:
|
||||||
self.time_of_day = TimeOfDay.DAY
|
self.time_of_day = TimeOfDay.DAY
|
||||||
else:
|
else:
|
||||||
self.time_of_day = TimeOfDay.NIGHT
|
self.time_of_day = TimeOfDay.NIGHT
|
||||||
|
|
||||||
|
# Update faction relations each turn
|
||||||
|
self.faction_relations.update_turn(self.current_turn)
|
||||||
|
|
||||||
def is_night(self) -> bool:
|
def is_night(self) -> bool:
|
||||||
"""Check if it's currently night."""
|
"""Check if it's currently night."""
|
||||||
return self.time_of_day == TimeOfDay.NIGHT
|
return self.time_of_day == TimeOfDay.NIGHT
|
||||||
@ -167,13 +266,25 @@ class World:
|
|||||||
living = self.get_living_agents()
|
living = self.get_living_agents()
|
||||||
total_money = sum(a.money for a in living)
|
total_money = sum(a.money for a in living)
|
||||||
|
|
||||||
# Count emergent professions (updated based on current skills)
|
# Count emergent professions
|
||||||
profession_counts = {}
|
profession_counts = {}
|
||||||
for agent in living:
|
for agent in living:
|
||||||
agent._update_profession() # Update based on current state
|
agent._update_profession()
|
||||||
prof = agent.profession.value
|
prof = agent.profession.value
|
||||||
profession_counts[prof] = profession_counts.get(prof, 0) + 1
|
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
|
# Calculate wealth inequality metrics
|
||||||
if living:
|
if living:
|
||||||
moneys = sorted([a.money for a in living])
|
moneys = sorted([a.money for a in living])
|
||||||
@ -182,15 +293,21 @@ class World:
|
|||||||
richest = moneys[-1] if moneys else 0
|
richest = moneys[-1] if moneys else 0
|
||||||
poorest = moneys[0] 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)
|
n = len(moneys)
|
||||||
if n > 1 and total_money > 0:
|
if n > 1 and total_money > 0:
|
||||||
sum_of_diffs = sum(abs(m1 - m2) for m1 in moneys for m2 in moneys)
|
sum_of_diffs = sum(abs(m1 - m2) for m1 in moneys for m2 in moneys)
|
||||||
gini = sum_of_diffs / (2 * n * total_money)
|
gini = sum_of_diffs / (2 * n * total_money)
|
||||||
else:
|
else:
|
||||||
gini = 0
|
gini = 0
|
||||||
|
|
||||||
|
# Average faith
|
||||||
|
avg_faith = sum(a.stats.faith for a in living) / len(living)
|
||||||
else:
|
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 {
|
return {
|
||||||
"current_turn": self.current_turn,
|
"current_turn": self.current_turn,
|
||||||
@ -202,12 +319,20 @@ class World:
|
|||||||
"total_agents_died": self.total_agents_died,
|
"total_agents_died": self.total_agents_died,
|
||||||
"total_money_in_circulation": total_money,
|
"total_money_in_circulation": total_money,
|
||||||
"professions": profession_counts,
|
"professions": profession_counts,
|
||||||
# Wealth inequality metrics
|
# Wealth metrics
|
||||||
"avg_money": round(avg_money, 1),
|
"avg_money": round(avg_money, 1),
|
||||||
"median_money": median_money,
|
"median_money": median_money,
|
||||||
"richest_agent": richest,
|
"richest_agent": richest,
|
||||||
"poorest_agent": poorest,
|
"poorest_agent": poorest,
|
||||||
"gini_coefficient": round(gini, 3),
|
"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:
|
def get_state_snapshot(self) -> dict:
|
||||||
@ -220,21 +345,34 @@ class World:
|
|||||||
"world_size": {"width": self.config.width, "height": self.config.height},
|
"world_size": {"width": self.config.width, "height": self.config.height},
|
||||||
"agents": [a.to_dict() for a in self.agents],
|
"agents": [a.to_dict() for a in self.agents],
|
||||||
"statistics": self.get_statistics(),
|
"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:
|
def initialize(self) -> None:
|
||||||
"""Initialize the world with diverse starting agents.
|
"""Initialize the world with diverse starting agents.
|
||||||
|
|
||||||
Creates a mix of agent archetypes to seed profession diversity:
|
Creates a mix of agent archetypes to seed profession diversity.
|
||||||
- Some hunters (risk-takers who hunt)
|
Now also seeds religious and faction diversity.
|
||||||
- Some gatherers (cautious resource collectors)
|
|
||||||
- Some traders (market-focused wealth builders)
|
|
||||||
- Some generalists (balanced approach)
|
|
||||||
"""
|
"""
|
||||||
|
# Reset faction relations
|
||||||
|
self.faction_relations = reset_faction_relations()
|
||||||
|
|
||||||
|
# Generate special locations
|
||||||
|
self._generate_locations()
|
||||||
|
|
||||||
n = self.config.initial_agents
|
n = self.config.initial_agents
|
||||||
|
|
||||||
# Distribute archetypes for diversity
|
# Distribute archetypes for diversity
|
||||||
# ~15% hunters, ~15% gatherers, ~15% traders, ~10% woodcutters, 45% random
|
|
||||||
archetypes = (
|
archetypes = (
|
||||||
["hunter"] * max(1, n // 7) +
|
["hunter"] * max(1, n // 7) +
|
||||||
["gatherer"] * max(1, n // 7) +
|
["gatherer"] * max(1, n // 7) +
|
||||||
@ -242,13 +380,33 @@ class World:
|
|||||||
["woodcutter"] * max(1, n // 10)
|
["woodcutter"] * max(1, n // 10)
|
||||||
)
|
)
|
||||||
|
|
||||||
# Fill remaining slots with random (no archetype)
|
|
||||||
while len(archetypes) < n:
|
while len(archetypes) < n:
|
||||||
archetypes.append(None)
|
archetypes.append(None)
|
||||||
|
|
||||||
# Shuffle to randomize positions
|
|
||||||
random.shuffle(archetypes)
|
random.shuffle(archetypes)
|
||||||
|
|
||||||
for archetype in archetypes:
|
for archetype in archetypes:
|
||||||
self.spawn_agent(archetype=archetype)
|
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 .resources import ResourceType, Resource, RESOURCE_EFFECTS
|
||||||
from .action import ActionType, ActionConfig, ActionResult, ACTION_CONFIG
|
from .action import ActionType, ActionConfig, ActionResult, ACTION_CONFIG
|
||||||
from .agent import Agent, AgentStats, Position
|
from .agent import Agent, AgentStats, Position
|
||||||
|
from .religion import ReligionType, ReligiousBeliefs
|
||||||
|
from .diplomacy import FactionType, AgentDiplomacy, FactionRelations
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"ResourceType",
|
"ResourceType",
|
||||||
@ -15,5 +17,11 @@ __all__ = [
|
|||||||
"Agent",
|
"Agent",
|
||||||
"AgentStats",
|
"AgentStats",
|
||||||
"Position",
|
"Position",
|
||||||
|
# Religion
|
||||||
|
"ReligionType",
|
||||||
|
"ReligiousBeliefs",
|
||||||
|
# Diplomacy
|
||||||
|
"FactionType",
|
||||||
|
"AgentDiplomacy",
|
||||||
|
"FactionRelations",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@ -15,6 +15,7 @@ if TYPE_CHECKING:
|
|||||||
|
|
||||||
class ActionType(Enum):
|
class ActionType(Enum):
|
||||||
"""Types of actions an agent can perform."""
|
"""Types of actions an agent can perform."""
|
||||||
|
# Basic survival actions
|
||||||
SLEEP = "sleep" # Night action - restores energy
|
SLEEP = "sleep" # Night action - restores energy
|
||||||
REST = "rest" # Day action - restores some energy
|
REST = "rest" # Day action - restores some energy
|
||||||
HUNT = "hunt" # Produces meat and hide
|
HUNT = "hunt" # Produces meat and hide
|
||||||
@ -26,6 +27,20 @@ class ActionType(Enum):
|
|||||||
TRADE = "trade" # Market interaction
|
TRADE = "trade" # Market interaction
|
||||||
CONSUME = "consume" # Consume resource from inventory
|
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
|
@dataclass
|
||||||
class ActionConfig:
|
class ActionConfig:
|
||||||
@ -40,14 +55,13 @@ class ActionConfig:
|
|||||||
secondary_max: int = 0
|
secondary_max: int = 0
|
||||||
requires_resource: Optional[ResourceType] = None
|
requires_resource: Optional[ResourceType] = None
|
||||||
requires_quantity: int = 0
|
requires_quantity: int = 0
|
||||||
|
# NEW: Faith effects
|
||||||
|
faith_gain: int = 0
|
||||||
|
faith_spread: int = 0
|
||||||
|
|
||||||
|
|
||||||
def get_action_config() -> dict[ActionType, ActionConfig]:
|
def get_action_config() -> dict[ActionType, ActionConfig]:
|
||||||
"""Get action configurations from the global config.
|
"""Get action configurations from the global config."""
|
||||||
|
|
||||||
This function dynamically builds ACTION_CONFIG from config.json values.
|
|
||||||
"""
|
|
||||||
# Import here to avoid circular imports
|
|
||||||
from backend.config import get_config
|
from backend.config import get_config
|
||||||
|
|
||||||
config = get_config()
|
config = get_config()
|
||||||
@ -55,10 +69,10 @@ def get_action_config() -> dict[ActionType, ActionConfig]:
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
ActionType.SLEEP: ActionConfig(
|
ActionType.SLEEP: ActionConfig(
|
||||||
energy_cost=actions.sleep_energy, # Restores energy
|
energy_cost=actions.sleep_energy,
|
||||||
),
|
),
|
||||||
ActionType.REST: ActionConfig(
|
ActionType.REST: ActionConfig(
|
||||||
energy_cost=actions.rest_energy, # Restores some energy
|
energy_cost=actions.rest_energy,
|
||||||
),
|
),
|
||||||
ActionType.HUNT: ActionConfig(
|
ActionType.HUNT: ActionConfig(
|
||||||
energy_cost=actions.hunt_energy,
|
energy_cost=actions.hunt_energy,
|
||||||
@ -112,6 +126,53 @@ def get_action_config() -> dict[ActionType, ActionConfig]:
|
|||||||
ActionType.CONSUME: ActionConfig(
|
ActionType.CONSUME: ActionConfig(
|
||||||
energy_cost=0,
|
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
|
_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:
|
class _ActionConfigAccessor:
|
||||||
"""Accessor class that provides dict-like access to action configs."""
|
"""Accessor class that provides dict-like access to action configs."""
|
||||||
|
|
||||||
@ -161,6 +220,21 @@ class _ActionConfigAccessor:
|
|||||||
ACTION_CONFIG = _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
|
@dataclass
|
||||||
class ActionResult:
|
class ActionResult:
|
||||||
"""Result of executing an action."""
|
"""Result of executing an action."""
|
||||||
@ -170,8 +244,14 @@ class ActionResult:
|
|||||||
resources_gained: list = field(default_factory=list)
|
resources_gained: list = field(default_factory=list)
|
||||||
resources_consumed: list = field(default_factory=list)
|
resources_consumed: list = field(default_factory=list)
|
||||||
heat_gained: int = 0
|
heat_gained: int = 0
|
||||||
|
faith_gained: int = 0 # NEW
|
||||||
|
relation_change: int = 0 # NEW
|
||||||
message: str = ""
|
message: str = ""
|
||||||
|
|
||||||
|
# NEW: Diplomatic effects
|
||||||
|
target_faction: Optional[str] = None
|
||||||
|
diplomatic_effect: Optional[str] = None # "war", "peace", "improved", "degraded"
|
||||||
|
|
||||||
def to_dict(self) -> dict:
|
def to_dict(self) -> dict:
|
||||||
"""Convert to dictionary for API serialization."""
|
"""Convert to dictionary for API serialization."""
|
||||||
return {
|
return {
|
||||||
@ -187,5 +267,9 @@ class ActionResult:
|
|||||||
for r in self.resources_consumed
|
for r in self.resources_consumed
|
||||||
],
|
],
|
||||||
"heat_gained": self.heat_gained,
|
"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,
|
"message": self.message,
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3,6 +3,9 @@
|
|||||||
Agent stats are loaded dynamically from the global config.
|
Agent stats are loaded dynamically from the global config.
|
||||||
Each agent now has unique personality traits and skills that create
|
Each agent now has unique personality traits and skills that create
|
||||||
emergent professions and behavioral diversity.
|
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
|
import math
|
||||||
@ -17,6 +20,8 @@ from .personality import (
|
|||||||
PersonalityTraits, Skills, ProfessionType,
|
PersonalityTraits, Skills, ProfessionType,
|
||||||
determine_profession
|
determine_profession
|
||||||
)
|
)
|
||||||
|
from .religion import ReligiousBeliefs
|
||||||
|
from .diplomacy import AgentDiplomacy
|
||||||
|
|
||||||
|
|
||||||
def _get_agent_stats_config():
|
def _get_agent_stats_config():
|
||||||
@ -33,6 +38,8 @@ class Profession(Enum):
|
|||||||
WOODCUTTER = "woodcutter"
|
WOODCUTTER = "woodcutter"
|
||||||
TRADER = "trader"
|
TRADER = "trader"
|
||||||
CRAFTER = "crafter"
|
CRAFTER = "crafter"
|
||||||
|
OIL_WORKER = "oil_worker" # NEW: Oil industry worker
|
||||||
|
PRIEST = "priest" # NEW: Religious leader
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
@ -80,18 +87,21 @@ class AgentStats:
|
|||||||
hunger: int = field(default=80)
|
hunger: int = field(default=80)
|
||||||
thirst: int = field(default=70)
|
thirst: int = field(default=70)
|
||||||
heat: int = field(default=100)
|
heat: int = field(default=100)
|
||||||
|
faith: int = field(default=50) # NEW: Religious faith level
|
||||||
|
|
||||||
# Maximum values - loaded from config
|
# Maximum values - loaded from config
|
||||||
MAX_ENERGY: int = field(default=50)
|
MAX_ENERGY: int = field(default=50)
|
||||||
MAX_HUNGER: int = field(default=100)
|
MAX_HUNGER: int = field(default=100)
|
||||||
MAX_THIRST: int = field(default=100)
|
MAX_THIRST: int = field(default=100)
|
||||||
MAX_HEAT: 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
|
# Passive decay rates per turn - loaded from config
|
||||||
ENERGY_DECAY: int = field(default=1)
|
ENERGY_DECAY: int = field(default=1)
|
||||||
HUNGER_DECAY: int = field(default=2)
|
HUNGER_DECAY: int = field(default=2)
|
||||||
THIRST_DECAY: int = field(default=3)
|
THIRST_DECAY: int = field(default=3)
|
||||||
HEAT_DECAY: int = field(default=2)
|
HEAT_DECAY: int = field(default=2)
|
||||||
|
FAITH_DECAY: int = field(default=1) # NEW
|
||||||
|
|
||||||
# Critical threshold - loaded from config
|
# Critical threshold - loaded from config
|
||||||
CRITICAL_THRESHOLD: float = field(default=0.25)
|
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
|
heat_decay = self.HEAT_DECAY // 2 if has_clothes else self.HEAT_DECAY
|
||||||
self.heat = max(0, self.heat - 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:
|
def is_critical(self) -> bool:
|
||||||
"""Check if any vital stat is below critical threshold."""
|
"""Check if any vital stat is below critical threshold."""
|
||||||
threshold_hunger = int(self.MAX_HUNGER * self.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."""
|
"""Check if agent has enough energy to perform an action."""
|
||||||
return self.energy >= abs(energy_required)
|
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:
|
def to_dict(self) -> dict:
|
||||||
return {
|
return {
|
||||||
"energy": self.energy,
|
"energy": self.energy,
|
||||||
"hunger": self.hunger,
|
"hunger": self.hunger,
|
||||||
"thirst": self.thirst,
|
"thirst": self.thirst,
|
||||||
"heat": self.heat,
|
"heat": self.heat,
|
||||||
|
"faith": self.faith,
|
||||||
"max_energy": self.MAX_ENERGY,
|
"max_energy": self.MAX_ENERGY,
|
||||||
"max_hunger": self.MAX_HUNGER,
|
"max_hunger": self.MAX_HUNGER,
|
||||||
"max_thirst": self.MAX_THIRST,
|
"max_thirst": self.MAX_THIRST,
|
||||||
"max_heat": self.MAX_HEAT,
|
"max_heat": self.MAX_HEAT,
|
||||||
|
"max_faith": self.MAX_FAITH,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@ -156,14 +184,17 @@ def create_agent_stats() -> AgentStats:
|
|||||||
hunger=config.start_hunger,
|
hunger=config.start_hunger,
|
||||||
thirst=config.start_thirst,
|
thirst=config.start_thirst,
|
||||||
heat=config.start_heat,
|
heat=config.start_heat,
|
||||||
|
faith=getattr(config, 'start_faith', 50),
|
||||||
MAX_ENERGY=config.max_energy,
|
MAX_ENERGY=config.max_energy,
|
||||||
MAX_HUNGER=config.max_hunger,
|
MAX_HUNGER=config.max_hunger,
|
||||||
MAX_THIRST=config.max_thirst,
|
MAX_THIRST=config.max_thirst,
|
||||||
MAX_HEAT=config.max_heat,
|
MAX_HEAT=config.max_heat,
|
||||||
|
MAX_FAITH=getattr(config, 'max_faith', 100),
|
||||||
ENERGY_DECAY=config.energy_decay,
|
ENERGY_DECAY=config.energy_decay,
|
||||||
HUNGER_DECAY=config.hunger_decay,
|
HUNGER_DECAY=config.hunger_decay,
|
||||||
THIRST_DECAY=config.thirst_decay,
|
THIRST_DECAY=config.thirst_decay,
|
||||||
HEAT_DECAY=config.heat_decay,
|
HEAT_DECAY=config.heat_decay,
|
||||||
|
FAITH_DECAY=getattr(config, 'faith_decay', 1),
|
||||||
CRITICAL_THRESHOLD=config.critical_threshold,
|
CRITICAL_THRESHOLD=config.critical_threshold,
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -171,9 +202,10 @@ def create_agent_stats() -> AgentStats:
|
|||||||
@dataclass
|
@dataclass
|
||||||
class AgentAction:
|
class AgentAction:
|
||||||
"""Current action being performed by an agent."""
|
"""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_position: Optional[Position] = None
|
||||||
target_resource: Optional[str] = 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
|
progress: float = 0.0 # 0.0 to 1.0
|
||||||
is_moving: bool = False
|
is_moving: bool = False
|
||||||
message: str = ""
|
message: str = ""
|
||||||
@ -183,6 +215,7 @@ class AgentAction:
|
|||||||
"action_type": self.action_type,
|
"action_type": self.action_type,
|
||||||
"target_position": self.target_position.to_dict() if self.target_position else None,
|
"target_position": self.target_position.to_dict() if self.target_position else None,
|
||||||
"target_resource": self.target_resource,
|
"target_resource": self.target_resource,
|
||||||
|
"target_agent": self.target_agent,
|
||||||
"progress": round(self.progress, 2),
|
"progress": round(self.progress, 2),
|
||||||
"is_moving": self.is_moving,
|
"is_moving": self.is_moving,
|
||||||
"message": self.message,
|
"message": self.message,
|
||||||
@ -191,16 +224,27 @@ class AgentAction:
|
|||||||
|
|
||||||
# Action location mappings (relative positions on the map for each action type)
|
# Action location mappings (relative positions on the map for each action type)
|
||||||
ACTION_LOCATIONS = {
|
ACTION_LOCATIONS = {
|
||||||
"hunt": {"zone": "forest", "offset_range": (0.6, 0.9)}, # Right side (forest)
|
"hunt": {"zone": "forest", "offset_range": (0.6, 0.9)},
|
||||||
"gather": {"zone": "bushes", "offset_range": (0.1, 0.4)}, # Left side (bushes)
|
"gather": {"zone": "bushes", "offset_range": (0.1, 0.4)},
|
||||||
"chop_wood": {"zone": "forest", "offset_range": (0.7, 0.95)}, # Far right (forest)
|
"chop_wood": {"zone": "forest", "offset_range": (0.7, 0.95)},
|
||||||
"get_water": {"zone": "river", "offset_range": (0.0, 0.15)}, # Far left (river)
|
"get_water": {"zone": "river", "offset_range": (0.0, 0.15)},
|
||||||
"weave": {"zone": "village", "offset_range": (0.4, 0.6)}, # Center (village)
|
"weave": {"zone": "village", "offset_range": (0.4, 0.6)},
|
||||||
"build_fire": {"zone": "village", "offset_range": (0.45, 0.55)},
|
"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)},
|
"rest": {"zone": "home", "offset_range": (0.4, 0.6)},
|
||||||
"sleep": {"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.
|
Stats, inventory slots, and starting money are loaded from config.json.
|
||||||
Each agent now has unique personality traits and skills that create
|
Each agent now has unique personality traits and skills that create
|
||||||
emergent behaviors and professions.
|
emergent behaviors and professions.
|
||||||
|
|
||||||
|
NEW: Agents now have religious beliefs and faction membership.
|
||||||
"""
|
"""
|
||||||
id: str = field(default_factory=lambda: str(uuid4())[:8])
|
id: str = field(default_factory=lambda: str(uuid4())[:8])
|
||||||
name: str = ""
|
name: str = ""
|
||||||
profession: Profession = Profession.VILLAGER # Now derived from personality/skills
|
profession: Profession = Profession.VILLAGER
|
||||||
position: Position = field(default_factory=Position)
|
position: Position = field(default_factory=Position)
|
||||||
stats: AgentStats = field(default_factory=create_agent_stats)
|
stats: AgentStats = field(default_factory=create_agent_stats)
|
||||||
inventory: list[Resource] = field(default_factory=list)
|
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)
|
personality: PersonalityTraits = field(default_factory=PersonalityTraits)
|
||||||
skills: Skills = field(default_factory=Skills)
|
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
|
# Movement and action tracking
|
||||||
home_position: Position = field(default_factory=Position)
|
home_position: Position = field(default_factory=Position)
|
||||||
current_action: AgentAction = field(default_factory=AgentAction)
|
current_action: AgentAction = field(default_factory=AgentAction)
|
||||||
last_action_result: str = ""
|
last_action_result: str = ""
|
||||||
|
|
||||||
# Death tracking for corpse visualization
|
# Death tracking for corpse visualization
|
||||||
death_turn: int = -1 # Turn when agent died, -1 if alive
|
death_turn: int = -1
|
||||||
death_reason: str = "" # Cause of death
|
death_reason: str = ""
|
||||||
|
|
||||||
# Statistics tracking for profession determination
|
# Statistics tracking for profession determination
|
||||||
actions_performed: dict = field(default_factory=lambda: {
|
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_trades_completed: int = 0
|
||||||
total_money_earned: 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
|
# Configuration - loaded from config
|
||||||
INVENTORY_SLOTS: int = field(default=-1) # -1 signals to use config value
|
INVENTORY_SLOTS: int = field(default=-1)
|
||||||
MOVE_SPEED: float = 0.8 # Grid cells per turn
|
MOVE_SPEED: float = 0.8
|
||||||
|
|
||||||
def __post_init__(self):
|
def __post_init__(self):
|
||||||
if not self.name:
|
if not self.name:
|
||||||
self.name = f"Agent_{self.id}"
|
self.name = f"Agent_{self.id}"
|
||||||
# Set home position to initial position
|
|
||||||
self.home_position = self.position.copy()
|
self.home_position = self.position.copy()
|
||||||
|
|
||||||
# Load config values if defaults were used
|
|
||||||
config = _get_world_config()
|
config = _get_world_config()
|
||||||
if self.money == -1:
|
if self.money == -1:
|
||||||
self.money = config.starting_money
|
self.money = config.starting_money
|
||||||
if self.INVENTORY_SLOTS == -1:
|
if self.INVENTORY_SLOTS == -1:
|
||||||
self.INVENTORY_SLOTS = config.inventory_slots
|
self.INVENTORY_SLOTS = config.inventory_slots
|
||||||
|
|
||||||
# Update profession based on personality and skills
|
|
||||||
self._update_profession()
|
self._update_profession()
|
||||||
|
|
||||||
def _update_profession(self) -> None:
|
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)
|
prof_type = determine_profession(self.personality, self.skills)
|
||||||
profession_map = {
|
profession_map = {
|
||||||
ProfessionType.HUNTER: Profession.HUNTER,
|
ProfessionType.HUNTER: Profession.HUNTER,
|
||||||
@ -287,19 +346,6 @@ class Agent:
|
|||||||
if action_type in self.actions_performed:
|
if action_type in self.actions_performed:
|
||||||
self.actions_performed[action_type] += 1
|
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:
|
def record_trade(self, money_earned: int) -> None:
|
||||||
"""Record a completed trade for statistics."""
|
"""Record a completed trade for statistics."""
|
||||||
self.total_trades_completed += 1
|
self.total_trades_completed += 1
|
||||||
@ -315,7 +361,7 @@ class Agent:
|
|||||||
)
|
)
|
||||||
|
|
||||||
def is_corpse(self) -> bool:
|
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
|
return self.death_turn >= 0
|
||||||
|
|
||||||
def can_act(self) -> bool:
|
def can_act(self) -> bool:
|
||||||
@ -326,6 +372,14 @@ class Agent:
|
|||||||
"""Check if agent has clothes equipped."""
|
"""Check if agent has clothes equipped."""
|
||||||
return any(r.type == ResourceType.CLOTHES for r in self.inventory)
|
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:
|
def inventory_space(self) -> int:
|
||||||
"""Get remaining inventory slots."""
|
"""Get remaining inventory slots."""
|
||||||
total_items = sum(r.quantity for r in self.inventory)
|
total_items = sum(r.quantity for r in self.inventory)
|
||||||
@ -342,22 +396,20 @@ class Agent:
|
|||||||
world_height: int,
|
world_height: int,
|
||||||
message: str = "",
|
message: str = "",
|
||||||
target_resource: Optional[str] = None,
|
target_resource: Optional[str] = None,
|
||||||
|
target_agent: Optional[str] = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Set the current action and calculate target position."""
|
"""Set the current action and calculate target position."""
|
||||||
location = ACTION_LOCATIONS.get(action_type, {"zone": "current", "offset_range": (0, 0)})
|
location = ACTION_LOCATIONS.get(action_type, {"zone": "current", "offset_range": (0, 0)})
|
||||||
|
|
||||||
if location["zone"] == "current":
|
if location["zone"] == "current":
|
||||||
# Stay in place
|
|
||||||
target = self.position.copy()
|
target = self.position.copy()
|
||||||
is_moving = False
|
is_moving = False
|
||||||
else:
|
else:
|
||||||
# Calculate target position based on action zone
|
|
||||||
offset_range = location["offset_range"]
|
offset_range = location["offset_range"]
|
||||||
offset_min = float(offset_range[0])
|
offset_min = float(offset_range[0]) if offset_range else 0.0
|
||||||
offset_max = float(offset_range[1])
|
offset_max = float(offset_range[1]) if offset_range else 0.0
|
||||||
target_x = world_width * random.uniform(offset_min, offset_max)
|
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 = self.home_position.y + random.uniform(-2, 2)
|
||||||
target_y = max(0.5, min(world_height - 0.5, target_y))
|
target_y = max(0.5, min(world_height - 0.5, target_y))
|
||||||
|
|
||||||
@ -368,6 +420,7 @@ class Agent:
|
|||||||
action_type=action_type,
|
action_type=action_type,
|
||||||
target_position=target,
|
target_position=target,
|
||||||
target_resource=target_resource,
|
target_resource=target_resource,
|
||||||
|
target_agent=target_agent,
|
||||||
progress=0.0,
|
progress=0.0,
|
||||||
is_moving=is_moving,
|
is_moving=is_moving,
|
||||||
message=message,
|
message=message,
|
||||||
@ -382,7 +435,7 @@ class Agent:
|
|||||||
)
|
)
|
||||||
if reached:
|
if reached:
|
||||||
self.current_action.is_moving = False
|
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:
|
def complete_action(self, success: bool, message: str) -> None:
|
||||||
"""Mark current action as complete."""
|
"""Mark current action as complete."""
|
||||||
@ -399,13 +452,11 @@ class Agent:
|
|||||||
|
|
||||||
quantity_to_add = min(resource.quantity, space)
|
quantity_to_add = min(resource.quantity, space)
|
||||||
|
|
||||||
# Try to stack with existing resource of same type
|
|
||||||
for existing in self.inventory:
|
for existing in self.inventory:
|
||||||
if existing.type == resource.type:
|
if existing.type == resource.type:
|
||||||
existing.quantity += quantity_to_add
|
existing.quantity += quantity_to_add
|
||||||
return quantity_to_add
|
return quantity_to_add
|
||||||
|
|
||||||
# Add as new stack
|
|
||||||
new_resource = Resource(
|
new_resource = Resource(
|
||||||
type=resource.type,
|
type=resource.type,
|
||||||
quantity=quantity_to_add,
|
quantity=quantity_to_add,
|
||||||
@ -469,7 +520,7 @@ class Agent:
|
|||||||
return True
|
return True
|
||||||
|
|
||||||
def apply_heat(self, amount: int) -> None:
|
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)
|
self.stats.heat = min(self.stats.MAX_HEAT, self.stats.heat + amount)
|
||||||
|
|
||||||
def restore_energy(self, amount: int) -> None:
|
def restore_energy(self, amount: int) -> None:
|
||||||
@ -483,8 +534,13 @@ class Agent:
|
|||||||
self.stats.energy -= amount
|
self.stats.energy -= amount
|
||||||
return True
|
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]:
|
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 = []
|
expired = []
|
||||||
for resource in self.inventory[:]:
|
for resource in self.inventory[:]:
|
||||||
if resource.is_expired(current_turn):
|
if resource.is_expired(current_turn):
|
||||||
@ -495,15 +551,38 @@ class Agent:
|
|||||||
def apply_passive_decay(self) -> None:
|
def apply_passive_decay(self) -> None:
|
||||||
"""Apply passive stat decay for this turn."""
|
"""Apply passive stat decay for this turn."""
|
||||||
self.stats.apply_passive_decay(has_clothes=self.has_clothes())
|
self.stats.apply_passive_decay(has_clothes=self.has_clothes())
|
||||||
|
self.religion.apply_decay()
|
||||||
|
|
||||||
def mark_dead(self, turn: int, reason: str) -> None:
|
def mark_dead(self, turn: int, reason: str) -> None:
|
||||||
"""Mark this agent as dead."""
|
"""Mark this agent as dead."""
|
||||||
self.death_turn = turn
|
self.death_turn = turn
|
||||||
self.death_reason = reason
|
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:
|
def to_dict(self) -> dict:
|
||||||
"""Convert to dictionary for API serialization."""
|
"""Convert to dictionary for API serialization."""
|
||||||
# Update profession before serializing
|
|
||||||
self._update_profession()
|
self._update_profession()
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@ -522,12 +601,13 @@ class Agent:
|
|||||||
"last_action_result": self.last_action_result,
|
"last_action_result": self.last_action_result,
|
||||||
"death_turn": self.death_turn,
|
"death_turn": self.death_turn,
|
||||||
"death_reason": self.death_reason,
|
"death_reason": self.death_reason,
|
||||||
# New fields for agent diversity
|
# Personality and skills
|
||||||
"personality": self.personality.to_dict(),
|
"personality": self.personality.to_dict(),
|
||||||
"skills": self.skills.to_dict(),
|
"skills": self.skills.to_dict(),
|
||||||
"actions_performed": self.actions_performed.copy(),
|
"actions_performed": self.actions_performed.copy(),
|
||||||
"total_trades": self.total_trades_completed,
|
"total_trades": self.total_trades_completed,
|
||||||
"total_money_earned": self.total_money_earned,
|
"total_money_earned": self.total_money_earned,
|
||||||
# Personal action history
|
# NEW: Religion and diplomacy
|
||||||
"action_history": self.action_history.copy(),
|
"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"
|
WOOD = "wood"
|
||||||
HIDE = "hide"
|
HIDE = "hide"
|
||||||
CLOTHES = "clothes"
|
CLOTHES = "clothes"
|
||||||
|
# NEW: Oil industry resources
|
||||||
|
OIL = "oil" # Raw crude oil - must be refined
|
||||||
|
FUEL = "fuel" # Refined fuel - provides heat and energy
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
@ -32,7 +35,6 @@ class ResourceEffect:
|
|||||||
|
|
||||||
def get_resource_effects() -> dict[ResourceType, ResourceEffect]:
|
def get_resource_effects() -> dict[ResourceType, ResourceEffect]:
|
||||||
"""Get resource effects from the global config."""
|
"""Get resource effects from the global config."""
|
||||||
# Import here to avoid circular imports
|
|
||||||
from backend.config import get_config
|
from backend.config import get_config
|
||||||
|
|
||||||
config = 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.WOOD: ResourceEffect(), # Used as fuel, not consumed directly
|
||||||
ResourceType.HIDE: ResourceEffect(), # Used for crafting
|
ResourceType.HIDE: ResourceEffect(), # Used for crafting
|
||||||
ResourceType.CLOTHES: ResourceEffect(), # Passive heat reduction effect
|
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]]:
|
def get_resource_decay_rates() -> dict[ResourceType, Optional[int]]:
|
||||||
"""Get resource decay rates from the global config."""
|
"""Get resource decay rates from the global config."""
|
||||||
# Import here to avoid circular imports
|
|
||||||
from backend.config import get_config
|
from backend.config import get_config
|
||||||
|
|
||||||
config = get_config()
|
config = get_config()
|
||||||
@ -71,6 +80,9 @@ def get_resource_decay_rates() -> dict[ResourceType, Optional[int]]:
|
|||||||
ResourceType.WOOD: None, # Infinite
|
ResourceType.WOOD: None, # Infinite
|
||||||
ResourceType.HIDE: None, # Infinite
|
ResourceType.HIDE: None, # Infinite
|
||||||
ResourceType.CLOTHES: resources.clothes_decay if resources.clothes_decay > 0 else None,
|
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
|
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
|
# Cached values for performance
|
||||||
_resource_effects_cache: Optional[dict[ResourceType, ResourceEffect]] = None
|
_resource_effects_cache: Optional[dict[ResourceType, ResourceEffect]] = None
|
||||||
_resource_decay_cache: Optional[dict[ResourceType, Optional[int]]] = None
|
_resource_decay_cache: Optional[dict[ResourceType, Optional[int]]] = None
|
||||||
@ -140,6 +158,37 @@ RESOURCE_EFFECTS = _ResourceEffectsAccessor()
|
|||||||
RESOURCE_DECAY_RATES = _ResourceDecayAccessor()
|
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
|
@dataclass
|
||||||
class Resource:
|
class Resource:
|
||||||
"""A resource instance in the simulation."""
|
"""A resource instance in the simulation."""
|
||||||
@ -157,6 +206,16 @@ class Resource:
|
|||||||
"""Get the effect of consuming this resource."""
|
"""Get the effect of consuming this resource."""
|
||||||
return get_cached_resource_effects()[self.type]
|
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:
|
def is_expired(self, current_turn: int) -> bool:
|
||||||
"""Check if the resource has decayed."""
|
"""Check if the resource has decayed."""
|
||||||
if self.decay_rate is None:
|
if self.decay_rate is None:
|
||||||
@ -177,4 +236,5 @@ class Resource:
|
|||||||
"quantity": self.quantity,
|
"quantity": self.quantity,
|
||||||
"created_turn": self.created_turn,
|
"created_turn": self.created_turn,
|
||||||
"decay_rate": self.decay_rate,
|
"decay_rate": self.decay_rate,
|
||||||
|
"base_value": self.base_value,
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,17 +1,12 @@
|
|||||||
"""FastAPI entry point for the Village Simulation backend."""
|
"""FastAPI entry point for the Village Simulation backend."""
|
||||||
|
|
||||||
import os
|
|
||||||
import uvicorn
|
import uvicorn
|
||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
from fastapi.staticfiles import StaticFiles
|
|
||||||
|
|
||||||
from backend.api.routes import router
|
from backend.api.routes import router
|
||||||
from backend.core.engine import get_engine
|
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
|
# Create FastAPI app
|
||||||
app = FastAPI(
|
app = FastAPI(
|
||||||
title="Village Simulation API",
|
title="Village Simulation API",
|
||||||
@ -53,7 +48,6 @@ def root():
|
|||||||
"name": "Village Simulation API",
|
"name": "Village Simulation API",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"docs": "/docs",
|
"docs": "/docs",
|
||||||
"web_frontend": "/web/",
|
|
||||||
"status": "running",
|
"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():
|
def main():
|
||||||
"""Run the server."""
|
"""Run the server."""
|
||||||
uvicorn.run(
|
uvicorn.run(
|
||||||
|
|||||||
135
config.json
135
config.json
@ -1,73 +1,118 @@
|
|||||||
{
|
{
|
||||||
"agent_stats": {
|
"agent_stats": {
|
||||||
"max_energy": 50,
|
"max_energy": 60,
|
||||||
"max_hunger": 100,
|
"max_hunger": 100,
|
||||||
"max_thirst": 100,
|
"max_thirst": 100,
|
||||||
"max_heat": 100,
|
"max_heat": 100,
|
||||||
"start_energy": 50,
|
"max_faith": 100,
|
||||||
"start_hunger": 70,
|
"start_energy": 60,
|
||||||
"start_thirst": 75,
|
"start_hunger": 90,
|
||||||
|
"start_thirst": 90,
|
||||||
"start_heat": 100,
|
"start_heat": 100,
|
||||||
|
"start_faith": 45,
|
||||||
"energy_decay": 1,
|
"energy_decay": 1,
|
||||||
"hunger_decay": 2,
|
"hunger_decay": 1,
|
||||||
"thirst_decay": 3,
|
"thirst_decay": 2,
|
||||||
"heat_decay": 3,
|
"heat_decay": 2,
|
||||||
"critical_threshold": 0.25,
|
"faith_decay": 1,
|
||||||
|
"critical_threshold": 0.18,
|
||||||
"low_energy_threshold": 12
|
"low_energy_threshold": 12
|
||||||
},
|
},
|
||||||
"resources": {
|
"resources": {
|
||||||
"meat_decay": 10,
|
"meat_decay": 15,
|
||||||
"berries_decay": 6,
|
"berries_decay": 10,
|
||||||
"clothes_decay": 20,
|
"clothes_decay": 30,
|
||||||
"meat_hunger": 35,
|
"oil_decay": 0,
|
||||||
"meat_energy": 12,
|
"fuel_decay": 0,
|
||||||
"berries_hunger": 10,
|
"meat_hunger": 45,
|
||||||
"berries_thirst": 4,
|
"meat_energy": 15,
|
||||||
"water_thirst": 50,
|
"berries_hunger": 15,
|
||||||
"fire_heat": 20
|
"berries_thirst": 6,
|
||||||
|
"water_thirst": 60,
|
||||||
|
"fire_heat": 30,
|
||||||
|
"fuel_heat": 45,
|
||||||
|
"oil_energy": 0,
|
||||||
|
"fuel_energy": 12
|
||||||
},
|
},
|
||||||
"actions": {
|
"actions": {
|
||||||
"sleep_energy": 55,
|
"sleep_energy": 55,
|
||||||
"rest_energy": 12,
|
"rest_energy": 15,
|
||||||
"hunt_energy": -7,
|
"hunt_energy": -5,
|
||||||
"gather_energy": -3,
|
"gather_energy": -2,
|
||||||
"chop_wood_energy": -6,
|
"chop_wood_energy": -4,
|
||||||
"get_water_energy": -2,
|
"get_water_energy": -2,
|
||||||
"weave_energy": -6,
|
"weave_energy": -4,
|
||||||
"build_fire_energy": -4,
|
"build_fire_energy": -3,
|
||||||
"trade_energy": -1,
|
"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,
|
"chop_wood_success": 0.90,
|
||||||
"hunt_meat_min": 2,
|
"drill_oil_success": 0.70,
|
||||||
"hunt_meat_max": 5,
|
"hunt_meat_min": 3,
|
||||||
|
"hunt_meat_max": 6,
|
||||||
"hunt_hide_min": 0,
|
"hunt_hide_min": 0,
|
||||||
"hunt_hide_max": 2,
|
"hunt_hide_max": 2,
|
||||||
"gather_min": 2,
|
"gather_min": 3,
|
||||||
"gather_max": 4,
|
"gather_max": 6,
|
||||||
"chop_wood_min": 1,
|
"chop_wood_min": 2,
|
||||||
"chop_wood_max": 3
|
"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": {
|
"world": {
|
||||||
"width": 25,
|
"width": 50,
|
||||||
"height": 25,
|
"height": 50,
|
||||||
"initial_agents": 25,
|
"initial_agents": 100,
|
||||||
"day_steps": 10,
|
"day_steps": 10,
|
||||||
"night_steps": 1,
|
"night_steps": 1,
|
||||||
"inventory_slots": 12,
|
"inventory_slots": 14,
|
||||||
"starting_money": 80
|
"starting_money": 100,
|
||||||
|
"oil_fields_count": 5,
|
||||||
|
"temple_count": 5
|
||||||
},
|
},
|
||||||
"market": {
|
"market": {
|
||||||
"turns_before_discount": 15,
|
"turns_before_discount": 10,
|
||||||
"discount_rate": 0.12,
|
"discount_rate": 0.08,
|
||||||
"base_price_multiplier": 1.3
|
"base_price_multiplier": 1.15
|
||||||
},
|
},
|
||||||
"economy": {
|
"economy": {
|
||||||
"energy_to_money_ratio": 1.5,
|
"energy_to_money_ratio": 1.2,
|
||||||
"wealth_desire": 0.35,
|
"wealth_desire": 0.25,
|
||||||
"buy_efficiency_threshold": 0.75,
|
"buy_efficiency_threshold": 0.85,
|
||||||
"min_wealth_target": 50,
|
"min_wealth_target": 30,
|
||||||
"max_price_markup": 2.5,
|
"max_price_markup": 1.8,
|
||||||
"min_price_discount": 0.4
|
"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
|
import time
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass, field
|
||||||
from typing import Optional, Any
|
from typing import Optional, Any
|
||||||
|
|
||||||
import requests
|
import requests
|
||||||
@ -25,6 +28,15 @@ class SimulationState:
|
|||||||
is_running: bool
|
is_running: bool
|
||||||
recent_logs: list[dict]
|
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
|
@classmethod
|
||||||
def from_api_response(cls, data: dict) -> "SimulationState":
|
def from_api_response(cls, data: dict) -> "SimulationState":
|
||||||
"""Create from API response data."""
|
"""Create from API response data."""
|
||||||
@ -42,12 +54,75 @@ class SimulationState:
|
|||||||
mode=data.get("mode", "manual"),
|
mode=data.get("mode", "manual"),
|
||||||
is_running=data.get("is_running", False),
|
is_running=data.get("is_running", False),
|
||||||
recent_logs=data.get("recent_logs", []),
|
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]:
|
def get_living_agents(self) -> list[dict]:
|
||||||
"""Get only living agents."""
|
"""Get only living agents."""
|
||||||
return [a for a in self.agents if a.get("is_alive", False)]
|
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:
|
class SimulationClient:
|
||||||
"""HTTP client for the Village Simulation backend."""
|
"""HTTP client for the Village Simulation backend."""
|
||||||
@ -82,7 +157,7 @@ class SimulationClient:
|
|||||||
self.connected = True
|
self.connected = True
|
||||||
self._retry_count = 0
|
self._retry_count = 0
|
||||||
return response.json()
|
return response.json()
|
||||||
except RequestException as e:
|
except RequestException:
|
||||||
self._retry_count += 1
|
self._retry_count += 1
|
||||||
if self._retry_count >= self._max_retries:
|
if self._retry_count >= self._max_retries:
|
||||||
self.connected = False
|
self.connected = False
|
||||||
@ -107,7 +182,7 @@ class SimulationClient:
|
|||||||
if data:
|
if data:
|
||||||
self.last_state = SimulationState.from_api_response(data)
|
self.last_state = SimulationState.from_api_response(data)
|
||||||
return self.last_state
|
return self.last_state
|
||||||
return self.last_state # Return cached state if request failed
|
return self.last_state
|
||||||
|
|
||||||
def advance_turn(self) -> bool:
|
def advance_turn(self) -> bool:
|
||||||
"""Advance the simulation by one step."""
|
"""Advance the simulation by one step."""
|
||||||
@ -121,9 +196,9 @@ class SimulationClient:
|
|||||||
|
|
||||||
def initialize(
|
def initialize(
|
||||||
self,
|
self,
|
||||||
num_agents: int = 8,
|
num_agents: int = 100,
|
||||||
world_width: int = 20,
|
world_width: int = 30,
|
||||||
world_height: int = 20,
|
world_height: int = 30,
|
||||||
) -> bool:
|
) -> bool:
|
||||||
"""Initialize or reset the simulation."""
|
"""Initialize or reset the simulation."""
|
||||||
result = self._request("POST", "/control/initialize", json={
|
result = self._request("POST", "/control/initialize", json={
|
||||||
@ -177,4 +252,3 @@ class SimulationClient:
|
|||||||
"""Reset configuration to defaults."""
|
"""Reset configuration to defaults."""
|
||||||
result = self._request("POST", "/config/reset")
|
result = self._request("POST", "/config/reset")
|
||||||
return result is not None and result.get("success", False)
|
return result is not None and result.get("success", False)
|
||||||
|
|
||||||
|
|||||||
112
frontend/main.py
112
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 sys
|
||||||
import pygame
|
import pygame
|
||||||
@ -12,14 +15,13 @@ from frontend.renderer.stats_renderer import StatsRenderer
|
|||||||
|
|
||||||
|
|
||||||
# Window configuration
|
# Window configuration
|
||||||
WINDOW_WIDTH = 1200
|
WINDOW_TITLE = "Village Simulation - Economy, Religion & Diplomacy"
|
||||||
WINDOW_HEIGHT = 800
|
FPS = 60
|
||||||
WINDOW_TITLE = "Village Economy Simulation"
|
|
||||||
FPS = 30
|
|
||||||
|
|
||||||
# Layout configuration
|
# Layout ratios (will scale with screen)
|
||||||
TOP_PANEL_HEIGHT = 50
|
TOP_PANEL_HEIGHT_RATIO = 0.06
|
||||||
RIGHT_PANEL_WIDTH = 200
|
RIGHT_PANEL_WIDTH_RATIO = 0.22
|
||||||
|
BOTTOM_PANEL_HEIGHT_RATIO = 0.08
|
||||||
|
|
||||||
|
|
||||||
class VillageSimulationApp:
|
class VillageSimulationApp:
|
||||||
@ -30,31 +32,60 @@ class VillageSimulationApp:
|
|||||||
pygame.init()
|
pygame.init()
|
||||||
pygame.font.init()
|
pygame.font.init()
|
||||||
|
|
||||||
# Create window
|
# Get display info for fullscreen
|
||||||
self.screen = pygame.display.set_mode((WINDOW_WIDTH, WINDOW_HEIGHT))
|
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)
|
pygame.display.set_caption(WINDOW_TITLE)
|
||||||
|
|
||||||
|
# Hide mouse cursor briefly on startup
|
||||||
|
pygame.mouse.set_visible(True)
|
||||||
|
|
||||||
# Clock for FPS control
|
# Clock for FPS control
|
||||||
self.clock = pygame.time.Clock()
|
self.clock = pygame.time.Clock()
|
||||||
|
|
||||||
# Fonts
|
# Calculate layout dimensions
|
||||||
self.font = pygame.font.Font(None, 24)
|
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
|
# Network client
|
||||||
self.client = SimulationClient(server_url)
|
self.client = SimulationClient(server_url)
|
||||||
|
|
||||||
# Calculate map area
|
# Calculate map area (left side, between top and bottom panels)
|
||||||
self.map_rect = pygame.Rect(
|
self.map_rect = pygame.Rect(
|
||||||
0,
|
0,
|
||||||
TOP_PANEL_HEIGHT,
|
self.top_panel_height,
|
||||||
WINDOW_WIDTH - RIGHT_PANEL_WIDTH,
|
self.screen_width - self.right_panel_width,
|
||||||
WINDOW_HEIGHT - TOP_PANEL_HEIGHT,
|
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.map_renderer = MapRenderer(self.screen, self.map_rect)
|
||||||
self.agent_renderer = AgentRenderer(self.screen, self.map_renderer, self.font)
|
self.agent_renderer = AgentRenderer(self.screen, self.map_renderer, self.font_small)
|
||||||
self.ui_renderer = UIRenderer(self.screen, self.font)
|
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.settings_renderer = SettingsRenderer(self.screen)
|
||||||
self.stats_renderer = StatsRenderer(self.screen)
|
self.stats_renderer = StatsRenderer(self.screen)
|
||||||
|
|
||||||
@ -62,26 +93,25 @@ class VillageSimulationApp:
|
|||||||
self.state: SimulationState | None = None
|
self.state: SimulationState | None = None
|
||||||
self.running = True
|
self.running = True
|
||||||
self.hovered_agent: dict | None = None
|
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)
|
# Polling interval (ms)
|
||||||
self.last_poll_time = 0
|
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
|
# Setup settings callbacks
|
||||||
self._setup_settings_callbacks()
|
self._setup_settings_callbacks()
|
||||||
|
|
||||||
def _setup_settings_callbacks(self) -> None:
|
def _setup_settings_callbacks(self) -> None:
|
||||||
"""Set up callbacks for the settings panel."""
|
"""Set up callbacks for the settings panel."""
|
||||||
# Override the apply and reset callbacks
|
|
||||||
original_apply = self.settings_renderer._apply_config
|
original_apply = self.settings_renderer._apply_config
|
||||||
original_reset = self.settings_renderer._reset_config
|
original_reset = self.settings_renderer._reset_config
|
||||||
|
|
||||||
def apply_config():
|
def apply_config():
|
||||||
config = self.settings_renderer.get_config()
|
config = self.settings_renderer.get_config()
|
||||||
if self.client.update_config(config):
|
if self.client.update_config(config):
|
||||||
# Restart simulation with new config
|
num_agents = config.get("world", {}).get("initial_agents", 100)
|
||||||
if self.client.initialize():
|
if self.client.initialize(num_agents=num_agents):
|
||||||
self.state = self.client.get_state()
|
self.state = self.client.get_state()
|
||||||
self.settings_renderer.status_message = "Config applied & simulation restarted!"
|
self.settings_renderer.status_message = "Config applied & simulation restarted!"
|
||||||
self.settings_renderer.status_color = (80, 180, 100)
|
self.settings_renderer.status_color = (80, 180, 100)
|
||||||
@ -94,7 +124,6 @@ class VillageSimulationApp:
|
|||||||
|
|
||||||
def reset_config():
|
def reset_config():
|
||||||
if self.client.reset_config():
|
if self.client.reset_config():
|
||||||
# Reload config from server
|
|
||||||
config = self.client.get_config()
|
config = self.client.get_config()
|
||||||
if config:
|
if config:
|
||||||
self.settings_renderer.set_config(config)
|
self.settings_renderer.set_config(config)
|
||||||
@ -147,13 +176,12 @@ class VillageSimulationApp:
|
|||||||
# Advance one turn
|
# Advance one turn
|
||||||
if self.client.connected and not self.settings_renderer.visible and not self.stats_renderer.visible:
|
if self.client.connected and not self.settings_renderer.visible and not self.stats_renderer.visible:
|
||||||
if self.client.advance_turn():
|
if self.client.advance_turn():
|
||||||
# Immediately fetch new state
|
|
||||||
self.state = self.client.get_state()
|
self.state = self.client.get_state()
|
||||||
|
|
||||||
elif event.key == pygame.K_r:
|
elif event.key == pygame.K_r:
|
||||||
# Reset simulation
|
# Reset simulation
|
||||||
if self.client.connected and not self.settings_renderer.visible and not self.stats_renderer.visible:
|
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.state = self.client.get_state()
|
||||||
self.stats_renderer.clear_history()
|
self.stats_renderer.clear_history()
|
||||||
self._last_turn = -1
|
self._last_turn = -1
|
||||||
@ -177,6 +205,10 @@ class VillageSimulationApp:
|
|||||||
self._load_config()
|
self._load_config()
|
||||||
self.settings_renderer.toggle()
|
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:
|
def _handle_mouse_motion(self, event: pygame.event.Event) -> None:
|
||||||
"""Handle mouse motion for agent hover detection."""
|
"""Handle mouse motion for agent hover detection."""
|
||||||
if not self.state or self.settings_renderer.visible:
|
if not self.state or self.settings_renderer.visible:
|
||||||
@ -190,7 +222,7 @@ class VillageSimulationApp:
|
|||||||
if not self.map_rect.collidepoint(mouse_pos):
|
if not self.map_rect.collidepoint(mouse_pos):
|
||||||
return
|
return
|
||||||
|
|
||||||
# Check each agent
|
# Check each agent (only check visible ones for performance)
|
||||||
for agent in self.state.agents:
|
for agent in self.state.agents:
|
||||||
if not agent.get("is_alive", False):
|
if not agent.get("is_alive", False):
|
||||||
continue
|
continue
|
||||||
@ -242,8 +274,8 @@ class VillageSimulationApp:
|
|||||||
|
|
||||||
def draw(self) -> None:
|
def draw(self) -> None:
|
||||||
"""Draw all elements."""
|
"""Draw all elements."""
|
||||||
# Clear screen
|
# Clear screen with dark background
|
||||||
self.screen.fill((30, 35, 45))
|
self.screen.fill((15, 17, 23))
|
||||||
|
|
||||||
if self.state:
|
if self.state:
|
||||||
# Draw map
|
# Draw map
|
||||||
@ -252,7 +284,7 @@ class VillageSimulationApp:
|
|||||||
# Draw agents
|
# Draw agents
|
||||||
self.agent_renderer.draw(self.state)
|
self.agent_renderer.draw(self.state)
|
||||||
|
|
||||||
# Draw UI
|
# Draw UI panels
|
||||||
self.ui_renderer.draw(self.state)
|
self.ui_renderer.draw(self.state)
|
||||||
|
|
||||||
# Draw agent tooltip if hovering
|
# Draw agent tooltip if hovering
|
||||||
@ -272,16 +304,22 @@ class VillageSimulationApp:
|
|||||||
|
|
||||||
# Draw hints at bottom
|
# Draw hints at bottom
|
||||||
if not self.settings_renderer.visible and not self.stats_renderer.visible:
|
if not self.settings_renderer.visible and not self.stats_renderer.visible:
|
||||||
hint_font = pygame.font.Font(None, 18)
|
hint_font = pygame.font.Font(None, 16)
|
||||||
hint = hint_font.render("S: Settings | G: Statistics & Graphs", True, (100, 100, 120))
|
hint = hint_font.render(
|
||||||
self.screen.blit(hint, (5, self.screen.get_height() - 20))
|
"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
|
# Update display
|
||||||
pygame.display.flip()
|
pygame.display.flip()
|
||||||
|
|
||||||
def run(self) -> None:
|
def run(self) -> None:
|
||||||
"""Main game loop."""
|
"""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...")
|
print("Connecting to backend at http://localhost:8000...")
|
||||||
|
|
||||||
# Try to connect initially
|
# Try to connect initially
|
||||||
@ -293,10 +331,11 @@ class VillageSimulationApp:
|
|||||||
|
|
||||||
print("\nControls:")
|
print("\nControls:")
|
||||||
print(" SPACE - Advance turn")
|
print(" SPACE - Advance turn")
|
||||||
print(" R - Reset simulation")
|
print(" R - Reset simulation (100 agents)")
|
||||||
print(" M - Toggle auto/manual mode")
|
print(" M - Toggle auto/manual mode")
|
||||||
print(" S - Open settings")
|
print(" S - Open settings")
|
||||||
print(" G - Open statistics & graphs")
|
print(" G - Open statistics & graphs")
|
||||||
|
print(" F - Toggle fullscreen")
|
||||||
print(" ESC - Close panel / Quit")
|
print(" ESC - Close panel / Quit")
|
||||||
print()
|
print()
|
||||||
|
|
||||||
@ -311,7 +350,6 @@ class VillageSimulationApp:
|
|||||||
|
|
||||||
def main():
|
def main():
|
||||||
"""Entry point for the frontend application."""
|
"""Entry point for the frontend application."""
|
||||||
# Get server URL from command line if provided
|
|
||||||
server_url = "http://localhost:8000"
|
server_url = "http://localhost:8000"
|
||||||
if len(sys.argv) > 1:
|
if len(sys.argv) > 1:
|
||||||
server_url = sys.argv[1]
|
server_url = sys.argv[1]
|
||||||
|
|||||||
@ -4,6 +4,13 @@ from .map_renderer import MapRenderer
|
|||||||
from .agent_renderer import AgentRenderer
|
from .agent_renderer import AgentRenderer
|
||||||
from .ui_renderer import UIRenderer
|
from .ui_renderer import UIRenderer
|
||||||
from .settings_renderer import SettingsRenderer
|
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 math
|
||||||
import pygame
|
import pygame
|
||||||
@ -9,42 +12,53 @@ if TYPE_CHECKING:
|
|||||||
from frontend.renderer.map_renderer import MapRenderer
|
from frontend.renderer.map_renderer import MapRenderer
|
||||||
|
|
||||||
|
|
||||||
# Profession colors (villager is the default now)
|
# Faction colors - matches backend FactionType
|
||||||
PROFESSION_COLORS = {
|
FACTION_COLORS = {
|
||||||
"villager": (100, 140, 180), # Blue-gray for generic villager
|
"northlands": (100, 160, 220), # Ice blue
|
||||||
"hunter": (180, 80, 80), # Red
|
"riverfolk": (70, 160, 180), # River teal
|
||||||
"gatherer": (80, 160, 80), # Green
|
"forestkin": (90, 160, 80), # Forest green
|
||||||
"woodcutter": (139, 90, 43), # Brown
|
"mountaineer": (150, 120, 90), # Mountain brown
|
||||||
"crafter": (160, 120, 200), # Purple
|
"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
|
||||||
CORPSE_COLOR = (60, 60, 60) # Dark gray
|
CORPSE_COLOR = (50, 50, 55)
|
||||||
|
|
||||||
# Status bar colors
|
# Action symbols (simplified for performance)
|
||||||
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 = {
|
ACTION_SYMBOLS = {
|
||||||
"hunt": "🏹",
|
"hunt": "⚔",
|
||||||
"gather": "🍇",
|
"gather": "◆",
|
||||||
"chop_wood": "🪓",
|
"chop_wood": "▲",
|
||||||
"get_water": "💧",
|
"get_water": "◎",
|
||||||
"weave": "🧵",
|
"weave": "⊕",
|
||||||
"build_fire": "🔥",
|
"build_fire": "◈",
|
||||||
"trade": "💰",
|
"trade": "$",
|
||||||
"rest": "💤",
|
"rest": "○",
|
||||||
"sleep": "😴",
|
"sleep": "◐",
|
||||||
"consume": "🍖",
|
"consume": "●",
|
||||||
"dead": "💀",
|
"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 = {
|
ACTION_LETTERS = {
|
||||||
"hunt": "H",
|
"hunt": "H",
|
||||||
"gather": "G",
|
"gather": "G",
|
||||||
@ -56,12 +70,20 @@ ACTION_LETTERS = {
|
|||||||
"rest": "R",
|
"rest": "R",
|
||||||
"sleep": "Z",
|
"sleep": "Z",
|
||||||
"consume": "E",
|
"consume": "E",
|
||||||
|
"drill_oil": "O",
|
||||||
|
"refine": "U",
|
||||||
|
"pray": "P",
|
||||||
|
"preach": "!",
|
||||||
|
"negotiate": "N",
|
||||||
|
"declare_war": "!",
|
||||||
|
"make_peace": "+",
|
||||||
|
"burn_fuel": "B",
|
||||||
"dead": "X",
|
"dead": "X",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
class AgentRenderer:
|
class AgentRenderer:
|
||||||
"""Renders agents on the map with movement and action indicators."""
|
"""Renders agents on the map with faction/religion indicators."""
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
@ -72,89 +94,116 @@ class AgentRenderer:
|
|||||||
self.screen = screen
|
self.screen = screen
|
||||||
self.map_renderer = map_renderer
|
self.map_renderer = map_renderer
|
||||||
self.font = font
|
self.font = font
|
||||||
self.small_font = pygame.font.Font(None, 16)
|
self.small_font = pygame.font.Font(None, 14)
|
||||||
self.action_font = pygame.font.Font(None, 20)
|
self.action_font = pygame.font.Font(None, 16)
|
||||||
|
self.tooltip_font = pygame.font.Font(None, 18)
|
||||||
|
|
||||||
# Animation state
|
# Animation state
|
||||||
self.animation_tick = 0
|
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]:
|
def _get_agent_color(self, agent: dict) -> tuple[int, int, int]:
|
||||||
"""Get the color for an agent based on state."""
|
"""Get the main color for an agent (faction-based)."""
|
||||||
# Corpses are dark gray
|
|
||||||
if agent.get("is_corpse", False) or not agent.get("is_alive", True):
|
if agent.get("is_corpse", False) or not agent.get("is_alive", True):
|
||||||
return CORPSE_COLOR
|
return CORPSE_COLOR
|
||||||
|
|
||||||
profession = agent.get("profession", "villager")
|
base_color = self._get_faction_color(agent)
|
||||||
base_color = PROFESSION_COLORS.get(profession, (100, 140, 180))
|
|
||||||
|
|
||||||
if not agent.get("can_act", True):
|
if not agent.get("can_act", True):
|
||||||
# Slightly dimmed for exhausted agents
|
# Dimmed for exhausted agents
|
||||||
return tuple(int(c * 0.7) for c in base_color)
|
return tuple(int(c * 0.6) for c in base_color)
|
||||||
|
|
||||||
return base_color
|
return base_color
|
||||||
|
|
||||||
def _draw_status_bar(
|
def _draw_mini_bar(
|
||||||
self,
|
self,
|
||||||
x: int,
|
x: int,
|
||||||
y: int,
|
y: int,
|
||||||
width: int,
|
width: int,
|
||||||
height: int,
|
height: int,
|
||||||
value: int,
|
value: float,
|
||||||
max_value: int,
|
max_value: float,
|
||||||
color: tuple[int, int, int],
|
color: tuple[int, int, int],
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Draw a single status bar."""
|
"""Draw a tiny status bar."""
|
||||||
|
if max_value <= 0:
|
||||||
|
return
|
||||||
|
|
||||||
# Background
|
# 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
|
||||||
fill_width = int((value / max_value) * width) if max_value > 0 else 0
|
fill_width = int((value / max_value) * width)
|
||||||
if fill_width > 0:
|
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
|
def _draw_status_bars(
|
||||||
pygame.draw.rect(self.screen, (80, 80, 80), (x, y, width, height), 1)
|
self,
|
||||||
|
agent: dict,
|
||||||
def _draw_status_bars(self, agent: dict, center_x: int, center_y: int, size: int) -> None:
|
center_x: int,
|
||||||
"""Draw status bars below the agent."""
|
center_y: int,
|
||||||
|
size: int
|
||||||
|
) -> None:
|
||||||
|
"""Draw compact status bars below the agent."""
|
||||||
stats = agent.get("stats", {})
|
stats = agent.get("stats", {})
|
||||||
|
|
||||||
bar_width = size + 10
|
bar_width = size + 6
|
||||||
bar_height = 3
|
bar_height = 2
|
||||||
bar_spacing = 4
|
bar_spacing = 3
|
||||||
start_y = center_y + size // 2 + 4
|
start_y = center_y + size // 2 + 3
|
||||||
|
|
||||||
bars = [
|
bars = [
|
||||||
("energy", stats.get("energy", 0), stats.get("max_energy", 100)),
|
(stats.get("energy", 0), stats.get("max_energy", 100), (220, 200, 80)),
|
||||||
("hunger", stats.get("hunger", 0), stats.get("max_hunger", 100)),
|
(stats.get("hunger", 0), stats.get("max_hunger", 100), (200, 130, 80)),
|
||||||
("thirst", stats.get("thirst", 0), stats.get("max_thirst", 50)),
|
(stats.get("thirst", 0), stats.get("max_thirst", 100), (80, 160, 200)),
|
||||||
("heat", stats.get("heat", 0), stats.get("max_heat", 100)),
|
|
||||||
]
|
]
|
||||||
|
|
||||||
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
|
bar_y = start_y + i * bar_spacing
|
||||||
self._draw_status_bar(
|
self._draw_mini_bar(
|
||||||
center_x - bar_width // 2,
|
center_x - bar_width // 2,
|
||||||
bar_y,
|
bar_y,
|
||||||
bar_width,
|
bar_width,
|
||||||
bar_height,
|
bar_height,
|
||||||
value,
|
value,
|
||||||
max_value,
|
max_value,
|
||||||
BAR_COLORS[stat_name],
|
color,
|
||||||
)
|
)
|
||||||
|
|
||||||
def _draw_action_indicator(
|
def _draw_action_bubble(
|
||||||
self,
|
self,
|
||||||
agent: dict,
|
agent: dict,
|
||||||
center_x: int,
|
center_x: int,
|
||||||
center_y: int,
|
center_y: int,
|
||||||
agent_size: int,
|
agent_size: int,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Draw action indicator above the agent."""
|
"""Draw action indicator bubble above agent."""
|
||||||
current_action = agent.get("current_action", {})
|
current_action = agent.get("current_action", {})
|
||||||
action_type = current_action.get("action_type", "")
|
action_type = current_action.get("action_type", "")
|
||||||
is_moving = current_action.get("is_moving", False)
|
|
||||||
message = current_action.get("message", "")
|
|
||||||
|
|
||||||
if not action_type:
|
if not action_type:
|
||||||
return
|
return
|
||||||
@ -162,171 +211,192 @@ class AgentRenderer:
|
|||||||
# Get action symbol
|
# Get action symbol
|
||||||
symbol = ACTION_LETTERS.get(action_type, "?")
|
symbol = ACTION_LETTERS.get(action_type, "?")
|
||||||
|
|
||||||
# Draw action bubble above agent
|
# Position above agent
|
||||||
bubble_y = center_y - agent_size // 2 - 20
|
bubble_y = center_y - agent_size // 2 - 12
|
||||||
|
|
||||||
# Animate if moving
|
# Animate if moving
|
||||||
|
is_moving = current_action.get("is_moving", False)
|
||||||
if is_moving:
|
if is_moving:
|
||||||
# Bouncing animation
|
offset = int(2 * math.sin(self.animation_tick * 0.3))
|
||||||
offset = int(3 * math.sin(self.animation_tick * 0.3))
|
|
||||||
bubble_y += offset
|
bubble_y += offset
|
||||||
|
|
||||||
# Draw bubble background
|
# Draw small bubble
|
||||||
bubble_width = 22
|
bubble_w, bubble_h = 14, 12
|
||||||
bubble_height = 18
|
|
||||||
bubble_rect = pygame.Rect(
|
bubble_rect = pygame.Rect(
|
||||||
center_x - bubble_width // 2,
|
center_x - bubble_w // 2,
|
||||||
bubble_y - bubble_height // 2,
|
bubble_y - bubble_h // 2,
|
||||||
bubble_width,
|
bubble_w,
|
||||||
bubble_height,
|
bubble_h,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Color based on action success/failure
|
# Color based on action type
|
||||||
if "Failed" in message:
|
if action_type in ["pray", "preach"]:
|
||||||
bg_color = (120, 60, 60)
|
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)
|
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:
|
elif is_moving:
|
||||||
bg_color = (60, 80, 120)
|
bg_color = (50, 60, 80)
|
||||||
border_color = (100, 140, 200)
|
border_color = (100, 140, 200)
|
||||||
else:
|
else:
|
||||||
bg_color = (50, 70, 50)
|
bg_color = (40, 55, 45)
|
||||||
border_color = (80, 140, 80)
|
border_color = (80, 130, 90)
|
||||||
|
|
||||||
pygame.draw.rect(self.screen, bg_color, bubble_rect, 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=4)
|
pygame.draw.rect(self.screen, border_color, bubble_rect, 1, border_radius=3)
|
||||||
|
|
||||||
# Draw action letter
|
# Draw symbol
|
||||||
text = self.action_font.render(symbol, True, (255, 255, 255))
|
text = self.action_font.render(symbol, True, (230, 230, 230))
|
||||||
text_rect = text.get_rect(center=(center_x, bubble_y))
|
text_rect = text.get_rect(center=(center_x, bubble_y))
|
||||||
self.screen.blit(text, text_rect)
|
self.screen.blit(text, text_rect)
|
||||||
|
|
||||||
# Draw movement trail if moving
|
def _draw_religion_indicator(
|
||||||
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(
|
|
||||||
self,
|
self,
|
||||||
agent: dict,
|
agent: dict,
|
||||||
center_x: int,
|
center_x: int,
|
||||||
center_y: int,
|
center_y: int,
|
||||||
agent_size: int,
|
agent_size: int,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Draw the last action result as floating text."""
|
"""Draw a small religion indicator (faith glow)."""
|
||||||
result = agent.get("last_action_result", "")
|
faith = agent.get("faith", 50)
|
||||||
if not result:
|
religion_color = self._get_religion_color(agent)
|
||||||
return
|
|
||||||
|
|
||||||
# Truncate long messages
|
# Only show for agents with significant faith
|
||||||
if len(result) > 25:
|
if faith > 70:
|
||||||
result = result[:22] + "..."
|
# 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
|
# Small religion dot indicator
|
||||||
text_y = center_y + agent_size // 2 + 22
|
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))
|
def _draw_war_indicator(self, agent: dict, center_x: int, center_y: int) -> None:
|
||||||
text_rect = text.get_rect(center=(center_x, text_y))
|
"""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
|
if at_war:
|
||||||
bg_rect = text_rect.inflate(4, 2)
|
# Red war indicator
|
||||||
pygame.draw.rect(self.screen, (30, 30, 40, 180), bg_rect)
|
pulse = 0.5 + 0.5 * math.sin(self.animation_tick * 0.15)
|
||||||
|
war_color = (int(200 * pulse), int(50 * pulse), int(50 * pulse))
|
||||||
self.screen.blit(text, text_rect)
|
pygame.draw.circle(
|
||||||
|
self.screen, war_color,
|
||||||
|
(center_x - 6, center_y - 6),
|
||||||
|
3,
|
||||||
|
)
|
||||||
|
|
||||||
def draw(self, state: "SimulationState") -> None:
|
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
|
self.animation_tick += 1
|
||||||
|
|
||||||
cell_w, cell_h = self.map_renderer.get_cell_size()
|
cell_w, cell_h = self.map_renderer.get_cell_size()
|
||||||
agent_size = min(cell_w, cell_h) - 8
|
agent_size = min(cell_w, cell_h) - 6
|
||||||
agent_size = max(10, min(agent_size, 30)) # Clamp size
|
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:
|
for agent in state.agents:
|
||||||
is_corpse = agent.get("is_corpse", False)
|
if agent.get("is_corpse", False):
|
||||||
is_alive = agent.get("is_alive", True)
|
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})
|
pos = agent.get("position", {"x": 0, "y": 0})
|
||||||
screen_x, screen_y = self.map_renderer.grid_to_screen(pos["x"], pos["y"])
|
screen_x, screen_y = self.map_renderer.grid_to_screen(pos["x"], pos["y"])
|
||||||
|
|
||||||
if is_corpse:
|
# Religion glow (full detail only)
|
||||||
# Draw corpse with death indicator
|
if self.detail_level >= 2:
|
||||||
self._draw_corpse(agent, screen_x, screen_y, agent_size)
|
self._draw_religion_indicator(agent, screen_x, screen_y, agent_size)
|
||||||
continue
|
|
||||||
|
|
||||||
if not is_alive:
|
# Action bubble (basic+ detail)
|
||||||
continue
|
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)
|
# Main agent circle with faction color
|
||||||
self._draw_action_indicator(agent, screen_x, screen_y, agent_size)
|
|
||||||
|
|
||||||
# Draw agent circle
|
|
||||||
color = self._get_agent_color(agent)
|
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", {})
|
current_action = agent.get("current_action", {})
|
||||||
is_moving = current_action.get("is_moving", False)
|
is_moving = current_action.get("is_moving", False)
|
||||||
|
|
||||||
if is_moving:
|
if is_moving:
|
||||||
# Pulsing border when moving
|
pulse = int(180 + 75 * math.sin(self.animation_tick * 0.2))
|
||||||
pulse = int(127 + 127 * math.sin(self.animation_tick * 0.2))
|
|
||||||
border_color = (pulse, pulse, 255)
|
border_color = (pulse, pulse, 255)
|
||||||
elif agent.get("can_act"):
|
elif agent.get("can_act"):
|
||||||
border_color = (255, 255, 255)
|
border_color = (200, 200, 210)
|
||||||
else:
|
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)
|
money = agent.get("money", 0)
|
||||||
if money > 0:
|
if money > 50:
|
||||||
coin_x = screen_x + agent_size // 2 - 4
|
coin_x = screen_x + agent_size // 2 - 2
|
||||||
coin_y = screen_y - agent_size // 2 - 4
|
coin_y = screen_y - agent_size // 2 - 2
|
||||||
pygame.draw.circle(self.screen, (255, 215, 0), (coin_x, coin_y), 4)
|
pygame.draw.circle(self.screen, (255, 215, 0), (coin_x, coin_y), 3)
|
||||||
pygame.draw.circle(self.screen, (200, 160, 0), (coin_x, coin_y), 4, 1)
|
|
||||||
|
|
||||||
# Draw "V" for villager
|
# War indicator
|
||||||
text = self.small_font.render("V", True, (255, 255, 255))
|
if self.detail_level >= 1:
|
||||||
text_rect = text.get_rect(center=(screen_x, screen_y))
|
self._draw_war_indicator(agent, screen_x, screen_y)
|
||||||
self.screen.blit(text, text_rect)
|
|
||||||
|
|
||||||
# Draw status bars
|
# Status bars (basic+ detail)
|
||||||
|
if self.detail_level >= 1:
|
||||||
self._draw_status_bars(agent, screen_x, screen_y, agent_size)
|
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)
|
|
||||||
|
|
||||||
def _draw_corpse(
|
def _draw_corpse(
|
||||||
self,
|
self,
|
||||||
agent: dict,
|
agent: dict,
|
||||||
@ -334,97 +404,117 @@ class AgentRenderer:
|
|||||||
center_y: int,
|
center_y: int,
|
||||||
agent_size: int,
|
agent_size: int,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Draw a corpse with death reason displayed."""
|
"""Draw a corpse marker."""
|
||||||
# Draw corpse circle (dark gray)
|
# Simple X marker
|
||||||
pygame.draw.circle(self.screen, CORPSE_COLOR, (center_x, center_y), agent_size // 2)
|
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
|
# X symbol
|
||||||
pygame.draw.circle(self.screen, (150, 50, 50), (center_x, center_y), agent_size // 2, 2)
|
half = agent_size // 4
|
||||||
|
pygame.draw.line(
|
||||||
# Draw skull symbol
|
self.screen, (120, 60, 60),
|
||||||
text = self.action_font.render("X", True, (180, 80, 80))
|
(center_x - half, center_y - half),
|
||||||
text_rect = text.get_rect(center=(center_x, center_y))
|
(center_x + half, center_y + half),
|
||||||
self.screen.blit(text, text_rect)
|
1,
|
||||||
|
)
|
||||||
# Draw death reason above corpse
|
pygame.draw.line(
|
||||||
death_reason = agent.get("death_reason", "unknown")
|
self.screen, (120, 60, 60),
|
||||||
name = agent.get("name", "Unknown")
|
(center_x + half, center_y - half),
|
||||||
|
(center_x - half, center_y + half),
|
||||||
# Death indicator bubble
|
1,
|
||||||
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)
|
|
||||||
|
|
||||||
def draw_agent_tooltip(self, agent: dict, mouse_pos: tuple[int, int]) -> None:
|
def draw_agent_tooltip(self, agent: dict, mouse_pos: tuple[int, int]) -> None:
|
||||||
"""Draw a tooltip for an agent when hovered."""
|
"""Draw a detailed tooltip for hovered agent."""
|
||||||
# Build tooltip text
|
lines = []
|
||||||
lines = [
|
|
||||||
agent.get("name", "Unknown"),
|
# Name and faction
|
||||||
f"Profession: {agent.get('profession', '?').capitalize()}",
|
name = agent.get("name", "Unknown")
|
||||||
f"Money: {agent.get('money', 0)} coins",
|
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
|
||||||
current_action = agent.get("current_action", {})
|
current_action = agent.get("current_action", {})
|
||||||
action_type = current_action.get("action_type", "")
|
action_type = current_action.get("action_type", "")
|
||||||
if 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("")
|
||||||
|
lines.append(f"Action: {action_type.replace('_', ' ').title()}")
|
||||||
|
if current_action.get("is_moving"):
|
||||||
|
lines.append(" (moving)")
|
||||||
|
|
||||||
lines.append("Stats:")
|
# Inventory summary
|
||||||
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 = agent.get("inventory", [])
|
inventory = agent.get("inventory", [])
|
||||||
if inventory:
|
if inventory:
|
||||||
lines.append("")
|
lines.append("")
|
||||||
lines.append("Inventory:")
|
lines.append("Inventory:")
|
||||||
for item in inventory[:5]:
|
for item in inventory[:4]:
|
||||||
lines.append(f" {item.get('type', '?')}: {item.get('quantity', 0)}")
|
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
|
# Calculate size
|
||||||
last_result = agent.get("last_action_result", "")
|
|
||||||
if last_result:
|
|
||||||
lines.append("")
|
|
||||||
lines.append(f"Last: {last_result[:35]}")
|
|
||||||
|
|
||||||
# Calculate tooltip size
|
|
||||||
line_height = 16
|
line_height = 16
|
||||||
max_width = max(self.small_font.size(line)[0] for line in lines) + 20
|
max_width = max(self.tooltip_font.size(line)[0] for line in lines if line) + 24
|
||||||
height = len(lines) * line_height + 10
|
height = len(lines) * line_height + 16
|
||||||
|
|
||||||
# Position tooltip near mouse but not off screen
|
# Position
|
||||||
x = min(mouse_pos[0] + 15, self.screen.get_width() - max_width - 5)
|
x = min(mouse_pos[0] + 15, self.screen.get_width() - max_width - 10)
|
||||||
y = min(mouse_pos[1] + 15, self.screen.get_height() - height - 5)
|
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)
|
tooltip_rect = pygame.Rect(x, y, max_width, height)
|
||||||
pygame.draw.rect(self.screen, (30, 30, 40), tooltip_rect)
|
pygame.draw.rect(self.screen, (25, 28, 35), tooltip_rect, border_radius=6)
|
||||||
pygame.draw.rect(self.screen, (100, 100, 120), tooltip_rect, 1)
|
|
||||||
|
# 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
|
# Draw text
|
||||||
for i, line in enumerate(lines):
|
for i, line in enumerate(lines):
|
||||||
text = self.small_font.render(line, True, (220, 220, 220))
|
if not line:
|
||||||
self.screen.blit(text, (x + 10, y + 5 + i * line_height))
|
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
|
import pygame
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
@ -7,29 +12,58 @@ if TYPE_CHECKING:
|
|||||||
from frontend.client import SimulationState
|
from frontend.client import SimulationState
|
||||||
|
|
||||||
|
|
||||||
# Color palette
|
# Color palette - Cyberpunk dark theme
|
||||||
class Colors:
|
class Colors:
|
||||||
# Background colors
|
# Background colors
|
||||||
DAY_BG = (180, 200, 160) # Soft green for day
|
DAY_BG = (28, 35, 42)
|
||||||
NIGHT_BG = (40, 45, 60) # Dark blue for night
|
NIGHT_BG = (12, 14, 20)
|
||||||
GRID_LINE = (120, 140, 110) # Subtle grid lines
|
|
||||||
GRID_LINE_NIGHT = (60, 65, 80)
|
|
||||||
|
|
||||||
# Terrain features (for visual variety)
|
# Terrain
|
||||||
GRASS_LIGHT = (160, 190, 140)
|
GRASS_LIGHT = (32, 45, 38)
|
||||||
GRASS_DARK = (140, 170, 120)
|
GRASS_DARK = (26, 38, 32)
|
||||||
WATER_SPOT = (100, 140, 180)
|
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:
|
class MapRenderer:
|
||||||
"""Renders the map/terrain background."""
|
"""Renders the map/terrain background with special locations."""
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
screen: pygame.Surface,
|
screen: pygame.Surface,
|
||||||
map_rect: pygame.Rect,
|
map_rect: pygame.Rect,
|
||||||
world_width: int = 20,
|
world_width: int = 30,
|
||||||
world_height: int = 20,
|
world_height: int = 30,
|
||||||
):
|
):
|
||||||
self.screen = screen
|
self.screen = screen
|
||||||
self.map_rect = map_rect
|
self.map_rect = map_rect
|
||||||
@ -38,24 +72,40 @@ class MapRenderer:
|
|||||||
self._cell_width = map_rect.width / world_width
|
self._cell_width = map_rect.width / world_width
|
||||||
self._cell_height = map_rect.height / world_height
|
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()
|
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]]:
|
def _generate_terrain(self) -> list[list[int]]:
|
||||||
"""Generate simple terrain variation (0 = light, 1 = dark, 2 = water)."""
|
"""Generate terrain variation using noise-like pattern."""
|
||||||
import random
|
random.seed(42) # Consistent terrain
|
||||||
terrain = []
|
terrain = []
|
||||||
|
|
||||||
for y in range(self.world_height):
|
for y in range(self.world_height):
|
||||||
row = []
|
row = []
|
||||||
for x in range(self.world_width):
|
for x in range(self.world_width):
|
||||||
# Simple pattern: mostly grass with occasional water spots
|
# Create organic-looking patterns
|
||||||
if random.random() < 0.05:
|
noise = (
|
||||||
row.append(2) # Water spot
|
math.sin(x * 0.3) * math.cos(y * 0.3) +
|
||||||
elif (x + y) % 3 == 0:
|
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
|
row.append(1) # Dark grass
|
||||||
|
elif noise < -0.5:
|
||||||
|
row.append(3) # Accent grass
|
||||||
else:
|
else:
|
||||||
row.append(0) # Light grass
|
row.append(0) # Light grass
|
||||||
terrain.append(row)
|
terrain.append(row)
|
||||||
|
|
||||||
return terrain
|
return terrain
|
||||||
|
|
||||||
def update_dimensions(self, world_width: int, world_height: int) -> None:
|
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_width = self.map_rect.width / world_width
|
||||||
self._cell_height = self.map_rect.height / world_height
|
self._cell_height = self.map_rect.height / world_height
|
||||||
self._terrain_cache = self._generate_terrain()
|
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]:
|
def grid_to_screen(self, grid_x: int, grid_y: int) -> tuple[int, int]:
|
||||||
"""Convert grid coordinates to screen coordinates (center of cell)."""
|
"""Convert grid coordinates to screen coordinates (center of cell)."""
|
||||||
@ -77,70 +128,212 @@ class MapRenderer:
|
|||||||
"""Get the size of a single cell."""
|
"""Get the size of a single cell."""
|
||||||
return int(self._cell_width), int(self._cell_height)
|
return int(self._cell_width), int(self._cell_height)
|
||||||
|
|
||||||
def draw(self, state: "SimulationState") -> None:
|
def _render_terrain_surface(self, is_night: bool) -> pygame.Surface:
|
||||||
"""Draw the map background."""
|
"""Render terrain to a cached surface."""
|
||||||
is_night = state.time_of_day == "night"
|
surface = pygame.Surface((self.map_rect.width, self.map_rect.height))
|
||||||
|
|
||||||
# Fill background
|
# Fill background
|
||||||
bg_color = Colors.NIGHT_BG if is_night else Colors.DAY_BG
|
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
|
# Draw terrain cells
|
||||||
for y in range(self.world_height):
|
for y in range(self.world_height):
|
||||||
for x in range(self.world_width):
|
for x in range(self.world_width):
|
||||||
cell_rect = pygame.Rect(
|
cell_rect = pygame.Rect(
|
||||||
self.map_rect.left + x * self._cell_width,
|
x * self._cell_width,
|
||||||
self.map_rect.top + y * self._cell_height,
|
y * self._cell_height,
|
||||||
self._cell_width + 1, # +1 to avoid gaps
|
self._cell_width + 1,
|
||||||
self._cell_height + 1,
|
self._cell_height + 1,
|
||||||
)
|
)
|
||||||
|
|
||||||
terrain_type = self._terrain_cache[y][x]
|
terrain_type = self._terrain_cache[y][x]
|
||||||
|
|
||||||
if is_night:
|
if is_night:
|
||||||
# Darker colors at night
|
|
||||||
if terrain_type == 2:
|
if terrain_type == 2:
|
||||||
color = (60, 80, 110)
|
color = (15, 25, 40)
|
||||||
elif terrain_type == 1:
|
elif terrain_type == 1:
|
||||||
color = (35, 40, 55)
|
color = (18, 25, 22)
|
||||||
|
elif terrain_type == 3:
|
||||||
|
color = (22, 30, 26)
|
||||||
else:
|
else:
|
||||||
color = (45, 50, 65)
|
color = (20, 28, 24)
|
||||||
else:
|
else:
|
||||||
if terrain_type == 2:
|
if terrain_type == 2:
|
||||||
color = Colors.WATER_SPOT
|
color = Colors.WATER_SPOT
|
||||||
elif terrain_type == 1:
|
elif terrain_type == 1:
|
||||||
color = Colors.GRASS_DARK
|
color = Colors.GRASS_DARK
|
||||||
|
elif terrain_type == 3:
|
||||||
|
color = Colors.GRASS_ACCENT
|
||||||
else:
|
else:
|
||||||
color = Colors.GRASS_LIGHT
|
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
|
grid_color = Colors.GRID_LINE_NIGHT if is_night else Colors.GRID_LINE
|
||||||
|
|
||||||
# Vertical lines
|
|
||||||
for x in range(self.world_width + 1):
|
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(
|
pygame.draw.line(
|
||||||
self.screen,
|
surface,
|
||||||
grid_color,
|
grid_color,
|
||||||
(start_x, self.map_rect.top),
|
(start_x, 0),
|
||||||
(start_x, self.map_rect.bottom),
|
(start_x, self.map_rect.height),
|
||||||
1,
|
1,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Horizontal lines
|
|
||||||
for y in range(self.world_height + 1):
|
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(
|
pygame.draw.line(
|
||||||
self.screen,
|
surface,
|
||||||
grid_color,
|
grid_color,
|
||||||
(self.map_rect.left, start_y),
|
(0, start_y),
|
||||||
(self.map_rect.right, start_y),
|
(self.map_rect.width, start_y),
|
||||||
1,
|
1,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Draw border
|
return surface
|
||||||
border_color = (80, 90, 70) if not is_night else (80, 85, 100)
|
|
||||||
|
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)
|
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
|
import pygame
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
@ -7,76 +10,132 @@ from typing import Optional, Callable, Any
|
|||||||
|
|
||||||
class Colors:
|
class Colors:
|
||||||
"""Color palette for settings UI."""
|
"""Color palette for settings UI."""
|
||||||
BG = (25, 28, 35)
|
BG = (15, 17, 23)
|
||||||
PANEL_BG = (35, 40, 50)
|
PANEL_BG = (22, 26, 35)
|
||||||
PANEL_BORDER = (70, 80, 95)
|
PANEL_HEADER = (28, 33, 45)
|
||||||
TEXT_PRIMARY = (230, 230, 235)
|
PANEL_BORDER = (50, 60, 80)
|
||||||
TEXT_SECONDARY = (160, 165, 175)
|
TEXT_PRIMARY = (225, 228, 235)
|
||||||
TEXT_HIGHLIGHT = (100, 180, 255)
|
TEXT_SECONDARY = (140, 150, 165)
|
||||||
SLIDER_BG = (50, 55, 65)
|
TEXT_HIGHLIGHT = (100, 200, 255)
|
||||||
SLIDER_FILL = (80, 140, 200)
|
SLIDER_BG = (40, 45, 55)
|
||||||
|
SLIDER_FILL = (70, 130, 200)
|
||||||
SLIDER_HANDLE = (220, 220, 230)
|
SLIDER_HANDLE = (220, 220, 230)
|
||||||
BUTTON_BG = (60, 100, 160)
|
BUTTON_BG = (50, 90, 150)
|
||||||
BUTTON_HOVER = (80, 120, 180)
|
BUTTON_HOVER = (70, 110, 170)
|
||||||
BUTTON_TEXT = (255, 255, 255)
|
BUTTON_TEXT = (255, 255, 255)
|
||||||
SUCCESS = (80, 180, 100)
|
SUCCESS = (80, 180, 100)
|
||||||
WARNING = (200, 160, 80)
|
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
|
@dataclass
|
||||||
class SliderConfig:
|
class SliderConfig:
|
||||||
"""Configuration for a slider widget."""
|
"""Configuration for a slider widget."""
|
||||||
name: str
|
name: str
|
||||||
key: str # Dot-separated path like "agent_stats.max_energy"
|
key: str
|
||||||
min_val: float
|
min_val: float
|
||||||
max_val: float
|
max_val: float
|
||||||
step: float = 1.0
|
step: float = 1.0
|
||||||
is_int: bool = True
|
is_int: bool = True
|
||||||
description: str = ""
|
description: str = ""
|
||||||
|
section: str = "General"
|
||||||
|
|
||||||
|
|
||||||
# Define all configurable parameters with sliders
|
# Organized slider configs by section
|
||||||
SLIDER_CONFIGS = [
|
SLIDER_CONFIGS = [
|
||||||
# Agent Stats Section
|
# ═══════════════════════════════════════════════════════════════
|
||||||
SliderConfig("Max Energy", "agent_stats.max_energy", 50, 200, 10, True, "Maximum energy capacity"),
|
# WORLD SETTINGS
|
||||||
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("World Width", "world.width", 15, 60, 5, True, "Grid width", "World"),
|
||||||
SliderConfig("Max Heat", "agent_stats.max_heat", 50, 200, 10, True, "Maximum heat capacity"),
|
SliderConfig("World Height", "world.height", 15, 60, 5, True, "Grid height", "World"),
|
||||||
SliderConfig("Energy Decay", "agent_stats.energy_decay", 1, 10, 1, True, "Energy lost per turn"),
|
SliderConfig("Initial Agents", "world.initial_agents", 10, 200, 10, True, "Starting population", "World"),
|
||||||
SliderConfig("Hunger Decay", "agent_stats.hunger_decay", 1, 10, 1, True, "Hunger lost per turn"),
|
SliderConfig("Day Steps", "world.day_steps", 5, 20, 1, True, "Steps per day", "World"),
|
||||||
SliderConfig("Thirst Decay", "agent_stats.thirst_decay", 1, 10, 1, True, "Thirst lost per turn"),
|
SliderConfig("Starting Money", "world.starting_money", 50, 300, 25, True, "Initial coins", "World"),
|
||||||
SliderConfig("Heat Decay", "agent_stats.heat_decay", 1, 10, 1, True, "Heat lost per turn"),
|
SliderConfig("Inventory Slots", "world.inventory_slots", 6, 20, 2, True, "Max items", "World"),
|
||||||
SliderConfig("Critical %", "agent_stats.critical_threshold", 0.1, 0.5, 0.05, False, "Threshold for survival mode"),
|
|
||||||
|
|
||||||
# World Section
|
# ═══════════════════════════════════════════════════════════════
|
||||||
SliderConfig("World Width", "world.width", 10, 50, 5, True, "World grid width"),
|
# AGENT STATS
|
||||||
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("Max Energy", "agent_stats.max_energy", 30, 150, 10, True, "Energy capacity", "Stats"),
|
||||||
SliderConfig("Day Steps", "world.day_steps", 5, 20, 1, True, "Steps per day"),
|
SliderConfig("Max Hunger", "agent_stats.max_hunger", 50, 200, 10, True, "Hunger capacity", "Stats"),
|
||||||
SliderConfig("Inventory Slots", "world.inventory_slots", 5, 20, 1, True, "Agent inventory size"),
|
SliderConfig("Max Thirst", "agent_stats.max_thirst", 50, 200, 10, True, "Thirst capacity", "Stats"),
|
||||||
SliderConfig("Starting Money", "world.starting_money", 50, 500, 50, True, "Initial coins per agent"),
|
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"),
|
# ACTIONS
|
||||||
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("Hunt Energy", "actions.hunt_energy", -15, -3, 1, True, "Energy cost", "Actions"),
|
||||||
SliderConfig("Sleep Restore", "actions.sleep_energy", 30, 100, 10, True, "Energy restored by sleep"),
|
SliderConfig("Gather Energy", "actions.gather_energy", -8, -1, 1, True, "Energy cost", "Actions"),
|
||||||
SliderConfig("Rest Restore", "actions.rest_energy", 5, 30, 5, True, "Energy restored by rest"),
|
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"),
|
# RELIGION
|
||||||
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("Max Faith", "agent_stats.max_faith", 50, 150, 10, True, "Faith capacity", "Religion"),
|
||||||
SliderConfig("Water Thirst +", "resources.water_thirst", 20, 60, 5, True, "Thirst restored by water"),
|
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"),
|
# DIPLOMACY
|
||||||
SliderConfig("Discount Rate %", "market.discount_rate", 0.05, 0.30, 0.05, False, "Price reduction per period"),
|
# ═══════════════════════════════════════════════════════════════
|
||||||
|
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:
|
class Slider:
|
||||||
"""A slider widget for adjusting numeric values."""
|
"""A slider widget for adjusting numeric values."""
|
||||||
@ -97,17 +156,17 @@ class Slider:
|
|||||||
self.hovered = False
|
self.hovered = False
|
||||||
|
|
||||||
def set_value(self, value: float) -> None:
|
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))
|
self.value = max(self.config.min_val, min(self.config.max_val, value))
|
||||||
if self.config.is_int:
|
if self.config.is_int:
|
||||||
self.value = int(round(self.value))
|
self.value = int(round(self.value))
|
||||||
|
|
||||||
def get_value(self) -> Any:
|
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)
|
return int(self.value) if self.config.is_int else round(self.value, 2)
|
||||||
|
|
||||||
def handle_event(self, event: pygame.event.Event) -> bool:
|
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 event.type == pygame.MOUSEBUTTONDOWN:
|
||||||
if self._slider_area().collidepoint(event.pos):
|
if self._slider_area().collidepoint(event.pos):
|
||||||
self.dragging = True
|
self.dragging = True
|
||||||
@ -124,27 +183,23 @@ class Slider:
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
def _slider_area(self) -> pygame.Rect:
|
def _slider_area(self) -> pygame.Rect:
|
||||||
"""Get the actual slider track area."""
|
"""Get slider track area."""
|
||||||
return pygame.Rect(
|
return pygame.Rect(
|
||||||
self.rect.x + 120, # Leave space for label
|
self.rect.x + 130,
|
||||||
self.rect.y + 15,
|
self.rect.y + 12,
|
||||||
self.rect.width - 180, # Leave space for value display
|
self.rect.width - 200,
|
||||||
20,
|
16,
|
||||||
)
|
)
|
||||||
|
|
||||||
def _update_from_mouse(self, mouse_x: int) -> bool:
|
def _update_from_mouse(self, mouse_x: int) -> bool:
|
||||||
"""Update value based on mouse position."""
|
"""Update value from mouse."""
|
||||||
slider_area = self._slider_area()
|
slider_area = self._slider_area()
|
||||||
|
|
||||||
# Calculate position as 0-1
|
|
||||||
rel_x = mouse_x - slider_area.x
|
rel_x = mouse_x - slider_area.x
|
||||||
ratio = max(0, min(1, rel_x / slider_area.width))
|
ratio = max(0, min(1, rel_x / slider_area.width))
|
||||||
|
|
||||||
# Calculate value
|
|
||||||
range_val = self.config.max_val - self.config.min_val
|
range_val = self.config.max_val - self.config.min_val
|
||||||
new_value = self.config.min_val + ratio * range_val
|
new_value = self.config.min_val + ratio * range_val
|
||||||
|
|
||||||
# Apply step
|
|
||||||
if self.config.step > 0:
|
if self.config.step > 0:
|
||||||
new_value = round(new_value / self.config.step) * self.config.step
|
new_value = round(new_value / self.config.step) * self.config.step
|
||||||
|
|
||||||
@ -152,45 +207,44 @@ class Slider:
|
|||||||
self.set_value(new_value)
|
self.set_value(new_value)
|
||||||
return abs(old_value - self.value) > 0.001
|
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."""
|
"""Draw the slider."""
|
||||||
# Background
|
# Hover highlight
|
||||||
if self.hovered:
|
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
|
||||||
label = self.small_font.render(self.config.name, True, Colors.TEXT_PRIMARY)
|
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 track
|
||||||
slider_area = self._slider_area()
|
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
|
# Slider fill
|
||||||
ratio = (self.value - self.config.min_val) / (self.config.max_val - self.config.min_val)
|
ratio = (self.value - self.config.min_val) / (self.config.max_val - self.config.min_val)
|
||||||
fill_width = int(ratio * slider_area.width)
|
fill_width = int(ratio * slider_area.width)
|
||||||
fill_rect = pygame.Rect(slider_area.x, slider_area.y, fill_width, slider_area.height)
|
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
|
||||||
handle_x = slider_area.x + fill_width
|
handle_x = slider_area.x + fill_width
|
||||||
handle_rect = pygame.Rect(handle_x - 4, slider_area.y - 2, 8, slider_area.height + 4)
|
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=2)
|
pygame.draw.rect(screen, Colors.SLIDER_HANDLE, handle_rect, border_radius=3)
|
||||||
|
|
||||||
# Value display
|
# Value display
|
||||||
value_str = str(self.get_value())
|
value_str = str(self.get_value())
|
||||||
value_text = self.small_font.render(value_str, True, Colors.TEXT_HIGHLIGHT)
|
value_text = self.small_font.render(value_str, True, Colors.TEXT_HIGHLIGHT)
|
||||||
value_x = self.rect.right - 50
|
screen.blit(value_text, (self.rect.right - 55, self.rect.y + 6))
|
||||||
screen.blit(value_text, (value_x, self.rect.y + 5))
|
|
||||||
|
|
||||||
# Description on hover
|
# Description on hover
|
||||||
if self.hovered and self.config.description:
|
if self.hovered and self.config.description:
|
||||||
desc = self.small_font.render(self.config.description, True, Colors.TEXT_SECONDARY)
|
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:
|
class Button:
|
||||||
"""A simple button widget."""
|
"""Button widget."""
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
@ -208,7 +262,7 @@ class Button:
|
|||||||
self.hovered = False
|
self.hovered = False
|
||||||
|
|
||||||
def handle_event(self, event: pygame.event.Event) -> bool:
|
def handle_event(self, event: pygame.event.Event) -> bool:
|
||||||
"""Handle input events. Returns True if clicked."""
|
"""Handle events."""
|
||||||
if event.type == pygame.MOUSEMOTION:
|
if event.type == pygame.MOUSEMOTION:
|
||||||
self.hovered = self.rect.collidepoint(event.pos)
|
self.hovered = self.rect.collidepoint(event.pos)
|
||||||
|
|
||||||
@ -221,10 +275,10 @@ class Button:
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
def draw(self, screen: pygame.Surface) -> None:
|
def draw(self, screen: pygame.Surface) -> None:
|
||||||
"""Draw the button."""
|
"""Draw button."""
|
||||||
color = Colors.BUTTON_HOVER if self.hovered else self.color
|
color = Colors.BUTTON_HOVER if self.hovered else self.color
|
||||||
pygame.draw.rect(screen, color, self.rect, 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=5)
|
pygame.draw.rect(screen, Colors.PANEL_BORDER, self.rect, 1, border_radius=6)
|
||||||
|
|
||||||
text = self.font.render(self.text, True, Colors.BUTTON_TEXT)
|
text = self.font.render(self.text, True, Colors.BUTTON_TEXT)
|
||||||
text_rect = text.get_rect(center=self.rect.center)
|
text_rect = text.get_rect(center=self.rect.center)
|
||||||
@ -232,21 +286,23 @@ class Button:
|
|||||||
|
|
||||||
|
|
||||||
class SettingsRenderer:
|
class SettingsRenderer:
|
||||||
"""Renders the settings UI panel with sliders."""
|
"""Settings panel with organized sections and sliders."""
|
||||||
|
|
||||||
def __init__(self, screen: pygame.Surface):
|
def __init__(self, screen: pygame.Surface):
|
||||||
self.screen = screen
|
self.screen = screen
|
||||||
self.font = pygame.font.Font(None, 24)
|
self.font = pygame.font.Font(None, 22)
|
||||||
self.small_font = pygame.font.Font(None, 18)
|
self.small_font = pygame.font.Font(None, 16)
|
||||||
self.title_font = pygame.font.Font(None, 32)
|
self.title_font = pygame.font.Font(None, 28)
|
||||||
|
self.section_font = pygame.font.Font(None, 20)
|
||||||
|
|
||||||
self.visible = False
|
self.visible = False
|
||||||
self.scroll_offset = 0
|
self.scroll_offset = 0
|
||||||
self.max_scroll = 0
|
self.max_scroll = 0
|
||||||
|
self.current_section = 0
|
||||||
|
|
||||||
# Create sliders
|
|
||||||
self.sliders: list[Slider] = []
|
self.sliders: list[Slider] = []
|
||||||
self.buttons: list[Button] = []
|
self.buttons: list[Button] = []
|
||||||
|
self.section_tabs: list[pygame.Rect] = []
|
||||||
self.config_data: dict = {}
|
self.config_data: dict = {}
|
||||||
|
|
||||||
self._create_widgets()
|
self._create_widgets()
|
||||||
@ -254,32 +310,44 @@ class SettingsRenderer:
|
|||||||
self.status_color = Colors.TEXT_SECONDARY
|
self.status_color = Colors.TEXT_SECONDARY
|
||||||
|
|
||||||
def _create_widgets(self) -> None:
|
def _create_widgets(self) -> None:
|
||||||
"""Create slider widgets."""
|
"""Create widgets."""
|
||||||
panel_width = 400
|
screen_w, screen_h = self.screen.get_size()
|
||||||
slider_height = 45
|
|
||||||
start_y = 80
|
|
||||||
|
|
||||||
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):
|
self.panel_rect = pygame.Rect(panel_x, panel_y, panel_width, panel_height)
|
||||||
rect = pygame.Rect(
|
|
||||||
panel_x + 10,
|
# Tab bar for sections
|
||||||
start_y + i * slider_height,
|
tab_height = 30
|
||||||
panel_width - 20,
|
self.tab_rect = pygame.Rect(panel_x, panel_y, panel_width, tab_height)
|
||||||
slider_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)
|
slider = Slider(rect, config, self.font, self.small_font)
|
||||||
self.sliders.append(slider)
|
self.sliders.append(slider)
|
||||||
|
self.sliders_by_section[config.section].append(slider)
|
||||||
|
|
||||||
# Calculate max scroll
|
# Calculate positions for current section
|
||||||
total_height = len(SLIDER_CONFIGS) * slider_height + 150
|
self._layout_current_section()
|
||||||
visible_height = self.screen.get_height() - 150
|
|
||||||
self.max_scroll = max(0, total_height - visible_height)
|
|
||||||
|
|
||||||
# Create buttons at the bottom
|
# Buttons at bottom
|
||||||
button_y = self.screen.get_height() - 60
|
button_y = panel_y + panel_height - 50
|
||||||
button_width = 100
|
button_width = 120
|
||||||
button_height = 35
|
button_height = 35
|
||||||
|
button_spacing = 15
|
||||||
|
|
||||||
buttons_data = [
|
buttons_data = [
|
||||||
("Apply & Restart", self._apply_config, Colors.SUCCESS),
|
("Apply & Restart", self._apply_config, Colors.SUCCESS),
|
||||||
@ -287,26 +355,43 @@ class SettingsRenderer:
|
|||||||
("Close", self.toggle, Colors.PANEL_BORDER),
|
("Close", self.toggle, Colors.PANEL_BORDER),
|
||||||
]
|
]
|
||||||
|
|
||||||
total_button_width = len(buttons_data) * button_width + (len(buttons_data) - 1) * 10
|
total_w = len(buttons_data) * button_width + (len(buttons_data) - 1) * button_spacing
|
||||||
start_x = (self.screen.get_width() - total_button_width) // 2
|
start_x = panel_x + (panel_width - total_w) // 2
|
||||||
|
|
||||||
for i, (text, callback, color) in enumerate(buttons_data):
|
for i, (text, callback, color) in enumerate(buttons_data):
|
||||||
rect = pygame.Rect(
|
rect = pygame.Rect(
|
||||||
start_x + i * (button_width + 10),
|
start_x + i * (button_width + button_spacing),
|
||||||
button_y,
|
button_y,
|
||||||
button_width,
|
button_width,
|
||||||
button_height,
|
button_height,
|
||||||
)
|
)
|
||||||
self.buttons.append(Button(rect, text, self.small_font, callback, color))
|
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:
|
def toggle(self) -> None:
|
||||||
"""Toggle settings visibility."""
|
"""Toggle visibility."""
|
||||||
self.visible = not self.visible
|
self.visible = not self.visible
|
||||||
if self.visible:
|
if self.visible:
|
||||||
self.scroll_offset = 0
|
self.scroll_offset = 0
|
||||||
|
self._layout_current_section()
|
||||||
|
|
||||||
def set_config(self, config_data: dict) -> None:
|
def set_config(self, config_data: dict) -> None:
|
||||||
"""Set slider values from config data."""
|
"""Set slider values from config."""
|
||||||
self.config_data = config_data
|
self.config_data = config_data
|
||||||
|
|
||||||
for slider in self.sliders:
|
for slider in self.sliders:
|
||||||
@ -315,16 +400,14 @@ class SettingsRenderer:
|
|||||||
slider.set_value(value)
|
slider.set_value(value)
|
||||||
|
|
||||||
def get_config(self) -> dict:
|
def get_config(self) -> dict:
|
||||||
"""Get current config from slider values."""
|
"""Get config from sliders."""
|
||||||
result = {}
|
result = {}
|
||||||
|
|
||||||
for slider in self.sliders:
|
for slider in self.sliders:
|
||||||
self._set_nested_value(result, slider.config.key, slider.get_value())
|
self._set_nested_value(result, slider.config.key, slider.get_value())
|
||||||
|
|
||||||
return result
|
return result
|
||||||
|
|
||||||
def _get_nested_value(self, data: dict, key: str) -> Any:
|
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(".")
|
parts = key.split(".")
|
||||||
current = data
|
current = data
|
||||||
for part in parts:
|
for part in parts:
|
||||||
@ -335,7 +418,7 @@ class SettingsRenderer:
|
|||||||
return current
|
return current
|
||||||
|
|
||||||
def _set_nested_value(self, data: dict, key: str, value: Any) -> None:
|
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(".")
|
parts = key.split(".")
|
||||||
current = data
|
current = data
|
||||||
for part in parts[:-1]:
|
for part in parts[:-1]:
|
||||||
@ -345,104 +428,138 @@ class SettingsRenderer:
|
|||||||
current[parts[-1]] = value
|
current[parts[-1]] = value
|
||||||
|
|
||||||
def _apply_config(self) -> None:
|
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_message = "Config applied - restart to see changes"
|
||||||
self.status_color = Colors.SUCCESS
|
self.status_color = Colors.SUCCESS
|
||||||
|
|
||||||
def _reset_config(self) -> None:
|
def _reset_config(self) -> None:
|
||||||
"""Reset configuration callback (to be set externally)."""
|
"""Reset config callback."""
|
||||||
self.status_message = "Config reset to defaults"
|
self.status_message = "Config reset to defaults"
|
||||||
self.status_color = Colors.WARNING
|
self.status_color = Colors.WARNING
|
||||||
|
|
||||||
def handle_event(self, event: pygame.event.Event) -> bool:
|
def handle_event(self, event: pygame.event.Event) -> bool:
|
||||||
"""Handle input events. Returns True if event was consumed."""
|
"""Handle events."""
|
||||||
if not self.visible:
|
if not self.visible:
|
||||||
return False
|
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:
|
if event.type == pygame.MOUSEWHEEL:
|
||||||
self.scroll_offset -= event.y * 30
|
self.scroll_offset -= event.y * 30
|
||||||
self.scroll_offset = max(0, min(self.max_scroll, self.scroll_offset))
|
self.scroll_offset = max(0, min(self.max_scroll, self.scroll_offset))
|
||||||
|
self._layout_current_section()
|
||||||
return True
|
return True
|
||||||
|
|
||||||
# Handle sliders
|
# Sliders for current section
|
||||||
for slider in self.sliders:
|
section = SECTION_ORDER[self.current_section]
|
||||||
# Adjust slider position for scroll
|
for slider in self.sliders_by_section[section]:
|
||||||
original_y = slider.rect.y
|
adjusted_rect = slider.rect.copy()
|
||||||
slider.rect.y -= self.scroll_offset
|
|
||||||
|
|
||||||
if slider.handle_event(event):
|
if slider.handle_event(event):
|
||||||
slider.rect.y = original_y
|
|
||||||
return True
|
return True
|
||||||
|
|
||||||
slider.rect.y = original_y
|
# Buttons
|
||||||
|
|
||||||
# Handle buttons
|
|
||||||
for button in self.buttons:
|
for button in self.buttons:
|
||||||
if button.handle_event(event):
|
if button.handle_event(event):
|
||||||
return True
|
return True
|
||||||
|
|
||||||
# Consume all clicks when settings are visible
|
# Consume clicks
|
||||||
if event.type == pygame.MOUSEBUTTONDOWN:
|
if event.type == pygame.MOUSEBUTTONDOWN:
|
||||||
return True
|
return True
|
||||||
|
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def draw(self) -> None:
|
def draw(self) -> None:
|
||||||
"""Draw the settings panel."""
|
"""Draw settings panel."""
|
||||||
if not self.visible:
|
if not self.visible:
|
||||||
return
|
return
|
||||||
|
|
||||||
# Dim background
|
# Dim background
|
||||||
overlay = pygame.Surface(self.screen.get_size(), pygame.SRCALPHA)
|
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))
|
self.screen.blit(overlay, (0, 0))
|
||||||
|
|
||||||
# Panel background
|
# Panel
|
||||||
panel_width = 420
|
pygame.draw.rect(self.screen, Colors.PANEL_BG, self.panel_rect, border_radius=10)
|
||||||
panel_height = self.screen.get_height() - 40
|
pygame.draw.rect(self.screen, Colors.PANEL_BORDER, self.panel_rect, 2, border_radius=10)
|
||||||
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)
|
|
||||||
|
|
||||||
# Title
|
# Title
|
||||||
title = self.title_font.render("Simulation Settings", True, Colors.TEXT_PRIMARY)
|
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)
|
self.screen.blit(title, title_rect)
|
||||||
|
|
||||||
# Create clipping region for scrollable area
|
# Section tabs
|
||||||
clip_rect = pygame.Rect(panel_x, 70, panel_width, panel_height - 130)
|
self._draw_section_tabs()
|
||||||
|
|
||||||
# Draw sliders with scroll offset
|
# Clipping for sliders
|
||||||
for slider in self.sliders:
|
clip_rect = pygame.Rect(
|
||||||
# Adjust position for scroll
|
self.panel_rect.x + 10,
|
||||||
adjusted_rect = slider.rect.copy()
|
self.panel_rect.y + 45,
|
||||||
adjusted_rect.y -= self.scroll_offset
|
self.panel_rect.width - 20,
|
||||||
|
self.panel_rect.height - 110,
|
||||||
|
)
|
||||||
|
|
||||||
# Only draw if visible
|
# Draw sliders for current section
|
||||||
if clip_rect.colliderect(adjusted_rect):
|
section = SECTION_ORDER[self.current_section]
|
||||||
# Temporarily move slider for drawing
|
section_color = SECTION_COLORS.get(section, Colors.TEXT_SECONDARY)
|
||||||
original_y = slider.rect.y
|
|
||||||
slider.rect.y = adjusted_rect.y
|
|
||||||
slider.draw(self.screen)
|
|
||||||
slider.rect.y = original_y
|
|
||||||
|
|
||||||
# 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:
|
if self.max_scroll > 0:
|
||||||
scroll_ratio = self.scroll_offset / self.max_scroll
|
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))
|
bar_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))
|
bar_y = clip_rect.y + int(scroll_ratio * (clip_rect.height - bar_height))
|
||||||
scroll_rect = pygame.Rect(panel_rect.right - 8, scroll_y, 4, scroll_height)
|
bar_rect = pygame.Rect(self.panel_rect.right - 12, bar_y, 5, bar_height)
|
||||||
pygame.draw.rect(self.screen, Colors.SLIDER_FILL, scroll_rect, border_radius=2)
|
pygame.draw.rect(self.screen, Colors.SLIDER_FILL, bar_rect, border_radius=2)
|
||||||
|
|
||||||
# Draw buttons
|
# Buttons
|
||||||
for button in self.buttons:
|
for button in self.buttons:
|
||||||
button.draw(self.screen)
|
button.draw(self.screen)
|
||||||
|
|
||||||
# Status message
|
# Status message
|
||||||
if self.status_message:
|
if self.status_message:
|
||||||
status = self.small_font.render(self.status_message, True, self.status_color)
|
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)
|
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
|
import pygame
|
||||||
from typing import TYPE_CHECKING, Optional
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from frontend.client import SimulationState
|
from frontend.client import SimulationState
|
||||||
|
|
||||||
|
|
||||||
class Colors:
|
class Colors:
|
||||||
# UI colors
|
# Base UI colors - dark cyberpunk theme
|
||||||
PANEL_BG = (35, 40, 50)
|
BG = (15, 17, 23)
|
||||||
PANEL_BORDER = (70, 80, 95)
|
PANEL_BG = (22, 26, 35)
|
||||||
TEXT_PRIMARY = (230, 230, 235)
|
PANEL_HEADER = (28, 33, 45)
|
||||||
TEXT_SECONDARY = (160, 165, 175)
|
PANEL_BORDER = (45, 55, 70)
|
||||||
TEXT_HIGHLIGHT = (100, 180, 255)
|
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_WARNING = (255, 180, 80)
|
||||||
TEXT_DANGER = (255, 100, 100)
|
TEXT_DANGER = (255, 100, 100)
|
||||||
|
TEXT_SUCCESS = (100, 220, 140)
|
||||||
|
|
||||||
# Day/Night indicator
|
# Day/Night
|
||||||
DAY_COLOR = (255, 220, 100)
|
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:
|
class UIRenderer:
|
||||||
"""Renders UI elements (HUD, panels, text info)."""
|
"""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.screen = screen
|
||||||
self.font = font
|
self.font = font
|
||||||
self.small_font = pygame.font.Font(None, 20)
|
self.top_panel_height = top_panel_height
|
||||||
self.title_font = pygame.font.Font(None, 28)
|
self.right_panel_width = right_panel_width
|
||||||
|
self.bottom_panel_height = bottom_panel_height
|
||||||
|
|
||||||
# Panel dimensions
|
# Fonts
|
||||||
self.top_panel_height = 50
|
self.small_font = pygame.font.Font(None, 16)
|
||||||
self.right_panel_width = 200
|
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:
|
# Scrolling state for right panel
|
||||||
"""Draw a panel background."""
|
self.scroll_offset = 0
|
||||||
pygame.draw.rect(self.screen, Colors.PANEL_BG, rect)
|
self.max_scroll = 0
|
||||||
pygame.draw.rect(self.screen, Colors.PANEL_BORDER, rect, 1)
|
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:
|
if title:
|
||||||
title_text = self.small_font.render(title, True, Colors.TEXT_SECONDARY)
|
# Header area
|
||||||
self.screen.blit(title_text, (rect.x + 8, rect.y + 4))
|
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:
|
def draw_top_bar(self, state: "SimulationState") -> None:
|
||||||
"""Draw the top information bar."""
|
"""Draw the top information bar."""
|
||||||
|
self.animation_tick += 1
|
||||||
|
|
||||||
rect = pygame.Rect(0, 0, self.screen.get_width(), self.top_panel_height)
|
rect = pygame.Rect(0, 0, self.screen.get_width(), self.top_panel_height)
|
||||||
pygame.draw.rect(self.screen, Colors.PANEL_BG, rect)
|
pygame.draw.rect(self.screen, Colors.PANEL_BG, rect)
|
||||||
pygame.draw.line(
|
pygame.draw.line(
|
||||||
self.screen,
|
self.screen, Colors.PANEL_BORDER,
|
||||||
Colors.PANEL_BORDER,
|
(0, self.top_panel_height - 1),
|
||||||
(0, self.top_panel_height),
|
(self.screen.get_width(), self.top_panel_height - 1),
|
||||||
(self.screen.get_width(), self.top_panel_height),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# Day/Night and Turn info
|
# Day/Night indicator with animated glow
|
||||||
is_night = state.time_of_day == "night"
|
is_night = state.time_of_day == "night"
|
||||||
time_color = Colors.NIGHT_COLOR if is_night else Colors.DAY_COLOR
|
time_color = Colors.NIGHT_COLOR if is_night else Colors.DAY_COLOR
|
||||||
time_text = "NIGHT" if is_night else "DAY"
|
time_text = "NIGHT" if is_night else "DAY"
|
||||||
|
|
||||||
# Draw time indicator circle
|
# Glow effect
|
||||||
pygame.draw.circle(self.screen, time_color, (25, 25), 12)
|
glow_alpha = int(100 + 50 * math.sin(self.animation_tick * 0.05))
|
||||||
pygame.draw.circle(self.screen, Colors.PANEL_BORDER, (25, 25), 12, 1)
|
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}"
|
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)
|
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_color = Colors.TEXT_HIGHLIGHT if state.mode == "auto" else Colors.TEXT_SECONDARY
|
||||||
mode_text = f"Mode: {state.mode.upper()}"
|
mode_text = f"Mode: {state.mode.upper()}"
|
||||||
text = self.small_font.render(mode_text, True, mode_color)
|
text = self.medium_font.render(mode_text, True, mode_color)
|
||||||
self.screen.blit(text, (self.screen.get_width() - 120, 8))
|
self.screen.blit(text, (right_x, 10))
|
||||||
|
|
||||||
# Running indicator
|
# Running status
|
||||||
if state.is_running:
|
if state.is_running:
|
||||||
status_text = "RUNNING"
|
status_text = "● RUNNING"
|
||||||
status_color = (100, 200, 100)
|
status_color = Colors.TEXT_SUCCESS
|
||||||
else:
|
else:
|
||||||
status_text = "STOPPED"
|
status_text = "○ STOPPED"
|
||||||
status_color = Colors.TEXT_DANGER
|
status_color = Colors.TEXT_SECONDARY
|
||||||
|
|
||||||
text = self.small_font.render(status_text, True, status_color)
|
text = self.medium_font.render(status_text, True, status_color)
|
||||||
self.screen.blit(text, (self.screen.get_width() - 120, 28))
|
self.screen.blit(text, (right_x, 28))
|
||||||
|
|
||||||
def draw_right_panel(self, state: "SimulationState") -> None:
|
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_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(
|
rect = pygame.Rect(
|
||||||
panel_x,
|
panel_x, self.top_panel_height,
|
||||||
self.top_panel_height,
|
self.right_panel_width, panel_height,
|
||||||
self.right_panel_width,
|
|
||||||
self.screen.get_height() - self.top_panel_height,
|
|
||||||
)
|
)
|
||||||
pygame.draw.rect(self.screen, Colors.PANEL_BG, rect)
|
pygame.draw.rect(self.screen, Colors.PANEL_BG, rect)
|
||||||
pygame.draw.line(
|
pygame.draw.line(
|
||||||
self.screen,
|
self.screen, Colors.PANEL_BORDER,
|
||||||
Colors.PANEL_BORDER,
|
|
||||||
(panel_x, self.top_panel_height),
|
(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
|
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."""
|
# DIPLOMACY SECTION
|
||||||
# Title
|
# ═══════════════════════════════════════════════════════════════
|
||||||
title = self.title_font.render("Statistics", True, Colors.TEXT_PRIMARY)
|
y = self._draw_diplomacy_section(state, content_x, y, content_width)
|
||||||
self.screen.blit(title, (x, y))
|
y += 15
|
||||||
y += 30
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════
|
||||||
|
# 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
|
stats = state.statistics
|
||||||
|
|
||||||
|
# Population bar
|
||||||
living = len(state.get_living_agents())
|
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
|
pygame.draw.rect(
|
||||||
pop_color = Colors.TEXT_PRIMARY if living > 2 else Colors.TEXT_DANGER
|
self.screen, Colors.SCROLLBAR_BG,
|
||||||
text = self.small_font.render(f"Population: {living}", True, pop_color)
|
(x, y, width, 14), border_radius=2,
|
||||||
self.screen.blit(text, (x, y))
|
)
|
||||||
y += 18
|
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)
|
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)
|
total_money = stats.get("total_money_in_circulation", 0)
|
||||||
text = self.small_font.render(f"Total Coins: {total_money}", True, Colors.TEXT_SECONDARY)
|
|
||||||
self.screen.blit(text, (x, y))
|
|
||||||
y += 18
|
|
||||||
|
|
||||||
# Professions
|
text = self.small_font.render(f"Deaths: {deaths}", True, Colors.TEXT_DANGER)
|
||||||
professions = stats.get("professions", {})
|
self.screen.blit(text, (x, y))
|
||||||
if professions:
|
|
||||||
y += 5
|
text = self.small_font.render(f"💰 {total_money}", True, (255, 215, 0))
|
||||||
text = self.small_font.render("Professions:", True, Colors.TEXT_SECONDARY)
|
self.screen.blit(text, (x + width // 2, y))
|
||||||
|
y += 16
|
||||||
|
|
||||||
|
# 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))
|
self.screen.blit(text, (x, y))
|
||||||
y += 16
|
y += 16
|
||||||
|
|
||||||
for prof, count in professions.items():
|
return y
|
||||||
text = self.small_font.render(f" {prof}: {count}", True, Colors.TEXT_SECONDARY)
|
|
||||||
|
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
|
||||||
|
)
|
||||||
|
self.screen.blit(text, (x, y))
|
||||||
|
y += 16
|
||||||
|
|
||||||
|
# Price summary (show resources with stock)
|
||||||
|
prices = state.market_prices
|
||||||
|
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", "?")
|
||||||
|
|
||||||
|
# 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 += 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))
|
self.screen.blit(text, (x, y))
|
||||||
y += 14
|
y += 14
|
||||||
|
|
||||||
return y
|
return y
|
||||||
|
|
||||||
def _draw_market_section(self, state: "SimulationState", x: int, y: int) -> int:
|
def draw_bottom_bar(self, state: "SimulationState") -> None:
|
||||||
"""Draw the market section."""
|
"""Draw bottom information bar with event log."""
|
||||||
# Title
|
bar_y = self.screen.get_height() - self.bottom_panel_height
|
||||||
title = self.title_font.render("Market", True, Colors.TEXT_PRIMARY)
|
rect = pygame.Rect(0, bar_y, self.screen.get_width(), self.bottom_panel_height)
|
||||||
self.screen.blit(title, (x, y))
|
|
||||||
y += 30
|
|
||||||
|
|
||||||
# Order count
|
pygame.draw.rect(self.screen, Colors.PANEL_BG, rect)
|
||||||
order_count = len(state.market_orders)
|
|
||||||
text = self.small_font.render(f"Active Orders: {order_count}", True, Colors.TEXT_SECONDARY)
|
|
||||||
self.screen.blit(text, (x, y))
|
|
||||||
y += 20
|
|
||||||
|
|
||||||
# Price summary for each resource with available stock
|
|
||||||
prices = state.market_prices
|
|
||||||
for resource, data in prices.items():
|
|
||||||
if data.get("total_available", 0) > 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,
|
|
||||||
)
|
|
||||||
self.screen.blit(text, (x, y))
|
|
||||||
y += 16
|
|
||||||
|
|
||||||
return y
|
|
||||||
|
|
||||||
def _draw_controls_help(self, x: int, y: int) -> None:
|
|
||||||
"""Draw controls help at bottom of panel."""
|
|
||||||
pygame.draw.line(
|
pygame.draw.line(
|
||||||
self.screen,
|
self.screen, Colors.PANEL_BORDER,
|
||||||
Colors.PANEL_BORDER,
|
(0, bar_y), (self.screen.get_width(), bar_y),
|
||||||
(x - 5, y - 10),
|
|
||||||
(self.screen.get_width() - 5, y - 10),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
title = self.small_font.render("Controls", True, Colors.TEXT_PRIMARY)
|
# Recent events (religious + diplomatic)
|
||||||
self.screen.blit(title, (x, y))
|
x = 15
|
||||||
y += 20
|
y = bar_y + 8
|
||||||
|
|
||||||
controls = [
|
text = self.medium_font.render("Recent Events:", True, Colors.TEXT_SECONDARY)
|
||||||
"SPACE - Next Turn",
|
self.screen.blit(text, (x, y))
|
||||||
"R - Reset Simulation",
|
x += 120
|
||||||
"M - Toggle Mode",
|
|
||||||
"S - Settings",
|
# Show recent religious events
|
||||||
"ESC - Quit",
|
for event in state.religious_events[:2]:
|
||||||
]
|
event_type = event.get("type", "")
|
||||||
|
desc = event.get("description", event_type)[:30]
|
||||||
for control in controls:
|
|
||||||
text = self.small_font.render(control, True, Colors.TEXT_SECONDARY)
|
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))
|
self.screen.blit(text, (x, y))
|
||||||
y += 16
|
|
||||||
|
|
||||||
def draw_connection_status(self, connected: bool) -> None:
|
def draw_connection_status(self, connected: bool) -> None:
|
||||||
"""Draw connection status overlay when disconnected."""
|
"""Draw connection status overlay when disconnected."""
|
||||||
@ -220,20 +594,52 @@ class UIRenderer:
|
|||||||
|
|
||||||
# Semi-transparent overlay
|
# Semi-transparent overlay
|
||||||
overlay = pygame.Surface(self.screen.get_size(), pygame.SRCALPHA)
|
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))
|
self.screen.blit(overlay, (0, 0))
|
||||||
|
|
||||||
# Connection message
|
# Connection box
|
||||||
text = self.title_font.render("Connecting to server...", True, Colors.TEXT_WARNING)
|
box_w, box_h = 400, 150
|
||||||
text_rect = text.get_rect(center=(self.screen.get_width() // 2, self.screen.get_height() // 2))
|
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)
|
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 = self.medium_font.render(
|
||||||
hint_rect = hint.get_rect(center=(self.screen.get_width() // 2, self.screen.get_height() // 2 + 30))
|
"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)
|
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:
|
def draw(self, state: "SimulationState") -> None:
|
||||||
"""Draw all UI elements."""
|
"""Draw all UI elements."""
|
||||||
self.draw_top_bar(state)
|
self.draw_top_bar(state)
|
||||||
self.draw_right_panel(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