RayMelius Claude Opus 4.6 commited on
Commit
a4397fb
·
1 Parent(s): 0f429d3

Add gender, romance system, web UI, and API event log

Browse files

- Add gender field to all 20 personas (male/female/nonbinary)
- Implement romance system: romantic_interest tracking with relationship
progression (crushing -> dating -> engaged -> married)
- Add partner_id to agents for tracking romantic partners
- Create web UI (web/index.html): canvas-based city visualization with
buildings, animated agents with gender-specific sprites, weather effects,
day/night cycle, couple hearts/lines, event log, and agent detail panel
- Add /api/events endpoint and persistent event history in simulation
- Serve web UI from FastAPI with static file mounting
- Update default Ollama model to llama3.2

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

config/personas.yaml CHANGED
@@ -3,6 +3,7 @@ personas:
3
  - id: elena
4
  name: Elena Vasquez
5
  age: 34
 
6
  occupation: software engineer
7
  openness: 8
8
  conscientiousness: 7
@@ -26,6 +27,7 @@ personas:
26
  - id: marcus
27
  name: Marcus Chen
28
  age: 28
 
29
  occupation: fitness trainer
30
  openness: 5
31
  conscientiousness: 8
@@ -49,6 +51,7 @@ personas:
49
  - id: helen
50
  name: Helen Park
51
  age: 67
 
52
  occupation: retired teacher
53
  openness: 6
54
  conscientiousness: 8
@@ -72,6 +75,7 @@ personas:
72
  - id: kai
73
  name: Kai Okonkwo
74
  age: 22
 
75
  occupation: barista and aspiring musician
76
  openness: 9
77
  conscientiousness: 3
@@ -95,6 +99,7 @@ personas:
95
  - id: diana
96
  name: Diana Novak
97
  age: 41
 
98
  occupation: small business owner (grocery store)
99
  openness: 4
100
  conscientiousness: 9
@@ -119,6 +124,7 @@ personas:
119
  - id: james
120
  name: James "Jimmy" O'Brien
121
  age: 55
 
122
  occupation: bartender and bar owner
123
  openness: 5
124
  conscientiousness: 6
@@ -142,6 +148,7 @@ personas:
142
  - id: rosa
143
  name: Rosa Martelli
144
  age: 62
 
145
  occupation: restaurant owner and chef
146
  openness: 6
147
  conscientiousness: 9
@@ -165,6 +172,7 @@ personas:
165
  - id: devon
166
  name: Devon Reeves
167
  age: 30
 
168
  occupation: freelance journalist
169
  openness: 9
170
  conscientiousness: 5
@@ -188,6 +196,7 @@ personas:
188
  - id: yuki
189
  name: Yuki Tanaka
190
  age: 26
 
191
  occupation: yoga instructor and massage therapist
192
  openness: 8
193
  conscientiousness: 6
@@ -211,6 +220,7 @@ personas:
211
  - id: theo
212
  name: Theo Blackwood
213
  age: 45
 
214
  occupation: construction worker
215
  openness: 3
216
  conscientiousness: 7
@@ -235,6 +245,7 @@ personas:
235
  - id: priya
236
  name: Priya Sharma
237
  age: 38
 
238
  occupation: doctor (works at a clinic outside the city, spends free time locally)
239
  openness: 7
240
  conscientiousness: 9
@@ -258,6 +269,7 @@ personas:
258
  - id: omar
259
  name: Omar Hassan
260
  age: 50
 
261
  occupation: taxi driver and part-time cook
262
  openness: 6
263
  conscientiousness: 6
@@ -281,6 +293,7 @@ personas:
281
  - id: zoe
282
  name: Zoe Chen-Williams
283
  age: 19
 
284
  occupation: college student (home for the semester)
285
  openness: 8
286
  conscientiousness: 4
@@ -304,6 +317,7 @@ personas:
304
  - id: frank
305
  name: Frank Kowalski
306
  age: 72
 
307
  occupation: retired mechanic
308
  openness: 3
309
  conscientiousness: 7
@@ -327,6 +341,7 @@ personas:
327
  - id: lila
328
  name: Lila Santos
329
  age: 33
 
330
  occupation: artist and part-time art teacher
331
  openness: 10
332
  conscientiousness: 3
@@ -351,6 +366,7 @@ personas:
351
  - id: sam
352
  name: Sam Nakamura
353
  age: 40
 
354
  occupation: librarian
355
  openness: 7
356
  conscientiousness: 8
@@ -374,6 +390,7 @@ personas:
374
  - id: marco
375
  name: Marco Delgado
376
  age: 16
 
377
  occupation: high school student (Diana's son)
378
  openness: 7
379
  conscientiousness: 4
@@ -398,6 +415,7 @@ personas:
398
  - id: nina
399
  name: Nina Volkov
400
  age: 29
 
401
  occupation: real estate agent
402
  openness: 5
403
  conscientiousness: 8
@@ -422,6 +440,7 @@ personas:
422
  - id: george
423
  name: George Adeyemi
424
  age: 47
 
425
  occupation: night shift security guard
426
  openness: 4
427
  conscientiousness: 7
@@ -445,6 +464,7 @@ personas:
445
  - id: alice
446
  name: Alice Fontaine
447
  age: 58
 
448
  occupation: retired accountant, amateur baker
449
  openness: 5
450
  conscientiousness: 8
 
3
  - id: elena
4
  name: Elena Vasquez
5
  age: 34
6
+ gender: female
7
  occupation: software engineer
8
  openness: 8
9
  conscientiousness: 7
 
27
  - id: marcus
28
  name: Marcus Chen
29
  age: 28
30
+ gender: male
31
  occupation: fitness trainer
32
  openness: 5
33
  conscientiousness: 8
 
51
  - id: helen
52
  name: Helen Park
53
  age: 67
54
+ gender: female
55
  occupation: retired teacher
56
  openness: 6
57
  conscientiousness: 8
 
75
  - id: kai
76
  name: Kai Okonkwo
77
  age: 22
78
+ gender: nonbinary
79
  occupation: barista and aspiring musician
80
  openness: 9
81
  conscientiousness: 3
 
99
  - id: diana
100
  name: Diana Novak
101
  age: 41
102
+ gender: female
103
  occupation: small business owner (grocery store)
104
  openness: 4
105
  conscientiousness: 9
 
124
  - id: james
125
  name: James "Jimmy" O'Brien
126
  age: 55
127
+ gender: male
128
  occupation: bartender and bar owner
129
  openness: 5
130
  conscientiousness: 6
 
148
  - id: rosa
149
  name: Rosa Martelli
150
  age: 62
151
+ gender: female
152
  occupation: restaurant owner and chef
153
  openness: 6
154
  conscientiousness: 9
 
172
  - id: devon
173
  name: Devon Reeves
174
  age: 30
175
+ gender: male
176
  occupation: freelance journalist
177
  openness: 9
178
  conscientiousness: 5
 
196
  - id: yuki
197
  name: Yuki Tanaka
198
  age: 26
199
+ gender: female
200
  occupation: yoga instructor and massage therapist
201
  openness: 8
202
  conscientiousness: 6
 
220
  - id: theo
221
  name: Theo Blackwood
222
  age: 45
223
+ gender: male
224
  occupation: construction worker
225
  openness: 3
226
  conscientiousness: 7
 
245
  - id: priya
246
  name: Priya Sharma
247
  age: 38
248
+ gender: female
249
  occupation: doctor (works at a clinic outside the city, spends free time locally)
250
  openness: 7
251
  conscientiousness: 9
 
269
  - id: omar
270
  name: Omar Hassan
271
  age: 50
272
+ gender: male
273
  occupation: taxi driver and part-time cook
274
  openness: 6
275
  conscientiousness: 6
 
293
  - id: zoe
294
  name: Zoe Chen-Williams
295
  age: 19
296
+ gender: female
297
  occupation: college student (home for the semester)
298
  openness: 8
299
  conscientiousness: 4
 
317
  - id: frank
318
  name: Frank Kowalski
319
  age: 72
320
+ gender: male
321
  occupation: retired mechanic
322
  openness: 3
323
  conscientiousness: 7
 
341
  - id: lila
342
  name: Lila Santos
343
  age: 33
344
+ gender: female
345
  occupation: artist and part-time art teacher
346
  openness: 10
347
  conscientiousness: 3
 
366
  - id: sam
367
  name: Sam Nakamura
368
  age: 40
369
+ gender: nonbinary
370
  occupation: librarian
371
  openness: 7
372
  conscientiousness: 8
 
390
  - id: marco
391
  name: Marco Delgado
392
  age: 16
393
+ gender: male
394
  occupation: high school student (Diana's son)
395
  openness: 7
396
  conscientiousness: 4
 
415
  - id: nina
416
  name: Nina Volkov
417
  age: 29
418
+ gender: female
419
  occupation: real estate agent
420
  openness: 5
421
  conscientiousness: 8
 
440
  - id: george
441
  name: George Adeyemi
442
  age: 47
443
+ gender: male
444
  occupation: night shift security guard
445
  openness: 4
446
  conscientiousness: 7
 
464
  - id: alice
465
  name: Alice Fontaine
466
  age: 58
467
+ gender: female
468
  occupation: retired accountant, amateur baker
469
  openness: 5
470
  conscientiousness: 8
src/soci/agents/agent.py CHANGED
@@ -74,6 +74,8 @@ class Agent:
74
  self._last_llm_tick: int = -1
75
  # Whether this agent is a human player
76
  self.is_player: bool = False
 
 
77
 
78
  @property
79
  def is_busy(self) -> bool:
@@ -242,6 +244,7 @@ class Agent:
242
  "has_plan_today": self._has_plan_today,
243
  "last_llm_tick": self._last_llm_tick,
244
  "is_player": self.is_player,
 
245
  }
246
 
247
  @classmethod
@@ -261,4 +264,5 @@ class Agent:
261
  agent._has_plan_today = data["has_plan_today"]
262
  agent._last_llm_tick = data["last_llm_tick"]
263
  agent.is_player = data["is_player"]
 
264
  return agent
 
74
  self._last_llm_tick: int = -1
75
  # Whether this agent is a human player
76
  self.is_player: bool = False
77
+ # Romance: current partner ID (dating/engaged/married)
78
+ self.partner_id: Optional[str] = None
79
 
80
  @property
81
  def is_busy(self) -> bool:
 
244
  "has_plan_today": self._has_plan_today,
245
  "last_llm_tick": self._last_llm_tick,
246
  "is_player": self.is_player,
247
+ "partner_id": self.partner_id,
248
  }
