villsim/backend/domain/diplomacy.py

516 lines
19 KiB
Python

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