516 lines
19 KiB
Python
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
|
|
|