249
 
250
  @classmethod
 
264
  agent._has_plan_today = data["has_plan_today"]
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
src/soci/agents/persona.py CHANGED
@@ -15,6 +15,7 @@ class Persona:
15
  name: str
16
  age: int
17
  occupation: str
 
18
  # Big Five personality traits (1-10 scale)
19
  openness: int = 5
20
  conscientiousness: int = 5
@@ -81,6 +82,7 @@ class Persona:
81
  "name": self.name,
82
  "age": self.age,
83
  "occupation": self.occupation,
 
84
  "openness": self.openness,
85
  "conscientiousness": self.conscientiousness,
86
  "extraversion": self.extraversion,
 
15
  name: str
16
  age: int
17
  occupation: str
18
+ gender: str = "unknown" # male, female, nonbinary
19
  # Big Five personality traits (1-10 scale)
20
  openness: int = 5
21
  conscientiousness: int = 5
 
82
  "name": self.name,
83
  "age": self.age,
84
  "occupation": self.occupation,
85
+ "gender": self.gender,
86
  "openness": self.openness,
87
  "conscientiousness": self.conscientiousness,
88
  "extraversion": self.extraversion,
src/soci/agents/relationships.py CHANGED
@@ -14,6 +14,8 @@ class Relationship:
14
  familiarity: float = 0.0 # 0 (stranger) to 1 (well-known)
15
  trust: float = 0.5 # 0 (distrust) to 1 (full trust)
16
  sentiment: float = 0.5 # 0 (dislike) to 1 (like)
 
 
17
  interaction_count: int = 0
18
  last_interaction_tick: int = 0
19
  # Short notes about the relationship
@@ -50,12 +52,22 @@ class Relationship:
50
  if self.familiarity < 0.1:
51
  return f"{self.agent_name} — a stranger"
52
  parts = [self.agent_name]
53
- if self.familiarity > 0.7:
 
 
 
 
 
 
 
 
54
  parts.append("someone I know well")
55
  elif self.familiarity > 0.3:
56
  parts.append("an acquaintance")
57
  else:
58
  parts.append("someone I've met briefly")
 
 
59
  if self.sentiment > 0.7:
60
  parts.append("(I like them)")
61
  elif self.sentiment < 0.3:
@@ -76,6 +88,8 @@ class Relationship:
76
  "familiarity": round(self.familiarity, 3),
77
  "trust": round(self.trust, 3),
78
  "sentiment": round(self.sentiment, 3),
 
 
79
  "interaction_count": self.interaction_count,
80
  "last_interaction_tick": self.last_interaction_tick,
81
  "notes": list(self.notes),
 
14
  familiarity: float = 0.0 # 0 (stranger) to 1 (well-known)
15
  trust: float = 0.5 # 0 (distrust) to 1 (full trust)
16
  sentiment: float = 0.5 # 0 (dislike) to 1 (like)
17
+ romantic_interest: float = 0.0 # 0 (none) to 1 (deeply in love)
18
+ relationship_status: str = "none" # none, crushing, dating, engaged, married
19
  interaction_count: int = 0
20
  last_interaction_tick: int = 0
21
  # Short notes about the relationship
 
52
  if self.familiarity < 0.1:
53
  return f"{self.agent_name} — a stranger"
54
  parts = [self.agent_name]
55
+ if self.relationship_status == "married":
56
+ parts.append("my spouse")
57
+ elif self.relationship_status == "engaged":
58
+ parts.append("my fiance(e)")
59
+ elif self.relationship_status == "dating":
60
+ parts.append("someone I'm dating")
61
+ elif self.relationship_status == "crushing":
62
+ parts.append("someone I have feelings for")
63
+ elif self.familiarity > 0.7:
64
  parts.append("someone I know well")
65
  elif self.familiarity > 0.3:
66
  parts.append("an acquaintance")
67
  else:
68
  parts.append("someone I've met briefly")
69
+ if self.romantic_interest > 0.5 and self.relationship_status == "none":
70
+ parts.append("(I find them attractive)")
71
  if self.sentiment > 0.7:
72
  parts.append("(I like them)")
73
  elif self.sentiment < 0.3:
 
88
  "familiarity": round(self.familiarity, 3),
89
  "trust": round(self.trust, 3),
90
  "sentiment": round(self.sentiment, 3),
91
+ "romantic_interest": round(self.romantic_interest, 3),
92
+ "relationship_status": self.relationship_status,
93
  "interaction_count": self.interaction_count,
94
  "last_interaction_tick": self.last_interaction_tick,
95
  "notes": list(self.notes),
src/soci/api/routes.py CHANGED
@@ -56,6 +56,7 @@ async def get_agents():
56
  aid: {
57
  "name": a.name,
58
  "age": a.persona.age,
 
59
  "occupation": a.persona.occupation,
60
  "location": a.location,
61
  "state": a.state.value,
@@ -81,6 +82,7 @@ async def get_agent(agent_id: str):
81
  "id": agent.id,
82
  "name": agent.name,
83
  "age": agent.persona.age,
 
84
  "occupation": agent.persona.occupation,
85
  "traits": agent.persona.trait_summary,
86
  "location": {"id": agent.location, "name": loc.name if loc else "unknown"},
@@ -227,6 +229,15 @@ async def player_action(player_id: str, request: PlayerActionRequest):
227
  }
228
 
229
 
 
 
 
 
 
 
 
 
 
230
  @router.post("/save")
231
  async def save_state(name: str = "manual_save"):
232
  """Manually save the simulation state."""
 
56
  aid: {
57
  "name": a.name,
58
  "age": a.persona.age,
59
+ "gender": a.persona.gender,
60
  "occupation": a.persona.occupation,
61
  "location": a.location,
62
  "state": a.state.value,
 
82
  "id": agent.id,
83
  "name": agent.name,
84
  "age": agent.persona.age,
85
+ "gender": agent.persona.gender,
86
  "occupation": agent.persona.occupation,
87
  "traits": agent.persona.trait_summary,
88
  "location": {"id": agent.location, "name": loc.name if loc else "unknown"},
 
229
  }
230
 
231
 
232
+ @router.get("/events")
233
+ async def get_events(limit: int = 50):
234
+ """Get recent simulation events for the event log."""
235
+ from soci.api.server import get_simulation
236
+ sim = get_simulation()
237
+ events = sim._event_history[-limit:]
238
+ return {"events": events}
239
+
240
+
241
  @router.post("/save")
242
  async def save_state(name: str = "manual_save"):
243
  """Manually save the simulation state."""
src/soci/api/server.py CHANGED
@@ -10,6 +10,8 @@ from typing import Optional
10
 
11
  from fastapi import FastAPI
12
  from fastapi.middleware.cors import CORSMiddleware
 
 
13
 
14
  from soci.engine.llm import create_llm_client
15
  from soci.engine.simulation import Simulation
@@ -118,6 +120,15 @@ def create_app() -> FastAPI:
118
  app.include_router(router, prefix="/api")
119
  app.include_router(ws_router)
120
 
 
 
 
 
 
 
 
 
 
121
  return app
122
 
123
 
 
10
 
11
  from fastapi import FastAPI
12
  from fastapi.middleware.cors import CORSMiddleware
13
+ from fastapi.responses import FileResponse
14
+ from fastapi.staticfiles import StaticFiles
15
 
16
  from soci.engine.llm import create_llm_client
17
  from soci.engine.simulation import Simulation
 
120
  app.include_router(router, prefix="/api")
121
  app.include_router(ws_router)
122
 
123
+ # Serve web UI
124
+ web_dir = Path(__file__).parents[3] / "web"
125
+ if web_dir.exists():
126
+ @app.get("/")
127
+ async def serve_index():
128
+ return FileResponse(web_dir / "index.html")
129
+
130
+ app.mount("/static", StaticFiles(directory=str(web_dir)), name="static")
131
+
132
  return app
133
 
134
 
src/soci/engine/llm.py CHANGED
@@ -22,7 +22,7 @@ MODEL_SONNET = "claude-sonnet-4-5-20250929"
22
  MODEL_HAIKU = "claude-haiku-4-5-20251001"
23
 
24
  # Ollama model IDs (popular open-source models)
25
- MODEL_LLAMA = "llama3.1"
26
  MODEL_LLAMA_SMALL = "llama3.2"
27
  MODEL_MISTRAL = "mistral"
28
  MODEL_QWEN = "qwen2.5"
 
22
  MODEL_HAIKU = "claude-haiku-4-5-20251001"
23
 
24
  # Ollama model IDs (popular open-source models)
25
+ MODEL_LLAMA = "llama3.2"
26
  MODEL_LLAMA_SMALL = "llama3.2"
27
  MODEL_MISTRAL = "mistral"
28
  MODEL_QWEN = "qwen2.5"
src/soci/engine/simulation.py CHANGED
@@ -50,6 +50,8 @@ class Simulation:
50
  self._conversation_counter: int = 0
51
  self._max_concurrent = max_concurrent_llm
52
  self._tick_log: list[str] = [] # Log of events this tick
 
 
53
  # Callback for real-time output
54
  self.on_event: Optional[Callable[[str], None]] = None
55
 
@@ -69,6 +71,13 @@ class Simulation:
69
  def _emit(self, message: str) -> None:
70
  """Emit an event message."""
71
  self._tick_log.append(message)
 
 
 
 
 
 
 
72
  if self.on_event:
73
  self.on_event(message)
74
 
@@ -173,7 +182,10 @@ class Simulation:
173
  if reflect_coros:
174
  await batch_llm_calls(reflect_coros, self._max_concurrent)
175
 
