Add life events, goals, pregnancy system; fix profile editor bugs
Browse files- Life events: append-only timeline seeded from persona, extended by
LLM reflections and romance milestones (dating/engaged/married/birth)
- Goals: personal aspirations with progress tracking, managed by LLM
reflections (add/complete/progress)
- Pregnancy: married couples can conceive, birth after ~7 sim-days,
baby names, both parents get memories and life events
- Agent context: LLM now sees life story, goals, children, pregnancy
- Fix profile editor: fetch full agent detail instead of WS summary
(occupation, background, personality sliders now load correctly)
- Fix Go dropdown: only rebuild when empty, preserves user selection
- Fix WS state summary: include age, occupation, daily_plan, is_player
- NN self-improve: track accuracy in training_stats.json, compare with
previous model on push
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- scripts/nn_selfimprove.py +22 -4
- src/soci/agents/agent.py +94 -1
- src/soci/api/routes.py +5 -0
- src/soci/engine/llm.py +12 -1
- src/soci/engine/simulation.py +129 -0
- web/index.html +91 -12
|
@@ -465,7 +465,7 @@ def train(epochs: int = 20, batch_size: int = 512, lr: float = 3e-4):
|
|
| 465 |
# STEP 3: PUSH β Upload improved model to HuggingFace Hub
|
| 466 |
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 467 |
|
| 468 |
-
def push(repo_id: str = "RayMelius/soci-agent-nn"):
|
| 469 |
"""Push the retrained ONNX model to HuggingFace Hub."""
|
| 470 |
from huggingface_hub import HfApi, login
|
| 471 |
|
|
@@ -480,6 +480,21 @@ def push(repo_id: str = "RayMelius/soci-agent-nn"):
|
|
| 480 |
|
| 481 |
login(token=token)
|
| 482 |
api = HfApi()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 483 |
api.create_repo(repo_id, exist_ok=True)
|
| 484 |
|
| 485 |
# Upload ONNX
|
|
@@ -508,6 +523,8 @@ def push(repo_id: str = "RayMelius/soci-agent-nn"):
|
|
| 508 |
"model_size_kb": ONNX_PATH.stat().st_size / 1024,
|
| 509 |
"timestamp": time.strftime("%Y-%m-%dT%H:%M:%S"),
|
| 510 |
}
|
|
|
|
|
|
|
| 511 |
stats_path = MODEL_DIR / "training_stats.json"
|
| 512 |
stats_path.write_text(json.dumps(stats, indent=2))
|
| 513 |
api.upload_file(
|
|
@@ -911,7 +928,7 @@ async def scheduled(
|
|
| 911 |
# 7. Push improved model
|
| 912 |
if os.environ.get("HF_TOKEN"):
|
| 913 |
logger.info("Pushing improved model to HF Hub...")
|
| 914 |
-
push(repo_id=repo_id)
|
| 915 |
else:
|
| 916 |
logger.warning("HF_TOKEN not set β skipping push")
|
| 917 |
|
|
@@ -1022,10 +1039,11 @@ def main():
|
|
| 1022 |
asyncio.run(collect(base_url=args.url, duration_minutes=args.minutes))
|
| 1023 |
|
| 1024 |
if args.mode in ("train", "all"):
|
| 1025 |
-
train(epochs=args.epochs)
|
| 1026 |
|
| 1027 |
if args.mode in ("push", "all"):
|
| 1028 |
-
|
|
|
|
| 1029 |
|
| 1030 |
if args.mode == "scheduled":
|
| 1031 |
asyncio.run(scheduled(
|
|
|
|
| 465 |
# STEP 3: PUSH β Upload improved model to HuggingFace Hub
|
| 466 |
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 467 |
|
| 468 |
+
def push(repo_id: str = "RayMelius/soci-agent-nn", accuracy: float = None):
|
| 469 |
"""Push the retrained ONNX model to HuggingFace Hub."""
|
| 470 |
from huggingface_hub import HfApi, login
|
| 471 |
|
|
|
|
| 480 |
|
| 481 |
login(token=token)
|
| 482 |
api = HfApi()
|
| 483 |
+
|
| 484 |
+
# Compare against previous accuracy if available
|
| 485 |
+
try:
|
| 486 |
+
from huggingface_hub import hf_hub_download
|
| 487 |
+
prev_stats_path = hf_hub_download(repo_id=repo_id, filename="training_stats.json", token=token)
|
| 488 |
+
prev_stats = json.loads(open(prev_stats_path).read())
|
| 489 |
+
prev_acc = prev_stats.get("best_accuracy")
|
| 490 |
+
if prev_acc is not None and accuracy is not None:
|
| 491 |
+
delta = accuracy - prev_acc
|
| 492 |
+
symbol = "+" if delta >= 0 else ""
|
| 493 |
+
logger.info(f"Previous accuracy: {prev_acc:.1%} β New: {accuracy:.1%} ({symbol}{delta:.1%})")
|
| 494 |
+
elif prev_acc is not None:
|
| 495 |
+
logger.info(f"Previous accuracy: {prev_acc:.1%} (no new accuracy to compare)")
|
| 496 |
+
except Exception:
|
| 497 |
+
logger.info("No previous training_stats.json found β first push")
|
| 498 |
api.create_repo(repo_id, exist_ok=True)
|
| 499 |
|
| 500 |
# Upload ONNX
|
|
|
|
| 523 |
"model_size_kb": ONNX_PATH.stat().st_size / 1024,
|
| 524 |
"timestamp": time.strftime("%Y-%m-%dT%H:%M:%S"),
|
| 525 |
}
|
| 526 |
+
if accuracy is not None:
|
| 527 |
+
stats["best_accuracy"] = round(accuracy, 4)
|
| 528 |
stats_path = MODEL_DIR / "training_stats.json"
|
| 529 |
stats_path.write_text(json.dumps(stats, indent=2))
|
| 530 |
api.upload_file(
|
|
|
|
| 928 |
# 7. Push improved model
|
| 929 |
if os.environ.get("HF_TOKEN"):
|
| 930 |
logger.info("Pushing improved model to HF Hub...")
|
| 931 |
+
push(repo_id=repo_id, accuracy=best_acc)
|
| 932 |
else:
|
| 933 |
logger.warning("HF_TOKEN not set β skipping push")
|
| 934 |
|
|
|
|
| 1039 |
asyncio.run(collect(base_url=args.url, duration_minutes=args.minutes))
|
| 1040 |
|
| 1041 |
if args.mode in ("train", "all"):
|
| 1042 |
+
best_acc = train(epochs=args.epochs)
|
| 1043 |
|
| 1044 |
if args.mode in ("push", "all"):
|
| 1045 |
+
acc = best_acc if args.mode == "all" else None
|
| 1046 |
+
push(repo_id=args.repo, accuracy=acc)
|
| 1047 |
|
| 1048 |
if args.mode == "scheduled":
|
| 1049 |
asyncio.run(scheduled(
|
|
@@ -77,6 +77,18 @@ class Agent:
|
|
| 77 |
# Romance: current partner ID (dating/engaged/married)
|
| 78 |
self.partner_id: Optional[str] = None
|
| 79 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 80 |
@property
|
| 81 |
def is_busy(self) -> bool:
|
| 82 |
return self._action_ticks_remaining > 0
|
|
@@ -195,6 +207,55 @@ class Agent:
|
|
| 195 |
"""Reset plan flag for a new day."""
|
| 196 |
self._has_plan_today = False
|
| 197 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 198 |
def build_context(self, tick: int, world_description: str, location_description: str) -> str:
|
| 199 |
"""Build the full context string for LLM prompts."""
|
| 200 |
parts = [
|
|
@@ -207,12 +268,30 @@ class Agent:
|
|
| 207 |
f"",
|
| 208 |
f"WORLD: {world_description}",
|
| 209 |
f"",
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 210 |
f"PEOPLE I KNOW:",
|
| 211 |
self.relationships.describe_known_people(),
|
| 212 |
f"",
|
| 213 |
f"RECENT MEMORIES:",
|
| 214 |
self.memory.context_summary(tick),
|
| 215 |
-
]
|
| 216 |
if self.daily_plan:
|
| 217 |
parts.insert(5, f"- Today's plan: {'; '.join(self.daily_plan)}")
|
| 218 |
return "\n".join(parts)
|
|
@@ -245,6 +324,13 @@ class Agent:
|
|
| 245 |
"last_llm_tick": self._last_llm_tick,
|
| 246 |
"is_player": self.is_player,
|
| 247 |
"partner_id": self.partner_id,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 248 |
}
|
| 249 |
|
| 250 |
@classmethod
|
|
@@ -265,4 +351,11 @@ class Agent:
|
|
| 265 |
agent._last_llm_tick = data["last_llm_tick"]
|
| 266 |
agent.is_player = data["is_player"]
|
| 267 |
agent.partner_id = data.get("partner_id")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 268 |
return agent
|
|
|
|
| 77 |
# Romance: current partner ID (dating/engaged/married)
|
| 78 |
self.partner_id: Optional[str] = None
|
| 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)
|
| 86 |
+
self.pregnant: bool = False
|
| 87 |
+
self.pregnancy_start_tick: int = 0
|
| 88 |
+
self.pregnancy_partner_id: Optional[str] = None
|
| 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 |
"""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
|
| 222 |
+
|
| 223 |
+
def update_goal(self, goal_id: int, status: str = None, progress: float = None) -> None:
|
| 224 |
+
"""Update goal status or progress."""
|
| 225 |
+
for g in self.goals:
|
| 226 |
+
if g["id"] == goal_id:
|
| 227 |
+
if status:
|
| 228 |
+
g["status"] = status
|
| 229 |
+
if progress is not None:
|
| 230 |
+
g["progress"] = min(1.0, max(0.0, progress))
|
| 231 |
+
break
|
| 232 |
+
|
| 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."""
|
| 254 |
+
parts = []
|
| 255 |
+
for e in self.life_events[-10:]:
|
| 256 |
+
parts.append(f"Day {e['day']}: {e['description']}")
|
| 257 |
+
return "\n".join(parts) if parts else "No significant life events yet."
|
| 258 |
+
|
| 259 |
def build_context(self, tick: int, world_description: str, location_description: str) -> str:
|
| 260 |
"""Build the full context string for LLM prompts."""
|
| 261 |
parts = [
|
|
|
|
| 268 |
f"",
|
| 269 |
f"WORLD: {world_description}",
|
| 270 |
f"",
|
| 271 |
+
f"MY LIFE STORY:",
|
| 272 |
+
self.biography_summary(),
|
| 273 |
+
f"",
|
| 274 |
+
]
|
| 275 |
+
if self.children:
|
| 276 |
+
parts.append(f"MY CHILDREN: {', '.join(self.children)}")
|
| 277 |
+
parts.append("")
|
| 278 |
+
if self.pregnant:
|
| 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("")
|
| 288 |
+
parts.extend([
|
| 289 |
f"PEOPLE I KNOW:",
|
| 290 |
self.relationships.describe_known_people(),
|
| 291 |
f"",
|
| 292 |
f"RECENT MEMORIES:",
|
| 293 |
self.memory.context_summary(tick),
|
| 294 |
+
])
|
| 295 |
if self.daily_plan:
|
| 296 |
parts.insert(5, f"- Today's plan: {'; '.join(self.daily_plan)}")
|
| 297 |
return "\n".join(parts)
|
|
|
|
| 324 |
"last_llm_tick": self._last_llm_tick,
|
| 325 |
"is_player": self.is_player,
|
| 326 |
"partner_id": self.partner_id,
|
| 327 |
+
"life_events": self.life_events,
|
| 328 |
+
"goals": self.goals,
|
| 329 |
+
"_next_goal_id": self._next_goal_id,
|
| 330 |
+
"pregnant": self.pregnant,
|
| 331 |
+
"pregnancy_start_tick": self.pregnancy_start_tick,
|
| 332 |
+
"pregnancy_partner_id": self.pregnancy_partner_id,
|
| 333 |
+
"children": self.children,
|
| 334 |
}
|
| 335 |
|
| 336 |
@classmethod
|
|
|
|
| 351 |
agent._last_llm_tick = data["last_llm_tick"]
|
| 352 |
agent.is_player = data["is_player"]
|
| 353 |
agent.partner_id = data.get("partner_id")
|
| 354 |
+
agent.life_events = data.get("life_events", [])
|
| 355 |
+
agent.goals = data.get("goals", [])
|
| 356 |
+
agent._next_goal_id = data.get("_next_goal_id", 0)
|
| 357 |
+
agent.pregnant = data.get("pregnant", False)
|
| 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
|
|
@@ -165,6 +165,7 @@ async def get_agent(agent_id: str):
|
|
| 165 |
"age": p.age,
|
| 166 |
"gender": p.gender,
|
| 167 |
"occupation": p.occupation,
|
|
|
|
| 168 |
"traits": p.trait_summary,
|
| 169 |
"personality": {
|
| 170 |
"openness": getattr(p, "openness", 5),
|
|
@@ -207,6 +208,10 @@ async def get_agent(agent_id: str):
|
|
| 207 |
}
|
| 208 |
for m in agent.memory.get_recent(10)
|
| 209 |
],
|
|
|
|
|
|
|
|
|
|
|
|
|
| 210 |
}
|
| 211 |
|
| 212 |
|
|
|
|
| 165 |
"age": p.age,
|
| 166 |
"gender": p.gender,
|
| 167 |
"occupation": p.occupation,
|
| 168 |
+
"background": p.background,
|
| 169 |
"traits": p.trait_summary,
|
| 170 |
"personality": {
|
| 171 |
"openness": getattr(p, "openness", 5),
|
|
|
|
| 208 |
}
|
| 209 |
for m in agent.memory.get_recent(10)
|
| 210 |
],
|
| 211 |
+
"life_events": agent.life_events[-20:],
|
| 212 |
+
"goals": agent.goals,
|
| 213 |
+
"pregnant": agent.pregnant,
|
| 214 |
+
"children": agent.children,
|
| 215 |
}
|
| 216 |
|
| 217 |
|
|
@@ -1080,10 +1080,21 @@ Respond with a JSON object:
|
|
| 1080 |
{{
|
| 1081 |
"reflections": ["reflection 1", "reflection 2", ...],
|
| 1082 |
"mood_shift": -0.3 to 0.3,
|
| 1083 |
-
"reasoning": "why your mood shifted this way"
|
|
|
|
|
|
|
| 1084 |
}}
|
| 1085 |
|
| 1086 |
Generate 1-3 reflections. Each should be a genuine insight, not just a summary.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1087 |
"""
|
| 1088 |
|
| 1089 |
CONVERSATION_PROMPT = """\
|
|
|
|
| 1080 |
{{
|
| 1081 |
"reflections": ["reflection 1", "reflection 2", ...],
|
| 1082 |
"mood_shift": -0.3 to 0.3,
|
| 1083 |
+
"reasoning": "why your mood shifted this way",
|
| 1084 |
+
"life_event": null,
|
| 1085 |
+
"goal_update": null
|
| 1086 |
}}
|
| 1087 |
|
| 1088 |
Generate 1-3 reflections. Each should be a genuine insight, not just a summary.
|
| 1089 |
+
|
| 1090 |
+
If something truly significant happened recently (a promotion, finishing a project, personal milestone,
|
| 1091 |
+
making a close friend, learning something important), set life_event to:
|
| 1092 |
+
{{"type": "promotion|graduated|achievement|milestone|new_job|moved|breakup|friendship", "description": "what happened"}}
|
| 1093 |
+
Most reflections should have life_event as null β only include when genuinely noteworthy.
|
| 1094 |
+
|
| 1095 |
+
If you want to set a new goal or update progress on an existing one, set goal_update to:
|
| 1096 |
+
{{"action": "add|complete|progress", "description": "goal text", "goal_id": null}}
|
| 1097 |
+
For "complete" or "progress", include the goal_id number. For "add", include description only.
|
| 1098 |
"""
|
| 1099 |
|
| 1100 |
CONVERSATION_PROMPT = """\
|
|
@@ -80,6 +80,9 @@ class Simulation:
|
|
| 80 |
"""Add an agent to the simulation and place them in the city."""
|
| 81 |
self.agents[agent.id] = agent
|
| 82 |
self.city.place_agent(agent.id, agent.location)
|
|
|
|
|
|
|
|
|
|
| 83 |
|
| 84 |
def load_agents_from_yaml(self, path: str) -> None:
|
| 85 |
"""Load all personas from YAML and create agents."""
|
|
@@ -338,6 +341,9 @@ class Simulation:
|
|
| 338 |
# 9. Romance β develop attractions and relationships
|
| 339 |
self._tick_romance()
|
| 340 |
|
|
|
|
|
|
|
|
|
|
| 341 |
# 10. Advance clock
|
| 342 |
self.clock.tick()
|
| 343 |
|
|
@@ -711,6 +717,42 @@ class Simulation:
|
|
| 711 |
if reflections:
|
| 712 |
self._emit(f" [REFLECT] {agent.name}: {reflections[0]}")
|
| 713 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 714 |
def _tick_romance(self) -> None:
|
| 715 |
"""Develop romantic attractions between compatible agents at the same location."""
|
| 716 |
agents_list = list(self.agents.values())
|
|
@@ -790,6 +832,8 @@ class Simulation:
|
|
| 790 |
content=f"I'm now dating {o.name}! I feel excited and nervous.",
|
| 791 |
importance=9, involved_agents=[o.id],
|
| 792 |
)
|
|
|
|
|
|
|
| 793 |
agent.mood = min(1.0, agent.mood + 0.3)
|
| 794 |
other.mood = min(1.0, other.mood + 0.3)
|
| 795 |
|
|
@@ -806,6 +850,8 @@ class Simulation:
|
|
| 806 |
content=f"{o.name} and I are engaged! This is the happiest day of my life.",
|
| 807 |
importance=10, involved_agents=[o.id],
|
| 808 |
)
|
|
|
|
|
|
|
| 809 |
agent.mood = min(1.0, agent.mood + 0.4)
|
| 810 |
other.mood = min(1.0, other.mood + 0.4)
|
| 811 |
|
|
@@ -822,6 +868,83 @@ class Simulation:
|
|
| 822 |
content=f"I married {o.name} today. I couldn't be happier.",
|
| 823 |
importance=10, involved_agents=[o.id],
|
| 824 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 825 |
|
| 826 |
def get_state_summary(self) -> dict:
|
| 827 |
"""Get a summary of the current simulation state."""
|
|
@@ -832,13 +955,19 @@ class Simulation:
|
|
| 832 |
"agents": {
|
| 833 |
aid: {
|
| 834 |
"name": a.name,
|
|
|
|
| 835 |
"gender": a.persona.gender,
|
|
|
|
| 836 |
"location": a.location,
|
| 837 |
"state": a.state.value,
|
| 838 |
"mood": round(a.mood, 2),
|
| 839 |
"needs": a.needs.to_dict(),
|
| 840 |
"action": a.current_action.detail if a.current_action else "idle",
|
|
|
|
| 841 |
"partner_id": a.partner_id,
|
|
|
|
|
|
|
|
|
|
| 842 |
}
|
| 843 |
for aid, a in self.agents.items()
|
| 844 |
},
|
|
|
|
| 80 |
"""Add an agent to the simulation and place them in the city."""
|
| 81 |
self.agents[agent.id] = agent
|
| 82 |
self.city.place_agent(agent.id, agent.location)
|
| 83 |
+
# Seed biography from persona on first creation
|
| 84 |
+
if not agent.life_events:
|
| 85 |
+
agent.seed_biography(self.clock.day, self.clock.total_ticks)
|
| 86 |
|
| 87 |
def load_agents_from_yaml(self, path: str) -> None:
|
| 88 |
"""Load all personas from YAML and create agents."""
|
|
|
|
| 341 |
# 9. Romance β develop attractions and relationships
|
| 342 |
self._tick_romance()
|
| 343 |
|
| 344 |
+
# 9b. Pregnancy β check for new pregnancies and births
|
| 345 |
+
self._tick_pregnancy()
|
| 346 |
+
|
| 347 |
# 10. Advance clock
|
| 348 |
self.clock.tick()
|
| 349 |
|
|
|
|
| 717 |
if reflections:
|
| 718 |
self._emit(f" [REFLECT] {agent.name}: {reflections[0]}")
|
| 719 |
|
| 720 |
+
# Life event from reflection (optional β LLM may return null)
|
| 721 |
+
life_event = result.get("life_event")
|
| 722 |
+
if life_event and isinstance(life_event, dict):
|
| 723 |
+
evt_type = life_event.get("type", "milestone")
|
| 724 |
+
evt_desc = life_event.get("description", "")
|
| 725 |
+
if evt_desc:
|
| 726 |
+
agent.add_life_event(self.clock.day, self.clock.total_ticks, evt_type, evt_desc)
|
| 727 |
+
agent.add_observation(
|
| 728 |
+
tick=self.clock.total_ticks, day=self.clock.day,
|
| 729 |
+
time_str=self.clock.time_str,
|
| 730 |
+
content=f"Life milestone: {evt_desc}", importance=9,
|
| 731 |
+
)
|
| 732 |
+
self._emit(f" [LIFE] {agent.name}: {evt_desc}")
|
| 733 |
+
|
| 734 |
+
# Goal updates from reflection (optional)
|
| 735 |
+
goal_update = result.get("goal_update")
|
| 736 |
+
if goal_update and isinstance(goal_update, dict):
|
| 737 |
+
action = goal_update.get("action", "")
|
| 738 |
+
if action == "add" and goal_update.get("description"):
|
| 739 |
+
agent.add_goal(goal_update["description"])
|
| 740 |
+
self._emit(f" [GOAL] {agent.name} new goal: {goal_update['description']}")
|
| 741 |
+
elif action == "complete" and goal_update.get("goal_id") is not None:
|
| 742 |
+
try:
|
| 743 |
+
gid = int(goal_update["goal_id"])
|
| 744 |
+
agent.update_goal(gid, status="completed", progress=1.0)
|
| 745 |
+
self._emit(f" [GOAL] {agent.name} completed a goal!")
|
| 746 |
+
except (TypeError, ValueError):
|
| 747 |
+
pass
|
| 748 |
+
elif action == "progress" and goal_update.get("goal_id") is not None:
|
| 749 |
+
try:
|
| 750 |
+
gid = int(goal_update["goal_id"])
|
| 751 |
+
prog = float(goal_update.get("progress", 0.5))
|
| 752 |
+
agent.update_goal(gid, progress=prog)
|
| 753 |
+
except (TypeError, ValueError):
|
| 754 |
+
pass
|
| 755 |
+
|
| 756 |
def _tick_romance(self) -> None:
|
| 757 |
"""Develop romantic attractions between compatible agents at the same location."""
|
| 758 |
agents_list = list(self.agents.values())
|
|
|
|
| 832 |
content=f"I'm now dating {o.name}! I feel excited and nervous.",
|
| 833 |
importance=9, involved_agents=[o.id],
|
| 834 |
)
|
| 835 |
+
a.add_life_event(self.clock.day, self.clock.total_ticks,
|
| 836 |
+
"dating", f"Started dating {o.name}")
|
| 837 |
agent.mood = min(1.0, agent.mood + 0.3)
|
| 838 |
other.mood = min(1.0, other.mood + 0.3)
|
| 839 |
|
|
|
|
| 850 |
content=f"{o.name} and I are engaged! This is the happiest day of my life.",
|
| 851 |
importance=10, involved_agents=[o.id],
|
| 852 |
)
|
| 853 |
+
a.add_life_event(self.clock.day, self.clock.total_ticks,
|
| 854 |
+
"engaged", f"Got engaged to {o.name}")
|
| 855 |
agent.mood = min(1.0, agent.mood + 0.4)
|
| 856 |
other.mood = min(1.0, other.mood + 0.4)
|
| 857 |
|
|
|
|
| 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
|
| 881 |
+
if (agent.persona.gender == "female"
|
| 882 |
+
and not agent.pregnant
|
| 883 |
+
and agent.partner_id
|
| 884 |
+
and agent.partner_id in self.agents
|
| 885 |
+
and len(agent.children) < 3): # max 3 children
|
| 886 |
+
partner = self.agents[agent.partner_id]
|
| 887 |
+
rel = agent.relationships.get(partner.id)
|
| 888 |
+
if (rel and rel.relationship_status == "married"
|
| 889 |
+
and agent.location == partner.location
|
| 890 |
+
and agent.location == agent.persona.home_location
|
| 891 |
+
and _rand.random() < 0.002):
|
| 892 |
+
agent.pregnant = True
|
| 893 |
+
agent.pregnancy_start_tick = self.clock.total_ticks
|
| 894 |
+
agent.pregnancy_partner_id = partner.id
|
| 895 |
+
agent.add_life_event(self.clock.day, self.clock.total_ticks,
|
| 896 |
+
"pregnant", f"Expecting a baby with {partner.name}!")
|
| 897 |
+
partner.add_life_event(self.clock.day, self.clock.total_ticks,
|
| 898 |
+
"pregnant", f"{agent.name} and I are expecting a baby!")
|
| 899 |
+
for a in (agent, partner):
|
| 900 |
+
a.add_observation(
|
| 901 |
+
tick=self.clock.total_ticks, day=self.clock.day,
|
| 902 |
+
time_str=self.clock.time_str,
|
| 903 |
+
content=f"We're going to have a baby!",
|
| 904 |
+
importance=10, involved_agents=[partner.id if a == agent else agent.id],
|
| 905 |
+
)
|
| 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
|
| 915 |
+
import random as _r
|
| 916 |
+
baby_names_m = ["Oliver", "Liam", "Noah", "Elias", "Lucas", "Theo", "Leo", "Max"]
|
| 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."""
|
|
|
|
| 955 |
"agents": {
|
| 956 |
aid: {
|
| 957 |
"name": a.name,
|
| 958 |
+
"age": a.persona.age,
|
| 959 |
"gender": a.persona.gender,
|
| 960 |
+
"occupation": a.persona.occupation,
|
| 961 |
"location": a.location,
|
| 962 |
"state": a.state.value,
|
| 963 |
"mood": round(a.mood, 2),
|
| 964 |
"needs": a.needs.to_dict(),
|
| 965 |
"action": a.current_action.detail if a.current_action else "idle",
|
| 966 |
+
"daily_plan": a.daily_plan,
|
| 967 |
"partner_id": a.partner_id,
|
| 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 |
},
|
|
@@ -2691,12 +2691,14 @@ function showDefaultDetail() {
|
|
| 2691 |
const gi = a.gender==='female'?'\uD83D\uDC69':a.gender==='male'?'\uD83D\uDC68':'\uD83E\uDDD1';
|
| 2692 |
const stateIcon = a.state==='in_conversation'?' \uD83D\uDCAC':(a.state==='sleeping'?' \uD83D\uDCA4':(a.state==='moving'?' \uD83D\uDEB6':''));
|
| 2693 |
const partner = a.partner_id && agents[a.partner_id] ? ` \u2764\uFE0F ${agents[a.partner_id].name.split(' ')[0]}` : '';
|
|
|
|
|
|
|
| 2694 |
return `
|
| 2695 |
<div class="agent-list-item" onclick="selectedAgentId='${aid}';fetchAgentDetail('${aid}');">
|
| 2696 |
<span class="agent-dot" style="background:${color}"></span>
|
| 2697 |
<span style="font-size:13px">${gi}</span>
|
| 2698 |
<div class="agent-info">
|
| 2699 |
-
<div class="agent-name" style="color:${color}">${a.name}${partner}${stateIcon}</div>
|
| 2700 |
<div class="agent-action">${esc(a.action||'idle')}</div>
|
| 2701 |
</div>
|
| 2702 |
</div>`;
|
|
@@ -2729,11 +2731,32 @@ function renderAgentDetail(data) {
|
|
| 2729 |
const romanceRels=(data.relationships||[]).filter(r=>r.relationship_status&&r.relationship_status!=='none');
|
| 2730 |
const locName = data.location ? (typeof data.location === 'object' ? data.location.name : data.location) : '?';
|
| 2731 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2732 |
document.getElementById('agent-detail').innerHTML=`
|
| 2733 |
<div style="margin-bottom:6px">
|
| 2734 |
<span style="cursor:pointer;color:#888;font-size:11px" onclick="selectedAgentId=null;showDefaultDetail();">← All Agents</span>
|
| 2735 |
</div>
|
| 2736 |
-
<h2>${gi} ${data.name||'?'}</h2>
|
| 2737 |
<p class="subtitle">${data.age||'?'} y/o ${data.occupation||''}</p>
|
| 2738 |
<p class="subtitle" style="color:#888;font-size:10px">${data.traits||''}</p>
|
| 2739 |
<p class="subtitle" style="color:#e0e0e0">@ ${esc(locName)} — ${esc(data.action||'idle')}</p>
|
|
@@ -2742,6 +2765,10 @@ function renderAgentDetail(data) {
|
|
| 2742 |
${romanceRels.map(r=>`\u2764 ${r.relationship_status} with ${r.name} (${(r.romantic_interest*100).toFixed(0)}%)`).join('<br>')}
|
| 2743 |
</div>`:''}
|
| 2744 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2745 |
<div class="bar-container">
|
| 2746 |
<div class="bar-label"><span>Mood: ${moodLabel}</span><span>${moodPct}%</span></div>
|
| 2747 |
<div class="bar-bg"><div class="bar-fill ${moodColor}" style="width:${moodPct}%"></div></div>
|
|
@@ -2759,6 +2786,35 @@ function renderAgentDetail(data) {
|
|
| 2759 |
<div style="font-size:10px;color:#888">Plan: ${esc((data.daily_plan||[]).slice(0,3).join('; ')||'None yet')}</div>
|
| 2760 |
</div>
|
| 2761 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2762 |
${(data.relationships||[]).length>0?`
|
| 2763 |
<div class="section-header">Relationships</div>
|
| 2764 |
${data.relationships.slice(0,8).map(r=>{
|
|
@@ -3202,10 +3258,10 @@ function renderPlayerPanel() {
|
|
| 3202 |
const locName = agent ? (locations[agent.location]?.name || agent.location || '') : '';
|
| 3203 |
document.getElementById('pp-loc').textContent = locName ? `@ ${locName}` : '';
|
| 3204 |
|
| 3205 |
-
// Populate move dropdown from known locations
|
| 3206 |
const sel = document.getElementById('pp-move-select');
|
| 3207 |
const locKeys = Object.keys(locations);
|
| 3208 |
-
if (locKeys.length > 0) {
|
| 3209 |
sel.innerHTML = locKeys.map(lid => {
|
| 3210 |
const ln = locations[lid]?.name || lid;
|
| 3211 |
const selected = agent && agent.location === lid ? ' selected' : '';
|
|
@@ -3226,15 +3282,38 @@ async function playerMove() {
|
|
| 3226 |
} catch(e){}
|
| 3227 |
}
|
| 3228 |
|
| 3229 |
-
function openProfileEditor() {
|
| 3230 |
if (!playerAgentId) return;
|
| 3231 |
-
|
| 3232 |
-
|
| 3233 |
-
|
| 3234 |
-
|
| 3235 |
-
|
| 3236 |
-
|
| 3237 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3238 |
}
|
| 3239 |
document.getElementById('profile-modal').style.display = 'flex';
|
| 3240 |
}
|
|
|
|
| 2691 |
const gi = a.gender==='female'?'\uD83D\uDC69':a.gender==='male'?'\uD83D\uDC68':'\uD83E\uDDD1';
|
| 2692 |
const stateIcon = a.state==='in_conversation'?' \uD83D\uDCAC':(a.state==='sleeping'?' \uD83D\uDCA4':(a.state==='moving'?' \uD83D\uDEB6':''));
|
| 2693 |
const partner = a.partner_id && agents[a.partner_id] ? ` \u2764\uFE0F ${agents[a.partner_id].name.split(' ')[0]}` : '';
|
| 2694 |
+
const pregIcon = a.pregnant ? ' \uD83E\uDD30' : '';
|
| 2695 |
+
const kidIcon = a.children_count > 0 ? ` \uD83D\uDC76${a.children_count}` : '';
|
| 2696 |
return `
|
| 2697 |
<div class="agent-list-item" onclick="selectedAgentId='${aid}';fetchAgentDetail('${aid}');">
|
| 2698 |
<span class="agent-dot" style="background:${color}"></span>
|
| 2699 |
<span style="font-size:13px">${gi}</span>
|
| 2700 |
<div class="agent-info">
|
| 2701 |
+
<div class="agent-name" style="color:${color}">${a.name}${partner}${pregIcon}${kidIcon}${stateIcon}</div>
|
| 2702 |
<div class="agent-action">${esc(a.action||'idle')}</div>
|
| 2703 |
</div>
|
| 2704 |
</div>`;
|
|
|
|
| 2731 |
const romanceRels=(data.relationships||[]).filter(r=>r.relationship_status&&r.relationship_status!=='none');
|
| 2732 |
const locName = data.location ? (typeof data.location === 'object' ? data.location.name : data.location) : '?';
|
| 2733 |
|
| 2734 |
+
// Life events
|
| 2735 |
+
const lifeEvents = (data.life_events || []).slice(-10).reverse();
|
| 2736 |
+
const evtIcon = {
|
| 2737 |
+
origin:'\uD83C\uDFE0', career:'\uD83D\uDCBC', dating:'\uD83D\uDC95', engaged:'\uD83D\uDC8D',
|
| 2738 |
+
married:'\uD83D\uDC92', pregnant:'\uD83E\uDD30', child_born:'\uD83D\uDC76',
|
| 2739 |
+
promotion:'\uD83D\uDCC8', graduated:'\uD83C\uDF93', achievement:'\u2B50',
|
| 2740 |
+
milestone:'\uD83C\uDFC6', new_job:'\uD83D\uDCBC', moved:'\uD83D\uDE9A',
|
| 2741 |
+
breakup:'\uD83D\uDC94', friendship:'\uD83E\uDD1D',
|
| 2742 |
+
};
|
| 2743 |
+
|
| 2744 |
+
// Goals
|
| 2745 |
+
const goals = data.goals || [];
|
| 2746 |
+
const activeGoals = goals.filter(g => g.status === 'active');
|
| 2747 |
+
const completedGoals = goals.filter(g => g.status === 'completed');
|
| 2748 |
+
|
| 2749 |
+
// Children & pregnancy
|
| 2750 |
+
const children = data.children || [];
|
| 2751 |
+
const isPregnant = data.pregnant || false;
|
| 2752 |
+
const pregnantBadge = isPregnant ? ' \uD83E\uDD30' : '';
|
| 2753 |
+
const childrenBadge = children.length > 0 ? ` \uD83D\uDC76${children.length}` : '';
|
| 2754 |
+
|
| 2755 |
document.getElementById('agent-detail').innerHTML=`
|
| 2756 |
<div style="margin-bottom:6px">
|
| 2757 |
<span style="cursor:pointer;color:#888;font-size:11px" onclick="selectedAgentId=null;showDefaultDetail();">← All Agents</span>
|
| 2758 |
</div>
|
| 2759 |
+
<h2>${gi} ${data.name||'?'}${pregnantBadge}${childrenBadge}</h2>
|
| 2760 |
<p class="subtitle">${data.age||'?'} y/o ${data.occupation||''}</p>
|
| 2761 |
<p class="subtitle" style="color:#888;font-size:10px">${data.traits||''}</p>
|
| 2762 |
<p class="subtitle" style="color:#e0e0e0">@ ${esc(locName)} — ${esc(data.action||'idle')}</p>
|
|
|
|
| 2765 |
${romanceRels.map(r=>`\u2764 ${r.relationship_status} with ${r.name} (${(r.romantic_interest*100).toFixed(0)}%)`).join('<br>')}
|
| 2766 |
</div>`:''}
|
| 2767 |
|
| 2768 |
+
${children.length>0?`<div style="margin:4px 0;padding:4px 8px;background:rgba(78,204,163,0.08);border:1px solid rgba(78,204,163,0.2);border-radius:4px;font-size:11px;color:#4ecca3">
|
| 2769 |
+
\uD83D\uDC76 Children: ${children.map(c=>esc(c)).join(', ')}
|
| 2770 |
+
</div>`:''}
|
| 2771 |
+
|
| 2772 |
<div class="bar-container">
|
| 2773 |
<div class="bar-label"><span>Mood: ${moodLabel}</span><span>${moodPct}%</span></div>
|
| 2774 |
<div class="bar-bg"><div class="bar-fill ${moodColor}" style="width:${moodPct}%"></div></div>
|
|
|
|
| 2786 |
<div style="font-size:10px;color:#888">Plan: ${esc((data.daily_plan||[]).slice(0,3).join('; ')||'None yet')}</div>
|
| 2787 |
</div>
|
| 2788 |
|
| 2789 |
+
${activeGoals.length>0||completedGoals.length>0?`
|
| 2790 |
+
<div class="section-header">Goals</div>
|
| 2791 |
+
${activeGoals.map(g=>{
|
| 2792 |
+
const pct=Math.round((g.progress||0)*100);
|
| 2793 |
+
return `<div style="margin:3px 0;font-size:11px">
|
| 2794 |
+
<div style="display:flex;justify-content:space-between;color:#e0e0f0">
|
| 2795 |
+
<span>\uD83C\uDFAF ${esc(g.description)}</span><span style="color:#888;font-size:9px">${pct}%</span>
|
| 2796 |
+
</div>
|
| 2797 |
+
<div style="height:3px;background:#1a1a3e;border-radius:2px;margin-top:2px">
|
| 2798 |
+
<div style="height:100%;width:${pct}%;background:#4ecca3;border-radius:2px"></div>
|
| 2799 |
+
</div>
|
| 2800 |
+
</div>`;
|
| 2801 |
+
}).join('')}
|
| 2802 |
+
${completedGoals.slice(-3).map(g=>`<div style="margin:2px 0;font-size:10px;color:#555">\u2705 ${esc(g.description)}</div>`).join('')}
|
| 2803 |
+
`:''}
|
| 2804 |
+
|
| 2805 |
+
${lifeEvents.length>0?`
|
| 2806 |
+
<div class="section-header">Life History</div>
|
| 2807 |
+
<div style="max-height:160px;overflow-y:auto">
|
| 2808 |
+
${lifeEvents.map(e=>{
|
| 2809 |
+
const icon = evtIcon[e.type] || '\uD83D\uDD39';
|
| 2810 |
+
const dayLabel = e.day > 0 ? `Day ${e.day}` : 'Background';
|
| 2811 |
+
return `<div style="margin:3px 0;font-size:11px;display:flex;gap:6px;align-items:start">
|
| 2812 |
+
<span style="flex-shrink:0">${icon}</span>
|
| 2813 |
+
<div><span style="color:#555;font-size:9px">${dayLabel}</span><br><span style="color:#c0c0d0">${esc(e.description)}</span></div>
|
| 2814 |
+
</div>`;
|
| 2815 |
+
}).join('')}
|
| 2816 |
+
</div>`:''}
|
| 2817 |
+
|
| 2818 |
${(data.relationships||[]).length>0?`
|
| 2819 |
<div class="section-header">Relationships</div>
|
| 2820 |
${data.relationships.slice(0,8).map(r=>{
|
|
|
|
| 3258 |
const locName = agent ? (locations[agent.location]?.name || agent.location || '') : '';
|
| 3259 |
document.getElementById('pp-loc').textContent = locName ? `@ ${locName}` : '';
|
| 3260 |
|
| 3261 |
+
// Populate move dropdown from known locations (only rebuild if empty)
|
| 3262 |
const sel = document.getElementById('pp-move-select');
|
| 3263 |
const locKeys = Object.keys(locations);
|
| 3264 |
+
if (locKeys.length > 0 && sel.options.length === 0) {
|
| 3265 |
sel.innerHTML = locKeys.map(lid => {
|
| 3266 |
const ln = locations[lid]?.name || lid;
|
| 3267 |
const selected = agent && agent.location === lid ? ' selected' : '';
|
|
|
|
| 3282 |
} catch(e){}
|
| 3283 |
}
|
| 3284 |
|
| 3285 |
+
async function openProfileEditor() {
|
| 3286 |
if (!playerAgentId) return;
|
| 3287 |
+
// Fetch full agent detail to get all fields (WS summary may not have everything)
|
| 3288 |
+
try {
|
| 3289 |
+
const res = await fetch(`${API_BASE}/agents/${playerAgentId}`);
|
| 3290 |
+
if (res.ok) {
|
| 3291 |
+
const data = await res.json();
|
| 3292 |
+
document.getElementById('pe-name').value = data.name || '';
|
| 3293 |
+
document.getElementById('pe-age').value = data.age || 30;
|
| 3294 |
+
document.getElementById('pe-occupation').value = data.occupation || '';
|
| 3295 |
+
document.getElementById('pe-gender').value = data.gender || 'unknown';
|
| 3296 |
+
document.getElementById('pe-background').value = data.background || '';
|
| 3297 |
+
const pers = data.personality || {};
|
| 3298 |
+
const extVal = pers.extraversion || 5;
|
| 3299 |
+
const agrVal = pers.agreeableness || 7;
|
| 3300 |
+
const opnVal = pers.openness || 6;
|
| 3301 |
+
document.getElementById('pe-extraversion').value = extVal;
|
| 3302 |
+
document.getElementById('pe-extra-val').textContent = extVal;
|
| 3303 |
+
document.getElementById('pe-agreeableness').value = agrVal;
|
| 3304 |
+
document.getElementById('pe-agree-val').textContent = agrVal;
|
| 3305 |
+
document.getElementById('pe-openness').value = opnVal;
|
| 3306 |
+
document.getElementById('pe-open-val').textContent = opnVal;
|
| 3307 |
+
}
|
| 3308 |
+
} catch(e) {
|
| 3309 |
+
// Fallback to WS data
|
| 3310 |
+
const agent = agents[playerAgentId];
|
| 3311 |
+
if (agent) {
|
| 3312 |
+
document.getElementById('pe-name').value = agent.name || '';
|
| 3313 |
+
document.getElementById('pe-age').value = agent.age || 30;
|
| 3314 |
+
document.getElementById('pe-occupation').value = agent.occupation || '';
|
| 3315 |
+
document.getElementById('pe-gender').value = agent.gender || 'unknown';
|
| 3316 |
+
}
|
| 3317 |
}
|
| 3318 |
document.getElementById('profile-modal').style.display = 'flex';
|
| 3319 |
}
|