413 lines
15 KiB
Python
413 lines
15 KiB
Python
"""World container for the Village Simulation.
|
|
|
|
The world spawns diverse agents with varied personality traits,
|
|
skills, and starting conditions to create emergent professions
|
|
and class inequality.
|
|
|
|
NEW: World now supports religion and faction systems for realistic
|
|
social dynamics including religious diversity and geopolitical factions.
|
|
"""
|
|
|
|
import random
|
|
from dataclasses import dataclass, field
|
|
from enum import Enum
|
|
from typing import Optional
|
|
|
|
from backend.domain.agent import Agent, Position, Profession
|
|
from backend.domain.personality import (
|
|
PersonalityTraits, Skills,
|
|
generate_random_personality, generate_random_skills
|
|
)
|
|
from backend.domain.religion import (
|
|
ReligiousBeliefs, ReligionType, generate_random_religion
|
|
)
|
|
from backend.domain.diplomacy import (
|
|
AgentDiplomacy, FactionType, FactionRelations,
|
|
generate_random_faction, reset_faction_relations, get_faction_relations
|
|
)
|
|
|
|
|
|
class TimeOfDay(Enum):
|
|
"""Current time of day in the simulation."""
|
|
DAY = "day"
|
|
NIGHT = "night"
|
|
|
|
|
|
def _get_world_config_from_file():
|
|
"""Load world configuration from config.json."""
|
|
from backend.config import get_config
|
|
return get_config().world
|
|
|
|
|
|
@dataclass
|
|
class WorldConfig:
|
|
"""Configuration for the world."""
|
|
width: int = 25
|
|
height: int = 25
|
|
initial_agents: int = 25
|
|
day_steps: int = 10
|
|
night_steps: int = 1
|
|
oil_fields_count: int = 3 # NEW
|
|
temple_count: int = 2 # NEW
|
|
|
|
|
|
def create_world_config() -> WorldConfig:
|
|
"""Factory function to create WorldConfig from config.json."""
|
|
cfg = _get_world_config_from_file()
|
|
return WorldConfig(
|
|
width=cfg.width,
|
|
height=cfg.height,
|
|
initial_agents=cfg.initial_agents,
|
|
day_steps=cfg.day_steps,
|
|
night_steps=cfg.night_steps,
|
|
oil_fields_count=getattr(cfg, 'oil_fields_count', 3),
|
|
temple_count=getattr(cfg, 'temple_count', 2),
|
|
)
|
|
|
|
|
|
@dataclass
|
|
class WorldLocation:
|
|
"""A special location in the world."""
|
|
name: str
|
|
position: Position
|
|
location_type: str # "oil_field", "temple", "market", etc.
|
|
faction: Optional[FactionType] = None
|
|
religion: Optional[ReligionType] = None
|
|
|
|
|
|
@dataclass
|
|
class World:
|
|
"""Container for all entities in the simulation."""
|
|
config: WorldConfig = field(default_factory=create_world_config)
|
|
agents: list[Agent] = field(default_factory=list)
|
|
current_turn: int = 0
|
|
current_day: int = 1
|
|
step_in_day: int = 0
|
|
time_of_day: TimeOfDay = TimeOfDay.DAY
|
|
|
|
# Special locations
|
|
oil_fields: list[WorldLocation] = field(default_factory=list)
|
|
temples: list[WorldLocation] = field(default_factory=list)
|
|
|
|
# Faction relations
|
|
faction_relations: FactionRelations = field(default_factory=FactionRelations)
|
|
|
|
# Statistics
|
|
total_agents_spawned: int = 0
|
|
total_agents_died: int = 0
|
|
total_wars: int = 0
|
|
total_peace_treaties: int = 0
|
|
total_conversions: int = 0
|
|
|
|
def _generate_locations(self) -> None:
|
|
"""Generate special locations in the world."""
|
|
# Generate oil fields (right side of map - "resource-rich" area)
|
|
self.oil_fields = []
|
|
for i in range(self.config.oil_fields_count):
|
|
x = self.config.width * random.uniform(0.75, 0.95)
|
|
y = self.config.height * (i + 1) / (self.config.oil_fields_count + 1)
|
|
self.oil_fields.append(WorldLocation(
|
|
name=f"Oil Field {i + 1}",
|
|
position=Position(x, y),
|
|
location_type="oil_field",
|
|
faction=random.choice([FactionType.MOUNTAINEER, FactionType.NORTHLANDS]),
|
|
))
|
|
|
|
# Generate temples (scattered across map)
|
|
self.temples = []
|
|
religions = [r for r in ReligionType if r != ReligionType.ATHEIST]
|
|
for i in range(self.config.temple_count):
|
|
x = self.config.width * random.uniform(0.3, 0.7)
|
|
y = self.config.height * (i + 1) / (self.config.temple_count + 1)
|
|
self.temples.append(WorldLocation(
|
|
name=f"Temple of {religions[i % len(religions)].value.title()}",
|
|
position=Position(x, y),
|
|
location_type="temple",
|
|
religion=religions[i % len(religions)],
|
|
))
|
|
|
|
def spawn_agent(
|
|
self,
|
|
name: Optional[str] = None,
|
|
profession: Optional[Profession] = None,
|
|
position: Optional[Position] = None,
|
|
archetype: Optional[str] = None,
|
|
starting_money: Optional[int] = None,
|
|
religion: Optional[ReligiousBeliefs] = None,
|
|
faction: Optional[AgentDiplomacy] = None,
|
|
) -> Agent:
|
|
"""Spawn a new agent in the world with unique personality.
|
|
|
|
Args:
|
|
name: Agent name (auto-generated if None)
|
|
profession: Deprecated, now derived from personality
|
|
position: Starting position (random if None)
|
|
archetype: Personality archetype ("hunter", "gatherer", "trader", etc.)
|
|
starting_money: Starting money (random with inequality if None)
|
|
religion: Religious beliefs (random if None)
|
|
faction: Faction membership (random if None)
|
|
"""
|
|
if position is None:
|
|
position = Position(
|
|
x=random.randint(0, self.config.width - 1),
|
|
y=random.randint(0, self.config.height - 1),
|
|
)
|
|
|
|
# Generate unique personality and skills
|
|
personality = generate_random_personality(archetype)
|
|
skills = generate_random_skills(personality)
|
|
|
|
# Generate religion if not provided
|
|
if religion is None:
|
|
religion = generate_random_religion(archetype)
|
|
|
|
# Generate faction if not provided
|
|
if faction is None:
|
|
faction = generate_random_faction(archetype)
|
|
|
|
# Variable starting money for class inequality
|
|
if starting_money is None:
|
|
from backend.config import get_config
|
|
base_money = get_config().world.starting_money
|
|
money_multiplier = random.uniform(0.3, 2.0)
|
|
|
|
# Traders start with more money (their capital)
|
|
if personality.trade_preference > 1.3:
|
|
money_multiplier *= 1.5
|
|
|
|
# Oil-controlling factions have wealth bonus
|
|
if faction.faction == FactionType.MOUNTAINEER:
|
|
money_multiplier *= 1.3
|
|
|
|
starting_money = int(base_money * money_multiplier)
|
|
|
|
agent = Agent(
|
|
name=name or f"Villager_{self.total_agents_spawned + 1}",
|
|
profession=Profession.VILLAGER,
|
|
position=position,
|
|
personality=personality,
|
|
skills=skills,
|
|
religion=religion,
|
|
diplomacy=faction,
|
|
money=starting_money,
|
|
)
|
|
|
|
self.agents.append(agent)
|
|
self.total_agents_spawned += 1
|
|
return agent
|
|
|
|
def get_agent(self, agent_id: str) -> Optional[Agent]:
|
|
"""Get an agent by ID."""
|
|
for agent in self.agents:
|
|
if agent.id == agent_id:
|
|
return agent
|
|
return None
|
|
|
|
def get_agents_by_faction(self, faction: FactionType) -> list[Agent]:
|
|
"""Get all living agents in a faction."""
|
|
return [
|
|
a for a in self.agents
|
|
if a.is_alive() and not a.is_corpse() and a.diplomacy.faction == faction
|
|
]
|
|
|
|
def get_agents_by_religion(self, religion: ReligionType) -> list[Agent]:
|
|
"""Get all living agents of a religion."""
|
|
return [
|
|
a for a in self.agents
|
|
if a.is_alive() and not a.is_corpse() and a.religion.religion == religion
|
|
]
|
|
|
|
def get_nearby_agents(self, agent: Agent, radius: float = 3.0) -> list[Agent]:
|
|
"""Get living agents near a given agent."""
|
|
nearby = []
|
|
for other in self.agents:
|
|
if other.id == agent.id:
|
|
continue
|
|
if not other.is_alive() or other.is_corpse():
|
|
continue
|
|
if agent.position.distance_to(other.position) <= radius:
|
|
nearby.append(other)
|
|
return nearby
|
|
|
|
def remove_dead_agents(self) -> list[Agent]:
|
|
"""Remove all dead agents from the world."""
|
|
dead_agents = [a for a in self.agents if not a.is_alive() and not a.is_corpse()]
|
|
return dead_agents
|
|
|
|
def advance_time(self) -> None:
|
|
"""Advance the simulation time by one step."""
|
|
self.current_turn += 1
|
|
self.step_in_day += 1
|
|
|
|
total_steps = self.config.day_steps + self.config.night_steps
|
|
|
|
if self.step_in_day > total_steps:
|
|
self.step_in_day = 1
|
|
self.current_day += 1
|
|
|
|
if self.step_in_day <= self.config.day_steps:
|
|
self.time_of_day = TimeOfDay.DAY
|
|
else:
|
|
self.time_of_day = TimeOfDay.NIGHT
|
|
|
|
# Update faction relations each turn
|
|
self.faction_relations.update_turn(self.current_turn)
|
|
|
|
def is_night(self) -> bool:
|
|
"""Check if it's currently night."""
|
|
return self.time_of_day == TimeOfDay.NIGHT
|
|
|
|
def get_living_agents(self) -> list[Agent]:
|
|
"""Get all living agents (excludes corpses)."""
|
|
return [a for a in self.agents if a.is_alive() and not a.is_corpse()]
|
|
|
|
def get_statistics(self) -> dict:
|
|
"""Get current world statistics including wealth distribution."""
|
|
living = self.get_living_agents()
|
|
total_money = sum(a.money for a in living)
|
|
|
|
# Count emergent professions
|
|
profession_counts = {}
|
|
for agent in living:
|
|
agent._update_profession()
|
|
prof = agent.profession.value
|
|
profession_counts[prof] = profession_counts.get(prof, 0) + 1
|
|
|
|
# Count religions
|
|
religion_counts = {}
|
|
for agent in living:
|
|
rel = agent.religion.religion.value
|
|
religion_counts[rel] = religion_counts.get(rel, 0) + 1
|
|
|
|
# Count factions
|
|
faction_counts = {}
|
|
for agent in living:
|
|
fac = agent.diplomacy.faction.value
|
|
faction_counts[fac] = faction_counts.get(fac, 0) + 1
|
|
|
|
# Calculate wealth inequality metrics
|
|
if living:
|
|
moneys = sorted([a.money for a in living])
|
|
avg_money = total_money / len(living)
|
|
median_money = moneys[len(moneys) // 2]
|
|
richest = moneys[-1] if moneys else 0
|
|
poorest = moneys[0] if moneys else 0
|
|
|
|
# Gini coefficient
|
|
n = len(moneys)
|
|
if n > 1 and total_money > 0:
|
|
sum_of_diffs = sum(abs(m1 - m2) for m1 in moneys for m2 in moneys)
|
|
gini = sum_of_diffs / (2 * n * total_money)
|
|
else:
|
|
gini = 0
|
|
|
|
# Average faith
|
|
avg_faith = sum(a.stats.faith for a in living) / len(living)
|
|
else:
|
|
avg_money = median_money = richest = poorest = gini = avg_faith = 0
|
|
|
|
# War status
|
|
active_wars = len(self.faction_relations.active_wars)
|
|
|
|
return {
|
|
"current_turn": self.current_turn,
|
|
"current_day": self.current_day,
|
|
"step_in_day": self.step_in_day,
|
|
"time_of_day": self.time_of_day.value,
|
|
"living_agents": len(living),
|
|
"total_agents_spawned": self.total_agents_spawned,
|
|
"total_agents_died": self.total_agents_died,
|
|
"total_money_in_circulation": total_money,
|
|
"professions": profession_counts,
|
|
# Wealth metrics
|
|
"avg_money": round(avg_money, 1),
|
|
"median_money": median_money,
|
|
"richest_agent": richest,
|
|
"poorest_agent": poorest,
|
|
"gini_coefficient": round(gini, 3),
|
|
# NEW: Religion and diplomacy stats
|
|
"religions": religion_counts,
|
|
"factions": faction_counts,
|
|
"active_wars": active_wars,
|
|
"avg_faith": round(avg_faith, 1),
|
|
"total_wars": self.total_wars,
|
|
"total_peace_treaties": self.total_peace_treaties,
|
|
"total_conversions": self.total_conversions,
|
|
}
|
|
|
|
def get_state_snapshot(self) -> dict:
|
|
"""Get a full snapshot of the world state for API."""
|
|
return {
|
|
"turn": self.current_turn,
|
|
"day": self.current_day,
|
|
"step_in_day": self.step_in_day,
|
|
"time_of_day": self.time_of_day.value,
|
|
"world_size": {"width": self.config.width, "height": self.config.height},
|
|
"agents": [a.to_dict() for a in self.agents],
|
|
"statistics": self.get_statistics(),
|
|
# NEW: Special locations
|
|
"oil_fields": [
|
|
{"name": l.name, "position": l.position.to_dict(), "faction": l.faction.value if l.faction else None}
|
|
for l in self.oil_fields
|
|
],
|
|
"temples": [
|
|
{"name": l.name, "position": l.position.to_dict(), "religion": l.religion.value if l.religion else None}
|
|
for l in self.temples
|
|
],
|
|
# NEW: Faction relations summary
|
|
"faction_relations": self.faction_relations.to_dict(),
|
|
}
|
|
|
|
def initialize(self) -> None:
|
|
"""Initialize the world with diverse starting agents.
|
|
|
|
Creates a mix of agent archetypes to seed profession diversity.
|
|
Now also seeds religious and faction diversity.
|
|
"""
|
|
# Reset faction relations
|
|
self.faction_relations = reset_faction_relations()
|
|
|
|
# Generate special locations
|
|
self._generate_locations()
|
|
|
|
n = self.config.initial_agents
|
|
|
|
# Distribute archetypes for diversity
|
|
archetypes = (
|
|
["hunter"] * max(1, n // 7) +
|
|
["gatherer"] * max(1, n // 7) +
|
|
["trader"] * max(1, n // 7) +
|
|
["woodcutter"] * max(1, n // 10)
|
|
)
|
|
|
|
while len(archetypes) < n:
|
|
archetypes.append(None)
|
|
|
|
random.shuffle(archetypes)
|
|
|
|
for archetype in archetypes:
|
|
self.spawn_agent(archetype=archetype)
|
|
|
|
# Set up some initial faction tensions for drama
|
|
self._create_initial_tensions()
|
|
|
|
def _create_initial_tensions(self) -> None:
|
|
"""Create some initial diplomatic tensions for realistic starting conditions."""
|
|
# Some factions have historical rivalries
|
|
rivalries = [
|
|
(FactionType.NORTHLANDS, FactionType.RIVERFOLK, -15),
|
|
(FactionType.FORESTKIN, FactionType.MOUNTAINEER, -10),
|
|
]
|
|
|
|
for faction1, faction2, modifier in rivalries:
|
|
self.faction_relations.modify_relation(faction1, faction2, modifier)
|
|
|
|
# Some factions have good relations
|
|
friendships = [
|
|
(FactionType.RIVERFOLK, FactionType.PLAINSMEN, 10),
|
|
(FactionType.PLAINSMEN, FactionType.FORESTKIN, 15),
|
|
]
|
|
|
|
for faction1, faction2, modifier in friendships:
|
|
self.faction_relations.modify_relation(faction1, faction2, modifier)
|