You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
680 lines
30 KiB
680 lines
30 KiB
from __future__ import annotations
|
|
import logging
|
|
import random
|
|
import uuid
|
|
|
|
from app.models.agent import AgentPersona, SocialEdge
|
|
from app.models.action import ActionType, AgentDecision, ActionEntry
|
|
from app.models.world import WorldState, WorldMetrics, Institution, Proposal
|
|
from app.constants import TIMES_OF_DAY, normalize_emotional_state
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
class ActionResolver:
|
|
@staticmethod
|
|
def _resolve_agent_id(ref, agent_map: dict[int, AgentPersona]) -> int | None:
|
|
if ref is None:
|
|
return None
|
|
try:
|
|
aid = int(ref)
|
|
if aid in agent_map:
|
|
return aid
|
|
except (ValueError, TypeError):
|
|
pass
|
|
ref_str = str(ref).strip().lower()
|
|
for a in agent_map.values():
|
|
if a.name.lower() == ref_str:
|
|
return a.id
|
|
return None
|
|
|
|
@staticmethod
|
|
def _update_relationship(agent: AgentPersona, target_id: int, description: str):
|
|
key = str(target_id)
|
|
existing = agent.relationships.get(key, "")
|
|
if existing:
|
|
parts = existing.split(" | ")
|
|
if description not in parts:
|
|
parts.append(description)
|
|
if len(parts) > 4:
|
|
parts = parts[-4:]
|
|
agent.relationships[key] = " | ".join(parts)
|
|
else:
|
|
agent.relationships[key] = description
|
|
|
|
@staticmethod
|
|
def _strengthen_social_edge(
|
|
agent: AgentPersona,
|
|
target_id: int,
|
|
strength_delta: float = 0.05,
|
|
sentiment_delta: float = 0.0,
|
|
):
|
|
for edge in agent.social_connections:
|
|
if edge.target_id == target_id:
|
|
edge.strength = min(1.0, max(0.0, edge.strength + strength_delta))
|
|
edge.sentiment = min(1.0, max(-1.0, edge.sentiment + sentiment_delta))
|
|
return
|
|
agent.social_connections.append(SocialEdge(
|
|
target_id=target_id,
|
|
strength=min(1.0, max(0.0, 0.3 + strength_delta)),
|
|
sentiment=min(1.0, max(-1.0, sentiment_delta)),
|
|
))
|
|
|
|
def resolve(
|
|
self,
|
|
decisions: list[tuple[AgentPersona, AgentDecision]],
|
|
world_state: WorldState,
|
|
all_agents: list[AgentPersona],
|
|
round_num: int,
|
|
forecast=None,
|
|
) -> tuple[list[ActionEntry], WorldState, list[AgentPersona]]:
|
|
entries: list[ActionEntry] = []
|
|
agent_map = {a.id: a for a in all_agents}
|
|
time_of_day = TIMES_OF_DAY[world_state.round_in_day % 3]
|
|
|
|
sorted_decisions = sorted(
|
|
decisions,
|
|
key=lambda d: d[0].resources.get("influence", 0),
|
|
reverse=True,
|
|
)
|
|
|
|
for agent, decision in sorted_decisions:
|
|
entry = self._process_decision(
|
|
agent, decision, world_state, agent_map, round_num, time_of_day
|
|
)
|
|
entries.append(entry)
|
|
|
|
if decision.belief_updates:
|
|
current = agent_map[agent.id]
|
|
for belief in decision.belief_updates:
|
|
if belief and belief not in current.beliefs:
|
|
current.beliefs.append(belief)
|
|
if len(current.beliefs) > 10:
|
|
current.beliefs.pop(0)
|
|
|
|
if decision.memory_promotion:
|
|
current = agent_map[agent.id]
|
|
promo = decision.memory_promotion
|
|
if promo not in current.core_memory:
|
|
current.core_memory.append(promo)
|
|
if len(current.core_memory) > 10:
|
|
current.core_memory.pop(0)
|
|
|
|
agent_map[agent.id].emotional_state = normalize_emotional_state(decision.feel)
|
|
|
|
old_metrics = world_state.metrics.model_copy()
|
|
world_state.metrics = self._update_metrics(world_state.metrics, entries, world_state)
|
|
|
|
if forecast is not None:
|
|
try:
|
|
proposed = world_state.metrics.model_dump()
|
|
current = old_metrics.model_dump()
|
|
sim_id = getattr(forecast, '_current_sim_id', '')
|
|
clamped = forecast.clamp_metrics(sim_id, proposed, current)
|
|
for key, val in clamped.items():
|
|
if hasattr(world_state.metrics, key):
|
|
setattr(world_state.metrics, key, val)
|
|
except Exception:
|
|
pass
|
|
|
|
all_agents = list(agent_map.values())
|
|
self._emotional_contagion(all_agents)
|
|
self._emotional_decay(all_agents)
|
|
|
|
return entries, world_state, all_agents
|
|
|
|
def _process_decision(
|
|
self,
|
|
agent: AgentPersona,
|
|
decision: AgentDecision,
|
|
world_state: WorldState,
|
|
agent_map: dict[int, AgentPersona],
|
|
round_num: int,
|
|
time_of_day: str,
|
|
) -> ActionEntry:
|
|
action_args = dict(decision.args)
|
|
targets: list[int] = []
|
|
world_changes: dict = {}
|
|
rel_changes: dict = {}
|
|
|
|
at = decision.action
|
|
|
|
if at == ActionType.ABANDON:
|
|
product = (decision.args or {}).get("product", "the product").lower()
|
|
if product in agent.abandoned_products:
|
|
at = ActionType.SPEAK_PUBLIC
|
|
decision = AgentDecision(
|
|
feel=decision.feel, want=decision.want, fear=decision.fear,
|
|
life_context=decision.life_context, past_echo=decision.past_echo,
|
|
action=ActionType.SPEAK_PUBLIC, args={},
|
|
speech=decision.speech or f"I already left {product}.",
|
|
internal_thought=decision.internal_thought,
|
|
belief_updates=decision.belief_updates,
|
|
memory_promotion=decision.memory_promotion,
|
|
)
|
|
|
|
if at == ActionType.DEFECT and agent.has_defected:
|
|
at = ActionType.SPEAK_PUBLIC
|
|
decision = AgentDecision(
|
|
feel=decision.feel, want=decision.want, fear=decision.fear,
|
|
life_context=decision.life_context, past_echo=decision.past_echo,
|
|
action=ActionType.SPEAK_PUBLIC, args={},
|
|
speech=decision.speech or "I've already made my switch.",
|
|
internal_thought=decision.internal_thought,
|
|
belief_updates=decision.belief_updates,
|
|
memory_promotion=decision.memory_promotion,
|
|
)
|
|
|
|
if at == ActionType.SPEAK_PUBLIC:
|
|
if decision.speech:
|
|
action_args["content"] = decision.speech
|
|
listeners = [
|
|
a for a in agent_map.values()
|
|
if a.id != agent.id and a.location == agent.location
|
|
]
|
|
if not listeners:
|
|
listeners = [a for a in agent_map.values() if a.id != agent.id]
|
|
for listener in listeners:
|
|
self._strengthen_social_edge(agent, listener.id, 0.03, 0.01)
|
|
self._strengthen_social_edge(listener, agent.id, 0.03, 0.01)
|
|
|
|
elif at == ActionType.SPEAK_PRIVATE:
|
|
raw_target = action_args.get("target_id")
|
|
target_id = self._resolve_agent_id(raw_target, agent_map)
|
|
if target_id is not None and target_id in agent_map:
|
|
targets = [target_id]
|
|
action_args["target_id"] = target_id
|
|
if decision.speech:
|
|
action_args["content"] = decision.speech
|
|
rel_changes[str(target_id)] = "private_conversation"
|
|
self._update_relationship(agent, target_id, "private_conversation")
|
|
self._strengthen_social_edge(agent, target_id, 0.05, 0.02)
|
|
self._strengthen_social_edge(agent_map[target_id], agent.id, 0.05, 0.02)
|
|
|
|
elif at == ActionType.TRADE:
|
|
raw_target = action_args.get("target_id")
|
|
target_id = self._resolve_agent_id(raw_target, agent_map)
|
|
give_resource = action_args.get("give_resource", "")
|
|
try:
|
|
give_amount = int(action_args.get("give_amount", 0))
|
|
except (ValueError, TypeError):
|
|
give_amount = 0
|
|
receive_resource = action_args.get("receive_resource", "")
|
|
try:
|
|
receive_amount = int(action_args.get("receive_amount", 0))
|
|
except (ValueError, TypeError):
|
|
receive_amount = 0
|
|
|
|
if target_id is not None and target_id in agent_map:
|
|
target = agent_map[target_id]
|
|
if (
|
|
agent.resources.get(give_resource, 0) >= give_amount
|
|
and target.resources.get(receive_resource, 0) >= receive_amount
|
|
):
|
|
agent.resources[give_resource] = agent.resources.get(give_resource, 0) - give_amount
|
|
agent.resources[receive_resource] = agent.resources.get(receive_resource, 0) + receive_amount
|
|
target.resources[give_resource] = target.resources.get(give_resource, 0) + give_amount
|
|
target.resources[receive_resource] = target.resources.get(receive_resource, 0) - receive_amount
|
|
targets = [target_id]
|
|
world_changes["trade"] = True
|
|
self._update_relationship(agent, target_id, f"Trade partner — exchanged {give_resource} for {receive_resource}")
|
|
self._update_relationship(target, agent.id, f"Trade partner — exchanged {receive_resource} for {give_resource}")
|
|
self._strengthen_social_edge(agent, target_id, 0.08, 0.05)
|
|
self._strengthen_social_edge(target, agent.id, 0.08, 0.05)
|
|
rel_changes[str(target_id)] = "trade_partner"
|
|
|
|
elif at == ActionType.FORM_GROUP:
|
|
group_name = action_args.get("name", "Unnamed Group")
|
|
purpose = action_args.get("purpose", "")
|
|
existing = next((i for i in world_state.institutions if i.name == group_name), None)
|
|
|
|
if not existing and len(world_state.institutions) >= 3:
|
|
name_lower = group_name.lower()
|
|
purpose_lower = purpose.lower()
|
|
for inst in world_state.institutions:
|
|
if (inst.name.lower() in name_lower or name_lower in inst.name.lower()
|
|
or (purpose_lower and inst.purpose and
|
|
any(w in inst.purpose.lower() for w in purpose_lower.split() if len(w) > 3))):
|
|
existing = inst
|
|
group_name = inst.name
|
|
break
|
|
|
|
if not existing and len(world_state.institutions) >= 8:
|
|
smallest = min(world_state.institutions, key=lambda i: len(i.member_ids))
|
|
existing = smallest
|
|
group_name = smallest.name
|
|
|
|
if existing:
|
|
if agent.id not in existing.member_ids:
|
|
existing.member_ids.append(agent.id)
|
|
agent.faction = group_name
|
|
for mid in existing.member_ids:
|
|
if mid != agent.id:
|
|
self._update_relationship(agent_map[mid], agent.id, f"Fellow member of {group_name}")
|
|
self._update_relationship(agent, mid, f"Fellow member of {group_name}")
|
|
self._strengthen_social_edge(agent, mid, 0.1, 0.08)
|
|
self._strengthen_social_edge(agent_map[mid], agent.id, 0.1, 0.08)
|
|
rel_changes[str(mid)] = "faction_ally"
|
|
else:
|
|
inst = Institution(
|
|
name=group_name,
|
|
purpose=purpose,
|
|
founder_id=agent.id,
|
|
member_ids=[agent.id],
|
|
created_day=world_state.day,
|
|
)
|
|
world_state.institutions.append(inst)
|
|
agent.faction = group_name
|
|
world_changes["institution_created"] = group_name
|
|
world_state.metrics.word_of_mouth = min(1.0, world_state.metrics.word_of_mouth + 0.03)
|
|
world_state.metrics.trust += 0.01
|
|
|
|
elif at == ActionType.PROPOSE_RULE:
|
|
content = action_args.get("content", decision.speech or "")
|
|
if content:
|
|
proposal = Proposal(
|
|
id=str(uuid.uuid4())[:8],
|
|
proposer_id=agent.id,
|
|
content=content,
|
|
votes_for=[agent.id],
|
|
created_round=round_num,
|
|
)
|
|
world_state.proposals.append(proposal)
|
|
world_changes["proposal_created"] = content
|
|
|
|
elif at == ActionType.VOTE:
|
|
proposal_id = action_args.get("proposal_id")
|
|
vote = action_args.get("vote", "for")
|
|
if proposal_id:
|
|
for p in world_state.proposals:
|
|
if p.id == proposal_id and p.status == "open":
|
|
if vote == "for" and agent.id not in p.votes_for:
|
|
p.votes_for.append(agent.id)
|
|
elif vote == "against" and agent.id not in p.votes_against:
|
|
p.votes_against.append(agent.id)
|
|
|
|
total_votes = len(p.votes_for) + len(p.votes_against)
|
|
total_agents = len(agent_map)
|
|
if total_votes >= total_agents * 0.5:
|
|
if len(p.votes_for) > len(p.votes_against):
|
|
p.status = "passed"
|
|
world_state.community_rules.append(p.content)
|
|
world_changes["rule_passed"] = p.content
|
|
else:
|
|
p.status = "rejected"
|
|
world_changes["rule_rejected"] = p.content
|
|
break
|
|
else:
|
|
open_proposals = [p for p in world_state.proposals if p.status == "open"]
|
|
if open_proposals:
|
|
p = open_proposals[0]
|
|
vote = action_args.get("vote", "for")
|
|
if vote == "for" and agent.id not in p.votes_for:
|
|
p.votes_for.append(agent.id)
|
|
elif vote == "against" and agent.id not in p.votes_against:
|
|
p.votes_against.append(agent.id)
|
|
|
|
total_votes = len(p.votes_for) + len(p.votes_against)
|
|
total_agents = len(agent_map)
|
|
if total_votes >= total_agents * 0.5:
|
|
if len(p.votes_for) > len(p.votes_against):
|
|
p.status = "passed"
|
|
world_state.community_rules.append(p.content)
|
|
world_changes["rule_passed"] = p.content
|
|
else:
|
|
p.status = "rejected"
|
|
world_changes["rule_rejected"] = p.content
|
|
|
|
elif at == ActionType.PROTEST:
|
|
target_rule = action_args.get("target", "the current order")
|
|
world_state.active_disputes.append(f"{agent.name} protests: {target_rule}")
|
|
if len(world_state.active_disputes) > 10:
|
|
world_state.active_disputes.pop(0)
|
|
|
|
elif at == ActionType.COMPLY:
|
|
pass
|
|
|
|
elif at == ActionType.DEFECT:
|
|
world_state.active_disputes.append(f"{agent.name} defected: {action_args.get('how', 'broke the rules')}")
|
|
if len(world_state.active_disputes) > 10:
|
|
world_state.active_disputes.pop(0)
|
|
agent.has_defected = True
|
|
agent.resources["influence"] = agent.resources.get("influence", 0) + 5
|
|
for other in agent_map.values():
|
|
if other.id != agent.id:
|
|
if other.personality.conformity > 0.6:
|
|
self._update_relationship(other, agent.id, f"Saw {agent.name} defy the rules — lost respect")
|
|
self._strengthen_social_edge(other, agent.id, 0.03, -0.15)
|
|
rel_changes[str(other.id)] = "lost_respect"
|
|
elif other.personality.confrontational > 0.6:
|
|
self._update_relationship(other, agent.id, f"Saw {agent.name} defy the rules — impressed")
|
|
self._strengthen_social_edge(other, agent.id, 0.05, 0.1)
|
|
rel_changes[str(other.id)] = "impressed"
|
|
|
|
elif at == ActionType.BUILD:
|
|
cost_resource = action_args.get("resource", "goods")
|
|
try:
|
|
cost_amount = int(action_args.get("cost", 20))
|
|
except (ValueError, TypeError):
|
|
cost_amount = 20
|
|
if agent.resources.get(cost_resource, 0) >= cost_amount:
|
|
agent.resources[cost_resource] -= cost_amount
|
|
world_changes["built"] = action_args.get("what", "something")
|
|
|
|
elif at == ActionType.OBSERVE:
|
|
agent.resources["knowledge"] = agent.resources.get("knowledge", 0) + 3
|
|
nearby = [a for a in agent_map.values() if a.id != agent.id]
|
|
if nearby:
|
|
scene_parts = []
|
|
for nb in nearby[:5]:
|
|
scene_parts.append(f"{nb.name} ({nb.emotional_state})")
|
|
agent.working_memory.append(f"Observed: {', '.join(scene_parts)}")
|
|
if len(agent.working_memory) > 9:
|
|
agent.working_memory = agent.working_memory[-9:]
|
|
|
|
elif at == ActionType.RECOMMEND:
|
|
raw_target = action_args.get("target_id")
|
|
target_id = self._resolve_agent_id(raw_target, agent_map)
|
|
product = action_args.get("product", "the product")
|
|
reason = action_args.get("reason", "")
|
|
if target_id is not None and target_id in agent_map:
|
|
target = agent_map[target_id]
|
|
targets = [target_id]
|
|
action_args["target_id"] = target_id
|
|
target.working_memory.append(
|
|
f"{agent.name} recommended {product}: \"{reason[:80]}\""
|
|
)
|
|
if len(target.working_memory) > 9:
|
|
target.working_memory = target.working_memory[-9:]
|
|
self._update_relationship(agent, target_id, f"Recommended {product} to them")
|
|
self._strengthen_social_edge(agent, target_id, 0.06, 0.04)
|
|
self._strengthen_social_edge(target, agent.id, 0.06, 0.04)
|
|
rel_changes[str(target_id)] = "recommendation"
|
|
world_changes["recommendation"] = product
|
|
|
|
elif at == ActionType.PURCHASE:
|
|
product = action_args.get("product", "the product")
|
|
try:
|
|
amount = int(action_args.get("amount", 0))
|
|
except (ValueError, TypeError):
|
|
amount = 0
|
|
if amount <= 0:
|
|
amount = 10
|
|
cost_resource = "money" if "money" in agent.resources else "goods"
|
|
if agent.resources.get(cost_resource, 0) >= amount:
|
|
agent.resources[cost_resource] -= amount
|
|
world_changes["purchase"] = product
|
|
else:
|
|
world_changes["purchase"] = product
|
|
|
|
elif at == ActionType.ABANDON:
|
|
product = action_args.get("product", "the product")
|
|
reason = action_args.get("reason", "")
|
|
world_state.active_disputes.append(f"{agent.name} abandoned {product}: {reason[:60]}")
|
|
if len(world_state.active_disputes) > 10:
|
|
world_state.active_disputes.pop(0)
|
|
world_changes["abandon"] = product
|
|
agent.abandoned_products.add(product.lower())
|
|
if decision.speech:
|
|
action_args["content"] = decision.speech
|
|
|
|
elif at == ActionType.COMPARE:
|
|
product_a = action_args.get("product_a", "")
|
|
product_b = action_args.get("product_b", "")
|
|
verdict = action_args.get("verdict", "")
|
|
world_changes["comparison"] = f"{product_a} vs {product_b}"
|
|
others = [a for a in agent_map.values() if a.id != agent.id][:6]
|
|
for other in others:
|
|
other.working_memory.append(
|
|
f"{agent.name} compared {product_a} vs {product_b}: \"{verdict[:60]}\""
|
|
)
|
|
if len(other.working_memory) > 9:
|
|
other.working_memory = other.working_memory[-9:]
|
|
|
|
elif at == ActionType.RESEARCH:
|
|
agent.resources["knowledge"] = agent.resources.get("knowledge", 0) + 3
|
|
world_changes["research_query"] = action_args.get("query", "")
|
|
|
|
elif at == ActionType.INVESTIGATE:
|
|
raw_target = action_args.get("target_id")
|
|
target_id = self._resolve_agent_id(raw_target, agent_map)
|
|
if target_id is not None and target_id in agent_map:
|
|
targets = [target_id]
|
|
action_args["target_id"] = target_id
|
|
agent.resources["knowledge"] = agent.resources.get("knowledge", 0) + 2
|
|
self._update_relationship(agent, target_id, f"Investigated — asked about '{action_args.get('question', '')[:40]}'")
|
|
self._update_relationship(agent_map[target_id], agent.id, f"Was questioned by {agent.name}")
|
|
self._strengthen_social_edge(agent, target_id, 0.04, 0.0)
|
|
self._strengthen_social_edge(agent_map[target_id], agent.id, 0.04, 0.0)
|
|
rel_changes[str(target_id)] = "investigated"
|
|
|
|
return ActionEntry(
|
|
round=round_num,
|
|
day=world_state.day,
|
|
time_of_day=time_of_day,
|
|
agent_id=agent.id,
|
|
agent_name=agent.name,
|
|
location=agent.location,
|
|
action_type=at,
|
|
action_args=action_args,
|
|
speech=decision.speech,
|
|
internal_thought=decision.internal_thought,
|
|
targets=targets,
|
|
world_state_changes=world_changes,
|
|
relationship_changes=rel_changes,
|
|
)
|
|
|
|
def _update_metrics(
|
|
self,
|
|
old: WorldMetrics,
|
|
actions: list[ActionEntry],
|
|
world_state: WorldState,
|
|
) -> WorldMetrics:
|
|
alpha = 0.3
|
|
d_stability = 0.0
|
|
d_prosperity = 0.0
|
|
d_trust = 0.0
|
|
d_freedom = 0.0
|
|
d_conflict = 0.0
|
|
d_brand_sentiment = 0.0
|
|
d_purchase_intent = 0.0
|
|
d_word_of_mouth = 0.0
|
|
d_churn_risk = 0.0
|
|
d_adoption_rate = 0.0
|
|
|
|
for a in actions:
|
|
at = a.action_type
|
|
if at == ActionType.COMPLY:
|
|
d_stability += 0.02
|
|
d_trust += 0.005
|
|
d_brand_sentiment += 0.01
|
|
d_churn_risk -= 0.01
|
|
d_adoption_rate += 0.02
|
|
elif at == ActionType.VOTE:
|
|
d_stability += 0.01
|
|
elif at == ActionType.PROTEST:
|
|
d_stability -= 0.05
|
|
d_conflict += 0.03
|
|
d_trust -= 0.02
|
|
d_freedom += 0.03
|
|
d_brand_sentiment -= 0.03
|
|
d_purchase_intent -= 0.02
|
|
d_word_of_mouth += 0.03
|
|
d_churn_risk += 0.03
|
|
d_adoption_rate -= 0.01
|
|
elif at == ActionType.DEFECT:
|
|
d_stability -= 0.08
|
|
d_conflict += 0.05
|
|
d_trust -= 0.04
|
|
d_freedom += 0.04
|
|
d_brand_sentiment -= 0.04
|
|
d_purchase_intent -= 0.02
|
|
d_word_of_mouth += 0.01
|
|
d_churn_risk += 0.04
|
|
d_adoption_rate -= 0.02
|
|
elif at == ActionType.TRADE:
|
|
d_prosperity += 0.01
|
|
d_trust += 0.015
|
|
elif at == ActionType.BUILD:
|
|
d_prosperity += 0.02
|
|
elif at == ActionType.FORM_GROUP:
|
|
d_stability += 0.01
|
|
d_trust += 0.01
|
|
elif at == ActionType.PROPOSE_RULE:
|
|
d_conflict += 0.02
|
|
elif at == ActionType.SPEAK_PUBLIC:
|
|
d_trust += 0.01
|
|
d_word_of_mouth += 0.01
|
|
elif at == ActionType.SPEAK_PRIVATE:
|
|
d_trust -= 0.005
|
|
elif at == ActionType.RECOMMEND:
|
|
d_brand_sentiment += 0.01
|
|
d_purchase_intent += 0.02
|
|
d_word_of_mouth += 0.03
|
|
d_churn_risk -= 0.01
|
|
d_adoption_rate += 0.01
|
|
elif at == ActionType.PURCHASE:
|
|
d_brand_sentiment += 0.02
|
|
d_purchase_intent += 0.01
|
|
d_word_of_mouth += 0.01
|
|
d_churn_risk -= 0.02
|
|
d_adoption_rate += 0.03
|
|
d_prosperity += 0.01
|
|
elif at == ActionType.ABANDON:
|
|
d_brand_sentiment -= 0.05
|
|
d_purchase_intent -= 0.03
|
|
d_word_of_mouth += 0.02
|
|
d_churn_risk += 0.05
|
|
d_adoption_rate -= 0.02
|
|
elif at == ActionType.COMPARE:
|
|
d_word_of_mouth += 0.02
|
|
elif at == ActionType.RESEARCH:
|
|
d_trust += 0.005
|
|
elif at == ActionType.INVESTIGATE:
|
|
d_trust += 0.01
|
|
d_word_of_mouth += 0.01
|
|
|
|
if a.world_state_changes.get("rule_passed"):
|
|
d_freedom -= 0.03
|
|
d_stability += 0.03
|
|
if a.world_state_changes.get("rule_rejected"):
|
|
d_freedom += 0.03
|
|
d_stability -= 0.01
|
|
if a.world_state_changes.get("institution_created"):
|
|
d_freedom -= 0.02
|
|
|
|
n_rules = len(world_state.community_rules)
|
|
n_institutions = len(world_state.institutions)
|
|
freedom_pressure = -0.005 * (n_rules + n_institutions)
|
|
d_freedom += freedom_pressure
|
|
|
|
if not any(a.action_type in (ActionType.PROTEST, ActionType.DEFECT) for a in actions):
|
|
d_conflict -= 0.01
|
|
d_trust += 0.005
|
|
|
|
if not any(a.action_type in (ActionType.ABANDON, ActionType.PROTEST, ActionType.DEFECT) for a in actions):
|
|
d_churn_risk -= 0.005
|
|
d_brand_sentiment += 0.005
|
|
|
|
for inst in world_state.institutions:
|
|
member_count = len(inst.member_ids)
|
|
if member_count >= 3:
|
|
influence_bonus = 0.005 * member_count
|
|
d_word_of_mouth += influence_bonus
|
|
d_brand_sentiment += influence_bonus * 0.5
|
|
|
|
def ema(old_val: float, delta: float) -> float:
|
|
new = old_val + alpha * delta
|
|
return max(0.0, min(1.0, new))
|
|
|
|
return WorldMetrics(
|
|
stability=ema(old.stability, d_stability),
|
|
prosperity=ema(old.prosperity, d_prosperity),
|
|
trust=ema(old.trust, d_trust),
|
|
freedom=ema(old.freedom, d_freedom),
|
|
conflict=ema(old.conflict, d_conflict),
|
|
brand_sentiment=ema(old.brand_sentiment, d_brand_sentiment),
|
|
purchase_intent=ema(old.purchase_intent, d_purchase_intent),
|
|
word_of_mouth=ema(old.word_of_mouth, d_word_of_mouth),
|
|
churn_risk=ema(old.churn_risk, d_churn_risk),
|
|
adoption_rate=ema(old.adoption_rate, d_adoption_rate),
|
|
information_spread=old.information_spread,
|
|
echo_chamber_index=old.echo_chamber_index,
|
|
rumor_distortion=old.rumor_distortion,
|
|
)
|
|
|
|
@staticmethod
|
|
def _emotional_contagion(agents: list[AgentPersona]):
|
|
"""Emotions spread through the social graph. If most of your strong connections
|
|
are angry/frustrated, you drift negative even if your own experience is fine.
|
|
Gated by social_proof — high social_proof agents are more susceptible."""
|
|
NEGATIVE_STATES = {"angry", "frustrated", "fearful", "hostile", "desperate"}
|
|
POSITIVE_STATES = {"calm", "content", "curious", "satisfied", "hopeful"}
|
|
SUSCEPTIBLE_STATES = POSITIVE_STATES | {"restless", "uneasy", "confused"}
|
|
|
|
agent_map = {a.id: a for a in agents}
|
|
changes: list[tuple[AgentPersona, str]] = []
|
|
|
|
for agent in agents:
|
|
if agent.emotional_state not in SUSCEPTIBLE_STATES:
|
|
continue
|
|
strong_neighbors = [
|
|
e for e in agent.social_connections if e.strength > 0.5
|
|
]
|
|
if not strong_neighbors:
|
|
continue
|
|
|
|
neighbor_states = []
|
|
for edge in strong_neighbors:
|
|
other = agent_map.get(edge.target_id)
|
|
if other:
|
|
neighbor_states.append(other.emotional_state)
|
|
|
|
if not neighbor_states:
|
|
continue
|
|
|
|
negative_ratio = sum(1 for s in neighbor_states if s in NEGATIVE_STATES) / len(neighbor_states)
|
|
positive_ratio = sum(1 for s in neighbor_states if s in POSITIVE_STATES) / len(neighbor_states)
|
|
|
|
susceptibility = agent.personality.social_proof * 0.6 + agent.personality.empathy * 0.3
|
|
|
|
if negative_ratio > 0.5 and random.random() < negative_ratio * susceptibility:
|
|
if agent.emotional_state in POSITIVE_STATES:
|
|
changes.append((agent, "uneasy"))
|
|
elif agent.emotional_state in ("restless", "uneasy"):
|
|
changes.append((agent, "frustrated"))
|
|
elif positive_ratio > 0.7 and agent.emotional_state in ("uneasy", "restless") and random.random() < 0.2:
|
|
changes.append((agent, "calm"))
|
|
|
|
for agent, new_state in changes:
|
|
agent.emotional_state = new_state
|
|
|
|
EMOTIONAL_DECAY_MAP = {
|
|
"angry": "frustrated",
|
|
"hostile": "angry",
|
|
"desperate": "fearful",
|
|
"fearful": "anxious",
|
|
"frustrated": "restless",
|
|
"restless": "uneasy",
|
|
"uneasy": "calm",
|
|
"anxious": "uneasy",
|
|
}
|
|
|
|
@classmethod
|
|
def _emotional_decay(cls, agents: list[AgentPersona]):
|
|
"""Without reinforcement, extreme emotions gradually fade.
|
|
Base ~25% chance per round, boosted for conformist/loyal agents who
|
|
psychologically accept changes faster. This counterbalances contagion
|
|
to prevent uniform negativity cascades."""
|
|
for agent in agents:
|
|
if agent.emotional_state in cls.EMOTIONAL_DECAY_MAP:
|
|
p = agent.personality
|
|
decay_prob = 0.25
|
|
if p.conformity >= 0.6:
|
|
decay_prob += 0.12
|
|
if p.brand_loyalty >= 0.6:
|
|
decay_prob += 0.10
|
|
if p.confrontational <= 0.3:
|
|
decay_prob += 0.08
|
|
if random.random() < min(0.65, decay_prob):
|
|
agent.emotional_state = cls.EMOTIONAL_DECAY_MAP[agent.emotional_state]
|
|
|