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 +20 -0
- src/soci/agents/agent.py +4 -0
- src/soci/agents/persona.py +2 -0
- src/soci/agents/relationships.py +15 -1
- src/soci/api/routes.py +11 -0
- src/soci/api/server.py +11 -0
- src/soci/engine/llm.py +1 -1
- src/soci/engine/simulation.py +116 -1
- web/index.html +901 -0
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.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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.
|
| 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.
|
|
|
|
|
|
|
|
|
|
| 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:''} — <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||''} — ${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,'&').replace(/</g,'<').replace(/>/g,'>'):''}
|
| 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>
|