Add lifecycle, divorce, elections, memory query, baby agents, hospital births
Browse filesMajor simulation features:
- Age progression (1 year per 365 sim days) with lifecycle stages
- Death system for 80+ with exponential probability curve
- Divorce mechanic for unhappy marriages (low trust/sentiment/mood)
- Married couples move in together (cohabitation)
- Church wedding ceremonies, hospital births after 9 sim-day pregnancy
- Baby agents born as real citizens, living with parents, autonomous at age 4
- Annual mayor elections with weighted voting (18+ citizens)
- Short-term (weekly) and long-term goals with auto-refresh
- Memory query API: ask any agent about their memories via LLM
- Community score tracking for election candidacy
- UI: lifecycle badges, deceased agents, mayor display, split goals,
memory query input, parent info, new event icons
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- src/soci/agents/agent.py +134 -14
- src/soci/api/routes.py +75 -0
- src/soci/engine/simulation.py +380 -15
- web/index.html +84 -13
|
@@ -79,7 +79,7 @@ class Agent:
|
|
| 79 |
|
| 80 |
# Life history — append-only timeline of significant events
|
| 81 |
self.life_events: list[dict] = []
|
| 82 |
-
# Personal goals — evolving aspirations
|
| 83 |
self.goals: list[dict] = []
|
| 84 |
self._next_goal_id: int = 0
|
| 85 |
# Pregnancy (female agents)
|
|
@@ -89,6 +89,23 @@ class Agent:
|
|
| 89 |
# Children (name strings)
|
| 90 |
self.children: list[str] = []
|
| 91 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 92 |
@property
|
| 93 |
def is_busy(self) -> bool:
|
| 94 |
return self._action_ticks_remaining > 0
|
|
@@ -207,15 +224,76 @@ class Agent:
|
|
| 207 |
"""Reset plan flag for a new day."""
|
| 208 |
self._has_plan_today = False
|
| 209 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 210 |
def add_life_event(self, day: int, tick: int, event_type: str, description: str) -> dict:
|
| 211 |
"""Record a significant life event (promotion, marriage, birth, etc.)."""
|
| 212 |
event = {"day": day, "tick": tick, "type": event_type, "description": description}
|
| 213 |
self.life_events.append(event)
|
| 214 |
return event
|
| 215 |
|
| 216 |
-
def add_goal(self, description: str, status: str = "active") -> dict:
|
| 217 |
-
"""Add a personal goal."""
|
| 218 |
-
goal = {
|
|
|
|
|
|
|
|
|
|
| 219 |
self._next_goal_id += 1
|
| 220 |
self.goals.append(goal)
|
| 221 |
return goal
|
|
@@ -233,21 +311,35 @@ class Agent:
|
|
| 233 |
def seed_biography(self, day: int, tick: int) -> None:
|
| 234 |
"""Generate initial life events from persona background. Called on sim reset."""
|
| 235 |
p = self.persona
|
|
|
|
| 236 |
# Origin event
|
| 237 |
-
self.add_life_event(
|
| 238 |
# Career event
|
| 239 |
if p.occupation and p.occupation.lower() not in ("newcomer", "unknown", "unemployed"):
|
| 240 |
-
self.add_life_event(
|
| 241 |
-
# Seed initial goals based on persona
|
| 242 |
occ_lower = (p.occupation or "").lower()
|
| 243 |
if "student" in occ_lower:
|
| 244 |
-
self.add_goal("Graduate and find a career")
|
| 245 |
elif p.age < 30:
|
| 246 |
-
self.add_goal("Advance in my career")
|
| 247 |
if p.age >= 20 and p.age < 40:
|
| 248 |
-
self.add_goal("Find meaningful relationships")
|
| 249 |
if p.age >= 30:
|
| 250 |
-
self.add_goal("Build a stable and happy life")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 251 |
|
| 252 |
def biography_summary(self) -> str:
|
| 253 |
"""Short biography string for LLM context."""
|
|
@@ -279,9 +371,17 @@ class Agent:
|
|
| 279 |
parts.append("I am currently pregnant.")
|
| 280 |
parts.append("")
|
| 281 |
active_goals = [g for g in self.goals if g["status"] == "active"]
|
| 282 |
-
|
| 283 |
-
|
| 284 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 285 |
pct = int(g.get("progress", 0) * 100)
|
| 286 |
parts.append(f"- {g['description']} ({pct}% progress)")
|
| 287 |
parts.append("")
|
|
@@ -331,6 +431,16 @@ class Agent:
|
|
| 331 |
"pregnancy_start_tick": self.pregnancy_start_tick,
|
| 332 |
"pregnancy_partner_id": self.pregnancy_partner_id,
|
| 333 |
"children": self.children,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 334 |
}
|
| 335 |
|
| 336 |
@classmethod
|
|
@@ -358,4 +468,14 @@ class Agent:
|
|
| 358 |
agent.pregnancy_start_tick = data.get("pregnancy_start_tick", 0)
|
| 359 |
agent.pregnancy_partner_id = data.get("pregnancy_partner_id")
|
| 360 |
agent.children = data.get("children", [])
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 361 |
return agent
|
|
|
|
| 79 |
|
| 80 |
# Life history — append-only timeline of significant events
|
| 81 |
self.life_events: list[dict] = []
|
| 82 |
+
# Personal goals — evolving aspirations (each has "term": "short" or "long")
|
| 83 |
self.goals: list[dict] = []
|
| 84 |
self._next_goal_id: int = 0
|
| 85 |
# Pregnancy (female agents)
|
|
|
|
| 89 |
# Children (name strings)
|
| 90 |
self.children: list[str] = []
|
| 91 |
|
| 92 |
+
# Age progression — tracks starting age and the day it was set
|
| 93 |
+
self._birth_age: int = persona.age # Age when simulation started
|
| 94 |
+
self._birth_day: int = 0 # Sim day when agent was created
|
| 95 |
+
# Alive status
|
| 96 |
+
self.alive: bool = True
|
| 97 |
+
self.death_day: int = 0
|
| 98 |
+
self.death_cause: str = ""
|
| 99 |
+
# Parent agent IDs (for baby agents)
|
| 100 |
+
self.parent_ids: list[str] = []
|
| 101 |
+
# Lifecycle stage
|
| 102 |
+
self.lifecycle_stage: str = self._compute_lifecycle_stage()
|
| 103 |
+
# Mayor status
|
| 104 |
+
self.is_mayor: bool = False
|
| 105 |
+
self.mayor_term_start_day: int = 0
|
| 106 |
+
# Community contribution score (for election)
|
| 107 |
+
self.community_score: float = 0.0
|
| 108 |
+
|
| 109 |
@property
|
| 110 |
def is_busy(self) -> bool:
|
| 111 |
return self._action_ticks_remaining > 0
|
|
|
|
| 224 |
"""Reset plan flag for a new day."""
|
| 225 |
self._has_plan_today = False
|
| 226 |
|
| 227 |
+
def _compute_lifecycle_stage(self) -> str:
|
| 228 |
+
"""Compute lifecycle stage from current age."""
|
| 229 |
+
age = self.persona.age
|
| 230 |
+
if age < 4:
|
| 231 |
+
return "infant"
|
| 232 |
+
elif age < 6:
|
| 233 |
+
return "toddler"
|
| 234 |
+
elif age < 12:
|
| 235 |
+
return "child"
|
| 236 |
+
elif age < 18:
|
| 237 |
+
return "teenager"
|
| 238 |
+
elif age < 30:
|
| 239 |
+
return "young_adult"
|
| 240 |
+
elif age < 60:
|
| 241 |
+
return "adult"
|
| 242 |
+
elif age < 75:
|
| 243 |
+
return "senior"
|
| 244 |
+
else:
|
| 245 |
+
return "elderly"
|
| 246 |
+
|
| 247 |
+
def get_current_age(self, current_day: int) -> int:
|
| 248 |
+
"""Calculate agent's current age based on sim days elapsed.
|
| 249 |
+
Each 365 sim days = 1 year of age."""
|
| 250 |
+
days_elapsed = max(0, current_day - self._birth_day)
|
| 251 |
+
years_elapsed = days_elapsed // 365
|
| 252 |
+
return self._birth_age + years_elapsed
|
| 253 |
+
|
| 254 |
+
def tick_age(self, current_day: int) -> bool:
|
| 255 |
+
"""Update age based on current sim day. Returns True if age changed."""
|
| 256 |
+
new_age = self.get_current_age(current_day)
|
| 257 |
+
if new_age != self.persona.age:
|
| 258 |
+
self.persona.age = new_age
|
| 259 |
+
self.lifecycle_stage = self._compute_lifecycle_stage()
|
| 260 |
+
return True
|
| 261 |
+
return False
|
| 262 |
+
|
| 263 |
+
def check_death(self, current_day: int) -> bool:
|
| 264 |
+
"""Check if this agent should die of old age. Only agents 80+ have a chance."""
|
| 265 |
+
import random
|
| 266 |
+
if not self.alive or self.persona.age < 80:
|
| 267 |
+
return False
|
| 268 |
+
# Gentle curve: agents live a few years past 80 on average
|
| 269 |
+
# 80 = 0.05%/day (~17% annual), 90 = 0.2%/day (~52% annual),
|
| 270 |
+
# 100 = 0.5%/day (~84% annual), 110 = 1%/day (~97% annual)
|
| 271 |
+
age = self.persona.age
|
| 272 |
+
daily_death_chance = 0.0005 * (1.08 ** (age - 80)) # exponential growth
|
| 273 |
+
return random.random() < daily_death_chance
|
| 274 |
+
|
| 275 |
+
def die(self, day: int, tick: int, cause: str = "old age") -> None:
|
| 276 |
+
"""Mark agent as dead."""
|
| 277 |
+
self.alive = False
|
| 278 |
+
self.death_day = day
|
| 279 |
+
self.death_cause = cause
|
| 280 |
+
self.state = AgentState.IDLE
|
| 281 |
+
self.current_action = None
|
| 282 |
+
self._action_ticks_remaining = 0
|
| 283 |
+
self.add_life_event(day, tick, "death", f"Passed away from {cause} at age {self.persona.age}")
|
| 284 |
+
|
| 285 |
def add_life_event(self, day: int, tick: int, event_type: str, description: str) -> dict:
|
| 286 |
"""Record a significant life event (promotion, marriage, birth, etc.)."""
|
| 287 |
event = {"day": day, "tick": tick, "type": event_type, "description": description}
|
| 288 |
self.life_events.append(event)
|
| 289 |
return event
|
| 290 |
|
| 291 |
+
def add_goal(self, description: str, status: str = "active", term: str = "long") -> dict:
|
| 292 |
+
"""Add a personal goal. term: 'short' (day/week) or 'long' (life goal)."""
|
| 293 |
+
goal = {
|
| 294 |
+
"id": self._next_goal_id, "description": description,
|
| 295 |
+
"status": status, "progress": 0.0, "term": term,
|
| 296 |
+
}
|
| 297 |
self._next_goal_id += 1
|
| 298 |
self.goals.append(goal)
|
| 299 |
return goal
|
|
|
|
| 311 |
def seed_biography(self, day: int, tick: int) -> None:
|
| 312 |
"""Generate initial life events from persona background. Called on sim reset."""
|
| 313 |
p = self.persona
|
| 314 |
+
self._birth_day = day # Track when agent entered the sim
|
| 315 |
# Origin event
|
| 316 |
+
self.add_life_event(day, tick, "origin", f"Born and raised. {p.background}")
|
| 317 |
# Career event
|
| 318 |
if p.occupation and p.occupation.lower() not in ("newcomer", "unknown", "unemployed"):
|
| 319 |
+
self.add_life_event(day, tick, "career", f"Works as {p.occupation}")
|
| 320 |
+
# Seed initial LONG-TERM goals based on persona
|
| 321 |
occ_lower = (p.occupation or "").lower()
|
| 322 |
if "student" in occ_lower:
|
| 323 |
+
self.add_goal("Graduate and find a career", term="long")
|
| 324 |
elif p.age < 30:
|
| 325 |
+
self.add_goal("Advance in my career", term="long")
|
| 326 |
if p.age >= 20 and p.age < 40:
|
| 327 |
+
self.add_goal("Find meaningful relationships", term="long")
|
| 328 |
if p.age >= 30:
|
| 329 |
+
self.add_goal("Build a stable and happy life", term="long")
|
| 330 |
+
# Seed initial SHORT-TERM goals
|
| 331 |
+
if p.age >= 18:
|
| 332 |
+
self.add_goal("Meet someone new this week", term="short")
|
| 333 |
+
# Baby/child lifecycle goals
|
| 334 |
+
if p.age < 4:
|
| 335 |
+
self.lifecycle_stage = "infant"
|
| 336 |
+
elif p.age < 6:
|
| 337 |
+
self.add_goal("Go to kindergarten", term="long")
|
| 338 |
+
elif p.age < 12:
|
| 339 |
+
self.add_goal("Do well in school", term="long")
|
| 340 |
+
elif p.age < 18:
|
| 341 |
+
self.add_goal("Graduate high school", term="long")
|
| 342 |
+
self.add_goal("Figure out what to do after school", term="long")
|
| 343 |
|
| 344 |
def biography_summary(self) -> str:
|
| 345 |
"""Short biography string for LLM context."""
|
|
|
|
| 371 |
parts.append("I am currently pregnant.")
|
| 372 |
parts.append("")
|
| 373 |
active_goals = [g for g in self.goals if g["status"] == "active"]
|
| 374 |
+
long_goals = [g for g in active_goals if g.get("term") == "long"]
|
| 375 |
+
short_goals = [g for g in active_goals if g.get("term") == "short"]
|
| 376 |
+
if long_goals:
|
| 377 |
+
parts.append("MY LONG-TERM GOALS:")
|
| 378 |
+
for g in long_goals:
|
| 379 |
+
pct = int(g.get("progress", 0) * 100)
|
| 380 |
+
parts.append(f"- {g['description']} ({pct}% progress)")
|
| 381 |
+
parts.append("")
|
| 382 |
+
if short_goals:
|
| 383 |
+
parts.append("MY SHORT-TERM GOALS (this week):")
|
| 384 |
+
for g in short_goals:
|
| 385 |
pct = int(g.get("progress", 0) * 100)
|
| 386 |
parts.append(f"- {g['description']} ({pct}% progress)")
|
| 387 |
parts.append("")
|
|
|
|
| 431 |
"pregnancy_start_tick": self.pregnancy_start_tick,
|
| 432 |
"pregnancy_partner_id": self.pregnancy_partner_id,
|
| 433 |
"children": self.children,
|
| 434 |
+
"_birth_age": self._birth_age,
|
| 435 |
+
"_birth_day": self._birth_day,
|
| 436 |
+
"alive": self.alive,
|
| 437 |
+
"death_day": self.death_day,
|
| 438 |
+
"death_cause": self.death_cause,
|
| 439 |
+
"parent_ids": self.parent_ids,
|
| 440 |
+
"lifecycle_stage": self.lifecycle_stage,
|
| 441 |
+
"is_mayor": self.is_mayor,
|
| 442 |
+
"mayor_term_start_day": self.mayor_term_start_day,
|
| 443 |
+
"community_score": self.community_score,
|
| 444 |
}
|
| 445 |
|
| 446 |
@classmethod
|
|
|
|
| 468 |
agent.pregnancy_start_tick = data.get("pregnancy_start_tick", 0)
|
| 469 |
agent.pregnancy_partner_id = data.get("pregnancy_partner_id")
|
| 470 |
agent.children = data.get("children", [])
|
| 471 |
+
agent._birth_age = data.get("_birth_age", agent.persona.age)
|
| 472 |
+
agent._birth_day = data.get("_birth_day", 0)
|
| 473 |
+
agent.alive = data.get("alive", True)
|
| 474 |
+
agent.death_day = data.get("death_day", 0)
|
| 475 |
+
agent.death_cause = data.get("death_cause", "")
|
| 476 |
+
agent.parent_ids = data.get("parent_ids", [])
|
| 477 |
+
agent.lifecycle_stage = data.get("lifecycle_stage", agent._compute_lifecycle_stage())
|
| 478 |
+
agent.is_mayor = data.get("is_mayor", False)
|
| 479 |
+
agent.mayor_term_start_day = data.get("mayor_term_start_day", 0)
|
| 480 |
+
agent.community_score = data.get("community_score", 0.0)
|
| 481 |
return agent
|
|
@@ -212,6 +212,16 @@ async def get_agent(agent_id: str):
|
|
| 212 |
"goals": agent.goals,
|
| 213 |
"pregnant": agent.pregnant,
|
| 214 |
"children": agent.children,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 215 |
}
|
| 216 |
|
| 217 |
|
|
@@ -237,6 +247,71 @@ async def get_agent_memories(agent_id: str, limit: int = 20):
|
|
| 237 |
]
|
| 238 |
|
| 239 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 240 |
@router.get("/conversations")
|
| 241 |
async def get_conversations(include_history: bool = True, limit: int = 20):
|
| 242 |
"""Get active and recent conversations with full dialogue."""
|
|
|
|
| 212 |
"goals": agent.goals,
|
| 213 |
"pregnant": agent.pregnant,
|
| 214 |
"children": agent.children,
|
| 215 |
+
"alive": agent.alive,
|
| 216 |
+
"death_day": agent.death_day,
|
| 217 |
+
"death_cause": agent.death_cause,
|
| 218 |
+
"lifecycle_stage": agent.lifecycle_stage,
|
| 219 |
+
"parent_ids": agent.parent_ids,
|
| 220 |
+
"parent_names": [
|
| 221 |
+
sim.agents[pid].name for pid in agent.parent_ids if pid in sim.agents
|
| 222 |
+
],
|
| 223 |
+
"is_mayor": agent.is_mayor,
|
| 224 |
+
"community_score": round(agent.community_score, 1),
|
| 225 |
}
|
| 226 |
|
| 227 |
|
|
|
|
| 247 |
]
|
| 248 |
|
| 249 |
|
| 250 |
+
class MemoryQueryRequest(BaseModel):
|
| 251 |
+
question: str
|
| 252 |
+
|
| 253 |
+
|
| 254 |
+
@router.post("/agents/{agent_id}/ask")
|
| 255 |
+
async def ask_agent_memory(agent_id: str, request: MemoryQueryRequest):
|
| 256 |
+
"""Ask an agent about their memories using LLM. The agent reflects on their
|
| 257 |
+
experiences and answers the question in character."""
|
| 258 |
+
from soci.api.server import get_simulation
|
| 259 |
+
sim = get_simulation()
|
| 260 |
+
agent = sim.agents.get(agent_id)
|
| 261 |
+
if not agent:
|
| 262 |
+
raise HTTPException(status_code=404, detail=f"Agent {agent_id} not found")
|
| 263 |
+
|
| 264 |
+
# Gather relevant memories by searching for keywords in the question
|
| 265 |
+
all_memories = agent.memory.memories
|
| 266 |
+
question_lower = request.question.lower()
|
| 267 |
+
|
| 268 |
+
# Score memories by relevance to the question
|
| 269 |
+
scored = []
|
| 270 |
+
for m in all_memories:
|
| 271 |
+
score = m.importance
|
| 272 |
+
content_lower = m.content.lower()
|
| 273 |
+
# Boost if question keywords appear in memory
|
| 274 |
+
for word in question_lower.split():
|
| 275 |
+
if len(word) > 3 and word in content_lower:
|
| 276 |
+
score += 5
|
| 277 |
+
scored.append((score, m))
|
| 278 |
+
scored.sort(key=lambda x: x[0], reverse=True)
|
| 279 |
+
relevant = scored[:20]
|
| 280 |
+
|
| 281 |
+
memories_text = "\n".join(
|
| 282 |
+
f"- [Day {m.day} {m.time_str}] {m.content}" for _, m in relevant
|
| 283 |
+
)
|
| 284 |
+
|
| 285 |
+
# Also include life events
|
| 286 |
+
life_events_text = "\n".join(
|
| 287 |
+
f"- Day {e['day']}: {e['description']}" for e in agent.life_events[-15:]
|
| 288 |
+
)
|
| 289 |
+
|
| 290 |
+
# Build prompt
|
| 291 |
+
prompt = (
|
| 292 |
+
f"A person is asking you to reflect on your memories and life experiences.\n\n"
|
| 293 |
+
f"QUESTION: {request.question}\n\n"
|
| 294 |
+
f"YOUR RELEVANT MEMORIES:\n{memories_text}\n\n"
|
| 295 |
+
f"YOUR LIFE HISTORY:\n{life_events_text}\n\n"
|
| 296 |
+
f"Answer the question thoughtfully and in character, drawing on your actual "
|
| 297 |
+
f"memories and experiences. Be personal and emotional. If you don't have "
|
| 298 |
+
f"relevant memories, say so honestly."
|
| 299 |
+
)
|
| 300 |
+
|
| 301 |
+
try:
|
| 302 |
+
from soci.engine.llm import MODEL_HAIKU
|
| 303 |
+
response = await sim.llm.complete(
|
| 304 |
+
system=agent.persona.system_prompt(),
|
| 305 |
+
user_message=prompt,
|
| 306 |
+
model=MODEL_HAIKU,
|
| 307 |
+
temperature=0.8,
|
| 308 |
+
max_tokens=512,
|
| 309 |
+
)
|
| 310 |
+
return {"answer": response or "(no response)", "memories_used": len(relevant)}
|
| 311 |
+
except Exception as e:
|
| 312 |
+
return {"answer": f"(Could not generate response: {str(e)})", "memories_used": 0}
|
| 313 |
+
|
| 314 |
+
|
| 315 |
@router.get("/conversations")
|
| 316 |
async def get_conversations(include_history: bool = True, limit: int = 20):
|
| 317 |
"""Get active and recent conversations with full dialogue."""
|
|
@@ -199,6 +199,10 @@ class Simulation:
|
|
| 199 |
routine_actions: list[tuple[Agent, AgentAction]] = []
|
| 200 |
|
| 201 |
for agent in ordered_agents:
|
|
|
|
|
|
|
|
|
|
|
|
|
| 202 |
# Tick needs
|
| 203 |
is_sleeping = agent.state.value == "sleeping"
|
| 204 |
agent.tick_needs(is_sleeping=is_sleeping)
|
|
@@ -344,6 +348,26 @@ class Simulation:
|
|
| 344 |
# 9b. Pregnancy — check for new pregnancies and births
|
| 345 |
self._tick_pregnancy()
|
| 346 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 347 |
# 10. Advance clock
|
| 348 |
self.clock.tick()
|
| 349 |
|
|
@@ -618,6 +642,12 @@ class Simulation:
|
|
| 618 |
if len(self.conversation_history) > self._max_conversation_history:
|
| 619 |
self.conversation_history = self.conversation_history[-self._max_conversation_history:]
|
| 620 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 621 |
self._emit(
|
| 622 |
f" [CONV END] Conversation about '{conv.topic}' between "
|
| 623 |
f"{', '.join(self.agents[p].name for p in conv.participants if p in self.agents)} ended."
|
|
@@ -758,6 +788,8 @@ class Simulation:
|
|
| 758 |
agents_list = list(self.agents.values())
|
| 759 |
|
| 760 |
for agent in agents_list:
|
|
|
|
|
|
|
| 761 |
loc = self.city.get_location(agent.location)
|
| 762 |
if not loc:
|
| 763 |
continue
|
|
@@ -860,21 +892,33 @@ class Simulation:
|
|
| 860 |
if other_rel and other_rel.romantic_interest > 0.8:
|
| 861 |
rel.relationship_status = "married"
|
| 862 |
other_rel.relationship_status = "married"
|
| 863 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 864 |
for a, o in [(agent, other), (other, agent)]:
|
| 865 |
a.add_observation(
|
| 866 |
tick=self.clock.total_ticks, day=self.clock.day,
|
| 867 |
time_str=self.clock.time_str,
|
| 868 |
-
content=f"I married {o.name} today. I couldn't be happier.",
|
| 869 |
importance=10, involved_agents=[o.id],
|
| 870 |
)
|
| 871 |
a.add_life_event(self.clock.day, self.clock.total_ticks,
|
| 872 |
-
"married", f"Married {o.name}")
|
|
|
|
|
|
|
| 873 |
|
| 874 |
def _tick_pregnancy(self) -> None:
|
| 875 |
-
"""Handle pregnancy for married couples. Children are born after ~
|
| 876 |
import random as _rand
|
| 877 |
-
|
|
|
|
| 878 |
|
| 879 |
for agent in list(self.agents.values()):
|
| 880 |
# New pregnancy chance: married female, at home with partner
|
|
@@ -906,9 +950,25 @@ class Simulation:
|
|
| 906 |
a.mood = min(1.0, a.mood + 0.4)
|
| 907 |
self._emit(f" [LIFE] {agent.name} and {partner.name} are expecting!")
|
| 908 |
|
| 909 |
-
#
|
| 910 |
if agent.pregnant:
|
| 911 |
elapsed = self.clock.total_ticks - agent.pregnancy_start_tick
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 912 |
if elapsed >= PREGNANCY_DURATION_TICKS:
|
| 913 |
partner = self.agents.get(agent.pregnancy_partner_id)
|
| 914 |
# Pick a baby name
|
|
@@ -917,41 +977,343 @@ class Simulation:
|
|
| 917 |
baby_names_f = ["Emma", "Olivia", "Sophia", "Mia", "Isabella", "Zoe", "Luna", "Aria"]
|
| 918 |
is_girl = _r.random() < 0.5
|
| 919 |
pool = baby_names_f if is_girl else baby_names_m
|
| 920 |
-
# Avoid duplicate names
|
| 921 |
-
used = set(agent.children)
|
| 922 |
available = [n for n in pool if n not in used]
|
| 923 |
baby_name = _r.choice(available) if available else _r.choice(pool)
|
|
|
|
|
|
|
|
|
|
| 924 |
|
| 925 |
agent.pregnant = False
|
| 926 |
-
agent.children.append(
|
| 927 |
agent.add_life_event(self.clock.day, self.clock.total_ticks,
|
| 928 |
-
"child_born", f"Gave birth to {
|
| 929 |
agent.add_observation(
|
| 930 |
tick=self.clock.total_ticks, day=self.clock.day,
|
| 931 |
time_str=self.clock.time_str,
|
| 932 |
-
content=f"Our baby {
|
| 933 |
importance=10,
|
| 934 |
)
|
| 935 |
if partner:
|
| 936 |
-
partner.children.append(
|
| 937 |
partner.add_life_event(self.clock.day, self.clock.total_ticks,
|
| 938 |
-
"child_born", f"{agent.name} and I welcomed {
|
| 939 |
partner.add_observation(
|
| 940 |
tick=self.clock.total_ticks, day=self.clock.day,
|
| 941 |
time_str=self.clock.time_str,
|
| 942 |
-
content=f"Our baby {
|
| 943 |
importance=10,
|
| 944 |
)
|
| 945 |
partner.mood = min(1.0, partner.mood + 0.5)
|
| 946 |
agent.mood = min(1.0, agent.mood + 0.5)
|
| 947 |
-
self._emit(f" [LIFE] {agent.name} gave birth to {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 948 |
|
| 949 |
def get_state_summary(self) -> dict:
|
| 950 |
"""Get a summary of the current simulation state."""
|
|
|
|
|
|
|
| 951 |
return {
|
| 952 |
"clock": self.clock.to_dict(),
|
| 953 |
"weather": self.events.weather.value,
|
| 954 |
"active_events": [e.to_dict() for e in self.events.active_events],
|
|
|
|
| 955 |
"agents": {
|
| 956 |
aid: {
|
| 957 |
"name": a.name,
|
|
@@ -968,6 +1330,9 @@ class Simulation:
|
|
| 968 |
"is_player": a.is_player,
|
| 969 |
"pregnant": a.pregnant,
|
| 970 |
"children_count": len(a.children),
|
|
|
|
|
|
|
|
|
|
| 971 |
}
|
| 972 |
for aid, a in self.agents.items()
|
| 973 |
},
|
|
|
|
| 199 |
routine_actions: list[tuple[Agent, AgentAction]] = []
|
| 200 |
|
| 201 |
for agent in ordered_agents:
|
| 202 |
+
# Skip dead agents and infants (under 4 — no autonomous behavior)
|
| 203 |
+
if not agent.alive or agent.persona.age < 4:
|
| 204 |
+
continue
|
| 205 |
+
|
| 206 |
# Tick needs
|
| 207 |
is_sleeping = agent.state.value == "sleeping"
|
| 208 |
agent.tick_needs(is_sleeping=is_sleeping)
|
|
|
|
| 348 |
# 9b. Pregnancy — check for new pregnancies and births
|
| 349 |
self._tick_pregnancy()
|
| 350 |
|
| 351 |
+
# 9c. Age progression — once per sim day at midnight
|
| 352 |
+
if self.clock.hour == 0 and self.clock.minute == 0:
|
| 353 |
+
self._tick_aging()
|
| 354 |
+
|
| 355 |
+
# 9d. Divorce — check for unhappy marriages
|
| 356 |
+
if self.clock.hour == 12 and self.clock.minute == 0:
|
| 357 |
+
self._tick_divorce()
|
| 358 |
+
|
| 359 |
+
# 9e. Cohabitation — married couples move in together
|
| 360 |
+
if self.clock.hour == 8 and self.clock.minute == 0:
|
| 361 |
+
self._tick_cohabitation()
|
| 362 |
+
|
| 363 |
+
# 9f. Mayor election — every 365 days
|
| 364 |
+
if self.clock.day > 0 and self.clock.day % 365 == 0 and self.clock.hour == 10 and self.clock.minute == 0:
|
| 365 |
+
self._tick_election()
|
| 366 |
+
|
| 367 |
+
# 9g. Short-term goal refresh — weekly
|
| 368 |
+
if self.clock.day > 0 and self.clock.day % 7 == 0 and self.clock.hour == 6 and self.clock.minute == 0:
|
| 369 |
+
self._tick_short_term_goals()
|
| 370 |
+
|
| 371 |
# 10. Advance clock
|
| 372 |
self.clock.tick()
|
| 373 |
|
|
|
|
| 642 |
if len(self.conversation_history) > self._max_conversation_history:
|
| 643 |
self.conversation_history = self.conversation_history[-self._max_conversation_history:]
|
| 644 |
|
| 645 |
+
# Boost community score for social interaction
|
| 646 |
+
for agent_id in conv.participants:
|
| 647 |
+
agent = self.agents.get(agent_id)
|
| 648 |
+
if agent and agent.alive:
|
| 649 |
+
agent.community_score += 0.5
|
| 650 |
+
|
| 651 |
self._emit(
|
| 652 |
f" [CONV END] Conversation about '{conv.topic}' between "
|
| 653 |
f"{', '.join(self.agents[p].name for p in conv.participants if p in self.agents)} ended."
|
|
|
|
| 788 |
agents_list = list(self.agents.values())
|
| 789 |
|
| 790 |
for agent in agents_list:
|
| 791 |
+
if not agent.alive or agent.persona.age < 16:
|
| 792 |
+
continue
|
| 793 |
loc = self.city.get_location(agent.location)
|
| 794 |
if not loc:
|
| 795 |
continue
|
|
|
|
| 892 |
if other_rel and other_rel.romantic_interest > 0.8:
|
| 893 |
rel.relationship_status = "married"
|
| 894 |
other_rel.relationship_status = "married"
|
| 895 |
+
# Move both to church for the ceremony
|
| 896 |
+
church = self.city.get_location("church")
|
| 897 |
+
if church:
|
| 898 |
+
for a in [agent, other]:
|
| 899 |
+
old_loc = self.city.get_location(a.location)
|
| 900 |
+
if old_loc and a.location != "church":
|
| 901 |
+
old_loc.remove_occupant(a.id)
|
| 902 |
+
church.add_occupant(a.id)
|
| 903 |
+
a.location = "church"
|
| 904 |
+
self._emit(f" [ROMANCE] {agent.name} and {other.name} got married at the church!")
|
| 905 |
for a, o in [(agent, other), (other, agent)]:
|
| 906 |
a.add_observation(
|
| 907 |
tick=self.clock.total_ticks, day=self.clock.day,
|
| 908 |
time_str=self.clock.time_str,
|
| 909 |
+
content=f"I married {o.name} today at the church. I couldn't be happier.",
|
| 910 |
importance=10, involved_agents=[o.id],
|
| 911 |
)
|
| 912 |
a.add_life_event(self.clock.day, self.clock.total_ticks,
|
| 913 |
+
"married", f"Married {o.name} at St. Mary's Church")
|
| 914 |
+
# Boost community score for getting married
|
| 915 |
+
a.community_score += 5.0
|
| 916 |
|
| 917 |
def _tick_pregnancy(self) -> None:
|
| 918 |
+
"""Handle pregnancy for married couples. Children are born at the hospital after ~9 sim-months."""
|
| 919 |
import random as _rand
|
| 920 |
+
# 9 sim-months: 9 * 30 days * 96 ticks/day = 25920, but compressed to ~9 sim-days
|
| 921 |
+
PREGNANCY_DURATION_TICKS = 864 # ~9 days (96 ticks/day × 9 days)
|
| 922 |
|
| 923 |
for agent in list(self.agents.values()):
|
| 924 |
# New pregnancy chance: married female, at home with partner
|
|
|
|
| 950 |
a.mood = min(1.0, a.mood + 0.4)
|
| 951 |
self._emit(f" [LIFE] {agent.name} and {partner.name} are expecting!")
|
| 952 |
|
| 953 |
+
# Move to hospital when close to due date (~1 day before)
|
| 954 |
if agent.pregnant:
|
| 955 |
elapsed = self.clock.total_ticks - agent.pregnancy_start_tick
|
| 956 |
+
if elapsed >= (PREGNANCY_DURATION_TICKS - 96) and agent.location != "hospital":
|
| 957 |
+
hospital = self.city.get_location("hospital")
|
| 958 |
+
if hospital:
|
| 959 |
+
old_loc = self.city.get_location(agent.location)
|
| 960 |
+
if old_loc:
|
| 961 |
+
old_loc.remove_occupant(agent.id)
|
| 962 |
+
hospital.add_occupant(agent.id)
|
| 963 |
+
agent.location = "hospital"
|
| 964 |
+
agent.add_observation(
|
| 965 |
+
tick=self.clock.total_ticks, day=self.clock.day,
|
| 966 |
+
time_str=self.clock.time_str,
|
| 967 |
+
content="Going to the hospital — the baby is coming soon!",
|
| 968 |
+
importance=8,
|
| 969 |
+
)
|
| 970 |
+
self._emit(f" [LIFE] {agent.name} went to the hospital for delivery!")
|
| 971 |
+
|
| 972 |
if elapsed >= PREGNANCY_DURATION_TICKS:
|
| 973 |
partner = self.agents.get(agent.pregnancy_partner_id)
|
| 974 |
# Pick a baby name
|
|
|
|
| 977 |
baby_names_f = ["Emma", "Olivia", "Sophia", "Mia", "Isabella", "Zoe", "Luna", "Aria"]
|
| 978 |
is_girl = _r.random() < 0.5
|
| 979 |
pool = baby_names_f if is_girl else baby_names_m
|
| 980 |
+
# Avoid duplicate names across all agents
|
| 981 |
+
used = set(agent.children) | {a.name.split()[-1] for a in self.agents.values()}
|
| 982 |
available = [n for n in pool if n not in used]
|
| 983 |
baby_name = _r.choice(available) if available else _r.choice(pool)
|
| 984 |
+
# Surname from mother
|
| 985 |
+
surname = agent.name.split()[-1] if " " in agent.name else ""
|
| 986 |
+
full_baby_name = f"{baby_name} {surname}".strip()
|
| 987 |
|
| 988 |
agent.pregnant = False
|
| 989 |
+
agent.children.append(full_baby_name)
|
| 990 |
agent.add_life_event(self.clock.day, self.clock.total_ticks,
|
| 991 |
+
"child_born", f"Gave birth to {full_baby_name}!")
|
| 992 |
agent.add_observation(
|
| 993 |
tick=self.clock.total_ticks, day=self.clock.day,
|
| 994 |
time_str=self.clock.time_str,
|
| 995 |
+
content=f"Our baby {full_baby_name} was born today! I'm overwhelmed with joy.",
|
| 996 |
importance=10,
|
| 997 |
)
|
| 998 |
if partner:
|
| 999 |
+
partner.children.append(full_baby_name)
|
| 1000 |
partner.add_life_event(self.clock.day, self.clock.total_ticks,
|
| 1001 |
+
"child_born", f"{agent.name} and I welcomed {full_baby_name}!")
|
| 1002 |
partner.add_observation(
|
| 1003 |
tick=self.clock.total_ticks, day=self.clock.day,
|
| 1004 |
time_str=self.clock.time_str,
|
| 1005 |
+
content=f"Our baby {full_baby_name} was born! I'm a parent now!",
|
| 1006 |
importance=10,
|
| 1007 |
)
|
| 1008 |
partner.mood = min(1.0, partner.mood + 0.5)
|
| 1009 |
agent.mood = min(1.0, agent.mood + 0.5)
|
| 1010 |
+
self._emit(f" [LIFE] {agent.name} gave birth to {full_baby_name}!")
|
| 1011 |
+
|
| 1012 |
+
# Create actual baby agent living with parents
|
| 1013 |
+
baby_id = f"baby_{full_baby_name.lower().replace(' ', '_')}_{self.clock.total_ticks}"
|
| 1014 |
+
baby_gender = "female" if is_girl else "male"
|
| 1015 |
+
baby_persona = Persona(
|
| 1016 |
+
id=baby_id,
|
| 1017 |
+
name=full_baby_name,
|
| 1018 |
+
age=0,
|
| 1019 |
+
occupation="infant",
|
| 1020 |
+
gender=baby_gender,
|
| 1021 |
+
background=f"Born to {agent.name}" + (f" and {partner.name}" if partner else "") + " in Soci City.",
|
| 1022 |
+
home_location=agent.persona.home_location,
|
| 1023 |
+
work_location="",
|
| 1024 |
+
openness=_r.randint(3, 8),
|
| 1025 |
+
conscientiousness=_r.randint(3, 8),
|
| 1026 |
+
extraversion=_r.randint(3, 8),
|
| 1027 |
+
agreeableness=_r.randint(4, 9),
|
| 1028 |
+
neuroticism=_r.randint(2, 7),
|
| 1029 |
+
)
|
| 1030 |
+
baby_agent = Agent(baby_persona)
|
| 1031 |
+
baby_agent._birth_day = self.clock.day
|
| 1032 |
+
baby_agent._birth_age = 0
|
| 1033 |
+
baby_agent.parent_ids = [agent.id] + ([partner.id] if partner else [])
|
| 1034 |
+
baby_agent.lifecycle_stage = "infant"
|
| 1035 |
+
# Baby lives at parents' home, no memories until age 4
|
| 1036 |
+
self.add_agent(baby_agent)
|
| 1037 |
+
self._emit(f" [LIFE] New citizen: {full_baby_name} born!")
|
| 1038 |
+
|
| 1039 |
+
def _tick_aging(self) -> None:
|
| 1040 |
+
"""Update ages for all agents. Check for death of elderly agents."""
|
| 1041 |
+
for agent in list(self.agents.values()):
|
| 1042 |
+
if not agent.alive:
|
| 1043 |
+
continue
|
| 1044 |
+
old_age = agent.persona.age
|
| 1045 |
+
changed = agent.tick_age(self.clock.day)
|
| 1046 |
+
if changed:
|
| 1047 |
+
new_age = agent.persona.age
|
| 1048 |
+
# Birthday event
|
| 1049 |
+
agent.add_life_event(
|
| 1050 |
+
self.clock.day, self.clock.total_ticks,
|
| 1051 |
+
"birthday", f"Turned {new_age} years old",
|
| 1052 |
+
)
|
| 1053 |
+
agent.add_observation(
|
| 1054 |
+
tick=self.clock.total_ticks, day=self.clock.day,
|
| 1055 |
+
time_str=self.clock.time_str,
|
| 1056 |
+
content=f"Today is my birthday! I'm now {new_age} years old.",
|
| 1057 |
+
importance=6,
|
| 1058 |
+
)
|
| 1059 |
+
self._emit(f" [LIFE] {agent.name} turned {new_age}!")
|
| 1060 |
+
|
| 1061 |
+
# Lifecycle stage transitions with goal updates
|
| 1062 |
+
if new_age == 4:
|
| 1063 |
+
agent.add_goal("Go to kindergarten", term="long")
|
| 1064 |
+
self._emit(f" [LIFE] {agent.name} is old enough for kindergarten!")
|
| 1065 |
+
elif new_age == 6:
|
| 1066 |
+
agent.add_goal("Do well in school", term="long")
|
| 1067 |
+
elif new_age == 12:
|
| 1068 |
+
agent.add_goal("Graduate high school", term="long")
|
| 1069 |
+
elif new_age == 18:
|
| 1070 |
+
agent.add_goal("Go to university", term="long")
|
| 1071 |
+
agent.add_goal("Get a job", term="long")
|
| 1072 |
+
elif new_age == 22:
|
| 1073 |
+
agent.add_goal("Start a career", term="long")
|
| 1074 |
+
|
| 1075 |
+
# Death check for elderly
|
| 1076 |
+
if agent.persona.age >= 80 and agent.check_death(self.clock.day):
|
| 1077 |
+
agent.die(self.clock.day, self.clock.total_ticks)
|
| 1078 |
+
self._emit(f" [DEATH] {agent.name} passed away at age {agent.persona.age}.")
|
| 1079 |
+
# Notify partner
|
| 1080 |
+
if agent.partner_id and agent.partner_id in self.agents:
|
| 1081 |
+
partner = self.agents[agent.partner_id]
|
| 1082 |
+
partner.partner_id = None
|
| 1083 |
+
partner.mood = max(-1.0, partner.mood - 0.6)
|
| 1084 |
+
partner.add_life_event(
|
| 1085 |
+
self.clock.day, self.clock.total_ticks,
|
| 1086 |
+
"bereavement", f"Lost my partner {agent.name}",
|
| 1087 |
+
)
|
| 1088 |
+
partner.add_observation(
|
| 1089 |
+
tick=self.clock.total_ticks, day=self.clock.day,
|
| 1090 |
+
time_str=self.clock.time_str,
|
| 1091 |
+
content=f"My partner {agent.name} passed away. I'm devastated.",
|
| 1092 |
+
importance=10, involved_agents=[agent.id],
|
| 1093 |
+
)
|
| 1094 |
+
# Remove from city location
|
| 1095 |
+
loc = self.city.get_location(agent.location)
|
| 1096 |
+
if loc:
|
| 1097 |
+
loc.remove_occupant(agent.id)
|
| 1098 |
+
|
| 1099 |
+
def _tick_divorce(self) -> None:
|
| 1100 |
+
"""Check for unhappy marriages that lead to divorce."""
|
| 1101 |
+
for agent in list(self.agents.values()):
|
| 1102 |
+
if not agent.alive or not agent.partner_id:
|
| 1103 |
+
continue
|
| 1104 |
+
partner = self.agents.get(agent.partner_id)
|
| 1105 |
+
if not partner or not partner.alive:
|
| 1106 |
+
continue
|
| 1107 |
+
rel = agent.relationships.get(partner.id)
|
| 1108 |
+
if not rel or rel.relationship_status != "married":
|
| 1109 |
+
continue
|
| 1110 |
+
# Divorce conditions: very low sentiment + trust + mood for extended period
|
| 1111 |
+
if (rel.sentiment < 0.2 and rel.trust < 0.25
|
| 1112 |
+
and agent.mood < -0.3
|
| 1113 |
+
and random.random() < 0.01): # 1% daily chance when unhappy
|
| 1114 |
+
# Divorce!
|
| 1115 |
+
rel.relationship_status = "divorced"
|
| 1116 |
+
rel.romantic_interest = max(0, rel.romantic_interest - 0.5)
|
| 1117 |
+
other_rel = partner.relationships.get(agent.id)
|
| 1118 |
+
if other_rel:
|
| 1119 |
+
other_rel.relationship_status = "divorced"
|
| 1120 |
+
other_rel.romantic_interest = max(0, other_rel.romantic_interest - 0.5)
|
| 1121 |
+
agent.partner_id = None
|
| 1122 |
+
partner.partner_id = None
|
| 1123 |
+
for a, o in [(agent, partner), (partner, agent)]:
|
| 1124 |
+
a.add_life_event(
|
| 1125 |
+
self.clock.day, self.clock.total_ticks,
|
| 1126 |
+
"divorce", f"Divorced {o.name}",
|
| 1127 |
+
)
|
| 1128 |
+
a.add_observation(
|
| 1129 |
+
tick=self.clock.total_ticks, day=self.clock.day,
|
| 1130 |
+
time_str=self.clock.time_str,
|
| 1131 |
+
content=f"I divorced {o.name}. Our marriage wasn't working anymore.",
|
| 1132 |
+
importance=9, involved_agents=[o.id],
|
| 1133 |
+
)
|
| 1134 |
+
a.mood = max(-1.0, a.mood - 0.4)
|
| 1135 |
+
self._emit(f" [ROMANCE] {agent.name} and {partner.name} got divorced!")
|
| 1136 |
+
|
| 1137 |
+
def _tick_cohabitation(self) -> None:
|
| 1138 |
+
"""Married couples move in together — move to one partner's home."""
|
| 1139 |
+
processed = set()
|
| 1140 |
+
for agent in list(self.agents.values()):
|
| 1141 |
+
if not agent.alive or not agent.partner_id or agent.id in processed:
|
| 1142 |
+
continue
|
| 1143 |
+
partner = self.agents.get(agent.partner_id)
|
| 1144 |
+
if not partner or not partner.alive or partner.id in processed:
|
| 1145 |
+
continue
|
| 1146 |
+
rel = agent.relationships.get(partner.id)
|
| 1147 |
+
if not rel or rel.relationship_status != "married":
|
| 1148 |
+
continue
|
| 1149 |
+
# Already living together?
|
| 1150 |
+
if agent.persona.home_location == partner.persona.home_location:
|
| 1151 |
+
processed.add(agent.id)
|
| 1152 |
+
processed.add(partner.id)
|
| 1153 |
+
continue
|
| 1154 |
+
# Move to the home of the agent who has lived there longer (or whichever is non-empty)
|
| 1155 |
+
new_home = agent.persona.home_location
|
| 1156 |
+
mover = partner
|
| 1157 |
+
partner.persona.home_location = new_home
|
| 1158 |
+
mover.add_life_event(
|
| 1159 |
+
self.clock.day, self.clock.total_ticks,
|
| 1160 |
+
"moved", f"Moved in with {agent.name} at {new_home}",
|
| 1161 |
+
)
|
| 1162 |
+
agent.add_observation(
|
| 1163 |
+
tick=self.clock.total_ticks, day=self.clock.day,
|
| 1164 |
+
time_str=self.clock.time_str,
|
| 1165 |
+
content=f"{partner.name} moved in with me!",
|
| 1166 |
+
importance=8, involved_agents=[partner.id],
|
| 1167 |
+
)
|
| 1168 |
+
self._emit(f" [LIFE] {partner.name} moved in with {agent.name}")
|
| 1169 |
+
processed.add(agent.id)
|
| 1170 |
+
processed.add(partner.id)
|
| 1171 |
+
|
| 1172 |
+
def _tick_election(self) -> None:
|
| 1173 |
+
"""Annual mayor election — all citizens 18+ vote for the most community-oriented agent."""
|
| 1174 |
+
eligible_voters = [
|
| 1175 |
+
a for a in self.agents.values()
|
| 1176 |
+
if a.alive and a.persona.age >= 18
|
| 1177 |
+
]
|
| 1178 |
+
if len(eligible_voters) < 3:
|
| 1179 |
+
return
|
| 1180 |
+
|
| 1181 |
+
# Candidates: all eligible non-player agents
|
| 1182 |
+
candidates = [a for a in eligible_voters if not a.is_player]
|
| 1183 |
+
if not candidates:
|
| 1184 |
+
return
|
| 1185 |
+
|
| 1186 |
+
# Score candidates by community contribution:
|
| 1187 |
+
# - interaction_count with diverse agents
|
| 1188 |
+
# - agreeableness trait
|
| 1189 |
+
# - community_score (accumulated from actions)
|
| 1190 |
+
# - mood (positive people inspire)
|
| 1191 |
+
scores: dict[str, float] = {}
|
| 1192 |
+
for c in candidates:
|
| 1193 |
+
score = 0.0
|
| 1194 |
+
# Social connections
|
| 1195 |
+
known = c.relationships.get_closest(20)
|
| 1196 |
+
score += len(known) * 2.0
|
| 1197 |
+
score += sum(r.sentiment for r in known) * 3.0
|
| 1198 |
+
# Personality
|
| 1199 |
+
score += c.persona.agreeableness * 1.5
|
| 1200 |
+
score += c.persona.extraversion * 0.5
|
| 1201 |
+
# Mood and contribution
|
| 1202 |
+
score += max(0, c.mood) * 5.0
|
| 1203 |
+
score += c.community_score
|
| 1204 |
+
scores[c.id] = score
|
| 1205 |
+
|
| 1206 |
+
# Each voter votes — weighted random choice biased toward high scores
|
| 1207 |
+
votes: dict[str, int] = {c.id: 0 for c in candidates}
|
| 1208 |
+
for voter in eligible_voters:
|
| 1209 |
+
# Bias toward people the voter knows and likes
|
| 1210 |
+
voter_scores = {}
|
| 1211 |
+
for c in candidates:
|
| 1212 |
+
if c.id == voter.id:
|
| 1213 |
+
continue
|
| 1214 |
+
base = scores.get(c.id, 0)
|
| 1215 |
+
rel = voter.relationships.get(c.id)
|
| 1216 |
+
if rel:
|
| 1217 |
+
base += rel.sentiment * 10 + rel.trust * 5
|
| 1218 |
+
voter_scores[c.id] = max(0.1, base)
|
| 1219 |
+
if not voter_scores:
|
| 1220 |
+
continue
|
| 1221 |
+
total = sum(voter_scores.values())
|
| 1222 |
+
r = random.random() * total
|
| 1223 |
+
cumulative = 0.0
|
| 1224 |
+
chosen = list(voter_scores.keys())[0]
|
| 1225 |
+
for cid, s in voter_scores.items():
|
| 1226 |
+
cumulative += s
|
| 1227 |
+
if r <= cumulative:
|
| 1228 |
+
chosen = cid
|
| 1229 |
+
break
|
| 1230 |
+
votes[chosen] = votes.get(chosen, 0) + 1
|
| 1231 |
+
|
| 1232 |
+
# Winner
|
| 1233 |
+
winner_id = max(votes, key=lambda k: votes[k])
|
| 1234 |
+
winner = self.agents[winner_id]
|
| 1235 |
+
vote_count = votes[winner_id]
|
| 1236 |
+
|
| 1237 |
+
# Remove old mayor
|
| 1238 |
+
for a in self.agents.values():
|
| 1239 |
+
if a.is_mayor:
|
| 1240 |
+
a.is_mayor = False
|
| 1241 |
+
a.add_life_event(
|
| 1242 |
+
self.clock.day, self.clock.total_ticks,
|
| 1243 |
+
"politics", "Term as Mayor ended",
|
| 1244 |
+
)
|
| 1245 |
+
|
| 1246 |
+
# Set new mayor
|
| 1247 |
+
winner.is_mayor = True
|
| 1248 |
+
winner.mayor_term_start_day = self.clock.day
|
| 1249 |
+
winner.add_life_event(
|
| 1250 |
+
self.clock.day, self.clock.total_ticks,
|
| 1251 |
+
"politics", f"Elected as Mayor of Soci City with {vote_count} votes!",
|
| 1252 |
+
)
|
| 1253 |
+
winner.add_observation(
|
| 1254 |
+
tick=self.clock.total_ticks, day=self.clock.day,
|
| 1255 |
+
time_str=self.clock.time_str,
|
| 1256 |
+
content=f"I was elected Mayor of Soci City! {vote_count} people voted for me.",
|
| 1257 |
+
importance=10,
|
| 1258 |
+
)
|
| 1259 |
+
winner.mood = min(1.0, winner.mood + 0.5)
|
| 1260 |
+
self._emit(f" [ELECTION] {winner.name} elected Mayor with {vote_count}/{len(eligible_voters)} votes!")
|
| 1261 |
+
|
| 1262 |
+
# All voters remember the election
|
| 1263 |
+
for voter in eligible_voters:
|
| 1264 |
+
if voter.id != winner_id:
|
| 1265 |
+
voter.add_observation(
|
| 1266 |
+
tick=self.clock.total_ticks, day=self.clock.day,
|
| 1267 |
+
time_str=self.clock.time_str,
|
| 1268 |
+
content=f"{winner.name} was elected as our new Mayor.",
|
| 1269 |
+
importance=6, involved_agents=[winner_id],
|
| 1270 |
+
)
|
| 1271 |
+
|
| 1272 |
+
def _tick_short_term_goals(self) -> None:
|
| 1273 |
+
"""Refresh short-term goals weekly — expire old ones, add new ones."""
|
| 1274 |
+
import random as _r
|
| 1275 |
+
short_goals_pool = [
|
| 1276 |
+
"Meet someone new this week",
|
| 1277 |
+
"Try a new restaurant",
|
| 1278 |
+
"Exercise at least twice",
|
| 1279 |
+
"Read something interesting",
|
| 1280 |
+
"Help a neighbor",
|
| 1281 |
+
"Visit the park",
|
| 1282 |
+
"Have a deep conversation",
|
| 1283 |
+
"Cook something special",
|
| 1284 |
+
"Explore a new part of town",
|
| 1285 |
+
"Catch up with an old friend",
|
| 1286 |
+
"Organize my home",
|
| 1287 |
+
"Learn something new",
|
| 1288 |
+
"Spend time outdoors",
|
| 1289 |
+
"Do something creative",
|
| 1290 |
+
]
|
| 1291 |
+
for agent in self.agents.values():
|
| 1292 |
+
if not agent.alive or agent.persona.age < 6:
|
| 1293 |
+
continue
|
| 1294 |
+
# Expire active short-term goals
|
| 1295 |
+
for g in agent.goals:
|
| 1296 |
+
if g.get("term") == "short" and g["status"] == "active":
|
| 1297 |
+
if g["progress"] >= 0.7:
|
| 1298 |
+
g["status"] = "completed"
|
| 1299 |
+
g["progress"] = 1.0
|
| 1300 |
+
else:
|
| 1301 |
+
g["status"] = "abandoned"
|
| 1302 |
+
# Add 1-2 new short-term goals
|
| 1303 |
+
count = _r.randint(1, 2)
|
| 1304 |
+
for _ in range(count):
|
| 1305 |
+
goal_desc = _r.choice(short_goals_pool)
|
| 1306 |
+
agent.add_goal(goal_desc, term="short")
|
| 1307 |
|
| 1308 |
def get_state_summary(self) -> dict:
|
| 1309 |
"""Get a summary of the current simulation state."""
|
| 1310 |
+
# Find current mayor
|
| 1311 |
+
mayor = next((a for a in self.agents.values() if a.is_mayor and a.alive), None)
|
| 1312 |
return {
|
| 1313 |
"clock": self.clock.to_dict(),
|
| 1314 |
"weather": self.events.weather.value,
|
| 1315 |
"active_events": [e.to_dict() for e in self.events.active_events],
|
| 1316 |
+
"mayor": {"id": mayor.id, "name": mayor.name} if mayor else None,
|
| 1317 |
"agents": {
|
| 1318 |
aid: {
|
| 1319 |
"name": a.name,
|
|
|
|
| 1330 |
"is_player": a.is_player,
|
| 1331 |
"pregnant": a.pregnant,
|
| 1332 |
"children_count": len(a.children),
|
| 1333 |
+
"alive": a.alive,
|
| 1334 |
+
"lifecycle_stage": a.lifecycle_stage,
|
| 1335 |
+
"is_mayor": a.is_mayor,
|
| 1336 |
}
|
| 1337 |
for aid, a in self.agents.items()
|
| 1338 |
},
|
|
@@ -2732,22 +2732,30 @@ function showDefaultDetail() {
|
|
| 2732 |
return (a[1].name || '').localeCompare(b[1].name || '');
|
| 2733 |
});
|
| 2734 |
|
|
|
|
|
|
|
|
|
|
| 2735 |
el.innerHTML = `
|
| 2736 |
-
<div class="section-header">Population (${
|
|
|
|
| 2737 |
${sorted.map(([aid,a]) => {
|
| 2738 |
-
const
|
| 2739 |
-
const
|
| 2740 |
-
const
|
|
|
|
|
|
|
| 2741 |
const partner = a.partner_id && agents[a.partner_id] ? ` \u2764\uFE0F ${agents[a.partner_id].name.split(' ')[0]}` : '';
|
| 2742 |
const pregIcon = a.pregnant ? ' \uD83E\uDD30' : '';
|
| 2743 |
const kidIcon = a.children_count > 0 ? ` \uD83D\uDC76${a.children_count}` : '';
|
|
|
|
|
|
|
| 2744 |
return `
|
| 2745 |
-
<div class="agent-list-item" onclick="selectedAgentId='${aid}';fetchAgentDetail('${aid}');">
|
| 2746 |
<span class="agent-dot" style="background:${color}"></span>
|
| 2747 |
<span style="font-size:13px">${gi}</span>
|
| 2748 |
<div class="agent-info">
|
| 2749 |
-
<div class="agent-name" style="color:${color}">${a.name}${partner}${pregIcon}${kidIcon}${stateIcon}</div>
|
| 2750 |
-
<div class="agent-action">${esc(a.action||'idle')}</div>
|
| 2751 |
</div>
|
| 2752 |
</div>`;
|
| 2753 |
}).join('')}`;
|
|
@@ -2787,12 +2795,16 @@ function renderAgentDetail(data) {
|
|
| 2787 |
promotion:'\uD83D\uDCC8', graduated:'\uD83C\uDF93', achievement:'\u2B50',
|
| 2788 |
milestone:'\uD83C\uDFC6', new_job:'\uD83D\uDCBC', moved:'\uD83D\uDE9A',
|
| 2789 |
breakup:'\uD83D\uDC94', friendship:'\uD83E\uDD1D',
|
|
|
|
|
|
|
| 2790 |
};
|
| 2791 |
|
| 2792 |
-
// Goals
|
| 2793 |
const goals = data.goals || [];
|
| 2794 |
const activeGoals = goals.filter(g => g.status === 'active');
|
| 2795 |
const completedGoals = goals.filter(g => g.status === 'completed');
|
|
|
|
|
|
|
| 2796 |
|
| 2797 |
// Children & pregnancy
|
| 2798 |
const children = data.children || [];
|
|
@@ -2800,15 +2812,26 @@ function renderAgentDetail(data) {
|
|
| 2800 |
const pregnantBadge = isPregnant ? ' \uD83E\uDD30' : '';
|
| 2801 |
const childrenBadge = children.length > 0 ? ` \uD83D\uDC76${children.length}` : '';
|
| 2802 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2803 |
document.getElementById('agent-detail').innerHTML=`
|
| 2804 |
<div style="margin-bottom:6px">
|
| 2805 |
<span style="cursor:pointer;color:#888;font-size:11px" onclick="selectedAgentId=null;showDefaultDetail();">← All Agents</span>
|
| 2806 |
</div>
|
| 2807 |
<h2>${gi} ${data.name||'?'}${pregnantBadge}${childrenBadge}</h2>
|
| 2808 |
-
<p class="subtitle">${data.age||'?'} y/o ${data.occupation||''}</p>
|
| 2809 |
<p class="subtitle" style="color:#888;font-size:10px">${data.traits||''}</p>
|
| 2810 |
<p class="subtitle" style="color:#e0e0e0">@ ${esc(locName)} — ${esc(data.action||'idle')}</p>
|
| 2811 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2812 |
${romanceRels.length>0?`<div style="margin:6px 0;padding:4px 8px;background:rgba(233,30,144,0.1);border:1px solid rgba(233,30,144,0.2);border-radius:4px;font-size:11px;color:#e91e90">
|
| 2813 |
${romanceRels.map(r=>`\u2764 ${r.relationship_status} with ${r.name} (${(r.romantic_interest*100).toFixed(0)}%)`).join('<br>')}
|
| 2814 |
</div>`:''}
|
|
@@ -2817,6 +2840,7 @@ function renderAgentDetail(data) {
|
|
| 2817 |
\uD83D\uDC76 Children: ${children.map(c=>esc(c)).join(', ')}
|
| 2818 |
</div>`:''}
|
| 2819 |
|
|
|
|
| 2820 |
<div class="bar-container">
|
| 2821 |
<div class="bar-label"><span>Mood: ${moodLabel}</span><span>${moodPct}%</span></div>
|
| 2822 |
<div class="bar-bg"><div class="bar-fill ${moodColor}" style="width:${moodPct}%"></div></div>
|
|
@@ -2833,10 +2857,11 @@ function renderAgentDetail(data) {
|
|
| 2833 |
<div style="margin-top:6px">
|
| 2834 |
<div style="font-size:10px;color:#888">Plan: ${esc((data.daily_plan||[]).slice(0,3).join('; ')||'None yet')}</div>
|
| 2835 |
</div>
|
|
|
|
| 2836 |
|
| 2837 |
-
${
|
| 2838 |
-
<div class="section-header">Goals</div>
|
| 2839 |
-
${
|
| 2840 |
const pct=Math.round((g.progress||0)*100);
|
| 2841 |
return `<div style="margin:3px 0;font-size:11px">
|
| 2842 |
<div style="display:flex;justify-content:space-between;color:#e0e0f0">
|
|
@@ -2847,9 +2872,28 @@ function renderAgentDetail(data) {
|
|
| 2847 |
</div>
|
| 2848 |
</div>`;
|
| 2849 |
}).join('')}
|
| 2850 |
-
${completedGoals.slice(-3).map(g=>`<div style="margin:2px 0;font-size:10px;color:#555">\u2705 ${esc(g.description)}</div>`).join('')}
|
| 2851 |
`:''}
|
| 2852 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2853 |
${lifeEvents.length>0?`
|
| 2854 |
<div class="section-header">Life History</div>
|
| 2855 |
<div style="max-height:160px;overflow-y:auto">
|
|
@@ -2888,6 +2932,13 @@ function renderAgentDetail(data) {
|
|
| 2888 |
<div class="section-header">Recent Memories</div>
|
| 2889 |
${memories.map(m=>`<div class="memory-item"><span class="memory-time">${m.time||''}</span> ${esc(m.content||'')}</div>`).join('')||'<div class="memory-item" style="color:#555">No memories yet</div>'}
|
| 2890 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2891 |
${playerToken && playerAgentId && data.id !== playerAgentId ? `
|
| 2892 |
<div style="margin-top:10px">
|
| 2893 |
<button class="btn-primary" style="width:100%" onclick="openChat('${data.id}')">Talk to ${esc(data.name||'them')}</button>
|
|
@@ -2895,6 +2946,25 @@ function renderAgentDetail(data) {
|
|
| 2895 |
`;
|
| 2896 |
}
|
| 2897 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2898 |
// ============================================================
|
| 2899 |
// CONVERSATIONS TAB
|
| 2900 |
// ============================================================
|
|
@@ -3026,6 +3096,7 @@ function processStateData(data) {
|
|
| 3026 |
}
|
| 3027 |
|
| 3028 |
agents = data.agents || {};
|
|
|
|
| 3029 |
renderPlayerPanel();
|
| 3030 |
|
| 3031 |
const tick = clock.total_ticks || 0;
|
|
|
|
| 2732 |
return (a[1].name || '').localeCompare(b[1].name || '');
|
| 2733 |
});
|
| 2734 |
|
| 2735 |
+
const aliveCount = sorted.filter(([,a]) => a.alive !== false).length;
|
| 2736 |
+
const deadCount = sorted.length - aliveCount;
|
| 2737 |
+
const mayorInfo = window._mayorData ? `<div style="font-size:10px;color:#f0c040;margin:4px 0">\uD83C\uDFDB\uFE0F Mayor: ${esc(window._mayorData.name)}</div>` : '';
|
| 2738 |
el.innerHTML = `
|
| 2739 |
+
<div class="section-header">Population (${aliveCount} alive${deadCount > 0 ? `, ${deadCount} deceased` : ''})</div>
|
| 2740 |
+
${mayorInfo}
|
| 2741 |
${sorted.map(([aid,a]) => {
|
| 2742 |
+
const isDead = a.alive === false;
|
| 2743 |
+
const color = isDead ? '#555' : AGENT_COLORS[getAgentIdx(aid) % AGENT_COLORS.length];
|
| 2744 |
+
const stg = a.lifecycle_stage || '';
|
| 2745 |
+
const gi = isDead ? '\u2620\uFE0F' : (stg==='infant'||stg==='toddler') ? '\uD83D\uDC76' : (stg==='child'||stg==='teenager') ? '\uD83E\uDDD2' : a.gender==='female'?'\uD83D\uDC69':a.gender==='male'?'\uD83D\uDC68':'\uD83E\uDDD1';
|
| 2746 |
+
const stateIcon = isDead ? '' : a.state==='in_conversation'?' \uD83D\uDCAC':(a.state==='sleeping'?' \uD83D\uDCA4':(a.state==='moving'?' \uD83D\uDEB6':''));
|
| 2747 |
const partner = a.partner_id && agents[a.partner_id] ? ` \u2764\uFE0F ${agents[a.partner_id].name.split(' ')[0]}` : '';
|
| 2748 |
const pregIcon = a.pregnant ? ' \uD83E\uDD30' : '';
|
| 2749 |
const kidIcon = a.children_count > 0 ? ` \uD83D\uDC76${a.children_count}` : '';
|
| 2750 |
+
const mayorBadge = a.is_mayor ? ' \uD83C\uDFDB\uFE0F' : '';
|
| 2751 |
+
const ageStr = a.age != null ? ` (${a.age})` : '';
|
| 2752 |
return `
|
| 2753 |
+
<div class="agent-list-item" onclick="selectedAgentId='${aid}';fetchAgentDetail('${aid}');" style="${isDead ? 'opacity:0.5' : ''}">
|
| 2754 |
<span class="agent-dot" style="background:${color}"></span>
|
| 2755 |
<span style="font-size:13px">${gi}</span>
|
| 2756 |
<div class="agent-info">
|
| 2757 |
+
<div class="agent-name" style="color:${color}">${a.name}${ageStr}${mayorBadge}${partner}${pregIcon}${kidIcon}${stateIcon}</div>
|
| 2758 |
+
<div class="agent-action">${isDead ? 'Deceased' : esc(a.action||'idle')}</div>
|
| 2759 |
</div>
|
| 2760 |
</div>`;
|
| 2761 |
}).join('')}`;
|
|
|
|
| 2795 |
promotion:'\uD83D\uDCC8', graduated:'\uD83C\uDF93', achievement:'\u2B50',
|
| 2796 |
milestone:'\uD83C\uDFC6', new_job:'\uD83D\uDCBC', moved:'\uD83D\uDE9A',
|
| 2797 |
breakup:'\uD83D\uDC94', friendship:'\uD83E\uDD1D',
|
| 2798 |
+
death:'\u2620\uFE0F', birthday:'\uD83C\uDF82', divorce:'\uD83D\uDC94',
|
| 2799 |
+
politics:'\uD83C\uDFDB\uFE0F', bereavement:'\uD83D\uDE22',
|
| 2800 |
};
|
| 2801 |
|
| 2802 |
+
// Goals — split into short-term and long-term
|
| 2803 |
const goals = data.goals || [];
|
| 2804 |
const activeGoals = goals.filter(g => g.status === 'active');
|
| 2805 |
const completedGoals = goals.filter(g => g.status === 'completed');
|
| 2806 |
+
const shortTermGoals = activeGoals.filter(g => g.term === 'short');
|
| 2807 |
+
const longTermGoals = activeGoals.filter(g => g.term !== 'short');
|
| 2808 |
|
| 2809 |
// Children & pregnancy
|
| 2810 |
const children = data.children || [];
|
|
|
|
| 2812 |
const pregnantBadge = isPregnant ? ' \uD83E\uDD30' : '';
|
| 2813 |
const childrenBadge = children.length > 0 ? ` \uD83D\uDC76${children.length}` : '';
|
| 2814 |
|
| 2815 |
+
const isDead = data.alive === false;
|
| 2816 |
+
const mayorBadge = data.is_mayor ? ' \uD83C\uDFDB\uFE0F Mayor' : '';
|
| 2817 |
+
const lifeStageBadge = data.lifecycle_stage ? `<span style="color:#888;font-size:10px;margin-left:6px">${data.lifecycle_stage}</span>` : '';
|
| 2818 |
+
const parentNames = (data.parent_names || []);
|
| 2819 |
+
const deathInfo = isDead ? `<div style="margin:6px 0;padding:6px 8px;background:rgba(233,69,96,0.15);border:1px solid rgba(233,69,96,0.3);border-radius:4px;font-size:11px;color:#e94560">
|
| 2820 |
+
\u2620\uFE0F Deceased (Day ${data.death_day}) — ${esc(data.death_cause || 'unknown')}</div>` : '';
|
| 2821 |
+
|
| 2822 |
document.getElementById('agent-detail').innerHTML=`
|
| 2823 |
<div style="margin-bottom:6px">
|
| 2824 |
<span style="cursor:pointer;color:#888;font-size:11px" onclick="selectedAgentId=null;showDefaultDetail();">← All Agents</span>
|
| 2825 |
</div>
|
| 2826 |
<h2>${gi} ${data.name||'?'}${pregnantBadge}${childrenBadge}</h2>
|
| 2827 |
+
<p class="subtitle">${data.age||'?'} y/o ${data.occupation||''}${mayorBadge}${lifeStageBadge}</p>
|
| 2828 |
<p class="subtitle" style="color:#888;font-size:10px">${data.traits||''}</p>
|
| 2829 |
<p class="subtitle" style="color:#e0e0e0">@ ${esc(locName)} — ${esc(data.action||'idle')}</p>
|
| 2830 |
|
| 2831 |
+
${deathInfo}
|
| 2832 |
+
|
| 2833 |
+
${parentNames.length>0?`<div style="margin:4px 0;font-size:10px;color:#888">\uD83D\uDC68\u200D\uD83D\uDC69\u200D\uD83D\uDC67 Parents: ${parentNames.map(n=>esc(n)).join(' & ')}</div>`:''}
|
| 2834 |
+
|
| 2835 |
${romanceRels.length>0?`<div style="margin:6px 0;padding:4px 8px;background:rgba(233,30,144,0.1);border:1px solid rgba(233,30,144,0.2);border-radius:4px;font-size:11px;color:#e91e90">
|
| 2836 |
${romanceRels.map(r=>`\u2764 ${r.relationship_status} with ${r.name} (${(r.romantic_interest*100).toFixed(0)}%)`).join('<br>')}
|
| 2837 |
</div>`:''}
|
|
|
|
| 2840 |
\uD83D\uDC76 Children: ${children.map(c=>esc(c)).join(', ')}
|
| 2841 |
</div>`:''}
|
| 2842 |
|
| 2843 |
+
${!isDead ? `
|
| 2844 |
<div class="bar-container">
|
| 2845 |
<div class="bar-label"><span>Mood: ${moodLabel}</span><span>${moodPct}%</span></div>
|
| 2846 |
<div class="bar-bg"><div class="bar-fill ${moodColor}" style="width:${moodPct}%"></div></div>
|
|
|
|
| 2857 |
<div style="margin-top:6px">
|
| 2858 |
<div style="font-size:10px;color:#888">Plan: ${esc((data.daily_plan||[]).slice(0,3).join('; ')||'None yet')}</div>
|
| 2859 |
</div>
|
| 2860 |
+
` : ''}
|
| 2861 |
|
| 2862 |
+
${longTermGoals.length>0?`
|
| 2863 |
+
<div class="section-header">Long-Term Goals</div>
|
| 2864 |
+
${longTermGoals.map(g=>{
|
| 2865 |
const pct=Math.round((g.progress||0)*100);
|
| 2866 |
return `<div style="margin:3px 0;font-size:11px">
|
| 2867 |
<div style="display:flex;justify-content:space-between;color:#e0e0f0">
|
|
|
|
| 2872 |
</div>
|
| 2873 |
</div>`;
|
| 2874 |
}).join('')}
|
|
|
|
| 2875 |
`:''}
|
| 2876 |
|
| 2877 |
+
${shortTermGoals.length>0?`
|
| 2878 |
+
<div class="section-header">This Week's Goals</div>
|
| 2879 |
+
${shortTermGoals.map(g=>{
|
| 2880 |
+
const pct=Math.round((g.progress||0)*100);
|
| 2881 |
+
return `<div style="margin:3px 0;font-size:11px">
|
| 2882 |
+
<div style="display:flex;justify-content:space-between;color:#e0e0f0">
|
| 2883 |
+
<span>\u26A1 ${esc(g.description)}</span><span style="color:#888;font-size:9px">${pct}%</span>
|
| 2884 |
+
</div>
|
| 2885 |
+
<div style="height:3px;background:#1a1a3e;border-radius:2px;margin-top:2px">
|
| 2886 |
+
<div style="height:100%;width:${pct}%;background:#f0c040;border-radius:2px"></div>
|
| 2887 |
+
</div>
|
| 2888 |
+
</div>`;
|
| 2889 |
+
}).join('')}
|
| 2890 |
+
`:''}
|
| 2891 |
+
|
| 2892 |
+
${completedGoals.length>0?`
|
| 2893 |
+
<div style="margin-top:4px">
|
| 2894 |
+
${completedGoals.slice(-3).map(g=>`<div style="margin:2px 0;font-size:10px;color:#555">\u2705 ${esc(g.description)}</div>`).join('')}
|
| 2895 |
+
</div>`:''}
|
| 2896 |
+
|
| 2897 |
${lifeEvents.length>0?`
|
| 2898 |
<div class="section-header">Life History</div>
|
| 2899 |
<div style="max-height:160px;overflow-y:auto">
|
|
|
|
| 2932 |
<div class="section-header">Recent Memories</div>
|
| 2933 |
${memories.map(m=>`<div class="memory-item"><span class="memory-time">${m.time||''}</span> ${esc(m.content||'')}</div>`).join('')||'<div class="memory-item" style="color:#555">No memories yet</div>'}
|
| 2934 |
|
| 2935 |
+
<div class="section-header">Ask About Memories</div>
|
| 2936 |
+
<div style="display:flex;gap:4px;margin:4px 0">
|
| 2937 |
+
<input type="text" id="memory-query-input" placeholder="e.g. Describe your feelings about..." style="flex:1;background:#0a1628;color:#e0e0e0;border:1px solid #1a3a6e;padding:5px 8px;border-radius:3px;font-size:11px" onkeydown="if(event.key==='Enter')askAgentMemory('${data.id}')">
|
| 2938 |
+
<button onclick="askAgentMemory('${data.id}')" style="background:#4ecca3;color:#0a1628;border:none;padding:5px 10px;border-radius:3px;cursor:pointer;font-weight:700;font-size:11px">Ask</button>
|
| 2939 |
+
</div>
|
| 2940 |
+
<div id="memory-query-result" style="font-size:11px;color:#c0c0d0;margin:4px 0;max-height:200px;overflow-y:auto"></div>
|
| 2941 |
+
|
| 2942 |
${playerToken && playerAgentId && data.id !== playerAgentId ? `
|
| 2943 |
<div style="margin-top:10px">
|
| 2944 |
<button class="btn-primary" style="width:100%" onclick="openChat('${data.id}')">Talk to ${esc(data.name||'them')}</button>
|
|
|
|
| 2946 |
`;
|
| 2947 |
}
|
| 2948 |
|
| 2949 |
+
async function askAgentMemory(agentId) {
|
| 2950 |
+
const input = document.getElementById('memory-query-input');
|
| 2951 |
+
const result = document.getElementById('memory-query-result');
|
| 2952 |
+
const question = input.value.trim();
|
| 2953 |
+
if (!question) return;
|
| 2954 |
+
result.innerHTML = '<span style="color:#888">Thinking...</span>';
|
| 2955 |
+
try {
|
| 2956 |
+
const res = await fetch(`${API_BASE}/agents/${agentId}/ask`, {
|
| 2957 |
+
method: 'POST',
|
| 2958 |
+
headers: {'Content-Type': 'application/json'},
|
| 2959 |
+
body: JSON.stringify({question}),
|
| 2960 |
+
});
|
| 2961 |
+
const data = await res.json();
|
| 2962 |
+
result.innerHTML = `<div style="padding:6px 8px;background:rgba(78,204,163,0.06);border:1px solid rgba(78,204,163,0.15);border-radius:4px;margin-top:4px;line-height:1.5">${esc(data.answer || '(no response)')}</div><div style="color:#555;font-size:9px;margin-top:2px">${data.memories_used || 0} memories referenced</div>`;
|
| 2963 |
+
} catch(e) {
|
| 2964 |
+
result.innerHTML = `<span style="color:#e94560">Error: ${e.message}</span>`;
|
| 2965 |
+
}
|
| 2966 |
+
}
|
| 2967 |
+
|
| 2968 |
// ============================================================
|
| 2969 |
// CONVERSATIONS TAB
|
| 2970 |
// ============================================================
|
|
|
|
| 3096 |
}
|
| 3097 |
|
| 3098 |
agents = data.agents || {};
|
| 3099 |
+
window._mayorData = data.mayor || null;
|
| 3100 |
renderPlayerPanel();
|
| 3101 |
|
| 3102 |
const tick = clock.total_ticks || 0;
|