176
- # 9. Advance clock
 
 
 
177
  self.clock.tick()
178
 
179
  return self._tick_log
@@ -453,6 +465,107 @@ class Simulation:
453
  if reflections:
454
  self._emit(f" [REFLECT] {agent.name}: {reflections[0]}")
455
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
456
  def get_state_summary(self) -> dict:
457
  """Get a summary of the current simulation state."""
458
  return {
@@ -462,11 +575,13 @@ class Simulation:
462
  "agents": {
463
  aid: {
464
  "name": a.name,
 
465
  "location": a.location,
466
  "state": a.state.value,
467
  "mood": round(a.mood, 2),
468
  "needs": a.needs.to_dict(),
469
  "action": a.current_action.detail if a.current_action else "idle",
 
470
  }
471
  for aid, a in self.agents.items()
472
  },
 
50
  self._conversation_counter: int = 0
51
  self._max_concurrent = max_concurrent_llm
52
  self._tick_log: list[str] = [] # Log of events this tick
53
+ self._event_history: list[dict] = [] # Persistent event log for API
54
+ self._max_event_history: int = 200
55
  # Callback for real-time output
56
  self.on_event: Optional[Callable[[str], None]] = None
57
 
 
71
  def _emit(self, message: str) -> None:
72
  """Emit an event message."""
73
  self._tick_log.append(message)
74
+ self._event_history.append({
75
+ "tick": self.clock.total_ticks,
76
+ "time": self.clock.datetime_str,
77
+ "message": message,
78
+ })
79
+ if len(self._event_history) > self._max_event_history:
80
+ self._event_history = self._event_history[-self._max_event_history:]
81
  if self.on_event:
82
  self.on_event(message)
83
 
 
182
  if reflect_coros:
183
  await batch_llm_calls(reflect_coros, self._max_concurrent)
184
 
185
+ # 9. Romance — develop attractions and relationships
186
+ self._tick_romance()
187
+
188
+ # 10. Advance clock
189
  self.clock.tick()
190
 
191
  return self._tick_log
 
465
  if reflections:
466
  self._emit(f" [REFLECT] {agent.name}: {reflections[0]}")
467
 
468
+ def _tick_romance(self) -> None:
469
+ """Develop romantic attractions between compatible agents at the same location."""
470
+ agents_list = list(self.agents.values())
471
+
472
+ for agent in agents_list:
473
+ if agent.is_player:
474
+ continue
475
+ loc = self.city.get_location(agent.location)
476
+ if not loc:
477
+ continue
478
+
479
+ for other_id in loc.occupants:
480
+ if other_id == agent.id or other_id not in self.agents:
481
+ continue
482
+ other = self.agents[other_id]
483
+
484
+ rel = agent.relationships.get(other_id)
485
+ if not rel or rel.familiarity < 0.15:
486
+ continue # Need to know someone a bit first
487
+
488
+ # Skip if already married to someone else
489
+ if agent.partner_id and agent.partner_id != other_id:
490
+ continue
491
+
492
+ # Attraction grows from positive interactions
493
+ if rel.sentiment > 0.6 and rel.trust > 0.5:
494
+ # Base attraction growth per tick
495
+ growth = 0.008
496
+ # Boost from high agreeableness and extraversion
497
+ growth += (agent.persona.agreeableness / 100.0) * 0.005
498
+ # Boost from familiarity
499
+ growth += rel.familiarity * 0.005
500
+ # Boost when both at same location and interacting
501
+ if agent.state.value == "in_conversation":
502
+ growth += 0.01
503
+
504
+ rel.romantic_interest = min(1.0, rel.romantic_interest + growth)
505
+
506
+ # Relationship status progression (no LLM calls — pure rules)
507
+ ri = rel.romantic_interest
508
+ status = rel.relationship_status
509
+
510
+ if status == "none" and ri > 0.25:
511
+ rel.relationship_status = "crushing"
512
+ self._emit(f" [ROMANCE] {agent.name} has developed a crush on {other.name}!")
513
+ agent.add_observation(
514
+ tick=self.clock.total_ticks, day=self.clock.day,
515
+ time_str=self.clock.time_str,
516
+ content=f"I think I'm developing feelings for {other.name}...",
517
+ importance=7, involved_agents=[other_id],
518
+ )
519
+
520
+ elif status == "crushing" and ri > 0.5 and rel.familiarity > 0.4:
521
+ # Check if the other person also has feelings
522
+ other_rel = other.relationships.get(agent.id)
523
+ if other_rel and other_rel.romantic_interest > 0.3:
524
+ rel.relationship_status = "dating"
525
+ other_rel.relationship_status = "dating"
526
+ agent.partner_id = other_id
527
+ other.partner_id = agent.id
528
+ self._emit(f" [ROMANCE] {agent.name} and {other.name} have started dating!")
529
+ for a, o in [(agent, other), (other, agent)]:
530
+ a.add_observation(
531
+ tick=self.clock.total_ticks, day=self.clock.day,
532
+ time_str=self.clock.time_str,
533
+ content=f"I'm now dating {o.name}! I feel excited and nervous.",
534
+ importance=9, involved_agents=[o.id],
535
+ )
536
+ agent.mood = min(1.0, agent.mood + 0.3)
537
+ other.mood = min(1.0, other.mood + 0.3)
538
+
539
+ elif status == "dating" and ri > 0.75 and rel.familiarity > 0.7:
540
+ other_rel = other.relationships.get(agent.id)
541
+ if other_rel and other_rel.romantic_interest > 0.65:
542
+ rel.relationship_status = "engaged"
543
+ other_rel.relationship_status = "engaged"
544
+ self._emit(f" [ROMANCE] {agent.name} and {other.name} got engaged!")
545
+ for a, o in [(agent, other), (other, agent)]:
546
+ a.add_observation(
547
+ tick=self.clock.total_ticks, day=self.clock.day,
548
+ time_str=self.clock.time_str,
549
+ content=f"{o.name} and I are engaged! This is the happiest day of my life.",
550
+ importance=10, involved_agents=[o.id],
551
+ )
552
+ agent.mood = min(1.0, agent.mood + 0.4)
553
+ other.mood = min(1.0, other.mood + 0.4)
554
+
555
+ elif status == "engaged" and ri > 0.9 and rel.interaction_count > 15:
556
+ other_rel = other.relationships.get(agent.id)
557
+ if other_rel and other_rel.romantic_interest > 0.8:
558
+ rel.relationship_status = "married"
559
+ other_rel.relationship_status = "married"
560
+ self._emit(f" [ROMANCE] {agent.name} and {other.name} got married!")
561
+ for a, o in [(agent, other), (other, agent)]:
562
+ a.add_observation(
563
+ tick=self.clock.total_ticks, day=self.clock.day,
564
+ time_str=self.clock.time_str,
565
+ content=f"I married {o.name} today. I couldn't be happier.",
566
+ importance=10, involved_agents=[o.id],
567
+ )
568
+
569
  def get_state_summary(self) -> dict:
570
  """Get a summary of the current simulation state."""
571
  return {
 
575
  "agents": {
576
  aid: {
577
  "name": a.name,
578
+ "gender": a.persona.gender,
579
  "location": a.location,
580
  "state": a.state.value,
581
  "mood": round(a.mood, 2),
582
  "needs": a.needs.to_dict(),
583
  "action": a.current_action.detail if a.current_action else "idle",
584
+ "partner_id": a.partner_id,
585
  }
586
  for aid, a in self.agents.items()
587
  },
web/index.html ADDED
@@ -0,0 +1,901 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>Soci City — Population Simulator</title>
7
+ <style>
8
+ * { margin: 0; padding: 0; box-sizing: border-box; }
9
+ body {
10
+ font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
11
+ background: #1a1a2e;
12
+ color: #e0e0e0;
13
+ overflow: hidden;
14
+ height: 100vh;
15
+ }
16
+ #header {
17
+ background: #16213e;
18
+ padding: 10px 20px;
19
+ display: flex;
20
+ align-items: center;
21
+ justify-content: space-between;
22
+ border-bottom: 2px solid #0f3460;
23
+ height: 50px;
24
+ }
25
+ #header h1 { font-size: 18px; color: #e94560; letter-spacing: 2px; }
26
+ #header .info { display: flex; gap: 20px; font-size: 13px; color: #a0a0c0; }
27
+ #header .info span { display: flex; align-items: center; gap: 6px; }
28
+ .dot { width: 8px; height: 8px; border-radius: 50%; display: inline-block; }
29
+ .dot.green { background: #4ecca3; } .dot.yellow { background: #f0c040; } .dot.red { background: #e94560; }
30
+ #main { display: flex; height: calc(100vh - 50px); }
31
+ #canvas-container { flex: 1; position: relative; min-width: 0; }
32
+ #cityCanvas { width: 100%; height: 100%; display: block; }
33
+ #sidebar {
34
+ width: 340px; background: #16213e; border-left: 2px solid #0f3460;
35
+ display: flex; flex-direction: column; overflow: hidden;
36
+ }
37
+ #agent-detail {
38
+ padding: 12px; border-bottom: 1px solid #0f3460;
39
+ min-height: 240px; max-height: 50%; overflow-y: auto;
40
+ }
41
+ #agent-detail h2 { font-size: 16px; color: #4ecca3; margin-bottom: 8px; }
42
+ #agent-detail .subtitle { font-size: 12px; color: #a0a0c0; margin-bottom: 10px; }
43
+ .bar-container { margin: 4px 0; }
44
+ .bar-label { font-size: 11px; color: #a0a0c0; display: flex; justify-content: space-between; }
45
+ .bar-bg { height: 8px; background: #0f3460; border-radius: 4px; overflow: hidden; margin-top: 2px; }
46
+ .bar-fill { height: 100%; border-radius: 4px; transition: width 0.5s ease; }
47
+ .bar-fill.green { background: #4ecca3; } .bar-fill.yellow { background: #f0c040; }
48
+ .bar-fill.red { background: #e94560; } .bar-fill.blue { background: #4e9eca; }
49
+ .bar-fill.purple { background: #9b59b6; } .bar-fill.orange { background: #e67e22; }
50
+ .bar-fill.pink { background: #e91e90; }
51
+ .memory-item { font-size: 11px; color: #c0c0d0; padding: 3px 0; border-bottom: 1px solid #0f346030; }
52
+ .memory-time { color: #666; font-size: 10px; }
53
+ #event-log { flex: 1; padding: 8px 12px; overflow-y: auto; font-size: 11px; line-height: 1.5; }
54
+ #event-log h3 { font-size: 13px; color: #e94560; margin-bottom: 6px; position: sticky; top: 0; background: #16213e; padding: 4px 0; z-index: 1; }
55
+ .event-line { padding: 2px 0; color: #b0b0c0; border-bottom: 1px solid #0f346020; }
56
+ .event-line.plan { color: #4ecca3; } .event-line.conv { color: #f0c040; }
57
+ .event-line.event { color: #e94560; } .event-line.time { color: #666; font-weight: bold; margin-top: 6px; }
58
+ .event-line.romance { color: #e91e90; }
59
+ #tooltip {
60
+ position: absolute; background: #16213eee; border: 1px solid #4ecca3;
61
+ border-radius: 6px; padding: 8px 12px; font-size: 12px;
62
+ pointer-events: none; display: none; z-index: 100; max-width: 250px;
63
+ }
64
+ ::-webkit-scrollbar { width: 6px; }
65
+ ::-webkit-scrollbar-track { background: #1a1a2e; }
66
+ ::-webkit-scrollbar-thumb { background: #0f3460; border-radius: 3px; }
67
+ </style>
68
+ </head>
69
+ <body>
70
+ <div id="header">
71
+ <h1>SOCI CITY</h1>
72
+ <div class="info">
73
+ <span id="clock">Day 1, 06:00</span>
74
+ <span><span id="weather-icon"></span> <span id="weather">Sunny</span></span>
75
+ <span id="agent-count"><span class="dot green"></span> 0 agents</span>
76
+ <span id="api-calls">API: 0</span>
77
+ <span id="cost">$0.00</span>
78
+ <span id="status"><span class="dot yellow"></span> Connecting...</span>
79
+ </div>
80
+ </div>
81
+ <div id="main">
82
+ <div id="canvas-container">
83
+ <canvas id="cityCanvas"></canvas>
84
+ <div id="tooltip"></div>
85
+ </div>
86
+ <div id="sidebar">
87
+ <div id="agent-detail">
88
+ <h2>Select an Agent</h2>
89
+ <p class="subtitle">Click on any agent in the city to see their details</p>
90
+ </div>
91
+ <div id="event-log">
92
+ <h3>Event Log</h3>
93
+ <div id="events-container"></div>
94
+ </div>
95
+ </div>
96
+ </div>
97
+ <script>
98
+ // ============================================================
99
+ // CONFIG
100
+ // ============================================================
101
+ const API_BASE = window.location.origin + '/api';
102
+ const POLL_INTERVAL = 2000;
103
+ const HORIZON = 0.18; // Sky is top 18%
104
+
105
+ // All locations below the horizon
106
+ const LOCATION_POSITIONS = {
107
+ home_north: { x: 0.07, y: 0.28 },
108
+ park: { x: 0.40, y: 0.22 },
109
+ cafe: { x: 0.24, y: 0.40 },
110
+ street_north: { x: 0.54, y: 0.32 },
111
+ office: { x: 0.80, y: 0.26 },
112
+ grocery: { x: 0.14, y: 0.56 },
113
+ restaurant: { x: 0.84, y: 0.50 },
114
+ gym: { x: 0.36, y: 0.66 },
115
+ library: { x: 0.60, y: 0.60 },
116
+ street_south: { x: 0.44, y: 0.82 },
117
+ bar: { x: 0.74, y: 0.72 },
118
+ home_south: { x: 0.12, y: 0.84 },
119
+ };
120
+
121
+ const BUILDING_TYPE = {
122
+ home_north: 'house', home_south: 'house',
123
+ cafe: 'shop', grocery: 'shop', restaurant: 'shop', bar: 'shop',
124
+ office: 'office', gym: 'office', library: 'public',
125
+ park: 'park', street_north: 'street', street_south: 'street',
126
+ };
127
+
128
+ const ZONE_COLORS = {
129
+ residential: { main: '#5a8a6a', roof: '#3d6b4a', accent: '#7ab88a' },
130
+ commercial: { main: '#c49663', roof: '#9e7040', accent: '#e0b880' },
131
+ work: { main: '#5a7a9d', roof: '#3d5a7a', accent: '#80a0c0' },
132
+ public: { main: '#6a9a5e', roof: '#4a7a3e', accent: '#90c080' },
133
+ };
134
+
135
+ const AGENT_COLORS = [
136
+ '#e94560','#4ecca3','#f0c040','#4e9eca','#9b59b6',
137
+ '#e67e22','#1abc9c','#e74c3c','#3498db','#2ecc71',
138
+ '#f39c12','#8e44ad','#16a085','#c0392b','#2980b9',
139
+ '#27ae60','#d35400','#7d3c98','#148f77','#cb4335',
140
+ ];
141
+
142
+ const WEATHER_ICONS = {
143
+ sunny:'\u2600\uFE0F', clear:'\u2600\uFE0F', cloudy:'\u2601\uFE0F',
144
+ rainy:'\uD83C\uDF27\uFE0F', stormy:'\u26C8\uFE0F', foggy:'\uD83C\uDF2B\uFE0F',
145
+ };
146
+
147
+ const SKY = {
148
+ dawn: { top:'#2d1b4e', bot:'#e8a860', stars:false, sun:'low' },
149
+ morning: { top:'#4a90c8', bot:'#c8e0f0', stars:false, sun:'mid' },
150
+ afternoon: { top:'#2878b8', bot:'#88c8e8', stars:false, sun:'high' },
151
+ evening: { top:'#1a1040', bot:'#e07840', stars:false, sun:'low' },
152
+ night: { top:'#060610', bot:'#101028', stars:true, sun:'moon' },
153
+ };
154
+
155
+ // Ground tint per time (r,g,b multipliers)
156
+ const GROUND_TINT = {
157
+ dawn: { base:'#3a5a2a', shade: 0.7 },
158
+ morning: { base:'#4a7a38', shade: 1.0 },
159
+ afternoon: { base:'#4a7a38', shade: 1.0 },
160
+ evening: { base:'#3a4a28', shade: 0.6 },
161
+ night: { base:'#0e1a0e', shade: 0.25 },
162
+ };
163
+
164
+ // ============================================================
165
+ // STATE
166
+ // ============================================================
167
+ let canvas, ctx;
168
+ let locations = {};
169
+ let agents = {};
170
+ let agentPositions = {}; // current animated {x,y}
171
+ let agentTargets = {}; // target {x,y} from data
172
+ let selectedAgentId = null;
173
+ let eventLog = [];
174
+ let connected = false;
175
+ let hoveredAgent = null;
176
+ let currentTimeOfDay = 'morning';
177
+ let currentWeather = 'sunny';
178
+ let animFrame = 0;
179
+ let raindrops = [];
180
+ let clouds = [];
181
+ let stars = [];
182
+
183
+ function initParticles() {
184
+ for (let i = 0; i < 120; i++)
185
+ raindrops.push({x:Math.random(), y:Math.random(), speed:0.012+Math.random()*0.015, len:8+Math.random()*10});
186
+ for (let i = 0; i < 5; i++)
187
+ clouds.push({x:Math.random(), y:0.02+Math.random()*0.10, w:60+Math.random()*50, speed:0.00005+Math.random()*0.0001});
188
+ for (let i = 0; i < 70; i++)
189
+ stars.push({x:Math.random(), y:Math.random()*HORIZON, size:0.5+Math.random()*1.5, tw:Math.random()*6.28});
190
+ }
191
+
192
+ // ============================================================
193
+ // CANVAS
194
+ // ============================================================
195
+ function initCanvas() {
196
+ canvas = document.getElementById('cityCanvas');
197
+ ctx = canvas.getContext('2d');
198
+ resizeCanvas();
199
+ window.addEventListener('resize', resizeCanvas);
200
+ canvas.addEventListener('click', onCanvasClick);
201
+ canvas.addEventListener('mousemove', onCanvasMouseMove);
202
+ initParticles();
203
+ requestAnimationFrame(animate);
204
+ }
205
+ function resizeCanvas() {
206
+ const c = document.getElementById('canvas-container');
207
+ canvas.width = c.clientWidth;
208
+ canvas.height = c.clientHeight;
209
+ }
210
+
211
+ function animate() {
212
+ animFrame++;
213
+ // Lerp agent positions toward targets
214
+ for (const [id, target] of Object.entries(agentTargets)) {
215
+ if (!agentPositions[id]) { agentPositions[id] = {...target}; continue; }
216
+ const p = agentPositions[id];
217
+ p.x += (target.x - p.x) * 0.06;
218
+ p.y += (target.y - p.y) * 0.06;
219
+ }
220
+ draw();
221
+ requestAnimationFrame(animate);
222
+ }
223
+
224
+ // ============================================================
225
+ // DRAWING — MAIN
226
+ // ============================================================
227
+ function draw() {
228
+ if (!ctx) return;
229
+ const W = canvas.width, H = canvas.height;
230
+
231
+ drawSky(W, H);
232
+ drawGround(W, H);
233
+ drawWeather(W, H);
234
+ drawRoads(W, H);
235
+
236
+ for (const [id, loc] of Object.entries(locations)) drawBuilding(id, loc, W, H);
237
+
238
+ // Compute agent layout targets
239
+ const byLoc = {};
240
+ for (const [id, a] of Object.entries(agents)) {
241
+ const loc = a.location || 'home_north';
242
+ if (!byLoc[loc]) byLoc[loc] = [];
243
+ byLoc[loc].push({id, ...a});
244
+ }
245
+ let idx = 0;
246
+ for (const [id, a] of Object.entries(agents)) {
247
+ computeAgentTarget(id, a, idx, byLoc, W, H);
248
+ idx++;
249
+ }
250
+
251
+ // Draw couple lines first (behind agents)
252
+ drawCoupleLines(W, H);
253
+
254
+ idx = 0;
255
+ for (const [id, a] of Object.entries(agents)) {
256
+ drawPerson(id, a, idx, W, H);
257
+ idx++;
258
+ }
259
+
260
+ // Fog overlay
261
+ if (currentWeather === 'foggy') {
262
+ ctx.fillStyle = `rgba(180,190,200,${0.30 + Math.sin(animFrame*0.015)*0.08})`;
263
+ ctx.fillRect(0, 0, W, H);
264
+ }
265
+ }
266
+
267
+ // ============================================================
268
+ // SKY
269
+ // ============================================================
270
+ function drawSky(W, H) {
271
+ const s = SKY[currentTimeOfDay] || SKY.morning;
272
+ const hLine = H * HORIZON;
273
+ const grad = ctx.createLinearGradient(0, 0, 0, hLine);
274
+ grad.addColorStop(0, s.top);
275
+ grad.addColorStop(1, s.bot);
276
+ ctx.fillStyle = grad;
277
+ ctx.fillRect(0, 0, W, hLine);
278
+
279
+ if (s.stars) {
280
+ for (const st of stars) {
281
+ const tw = 0.3 + 0.7 * Math.abs(Math.sin(animFrame * 0.025 + st.tw));
282
+ ctx.fillStyle = `rgba(255,255,240,${tw})`;
283
+ ctx.beginPath(); ctx.arc(st.x*W, st.y*H, st.size, 0, 6.28); ctx.fill();
284
+ }
285
+ }
286
+ if (s.sun === 'moon') drawMoon(W*0.82, hLine*0.35, 18);
287
+ else if (s.sun === 'high') drawSun(W*0.78, hLine*0.25, 16);
288
+ else if (s.sun === 'mid') drawSun(W*0.80, hLine*0.45, 16);
289
+ else if (s.sun === 'low') drawSun(W*0.82, hLine*0.7, 16);
290
+ }
291
+
292
+ function drawSun(x, y, r) {
293
+ const glow = ctx.createRadialGradient(x, y, r*0.5, x, y, r*4);
294
+ glow.addColorStop(0, 'rgba(255,220,100,0.35)');
295
+ glow.addColorStop(1, 'rgba(255,220,100,0)');
296
+ ctx.fillStyle = glow;
297
+ ctx.fillRect(x-r*4, y-r*4, r*8, r*8);
298
+ ctx.save(); ctx.translate(x, y); ctx.rotate(animFrame*0.005);
299
+ for (let i = 0; i < 8; i++) { ctx.rotate(Math.PI/4); ctx.fillStyle='rgba(255,220,100,0.25)'; ctx.fillRect(-1.5,r+3,3,8); }
300
+ ctx.restore();
301
+ ctx.fillStyle='#ffe066'; ctx.beginPath(); ctx.arc(x,y,r,0,6.28); ctx.fill();
302
+ }
303
+
304
+ function drawMoon(x, y, r) {
305
+ const glow = ctx.createRadialGradient(x,y,r*0.5,x,y,r*3);
306
+ glow.addColorStop(0,'rgba(200,210,240,0.15)');
307
+ glow.addColorStop(1,'rgba(200,210,240,0)');
308
+ ctx.fillStyle=glow; ctx.fillRect(x-r*3,y-r*3,r*6,r*6);
309
+ ctx.fillStyle='#d8dff0'; ctx.beginPath(); ctx.arc(x,y,r,0,6.28); ctx.fill();
310
+ ctx.fillStyle=SKY.night.top; ctx.beginPath(); ctx.arc(x+7,y-2,r*0.85,0,6.28); ctx.fill();
311
+ }
312
+
313
+ // ============================================================
314
+ // GROUND — color changes with time & weather
315
+ // ============================================================
316
+ function drawGround(W, H) {
317
+ const hLine = H * HORIZON;
318
+ const gt = GROUND_TINT[currentTimeOfDay] || GROUND_TINT.morning;
319
+ const shade = gt.shade;
320
+
321
+ // Base ground gradient
322
+ const grad = ctx.createLinearGradient(0, hLine, 0, H);
323
+ const bc = hexToRgb(gt.base);
324
+ grad.addColorStop(0, `rgb(${bc.r+20},${bc.g+30},${bc.b+10})`);
325
+ grad.addColorStop(1, `rgb(${Math.max(0,bc.r-15)},${Math.max(0,bc.g-15)},${Math.max(0,bc.b-10)})`);
326
+ ctx.fillStyle = grad;
327
+ ctx.fillRect(0, hLine, W, H - hLine);
328
+
329
+ // Weather tint overlay
330
+ if (currentWeather === 'rainy' || currentWeather === 'stormy') {
331
+ ctx.fillStyle = `rgba(40, 50, 70, ${currentWeather === 'stormy' ? 0.35 : 0.2})`;
332
+ ctx.fillRect(0, hLine, W, H - hLine);
333
+ }
334
+
335
+ // Subtle grass dots
336
+ ctx.fillStyle = `rgba(${60+bc.r*0.3},${90+bc.g*0.3},${40+bc.b*0.2}, 0.15)`;
337
+ for (let i = 0; i < 80; i++) {
338
+ const gx = (i*37+13)%W;
339
+ const gy = hLine + 10 + ((i*53+7)%(H-hLine-15));
340
+ ctx.fillRect(gx, gy, 2, 3);
341
+ }
342
+
343
+ // Horizon line — soft blend
344
+ const horizGrad = ctx.createLinearGradient(0, hLine-4, 0, hLine+6);
345
+ const s = SKY[currentTimeOfDay] || SKY.morning;
346
+ horizGrad.addColorStop(0, s.bot);
347
+ horizGrad.addColorStop(1, gt.base);
348
+ ctx.fillStyle = horizGrad;
349
+ ctx.fillRect(0, hLine-4, W, 10);
350
+ }
351
+
352
+ function hexToRgb(hex) {
353
+ const r = parseInt(hex.slice(1,3),16), g = parseInt(hex.slice(3,5),16), b = parseInt(hex.slice(5,7),16);
354
+ return {r,g,b};
355
+ }
356
+
357
+ // ============================================================
358
+ // WEATHER
359
+ // ============================================================
360
+ function drawWeather(W, H) {
361
+ const w = currentWeather;
362
+ if (w==='cloudy'||w==='rainy'||w==='stormy') {
363
+ const op = w==='stormy'?0.65:(w==='rainy'?0.45:0.30);
364
+ for (const c of clouds) {
365
+ c.x+=c.speed; if(c.x>1.15) c.x=-0.15;
366
+ drawCloud(c.x*W, c.y*H, c.w, op);
367
+ }
368
+ }
369
+ if (w==='rainy'||w==='stormy') {
370
+ ctx.strokeStyle = w==='stormy'?'rgba(180,200,255,0.5)':'rgba(150,180,220,0.35)';
371
+ ctx.lineWidth=1;
372
+ for (const r of raindrops) {
373
+ r.y+=r.speed; if(r.y>1){r.y=-0.03;r.x=Math.random();}
374
+ const rx=r.x*W, ry=r.y*H;
375
+ ctx.beginPath(); ctx.moveTo(rx,ry); ctx.lineTo(rx-2,ry+r.len); ctx.stroke();
376
+ }
377
+ }
378
+ if (w==='stormy'&&animFrame%120<3) {
379
+ ctx.fillStyle=`rgba(255,255,255,${0.12+Math.random()*0.08})`;
380
+ ctx.fillRect(0,0,W,H);
381
+ }
382
+ }
383
+
384
+ function drawCloud(x,y,w,op) {
385
+ ctx.fillStyle=`rgba(200,210,220,${op})`;
386
+ const h=w*0.32;
387
+ ctx.beginPath(); ctx.ellipse(x,y,w*0.5,h*0.6,0,0,6.28); ctx.fill();
388
+ ctx.beginPath(); ctx.ellipse(x-w*0.25,y+h*0.15,w*0.3,h*0.45,0,0,6.28); ctx.fill();
389
+ ctx.beginPath(); ctx.ellipse(x+w*0.28,y+h*0.1,w*0.28,h*0.4,0,0,6.28); ctx.fill();
390
+ }
391
+
392
+ // ============================================================
393
+ // ROADS
394
+ // ============================================================
395
+ function drawRoads(W, H) {
396
+ const isNight = currentTimeOfDay==='night';
397
+ ctx.strokeStyle = isNight?'rgba(50,45,40,0.6)':'rgba(110,100,85,0.45)';
398
+ ctx.lineWidth=6;
399
+ const drawn = new Set();
400
+ for (const [id,loc] of Object.entries(locations)) {
401
+ const p = LOCATION_POSITIONS[id]; if(!p) continue;
402
+ for (const cid of (loc.connected_to||[])) {
403
+ const k=[id,cid].sort().join('-'); if(drawn.has(k)) continue; drawn.add(k);
404
+ const cp=LOCATION_POSITIONS[cid]; if(!cp) continue;
405
+ ctx.beginPath(); ctx.moveTo(p.x*W,p.y*H); ctx.lineTo(cp.x*W,cp.y*H); ctx.stroke();
406
+ }
407
+ }
408
+ // Center dashes
409
+ ctx.strokeStyle=isNight?'rgba(80,70,55,0.25)':'rgba(200,190,150,0.30)';
410
+ ctx.lineWidth=1; ctx.setLineDash([5,7]);
411
+ drawn.clear();
412
+ for (const [id,loc] of Object.entries(locations)) {
413
+ const p=LOCATION_POSITIONS[id]; if(!p) continue;
414
+ for (const cid of (loc.connected_to||[])) {
415
+ const k=[id,cid].sort().join('-'); if(drawn.has(k)) continue; drawn.add(k);
416
+ const cp=LOCATION_POSITIONS[cid]; if(!cp) continue;
417
+ ctx.beginPath(); ctx.moveTo(p.x*W,p.y*H); ctx.lineTo(cp.x*W,cp.y*H); ctx.stroke();
418
+ }
419
+ }
420
+ ctx.setLineDash([]);
421
+ }
422
+
423
+ // ============================================================
424
+ // BUILDINGS
425
+ // ============================================================
426
+ function drawBuilding(id, loc, W, H) {
427
+ const pos=LOCATION_POSITIONS[id]; if(!pos) return;
428
+ const x=pos.x*W, y=pos.y*H;
429
+ const zone=loc.zone||'public';
430
+ const type=BUILDING_TYPE[id]||'office';
431
+ const colors=ZONE_COLORS[zone]||ZONE_COLORS.public;
432
+ const isDark = currentTimeOfDay==='night'||currentTimeOfDay==='evening';
433
+
434
+ if (type==='park') drawPark(x,y,colors,isDark);
435
+ else if (type==='street') drawStreetSign(x,y,loc.name||id,isDark);
436
+ else if (type==='house') drawHouse(x,y,colors,isDark);
437
+ else if (type==='shop') drawShop(x,y,colors,isDark);
438
+ else drawOffice(x,y,colors,isDark);
439
+
440
+ // Label
441
+ if (type!=='park'&&type!=='street') {
442
+ const name=loc.name||id;
443
+ const short=name.length>18?name.slice(0,16)+'..':name;
444
+ ctx.font='bold 10px Segoe UI'; ctx.textAlign='center'; ctx.textBaseline='top';
445
+ const ly = type==='house'?y+20:y+28;
446
+ ctx.fillStyle='rgba(0,0,0,0.5)'; ctx.fillText(short,x+1,ly+1);
447
+ ctx.fillStyle=isDark?'#a0a8c0':'#fff'; ctx.fillText(short,x,ly);
448
+ }
449
+
450
+ // Occupant badge
451
+ const occ=(loc.occupants||[]).length;
452
+ if (occ>0) {
453
+ const bx=x+32, by=y-20;
454
+ ctx.fillStyle='#e94560'; ctx.beginPath(); ctx.arc(bx,by,9,0,6.28); ctx.fill();
455
+ ctx.fillStyle='#fff'; ctx.font='bold 9px Segoe UI'; ctx.textAlign='center'; ctx.textBaseline='middle';
456
+ ctx.fillText(occ.toString(),bx,by);
457
+ }
458
+ }
459
+
460
+ function drawHouse(x,y,c,dk) {
461
+ const w=48,h=30;
462
+ ctx.fillStyle=dk?dim(c.main,0.45):c.main;
463
+ ctx.fillRect(x-w/2,y-h/2,w,h);
464
+ ctx.fillStyle=dk?dim(c.roof,0.45):c.roof;
465
+ ctx.beginPath(); ctx.moveTo(x-w/2-5,y-h/2); ctx.lineTo(x,y-h/2-18); ctx.lineTo(x+w/2+5,y-h/2); ctx.closePath(); ctx.fill();
466
+ ctx.fillStyle=dk?'#3a2a1a':'#6b4226'; ctx.fillRect(x-4,y+h/2-13,8,13);
467
+ const wc=dk?'rgba(255,210,100,0.75)':'rgba(180,220,255,0.5)';
468
+ ctx.fillStyle=wc; ctx.fillRect(x-w/2+5,y-h/2+5,9,7); ctx.fillRect(x+w/2-14,y-h/2+5,9,7);
469
+ ctx.fillStyle=dk?dim(c.roof,0.35):dim(c.roof,0.8); ctx.fillRect(x+10,y-h/2-16,6,10);
470
+ }
471
+
472
+ function drawShop(x,y,c,dk) {
473
+ const w=54,h=34;
474
+ ctx.fillStyle=dk?dim(c.main,0.45):c.main; ctx.fillRect(x-w/2,y-h/2,w,h);
475
+ ctx.fillStyle=dk?dim(c.roof,0.45):c.roof; ctx.fillRect(x-w/2-2,y-h/2-3,w+4,5);
476
+ ctx.fillStyle=dk?dim(c.accent,0.45):c.accent;
477
+ ctx.beginPath(); ctx.moveTo(x-w/2,y-h/2+2); ctx.lineTo(x-w/2-5,y-h/2+13); ctx.lineTo(x+w/2+5,y-h/2+13); ctx.lineTo(x+w/2,y-h/2+2); ctx.closePath(); ctx.fill();
478
+ ctx.fillStyle=dk?'#2a2a3a':'#4a4a5a'; ctx.fillRect(x-5,y+h/2-14,10,14);
479
+ const wc=dk?'rgba(255,210,100,0.65)':'rgba(200,230,255,0.5)';
480
+ ctx.fillStyle=wc; ctx.fillRect(x-w/2+4,y-h/2+15,w/2-10,10); ctx.fillRect(x+5,y-h/2+15,w/2-10,10);
481
+ }
482
+
483
+ function drawOffice(x,y,c,dk) {
484
+ const w=50,h=42;
485
+ ctx.fillStyle=dk?dim(c.main,0.45):c.main; ctx.fillRect(x-w/2,y-h/2,w,h);
486
+ ctx.fillStyle=dk?dim(c.roof,0.45):c.roof; ctx.fillRect(x-w/2-2,y-h/2-3,w+4,5);
487
+ ctx.fillStyle=dk?'#2a2a3a':'#4a4a5a'; ctx.fillRect(x-4,y+h/2-13,8,13);
488
+ const wc=dk?'rgba(255,210,100,0.65)':'rgba(180,220,255,0.5)';
489
+ ctx.fillStyle=wc;
490
+ for (let r=0;r<3;r++) for (let col=0;col<3;col++) ctx.fillRect(x-w/2+6+col*15,y-h/2+5+r*11,9,6);
491
+ }
492
+
493
+ function drawPark(x,y,c,dk) {
494
+ ctx.fillStyle=dk?'#1a3018':'#4a9040';
495
+ ctx.beginPath(); ctx.ellipse(x,y,42,22,0,0,6.28); ctx.fill();
496
+ ctx.strokeStyle=dk?'#2a4a25':'#6ab850'; ctx.lineWidth=2; ctx.stroke();
497
+ for (let i=-1;i<=1;i++) {
498
+ const tx=x+i*20;
499
+ ctx.fillStyle=dk?'#3a2a15':'#6b4226'; ctx.fillRect(tx-2,y-2,4,12);
500
+ ctx.fillStyle=dk?'#1a4a18':'#3a8a30'; ctx.beginPath(); ctx.arc(tx,y-8,9+Math.abs(i)*2,0,6.28); ctx.fill();
501
+ ctx.fillStyle=dk?'#2a5a28':'#60b050'; ctx.beginPath(); ctx.arc(tx-2,y-10,4,0,6.28); ctx.fill();
502
+ }
503
+ ctx.fillStyle=dk?'#3a2a15':'#7a5a30'; ctx.fillRect(x-10,y+9,20,3); ctx.fillRect(x-8,y+12,2,3); ctx.fillRect(x+6,y+12,2,3);
504
+ ctx.font='bold 10px Segoe UI'; ctx.textAlign='center'; ctx.textBaseline='top';
505
+ ctx.fillStyle='rgba(0,0,0,0.5)'; ctx.fillText('City Park',x+1,y+19);
506
+ ctx.fillStyle=dk?'#90a880':'#fff'; ctx.fillText('City Park',x,y+18);
507
+ }
508
+
509
+ function drawStreetSign(x,y,name,dk) {
510
+ ctx.fillStyle=dk?'#4a4a4a':'#888'; ctx.fillRect(x-1.5,y-6,3,16);
511
+ const sw=48;
512
+ ctx.fillStyle=dk?'#1a3a1a':'#2a6a2a'; ctx.fillRect(x-sw/2,y-6-12,sw,12);
513
+ ctx.fillStyle='#fff'; ctx.font='bold 8px Segoe UI'; ctx.textAlign='center'; ctx.textBaseline='middle';
514
+ const s=(name||'').length>12?name.slice(0,10)+'..':name;
515
+ ctx.fillText(s,x,y-6-6);
516
+ }
517
+
518
+ function dim(hex, f) {
519
+ const r=parseInt(hex.slice(1,3),16), g=parseInt(hex.slice(3,5),16), b=parseInt(hex.slice(5,7),16);
520
+ return `rgb(${~~(r*f)},${~~(g*f)},${~~(b*f)})`;
521
+ }
522
+
523
+ // ============================================================
524
+ // COUPLE LINES (hearts between partners)
525
+ // ============================================================
526
+ function drawCoupleLines(W, H) {
527
+ const drawn = new Set();
528
+ for (const [id, a] of Object.entries(agents)) {
529
+ if (!a.partner_id || drawn.has(id)) continue;
530
+ const other = agents[a.partner_id];
531
+ if (!other) continue;
532
+ drawn.add(id); drawn.add(a.partner_id);
533
+ const p1 = agentPositions[id], p2 = agentPositions[a.partner_id];
534
+ if (!p1 || !p2) continue;
535
+ // Pink dashed line between partners
536
+ ctx.strokeStyle = 'rgba(233, 30, 144, 0.4)';
537
+ ctx.lineWidth = 1.5; ctx.setLineDash([4,4]);
538
+ ctx.beginPath(); ctx.moveTo(p1.x, p1.y); ctx.lineTo(p2.x, p2.y); ctx.stroke();
539
+ ctx.setLineDash([]);
540
+ // Heart at midpoint
541
+ const mx = (p1.x+p2.x)/2, my = (p1.y+p2.y)/2 - 10;
542
+ drawHeart(mx, my + Math.sin(animFrame*0.05)*3, 6, 'rgba(233,30,144,0.7)');
543
+ }
544
+ }
545
+
546
+ function drawHeart(x, y, s, color) {
547
+ ctx.fillStyle = color;
548
+ ctx.beginPath();
549
+ ctx.moveTo(x, y + s*0.4);
550
+ ctx.bezierCurveTo(x, y - s*0.2, x - s, y - s*0.5, x - s, y + s*0.1);
551
+ ctx.bezierCurveTo(x - s, y + s*0.6, x, y + s, x, y + s*1.2);
552
+ ctx.bezierCurveTo(x, y + s, x + s, y + s*0.6, x + s, y + s*0.1);
553
+ ctx.bezierCurveTo(x + s, y - s*0.5, x, y - s*0.2, x, y + s*0.4);
554
+ ctx.fill();
555
+ }
556
+
557
+ // ============================================================
558
+ // AGENT TARGET COMPUTATION
559
+ // ============================================================
560
+ function computeAgentTarget(id, agent, globalIdx, byLoc, W, H) {
561
+ const loc = agent.location || 'home_north';
562
+ const pos = LOCATION_POSITIONS[loc];
563
+ if (!pos) return;
564
+
565
+ const atLoc = byLoc[loc] || [];
566
+ const localIdx = atLoc.findIndex(a => a.id === id);
567
+ const count = atLoc.length;
568
+
569
+ const radius = 32 + Math.floor(count/6)*14;
570
+ const step = Math.PI / Math.max(count+1, 2);
571
+ const angle = step * (localIdx+1);
572
+ const ox = Math.cos(angle)*radius - radius/2;
573
+ const oy = Math.sin(angle)*radius*0.5 + 30;
574
+
575
+ agentTargets[id] = { x: pos.x*W + ox, y: pos.y*H + oy };
576
+ if (!agentPositions[id]) agentPositions[id] = {...agentTargets[id]};
577
+ }
578
+
579
+ // ============================================================
580
+ // PERSON DRAWING
581
+ // ============================================================
582
+ function drawPerson(id, agent, globalIdx, W, H) {
583
+ const pos = agentPositions[id];
584
+ if (!pos) return;
585
+ const ax = pos.x, ay = pos.y;
586
+ const color = AGENT_COLORS[globalIdx % AGENT_COLORS.length];
587
+ const isSel = id === selectedAgentId;
588
+ const isHov = id === hoveredAgent;
589
+ const gender = agent.gender || 'unknown';
590
+ const scale = isSel ? 1.3 : (isHov ? 1.15 : 1.0);
591
+ const isMoving = agent.state === 'moving';
592
+ const isSleeping = agent.state === 'sleeping';
593
+
594
+ // Check if actually animating (position differs from target)
595
+ const tgt = agentTargets[id];
596
+ const moving = tgt && Math.hypot(ax-tgt.x, ay-tgt.y) > 3;
597
+
598
+ ctx.save();
599
+ ctx.translate(ax, ay);
600
+ ctx.scale(scale, scale);
601
+
602
+ if (isSel) { ctx.shadowColor=color; ctx.shadowBlur=16; }
603
+
604
+ const bounce = (isMoving||moving) ? Math.sin(animFrame*0.15)*2 : 0;
605
+ const armSwing = (isMoving||moving) ? Math.sin(animFrame*0.15)*8 : 0;
606
+ const legSwing = (isMoving||moving) ? Math.sin(animFrame*0.15)*5 : 0;
607
+
608
+ // Shadow on ground
609
+ ctx.fillStyle = 'rgba(0,0,0,0.15)';
610
+ ctx.beginPath(); ctx.ellipse(0, 13, 7, 3, 0, 0, 6.28); ctx.fill();
611
+
612
+ // Head
613
+ ctx.fillStyle = '#f0d0b0';
614
+ ctx.beginPath(); ctx.arc(0, -18+bounce, 6.5, 0, 6.28); ctx.fill();
615
+
616
+ // Hair
617
+ ctx.fillStyle = dim(color, 0.55);
618
+ if (gender==='female') {
619
+ ctx.beginPath(); ctx.ellipse(0,-21+bounce,7.5,4.5,0,Math.PI,0); ctx.fill();
620
+ ctx.beginPath(); ctx.ellipse(-4,-16+bounce,2.5,7,0.3,0,6.28); ctx.fill();
621
+ ctx.beginPath(); ctx.ellipse(4,-16+bounce,2.5,7,-0.3,0,6.28); ctx.fill();
622
+ } else if (gender==='male') {
623
+ ctx.beginPath(); ctx.ellipse(0,-21+bounce,7,4,0,Math.PI,0); ctx.fill();
624
+ } else {
625
+ ctx.beginPath(); ctx.ellipse(0,-21+bounce,7.5,4.5,0,Math.PI,0); ctx.fill();
626
+ ctx.beginPath(); ctx.ellipse(-4.5,-17+bounce,2,5,0.2,0,6.28); ctx.fill();
627
+ }
628
+
629
+ // Body
630
+ if (gender==='female') {
631
+ ctx.fillStyle=color;
632
+ ctx.beginPath(); ctx.moveTo(-3,-10+bounce); ctx.lineTo(3,-10+bounce); ctx.lineTo(7,4+bounce); ctx.lineTo(-7,4+bounce); ctx.closePath(); ctx.fill();
633
+ } else {
634
+ ctx.fillStyle=color; ctx.fillRect(-4,-10+bounce,8,8);
635
+ ctx.fillStyle=dim(color,0.6); ctx.fillRect(-4,-2+bounce,3.5,6); ctx.fillRect(0.5,-2+bounce,3.5,6);
636
+ }
637
+
638
+ // Arms
639
+ ctx.strokeStyle='#f0d0b0'; ctx.lineWidth=2;
640
+ ctx.beginPath(); ctx.moveTo(-4,-8+bounce); ctx.lineTo(-9,-1+bounce+armSwing); ctx.stroke();
641
+ ctx.beginPath(); ctx.moveTo(4,-8+bounce); ctx.lineTo(9,-1+bounce-armSwing); ctx.stroke();
642
+
643
+ // Legs
644
+ ctx.strokeStyle=gender==='female'?'#f0d0b0':dim(color,0.5); ctx.lineWidth=2;
645
+ ctx.beginPath(); ctx.moveTo(-2.5,4+bounce); ctx.lineTo(-4,11+bounce+legSwing); ctx.stroke();
646
+ ctx.beginPath(); ctx.moveTo(2.5,4+bounce); ctx.lineTo(4,11+bounce-legSwing); ctx.stroke();
647
+
648
+ // Feet
649
+ ctx.fillStyle='#3a3a3a';
650
+ ctx.fillRect(-5.5,10+bounce+legSwing,3.5,2);
651
+ ctx.fillRect(1.5,10+bounce-legSwing,3.5,2);
652
+
653
+ ctx.shadowColor='transparent'; ctx.shadowBlur=0;
654
+
655
+ // State effects
656
+ if (agent.state==='in_conversation') {
657
+ ctx.fillStyle='rgba(240,192,64,0.85)';
658
+ ctx.beginPath(); ctx.ellipse(13,-22+bounce,9,6,0,0,6.28); ctx.fill();
659
+ ctx.fillStyle='#1a1a2e'; ctx.font='bold 8px Segoe UI'; ctx.textAlign='center'; ctx.textBaseline='middle';
660
+ ctx.fillText('...',13,-22+bounce);
661
+ }
662
+
663
+ if (isSleeping) {
664
+ const t=animFrame*0.04;
665
+ ctx.font='bold 9px Segoe UI'; ctx.textAlign='left';
666
+ for (let i=0;i<3;i++) {
667
+ ctx.globalAlpha=0.3+i*0.25; ctx.fillStyle='#8ab4f8';
668
+ ctx.fillText('z',9+i*4,-24-i*6+Math.sin(t+i)*2);
669
+ }
670
+ ctx.globalAlpha=1;
671
+ }
672
+
673
+ // Heart above head if has partner
674
+ if (agent.partner_id) {
675
+ drawHeart(0, -30+bounce+Math.sin(animFrame*0.04)*2, 4, 'rgba(233,30,144,0.7)');
676
+ }
677
+
678
+ ctx.restore();
679
+
680
+ // Name label
681
+ const firstName = (agent.name||id).split(' ')[0];
682
+ ctx.font=`${isSel?'bold ':''}10px Segoe UI`; ctx.textAlign='center'; ctx.textBaseline='top';
683
+ ctx.fillStyle='rgba(0,0,0,0.5)'; ctx.fillText(firstName,ax+1,ay+15*scale+1);
684
+ ctx.fillStyle=isSel?'#fff':'#d0d0e0'; ctx.fillText(firstName,ax,ay+15*scale);
685
+
686
+ // Mood bar
687
+ const mood=agent.mood||0;
688
+ const mw=20, mx=ax-mw/2, my=ay+15*scale+13;
689
+ ctx.fillStyle='rgba(15,52,96,0.5)'; ctx.fillRect(mx,my,mw,3);
690
+ const mf=(mood+1)/2;
691
+ ctx.fillStyle=mf>0.6?'#4ecca3':(mf>0.3?'#f0c040':'#e94560');
692
+ ctx.fillRect(mx,my,mw*mf,3);
693
+ }
694
+
695
+ // ============================================================
696
+ // INTERACTION
697
+ // ============================================================
698
+ function onCanvasClick(e) {
699
+ const rect=canvas.getBoundingClientRect();
700
+ const mx=e.clientX-rect.left, my=e.clientY-rect.top;
701
+ let clicked=null, minD=24;
702
+ for (const [id,pos] of Object.entries(agentPositions)) {
703
+ const d=Math.hypot(mx-pos.x,my-pos.y);
704
+ if(d<minD){minD=d;clicked=id;}
705
+ }
706
+ if(clicked){selectedAgentId=clicked;fetchAgentDetail(clicked);}
707
+ else{selectedAgentId=null;showDefaultDetail();}
708
+ }
709
+
710
+ function onCanvasMouseMove(e) {
711
+ const rect=canvas.getBoundingClientRect();
712
+ const mx=e.clientX-rect.left, my=e.clientY-rect.top;
713
+ let found=null;
714
+ for (const [id,pos] of Object.entries(agentPositions)) {
715
+ if(Math.hypot(mx-pos.x,my-pos.y)<22){found=id;break;}
716
+ }
717
+ if(found!==hoveredAgent){
718
+ hoveredAgent=found;
719
+ canvas.style.cursor=found?'pointer':'default';
720
+ const tt=document.getElementById('tooltip');
721
+ if(found&&agents[found]){
722
+ const a=agents[found];
723
+ let extra='';
724
+ if(a.partner_id&&agents[a.partner_id]) extra=`<br><span style="color:#e91e90">Partner: ${agents[a.partner_id].name}</span>`;
725
+ tt.innerHTML=`<b>${a.name}</b><br><span style="color:#a0a0c0">${a.action||'idle'}</span>${extra}`;
726
+ tt.style.display='block';
727
+ } else tt.style.display='none';
728
+ }
729
+ if(found){
730
+ const tt=document.getElementById('tooltip');
731
+ tt.style.left=(e.clientX-canvas.getBoundingClientRect().left+15)+'px';
732
+ tt.style.top=(e.clientY-canvas.getBoundingClientRect().top+15)+'px';
733
+ }
734
+ }
735
+
736
+ // ============================================================
737
+ // AGENT DETAIL PANEL
738
+ // ============================================================
739
+ function showDefaultDetail() {
740
+ document.getElementById('agent-detail').innerHTML = `
741
+ <h2>Select an Agent</h2>
742
+ <p class="subtitle">Click on any agent in the city to see their details</p>
743
+ <div style="margin-top:12px">
744
+ <h3 style="font-size:13px;color:#a0a0c0;margin-bottom:8px">Population</h3>
745
+ ${Object.entries(agents).map(([aid,a],i) => `
746
+ <div style="font-size:11px;padding:3px 0;cursor:pointer;color:${AGENT_COLORS[i%AGENT_COLORS.length]};display:flex;align-items:center;gap:6px"
747
+ onclick="selectedAgentId='${aid}';fetchAgentDetail(selectedAgentId);">
748
+ <span style="font-size:14px">${a.gender==='female'?'\uD83D\uDC69':a.gender==='male'?'\uD83D\uDC68':'\uD83E\uDDD1'}</span>
749
+ <span>${a.name}${a.partner_id&&agents[a.partner_id]?' \u2764 '+agents[a.partner_id].name:''} &mdash; <span style="color:#888">${a.action||'idle'}</span></span>
750
+ </div>
751
+ `).join('')}
752
+ </div>`;
753
+ }
754
+
755
+ async function fetchAgentDetail(agentId) {
756
+ try {
757
+ const res=await fetch(`${API_BASE}/agents/${agentId}`);
758
+ if(!res.ok) return;
759
+ renderAgentDetail(await res.json());
760
+ } catch(e){}
761
+ }
762
+
763
+ function renderAgentDetail(data) {
764
+ const needs=data.needs||{};
765
+ const needBars=[
766
+ {name:'Hunger',key:'hunger',color:'orange'},
767
+ {name:'Energy',key:'energy',color:'blue'},
768
+ {name:'Social',key:'social',color:'purple'},
769
+ {name:'Purpose',key:'purpose',color:'green'},
770
+ {name:'Fun',key:'fun',color:'yellow'},
771
+ ];
772
+ const mood=data.mood||0;
773
+ const moodPct=((mood+1)/2*100).toFixed(0);
774
+ const moodColor=moodPct>60?'green':(moodPct>30?'yellow':'red');
775
+ const moodLabel=mood>0.3?'Happy':(mood>-0.3?'Okay':'Unhappy');
776
+ const gi=data.gender==='female'?'\uD83D\uDC69':data.gender==='male'?'\uD83D\uDC68':'\uD83E\uDDD1';
777
+ const memories=(data.recent_memories||[]).slice(-5).reverse();
778
+
779
+ // Find romance info from relationships
780
+ const romanceRels=(data.relationships||[]).filter(r=>r.relationship_status&&r.relationship_status!=='none');
781
+
782
+ document.getElementById('agent-detail').innerHTML=`
783
+ <h2>${gi} ${data.name||'?'}</h2>
784
+ <p class="subtitle">${data.age||'?'} y/o ${data.occupation||''} &mdash; ${data.traits||''}</p>
785
+ <p class="subtitle" style="color:#e0e0e0">${esc(data.action||'idle')}</p>
786
+
787
+ ${romanceRels.length>0?`<div style="margin:4px 0;padding:4px 8px;background:rgba(233,30,144,0.1);border-radius:4px;font-size:11px;color:#e91e90">
788
+ ${romanceRels.map(r=>`\u2764 ${r.relationship_status} with ${r.name} (love: ${(r.romantic_interest*100).toFixed(0)}%)`).join('<br>')}
789
+ </div>`:''}
790
+
791
+ <div class="bar-container">
792
+ <div class="bar-label"><span>Mood: ${moodLabel}</span><span>${moodPct}%</span></div>
793
+ <div class="bar-bg"><div class="bar-fill ${moodColor}" style="width:${moodPct}%"></div></div>
794
+ </div>
795
+
796
+ ${needBars.map(n=>{
797
+ const v=(needs[n.key]||0)*100;
798
+ return `<div class="bar-container">
799
+ <div class="bar-label"><span>${n.name}</span><span>${v.toFixed(0)}%</span></div>
800
+ <div class="bar-bg"><div class="bar-fill ${n.color}" style="width:${v}%"></div></div>
801
+ </div>`;
802
+ }).join('')}
803
+
804
+ <div style="margin-top:8px">
805
+ <div style="font-size:11px;color:#a0a0c0">Plan: ${esc((data.daily_plan||[]).slice(0,3).join('; ')||'None yet')}</div>
806
+ </div>
807
+
808
+ <div style="margin-top:6px">
809
+ <div style="font-size:12px;color:#4ecca3;margin-bottom:4px">Recent Memories</div>
810
+ ${memories.map(m=>`<div class="memory-item"><span class="memory-time">${m.time||''}</span> ${esc(m.content||'')}</div>`).join('')||'<div class="memory-item">No memories yet</div>'}
811
+ </div>
812
+
813
+ ${(data.relationships||[]).length>0?`
814
+ <div style="margin-top:6px">
815
+ <div style="font-size:12px;color:#4ecca3;margin-bottom:4px">Relationships</div>
816
+ ${data.relationships.slice(0,5).map(r=>`
817
+ <div style="font-size:11px;color:#b0b0c0;padding:1px 0">
818
+ ${esc(r.name)}: closeness ${(r.closeness*100).toFixed(0)}%${r.relationship_status&&r.relationship_status!=='none'?' <span style="color:#e91e90">\u2764 '+r.relationship_status+'</span>':''}
819
+ </div>
820
+ `).join('')}
821
+ </div>`:''}
822
+ `;
823
+ }
824
+
825
+ // ============================================================
826
+ // EVENT LOG
827
+ // ============================================================
828
+ function renderEventLog() {
829
+ const c=document.getElementById('events-container');
830
+ c.innerHTML=eventLog.slice(-60).map(line=>{
831
+ let cls='event-line';
832
+ if(line.includes('[PLAN]')) cls+=' plan';
833
+ else if(line.includes('[CONV]')) cls+=' conv';
834
+ else if(line.includes('[ROMANCE]')) cls+=' romance';
835
+ else if(line.includes('[EVENT]')||line.includes('[ENTROPY]')) cls+=' event';
836
+ else if(line.startsWith('---')||line.startsWith('\n---')) cls+=' time';
837
+ return `<div class="${cls}">${esc(line)}</div>`;
838
+ }).join('');
839
+ c.scrollTop=c.scrollHeight;
840
+ }
841
+
842
+ function esc(s){return s?s.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;'):''}
843
+
844
+ // ============================================================
845
+ // DATA FETCHING
846
+ // ============================================================
847
+ let lastTick = -1;
848
+
849
+ async function fetchState() {
850
+ try {
851
+ const res = await fetch(`${API_BASE}/city`);
852
+ if (!res.ok) throw new Error();
853
+ const data = await res.json();
854
+ if (!connected) { connected=true; document.getElementById('status').innerHTML='<span class="dot green"></span> Connected'; }
855
+
856
+ const clock=data.clock||{};
857
+ currentTimeOfDay=clock.time_of_day||'morning';
858
+ currentWeather=(data.weather||'sunny').toLowerCase();
859
+
860
+ document.getElementById('clock').textContent=`Day ${clock.day||1}, ${clock.time_str||'??:??'} (${currentTimeOfDay})`;
861
+ document.getElementById('weather-icon').textContent=WEATHER_ICONS[currentWeather]||'\u2600\uFE0F';
862
+ document.getElementById('weather').textContent=currentWeather;
863
+ document.getElementById('agent-count').innerHTML=`<span class="dot green"></span> ${Object.keys(data.agents||{}).length} agents`;
864
+
865
+ const usage=data.llm_usage||'';
866
+ const cm=usage.match(/calls:\s*(\d+)/i), $m=usage.match(/\$([0-9.]+)/);
867
+ document.getElementById('api-calls').textContent=`API: ${cm?cm[1]:'0'}`;
868
+ document.getElementById('cost').textContent=$m?`$${$m[1]}`:'$0.00';
869
+
870
+ agents=data.agents||{};
871
+
872
+ const locRes=await fetch(`${API_BASE}/city/locations`);
873
+ if(locRes.ok) locations=await locRes.json();
874
+
875
+ const tick=clock.total_ticks||0;
876
+ if(tick!==lastTick){
877
+ try{
878
+ const er=await fetch(`${API_BASE}/events`);
879
+ if(er.ok){const d2=await er.json(); eventLog=(d2.events||[]).map(e=>e.message||'').filter(m=>m.trim()); renderEventLog();}
880
+ }catch(e){}
881
+ }
882
+ lastTick=tick;
883
+
884
+ if(selectedAgentId) fetchAgentDetail(selectedAgentId);
885
+ else showDefaultDetail();
886
+
887
+ } catch(e){
888
+ if(connected){connected=false;document.getElementById('status').innerHTML='<span class="dot red"></span> Disconnected';}
889
+ }
890
+ }
891
+
892
+ // ============================================================
893
+ // INIT
894
+ // ============================================================
895
+ initCanvas();
896
+ showDefaultDetail();
897
+ fetchState();
898
+ setInterval(fetchState, POLL_INTERVAL);
899
+ </script>
900
+ </body>
901
+ </html>