"""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