RayMelius Claude Opus 4.6 commited on
Commit
72938c0
·
1 Parent(s): b596ed6

Add lifecycle, divorce, elections, memory query, baby agents, hospital births

Browse files

Major 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 CHANGED
@@ -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 = {"id": self._next_goal_id, "description": description, "status": status, "progress": 0.0}
 
 
 
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(0, 0, "origin", f"Born and raised. {p.background}")
238
  # Career event
239
  if p.occupation and p.occupation.lower() not in ("newcomer", "unknown", "unemployed"):
240
- self.add_life_event(0, 0, "career", f"Works as {p.occupation}")
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
- if active_goals:
283
- parts.append("MY GOALS:")
284
- for g in active_goals:
 
 
 
 
 
 
 
 
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
src/soci/api/routes.py CHANGED
@@ -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."""
src/soci/engine/simulation.py CHANGED
@@ -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
- self._emit(f" [ROMANCE] {agent.name} and {other.name} got married!")
 
 
 
 
 
 
 
 
 
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 ~7 sim-days."""
876
  import random as _rand
877
- PREGNANCY_DURATION_TICKS = 672 # ~7 days (96 ticks/day at 15-min intervals)
 
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
- # Birth check
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(baby_name)
927
  agent.add_life_event(self.clock.day, self.clock.total_ticks,
928
- "child_born", f"Gave birth to {baby_name}!")
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 {baby_name} was born today! I'm overwhelmed with joy.",
933
  importance=10,
934
  )
935
  if partner:
936
- partner.children.append(baby_name)
937
  partner.add_life_event(self.clock.day, self.clock.total_ticks,
938
- "child_born", f"{agent.name} and I welcomed {baby_name}!")
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 {baby_name} was born! I'm a parent now!",
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 {baby_name}!")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
  },
web/index.html CHANGED
@@ -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 (${Object.keys(agents).length})</div>
 
2737
  ${sorted.map(([aid,a]) => {
2738
- const color = AGENT_COLORS[getAgentIdx(aid) % AGENT_COLORS.length];
2739
- const gi = a.gender==='female'?'\uD83D\uDC69':a.gender==='male'?'\uD83D\uDC68':'\uD83E\uDDD1';
2740
- const stateIcon = a.state==='in_conversation'?' \uD83D\uDCAC':(a.state==='sleeping'?' \uD83D\uDCA4':(a.state==='moving'?' \uD83D\uDEB6':''));
 
 
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();">&larr; 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)} &mdash; ${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
- ${activeGoals.length>0||completedGoals.length>0?`
2838
- <div class="section-header">Goals</div>
2839
- ${activeGoals.map(g=>{
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();">&larr; 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)} &mdash; ${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;