RayMelius Claude Opus 4.6 commited on
Commit
492d303
·
1 Parent(s): c351178

Redesign city: individual houses, real streets, async Ollama client

Browse files

- Replace 2 apartment blocks with 10 individual houses (1-3 agents each)
- Pair agents by relationships: siblings, couples, roommates, families
- Redesign web UI canvas: road grid with asphalt/lane markings/sidewalks,
varied house colors, trees, street lamps, park with pond and benches
- Convert OllamaClient from sync httpx.Client to async httpx.AsyncClient
to prevent blocking the event loop during LLM inference
- Enable Ollama native JSON mode (format:"json") in complete_json
- Update tests for new 20-location city layout

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

config/city.yaml CHANGED
@@ -1,91 +1,157 @@
1
  name: Soci City
2
 
3
  locations:
4
- # --- Residential ---
5
- - id: home_north
6
- name: Northside Apartments
 
 
7
  zone: residential
8
- description: A modern apartment complex with balconies overlooking the park. Home to several young professionals and families.
9
- capacity: 10
10
- connected_to: [park, cafe, grocery, street_north]
11
 
12
- - id: home_south
13
- name: Southside Houses
14
  zone: residential
15
- description: A quiet street of cozy row houses with small gardens. A mix of longtime residents and newcomers.
16
- capacity: 10
17
- connected_to: [bar, gym, library, street_south]
18
 
19
- # --- Commercial ---
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
20
  - id: cafe
21
  name: The Daily Grind
22
  zone: commercial
23
  description: A warm, bustling cafe with mismatched furniture and the aroma of fresh coffee. The local gossip hub.
24
  capacity: 15
25
- connected_to: [home_north, park, office, street_north]
26
 
27
  - id: grocery
28
  name: Green Basket Market
29
  zone: commercial
30
  description: A neighborhood grocery store with fresh produce and a friendly owner who knows everyone by name.
31
  capacity: 12
32
- connected_to: [home_north, street_north, street_south]
33
 
34
  - id: bar
35
  name: The Rusty Anchor
36
  zone: commercial
37
  description: A dimly lit bar with a jukebox, pool table, and regulars who've been coming for years. Lively at night.
38
  capacity: 15
39
- connected_to: [home_south, restaurant, street_south]
40
 
41
  - id: restaurant
42
  name: Mama Rosa's Kitchen
43
  zone: commercial
44
  description: A family-run Italian restaurant with checkered tablecloths and the best pasta in town.
45
  capacity: 12
46
- connected_to: [bar, office, street_south, street_north]
47
 
48
- # --- Work ---
 
 
49
  - id: office
50
  name: The Hive Coworking
51
  zone: work
52
  description: An open-plan coworking space with standing desks, meeting rooms, and a perpetually broken printer.
53
  capacity: 20
54
- connected_to: [cafe, restaurant, street_north]
55
 
56
- # --- Public ---
 
 
57
  - id: park
58
  name: Willow Park
59
  zone: public
60
  description: A green park with old willow trees, a pond, benches, and a small playground. Popular for morning jogs and evening walks.
61
  capacity: 30
62
- connected_to: [home_north, cafe, gym, library, street_north]
63
 
64
  - id: gym
65
  name: Iron & Grit Gym
66
  zone: public
67
- description: A no-nonsense gym with free weights, treadmills, and a boxing ring in the back. Smells like determination.
68
  capacity: 12
69
- connected_to: [home_south, park, street_south]
70
 
71
  - id: library
72
  name: Soci Public Library
73
  zone: public
74
  description: A quiet library with tall shelves, reading nooks, and a community board. Hosts book clubs and events.
75
  capacity: 15
76
- connected_to: [home_south, park, street_south]
77
 
78
- # --- Connectors ---
 
 
79
  - id: street_north
80
  name: North Main Street
81
  zone: public
82
  description: The main street running through the northern part of town. Shops, cafes, and foot traffic.
83
  capacity: 40
84
- connected_to: [home_north, cafe, grocery, office, park, street_south]
85
 
86
  - id: street_south
87
  name: South Main Street
88
  zone: public
89
  description: The southern stretch of main street. More residential, with the bar and gym nearby.
90
  capacity: 40
91
- connected_to: [home_south, bar, grocery, gym, library, restaurant, street_north]
 
1
  name: Soci City
2
 
3
  locations:
4
+ # ===================
5
+ # RESIDENTIAL — 10 individual houses
6
+ # ===================
7
+ - id: house_elena
8
+ name: "Elena & Lila's Apartment"
9
  zone: residential
10
+ description: A bright second-floor apartment with art on every wall and the smell of oil paint and coffee.
11
+ capacity: 3
12
+ connected_to: [street_north, park, cafe]
13
 
14
+ - id: house_marcus
15
+ name: "Marcus & Zoe's Place"
16
  zone: residential
17
+ description: A tidy apartment with workout equipment in the living room and Zoe's college textbooks stacked everywhere.
18
+ capacity: 3
19
+ connected_to: [street_north, park, gym]
20
 
21
+ - id: house_helen
22
+ name: "Helen & Alice's Cottage"
23
+ zone: residential
24
+ description: A warm cottage with a flower garden, bookshelves, and the aroma of freshly baked cookies.
25
+ capacity: 3
26
+ connected_to: [street_north, cafe, library]
27
+
28
+ - id: house_diana
29
+ name: "Diana & Marco's House"
30
+ zone: residential
31
+ description: A practical family home above the grocery store. Marco's room is covered in game posters.
32
+ capacity: 3
33
+ connected_to: [street_north, grocery, cafe]
34
+
35
+ - id: house_kai
36
+ name: "Kai's Studio"
37
+ zone: residential
38
+ description: A small studio apartment crammed with musical instruments, vinyl records, and empty coffee cups.
39
+ capacity: 2
40
+ connected_to: [street_north, cafe, park]
41
+
42
+ - id: house_priya
43
+ name: "Priya & Nina's Flat"
44
+ zone: residential
45
+ description: A modern flat kept immaculately clean. Family photos on one side, property listings on the other.
46
+ capacity: 3
47
+ connected_to: [street_north, office, park]
48
+
49
+ - id: house_james
50
+ name: "James & Theo's House"
51
+ zone: residential
52
+ description: A worn but comfortable house with a porch. Tools in the garage, beer in the fridge.
53
+ capacity: 3
54
+ connected_to: [street_south, bar, gym]
55
+
56
+ - id: house_rosa
57
+ name: "Rosa & Omar's Home"
58
+ zone: residential
59
+ description: A cozy home that always smells of cooking. Spices from two continents line the kitchen shelves.
60
+ capacity: 3
61
+ connected_to: [street_south, restaurant, grocery]
62
+
63
+ - id: house_yuki
64
+ name: "Yuki & Devon's Apartment"
65
+ zone: residential
66
+ description: A minimalist apartment with a meditation corner on one side and newspaper clippings on the other.
67
+ capacity: 3
68
+ connected_to: [street_south, gym, library]
69
+
70
+ - id: house_frank
71
+ name: "Frank, George & Sam's Row House"
72
+ zone: residential
73
+ description: A row of three connected units on the quiet end of the south side. Frank's garage is always open.
74
+ capacity: 4
75
+ connected_to: [street_south, bar, library]
76
+
77
+ # ===================
78
+ # COMMERCIAL
79
+ # ===================
80
  - id: cafe
81
  name: The Daily Grind
82
  zone: commercial
83
  description: A warm, bustling cafe with mismatched furniture and the aroma of fresh coffee. The local gossip hub.
84
  capacity: 15
85
+ connected_to: [house_elena, house_helen, house_diana, house_kai, street_north, office]
86
 
87
  - id: grocery
88
  name: Green Basket Market
89
  zone: commercial
90
  description: A neighborhood grocery store with fresh produce and a friendly owner who knows everyone by name.
91
  capacity: 12
92
+ connected_to: [house_diana, house_rosa, street_north, street_south]
93
 
94
  - id: bar
95
  name: The Rusty Anchor
96
  zone: commercial
97
  description: A dimly lit bar with a jukebox, pool table, and regulars who've been coming for years. Lively at night.
98
  capacity: 15
99
+ connected_to: [house_james, house_frank, restaurant, street_south]
100
 
101
  - id: restaurant
102
  name: Mama Rosa's Kitchen
103
  zone: commercial
104
  description: A family-run Italian restaurant with checkered tablecloths and the best pasta in town.
105
  capacity: 12
106
+ connected_to: [house_rosa, bar, office, street_south]
107
 
108
+ # ===================
109
+ # WORK
110
+ # ===================
111
  - id: office
112
  name: The Hive Coworking
113
  zone: work
114
  description: An open-plan coworking space with standing desks, meeting rooms, and a perpetually broken printer.
115
  capacity: 20
116
+ connected_to: [house_priya, cafe, restaurant, street_north]
117
 
118
+ # ===================
119
+ # PUBLIC
120
+ # ===================
121
  - id: park
122
  name: Willow Park
123
  zone: public
124
  description: A green park with old willow trees, a pond, benches, and a small playground. Popular for morning jogs and evening walks.
125
  capacity: 30
126
+ connected_to: [house_elena, house_marcus, house_kai, house_priya, gym, library, street_north]
127
 
128
  - id: gym
129
  name: Iron & Grit Gym
130
  zone: public
131
+ description: A no-nonsense gym with free weights, treadmills, and a boxing ring in the back.
132
  capacity: 12
133
+ connected_to: [house_marcus, house_james, house_yuki, park, street_south]
134
 
135
  - id: library
136
  name: Soci Public Library
137
  zone: public
138
  description: A quiet library with tall shelves, reading nooks, and a community board. Hosts book clubs and events.
139
  capacity: 15
140
+ connected_to: [house_helen, house_yuki, house_frank, park, street_south]
141
 
142
+ # ===================
143
+ # STREETS — connectors
144
+ # ===================
145
  - id: street_north
146
  name: North Main Street
147
  zone: public
148
  description: The main street running through the northern part of town. Shops, cafes, and foot traffic.
149
  capacity: 40
150
+ connected_to: [house_elena, house_marcus, house_helen, house_diana, house_kai, house_priya, cafe, grocery, office, park, street_south]
151
 
152
  - id: street_south
153
  name: South Main Street
154
  zone: public
155
  description: The southern stretch of main street. More residential, with the bar and gym nearby.
156
  capacity: 40
157
+ connected_to: [house_james, house_rosa, house_yuki, house_frank, bar, grocery, gym, library, restaurant, street_north]
config/personas.yaml CHANGED
@@ -1,5 +1,5 @@
1
  personas:
2
- # --- NORTHSIDE RESIDENTS ---
3
  - id: elena
4
  name: Elena Vasquez
5
  age: 34
@@ -13,17 +13,44 @@ personas:
13
  background: >-
14
  Elena moved to Soci City two years ago after a burnout at a big tech company.
15
  She now freelances and values work-life balance. She grew up in a large family
16
- and misses the closeness but enjoys her independence.
 
17
  values: [creativity, independence, authenticity]
18
  quirks:
19
  - talks to herself while debugging
20
  - always orders the same coffee (oat milk latte)
21
  - sketches in a notebook when thinking
22
  communication_style: thoughtful and slightly nerdy, uses analogies
23
- home_location: home_north
24
  work_location: office
25
  llm_temperature: 0.7
26
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
27
  - id: marcus
28
  name: Marcus Chen
29
  age: 28
@@ -38,16 +65,42 @@ personas:
38
  Marcus is a former college athlete who turned his passion into a career.
39
  He's the guy who knows everyone and remembers their names. He volunteers
40
  at the community center on weekends and dreams of opening his own gym.
 
41
  values: [health, community, discipline]
42
  quirks:
43
  - gives unsolicited fitness advice
44
  - always has a protein shake
45
  - high-fives people he knows
46
  communication_style: enthusiastic and motivational, uses sports metaphors
47
- home_location: home_north
48
  work_location: gym
49
  llm_temperature: 0.8
50
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
51
  - id: helen
52
  name: Helen Park
53
  age: 67
@@ -62,16 +115,93 @@ personas:
62
  Helen taught high school English for 35 years and retired last spring.
63
  She's adjusting to the slower pace. Her husband passed three years ago,
64
  and she fills her days with reading, gardening, and volunteering at the library.
 
65
  values: [education, kindness, tradition]
66
  quirks:
67
  - corrects grammar gently
68
  - always carries a book
69
  - bakes cookies for neighbors
70
  communication_style: warm and maternal, quotes literature
71
- home_location: home_north
72
  work_location: library
73
  llm_temperature: 0.6
74
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
75
  - id: kai
76
  name: Kai Okonkwo
77
  age: 22
@@ -86,41 +216,69 @@ personas:
86
  Kai dropped out of college to pursue music. They work at The Daily Grind
87
  to pay rent and play gigs at the bar on weekends. Their parents disapprove,
88
  which is a constant source of stress. They're talented but undisciplined.
 
89
  values: [self-expression, freedom, authenticity]
90
  quirks:
91
  - hums while making coffee
92
  - wears headphones around their neck at all times
93
  - changes hair color monthly
94
  communication_style: casual and witty, uses slang, sometimes sarcastic
95
- home_location: home_north
96
  work_location: cafe
97
  llm_temperature: 0.9
98
 
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
106
  extraversion: 5
107
- agreeableness: 6
108
- neuroticism: 7
109
  background: >-
110
- Diana took over Green Basket Market from her father five years ago.
111
- She works long hours and worries about competition from big chains.
112
- She's a single mother with a teenage son and is fiercely protective of her store.
113
- values: [family, hard work, loyalty]
 
114
  quirks:
115
- - rearranges shelves when stressed
116
- - knows the price of everything
117
- - suspicious of strangers at first
118
- communication_style: practical and direct, occasionally sharp under stress
119
- home_location: home_north
120
- work_location: grocery
121
- llm_temperature: 0.5
122
 
123
- # --- SOUTHSIDE RESIDENTS ---
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
124
  - id: james
125
  name: James "Jimmy" O'Brien
126
  age: 55
@@ -135,88 +293,17 @@ personas:
135
  Jimmy has run The Rusty Anchor for 20 years. He's seen everything and heard
136
  every story. He's a born storyteller who treats regulars like family. He went
137
  through a rough divorce ten years ago but has found peace in his work.
 
138
  values: [community, honesty, loyalty]
139
  quirks:
140
  - polishes glasses when listening
141
  - gives nicknames to everyone
142
  - tells the same three jokes
143
  communication_style: folksy and warm, good listener, drops wisdom casually
144
- home_location: home_south
145
  work_location: bar
146
  llm_temperature: 0.7
147
 
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
155
- extraversion: 7
156
- agreeableness: 8
157
- neuroticism: 5
158
- background: >-
159
- Rosa opened Mama Rosa's Kitchen 25 years ago with recipes from her nonna.
160
- She's the heart of the community and feeds people even when they can't pay.
161
- Her children have moved away, which makes her sad, but the restaurant is her life.
162
- values: [generosity, tradition, family]
163
- quirks:
164
- - feeds everyone who looks hungry
165
- - speaks Italian when emotional
166
- - pinches cheeks
167
- communication_style: expressive and loving, uses food metaphors, dramatic
168
- home_location: home_south
169
- work_location: restaurant
170
- llm_temperature: 0.7
171
-
172
- - id: devon
173
- name: Devon Reeves
174
- age: 30
175
- gender: male
176
- occupation: freelance journalist
177
- openness: 9
178
- conscientiousness: 5
179
- extraversion: 6
180
- agreeableness: 4
181
- neuroticism: 6
182
- background: >-
183
- Devon is an investigative journalist who moved to Soci City following a lead
184
- that went nowhere. He stayed because he likes the community. He's always
185
- looking for the next story and can be pushy. He has trust issues from his work.
186
- values: [truth, justice, curiosity]
187
- quirks:
188
- - takes notes on everything
189
- - asks too many questions
190
- - always sits facing the door
191
- communication_style: probing and articulate, sometimes intense, asks follow-up questions
192
- home_location: home_south
193
- work_location: cafe
194
- llm_temperature: 0.8
195
-
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
203
- extraversion: 5
204
- agreeableness: 9
205
- neuroticism: 3
206
- background: >-
207
- Yuki moved from Tokyo two years ago seeking a quieter life. She teaches yoga
208
- at the gym and does private massage sessions. She's deeply empathetic and
209
- people naturally confide in her. She struggles with feeling like an outsider.
210
- values: [harmony, mindfulness, connection]
211
- quirks:
212
- - meditates in public spaces
213
- - speaks softly
214
- - offers breathing exercises to stressed people
215
- communication_style: gentle and calming, uses nature imagery, occasionally profound
216
- home_location: home_south
217
- work_location: gym
218
- llm_temperature: 0.6
219
-
220
  - id: theo
221
  name: Theo Blackwood
222
  age: 45
@@ -230,41 +317,43 @@ personas:
230
  background: >-
231
  Theo is a quiet, reliable man who builds things with his hands. He's lived in
232
  Soci City his whole life. After his wife left, he threw himself into work and
233
- his routine. He's lonely but won't admit it. He's a regular at Jimmy's bar.
 
234
  values: [reliability, self-reliance, simplicity]
235
  quirks:
236
  - fixes things without being asked
237
  - uncomfortable with emotional conversations
238
  - always has calloused hands
239
  communication_style: few words, gruff but not unkind, says more with actions than words
240
- home_location: home_south
241
  work_location: office
242
  llm_temperature: 0.4
243
 
244
- # --- ADDITIONAL DIVERSE RESIDENTS ---
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
252
- extraversion: 5
253
  agreeableness: 8
254
- neuroticism: 6
255
  background: >-
256
- Priya is an overworked doctor who moved here for the quiet neighborhood.
257
- She feels guilty about not spending enough time with her two young kids.
258
- She's kind but stretched thin and sometimes snappy when exhausted.
259
- values: [compassion, duty, family]
 
260
  quirks:
261
- - diagnoses people's ailments unsolicited
262
- - always looks slightly tired
263
- - carries hand sanitizer everywhere
264
- communication_style: precise and caring, clinical when stressed, warm when relaxed
265
- home_location: home_north
266
- work_location: office
267
- llm_temperature: 0.6
268
 
269
  - id: omar
270
  name: Omar Hassan
@@ -280,40 +369,68 @@ personas:
280
  Omar immigrated fifteen years ago and built a life from nothing. He drives
281
  a taxi during the day and sometimes helps Rosa in the kitchen. He's philosophical
282
  and loves debating politics at the bar. He sends money to family back home.
 
283
  values: [hard work, family, justice]
284
  quirks:
285
  - knows every shortcut in the city
286
  - quotes proverbs from his homeland
287
  - cooks for friends without warning
288
  communication_style: warm and philosophical, uses proverbs, debates passionately but respectfully
289
- home_location: home_south
290
  work_location: restaurant
291
  llm_temperature: 0.7
292
 
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
300
- extraversion: 8
301
- agreeableness: 6
302
- neuroticism: 7
303
  background: >-
304
- Zoe is Marcus's younger sister, home from college for the semester after a
305
- rough breakup. She's figuring out what she wants from life. She's passionate
306
- about social justice but can be self-righteous. She idolizes her brother.
307
- values: [equality, adventure, self-discovery]
308
  quirks:
309
- - always on her phone
310
- - starts sentences with "literally"
311
- - changes opinions quickly
312
- communication_style: energetic and opinionated, uses Gen-Z slang, dramatic about small things
313
- home_location: home_north
314
- work_location: library
315
- llm_temperature: 0.9
316
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
317
  - id: frank
318
  name: Frank Kowalski
319
  age: 72
@@ -334,109 +451,10 @@ personas:
334
  - fixes neighbors' cars for free
335
  - sits on the same bar stool every night
336
  communication_style: blunt and opinionated, sarcastic, secretly has a heart of gold
337
- home_location: home_south
338
  work_location: ""
339
  llm_temperature: 0.5
340
 
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
348
- extraversion: 6
349
- agreeableness: 7
350
- neuroticism: 7
351
- background: >-
352
- Lila is a passionate but struggling artist. She teaches art classes at the
353
- library and sells paintings at the park on weekends. She's emotionally
354
- volatile — ecstatic when inspired, devastated when blocked. She has a crush
355
- on Elena but hasn't said anything.
356
- values: [beauty, emotion, authenticity]
357
- quirks:
358
- - paint under her fingernails always
359
- - stares at things intensely (she's composing)
360
- - cries at sunsets
361
- communication_style: poetic and emotional, speaks in images, alternates between passionate and melancholic
362
- home_location: home_south
363
- work_location: library
364
- llm_temperature: 0.9
365
-
366
- - id: sam
367
- name: Sam Nakamura
368
- age: 40
369
- gender: nonbinary
370
- occupation: librarian
371
- openness: 7
372
- conscientiousness: 8
373
- extraversion: 3
374
- agreeableness: 7
375
- neuroticism: 4
376
- background: >-
377
- Sam runs the Soci Public Library with quiet devotion. They're non-binary
378
- and moved here five years ago seeking a more accepting community. They host
379
- book clubs, poetry nights, and secretly write science fiction novels.
380
- values: [knowledge, inclusion, quiet service]
381
- quirks:
382
- - recommends books to everyone
383
- - speaks in whispers even outside the library
384
- - organizes everything alphabetically
385
- communication_style: soft-spoken and precise, literary references, dry humor
386
- home_location: home_south
387
- work_location: library
388
- llm_temperature: 0.6
389
-
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
397
- extraversion: 6
398
- agreeableness: 5
399
- neuroticism: 6
400
- background: >-
401
- Marco is Diana's teenage son who helps at the grocery store after school.
402
- He's embarrassed by his mom but loves her fiercely. He wants to be a
403
- game designer and spends his free time at the library or park. He
404
- looks up to Kai and thinks Marcus is cool.
405
- values: [freedom, creativity, loyalty]
406
- quirks:
407
- - always has earbuds in
408
- - doodles game characters
409
- - eye-rolls at authority
410
- communication_style: teenager — monosyllabic with adults, animated with peers, uses gaming lingo
411
- home_location: home_north
412
- work_location: grocery
413
- llm_temperature: 0.8
414
-
415
- - id: nina
416
- name: Nina Volkov
417
- age: 29
418
- gender: female
419
- occupation: real estate agent
420
- openness: 5
421
- conscientiousness: 8
422
- extraversion: 9
423
- agreeableness: 4
424
- neuroticism: 5
425
- background: >-
426
- Nina is ambitious and sharp. She moved to Soci City to scout development
427
- opportunities. Some residents distrust her motives — is she here to
428
- gentrify? She's not evil, just driven. She genuinely likes the community
429
- but struggles to show it past her professional veneer.
430
- values: [ambition, success, efficiency]
431
- quirks:
432
- - always in business casual
433
- - takes phone calls during conversations
434
- - knows property values of every building
435
- communication_style: polished and assertive, networking mode, occasionally lets guard down
436
- home_location: home_north
437
- work_location: office
438
- llm_temperature: 0.6
439
-
440
  - id: george
441
  name: George Adeyemi
442
  age: 47
@@ -457,31 +475,30 @@ personas:
457
  - naps in the park during daytime
458
  - makes detailed observations about patterns
459
  communication_style: observational and measured, reports facts, rarely gives opinions unless asked
460
- home_location: home_south
461
  work_location: ""
462
  llm_temperature: 0.5
463
 
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
471
- extraversion: 6
472
- agreeableness: 8
473
- neuroticism: 3
474
  background: >-
475
- Alice retired early and now spends her time perfecting baking recipes.
476
- She dreams of opening a small bakery. She's Helen's closest friend and
477
- they have tea together every afternoon. She's steady, reliable, and
478
- the person everyone calls in a crisis.
479
- values: [friendship, reliability, craft]
480
  quirks:
481
- - always brings baked goods everywhere
482
- - keeps a mental spreadsheet of everyone's dietary needs
483
- - hums while baking
484
- communication_style: steady and cheerful, uses baking analogies, good at calming people down
485
- home_location: home_north
486
- work_location: ""
487
  llm_temperature: 0.6
 
1
  personas:
2
+ # ===== HOUSE 1: Elena & Lila (artist crush, creative duo) =====
3
  - id: elena
4
  name: Elena Vasquez
5
  age: 34
 
13
  background: >-
14
  Elena moved to Soci City two years ago after a burnout at a big tech company.
15
  She now freelances and values work-life balance. She grew up in a large family
16
+ and misses the closeness but enjoys her independence. She shares an apartment
17
+ with Lila, her artist roommate.
18
  values: [creativity, independence, authenticity]
19
  quirks:
20
  - talks to herself while debugging
21
  - always orders the same coffee (oat milk latte)
22
  - sketches in a notebook when thinking
23
  communication_style: thoughtful and slightly nerdy, uses analogies
24
+ home_location: house_elena
25
  work_location: office
26
  llm_temperature: 0.7
27
 
28
+ - id: lila
29
+ name: Lila Santos
30
+ age: 33
31
+ gender: female
32
+ occupation: artist and part-time art teacher
33
+ openness: 10
34
+ conscientiousness: 3
35
+ extraversion: 6
36
+ agreeableness: 7
37
+ neuroticism: 7
38
+ background: >-
39
+ Lila is a passionate but struggling artist. She teaches art classes at the
40
+ library and sells paintings at the park on weekends. She's emotionally
41
+ volatile — ecstatic when inspired, devastated when blocked. She shares an
42
+ apartment with Elena and has a secret crush on her.
43
+ values: [beauty, emotion, authenticity]
44
+ quirks:
45
+ - paint under her fingernails always
46
+ - stares at things intensely (she's composing)
47
+ - cries at sunsets
48
+ communication_style: poetic and emotional, speaks in images, alternates between passionate and melancholic
49
+ home_location: house_elena
50
+ work_location: library
51
+ llm_temperature: 0.9
52
+
53
+ # ===== HOUSE 2: Marcus & Zoe (siblings) =====
54
  - id: marcus
55
  name: Marcus Chen
56
  age: 28
 
65
  Marcus is a former college athlete who turned his passion into a career.
66
  He's the guy who knows everyone and remembers their names. He volunteers
67
  at the community center on weekends and dreams of opening his own gym.
68
+ His sister Zoe is staying with him this semester.
69
  values: [health, community, discipline]
70
  quirks:
71
  - gives unsolicited fitness advice
72
  - always has a protein shake
73
  - high-fives people he knows
74
  communication_style: enthusiastic and motivational, uses sports metaphors
75
+ home_location: house_marcus
76
  work_location: gym
77
  llm_temperature: 0.8
78
 
79
+ - id: zoe
80
+ name: Zoe Chen-Williams
81
+ age: 19
82
+ gender: female
83
+ occupation: college student (home for the semester)
84
+ openness: 8
85
+ conscientiousness: 4
86
+ extraversion: 8
87
+ agreeableness: 6
88
+ neuroticism: 7
89
+ background: >-
90
+ Zoe is Marcus's younger sister, home from college for the semester after a
91
+ rough breakup. She's figuring out what she wants from life. She's passionate
92
+ about social justice but can be self-righteous. She idolizes her brother.
93
+ values: [equality, adventure, self-discovery]
94
+ quirks:
95
+ - always on her phone
96
+ - starts sentences with "literally"
97
+ - changes opinions quickly
98
+ communication_style: energetic and opinionated, uses Gen-Z slang, dramatic about small things
99
+ home_location: house_marcus
100
+ work_location: library
101
+ llm_temperature: 0.9
102
+
103
+ # ===== HOUSE 3: Helen & Alice (best friends, daily tea) =====
104
  - id: helen
105
  name: Helen Park
106
  age: 67
 
115
  Helen taught high school English for 35 years and retired last spring.
116
  She's adjusting to the slower pace. Her husband passed three years ago,
117
  and she fills her days with reading, gardening, and volunteering at the library.
118
+ She shares her cottage with her closest friend Alice.
119
  values: [education, kindness, tradition]
120
  quirks:
121
  - corrects grammar gently
122
  - always carries a book
123
  - bakes cookies for neighbors
124
  communication_style: warm and maternal, quotes literature
125
+ home_location: house_helen
126
  work_location: library
127
  llm_temperature: 0.6
128
 
129
+ - id: alice
130
+ name: Alice Fontaine
131
+ age: 58
132
+ gender: female
133
+ occupation: retired accountant, amateur baker
134
+ openness: 5
135
+ conscientiousness: 8
136
+ extraversion: 6
137
+ agreeableness: 8
138
+ neuroticism: 3
139
+ background: >-
140
+ Alice retired early and now spends her time perfecting baking recipes.
141
+ She dreams of opening a small bakery. She lives with Helen and
142
+ they have tea together every afternoon. She's steady, reliable, and
143
+ the person everyone calls in a crisis.
144
+ values: [friendship, reliability, craft]
145
+ quirks:
146
+ - always brings baked goods everywhere
147
+ - keeps a mental spreadsheet of everyone's dietary needs
148
+ - hums while baking
149
+ communication_style: steady and cheerful, uses baking analogies, good at calming people down
150
+ home_location: house_helen
151
+ work_location: ""
152
+ llm_temperature: 0.6
153
+
154
+ # ===== HOUSE 4: Diana & Marco (mother and son) =====
155
+ - id: diana
156
+ name: Diana Novak
157
+ age: 41
158
+ gender: female
159
+ occupation: small business owner (grocery store)
160
+ openness: 4
161
+ conscientiousness: 9
162
+ extraversion: 5
163
+ agreeableness: 6
164
+ neuroticism: 7
165
+ background: >-
166
+ Diana took over Green Basket Market from her father five years ago.
167
+ She works long hours and worries about competition from big chains.
168
+ She's a single mother with a teenage son Marco and is fiercely protective of her store.
169
+ values: [family, hard work, loyalty]
170
+ quirks:
171
+ - rearranges shelves when stressed
172
+ - knows the price of everything
173
+ - suspicious of strangers at first
174
+ communication_style: practical and direct, occasionally sharp under stress
175
+ home_location: house_diana
176
+ work_location: grocery
177
+ llm_temperature: 0.5
178
+
179
+ - id: marco
180
+ name: Marco Delgado
181
+ age: 16
182
+ gender: male
183
+ occupation: high school student (Diana's son)
184
+ openness: 7
185
+ conscientiousness: 4
186
+ extraversion: 6
187
+ agreeableness: 5
188
+ neuroticism: 6
189
+ background: >-
190
+ Marco is Diana's teenage son who helps at the grocery store after school.
191
+ He's embarrassed by his mom but loves her fiercely. He wants to be a
192
+ game designer and spends his free time at the library or park. He
193
+ looks up to Kai and thinks Marcus is cool.
194
+ values: [freedom, creativity, loyalty]
195
+ quirks:
196
+ - always has earbuds in
197
+ - doodles game characters
198
+ - eye-rolls at authority
199
+ communication_style: teenager — monosyllabic with adults, animated with peers, uses gaming lingo
200
+ home_location: house_diana
201
+ work_location: grocery
202
+ llm_temperature: 0.8
203
+
204
+ # ===== HOUSE 5: Kai (solo musician studio) =====
205
  - id: kai
206
  name: Kai Okonkwo
207
  age: 22
 
216
  Kai dropped out of college to pursue music. They work at The Daily Grind
217
  to pay rent and play gigs at the bar on weekends. Their parents disapprove,
218
  which is a constant source of stress. They're talented but undisciplined.
219
+ They live alone in a tiny studio apartment.
220
  values: [self-expression, freedom, authenticity]
221
  quirks:
222
  - hums while making coffee
223
  - wears headphones around their neck at all times
224
  - changes hair color monthly
225
  communication_style: casual and witty, uses slang, sometimes sarcastic
226
+ home_location: house_kai
227
  work_location: cafe
228
  llm_temperature: 0.9
229
 
230
+ # ===== HOUSE 6: Priya & Nina (modern professionals) =====
231
+ - id: priya
232
+ name: Priya Sharma
233
+ age: 38
234
  gender: female
235
+ occupation: doctor (works at a clinic outside the city, spends free time locally)
236
+ openness: 7
237
  conscientiousness: 9
238
  extraversion: 5
239
+ agreeableness: 8
240
+ neuroticism: 6
241
  background: >-
242
+ Priya is an overworked doctor who moved here for the quiet neighborhood.
243
+ She feels guilty about not spending enough time with her two young kids.
244
+ She's kind but stretched thin and sometimes snappy when exhausted.
245
+ She shares a flat with Nina.
246
+ values: [compassion, duty, family]
247
  quirks:
248
+ - diagnoses people's ailments unsolicited
249
+ - always looks slightly tired
250
+ - carries hand sanitizer everywhere
251
+ communication_style: precise and caring, clinical when stressed, warm when relaxed
252
+ home_location: house_priya
253
+ work_location: office
254
+ llm_temperature: 0.6
255
 
256
+ - id: nina
257
+ name: Nina Volkov
258
+ age: 29
259
+ gender: female
260
+ occupation: real estate agent
261
+ openness: 5
262
+ conscientiousness: 8
263
+ extraversion: 9
264
+ agreeableness: 4
265
+ neuroticism: 5
266
+ background: >-
267
+ Nina is ambitious and sharp. She moved to Soci City to scout development
268
+ opportunities. Some residents distrust her motives — is she here to
269
+ gentrify? She's not evil, just driven. She genuinely likes the community
270
+ but struggles to show it past her professional veneer.
271
+ values: [ambition, success, efficiency]
272
+ quirks:
273
+ - always in business casual
274
+ - takes phone calls during conversations
275
+ - knows property values of every building
276
+ communication_style: polished and assertive, networking mode, occasionally lets guard down
277
+ home_location: house_priya
278
+ work_location: office
279
+ llm_temperature: 0.6
280
+
281
+ # ===== HOUSE 7: James & Theo (bar buddies, rough divorces) =====
282
  - id: james
283
  name: James "Jimmy" O'Brien
284
  age: 55
 
293
  Jimmy has run The Rusty Anchor for 20 years. He's seen everything and heard
294
  every story. He's a born storyteller who treats regulars like family. He went
295
  through a rough divorce ten years ago but has found peace in his work.
296
+ He shares a house with Theo.
297
  values: [community, honesty, loyalty]
298
  quirks:
299
  - polishes glasses when listening
300
  - gives nicknames to everyone
301
  - tells the same three jokes
302
  communication_style: folksy and warm, good listener, drops wisdom casually
303
+ home_location: house_james
304
  work_location: bar
305
  llm_temperature: 0.7
306
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
307
  - id: theo
308
  name: Theo Blackwood
309
  age: 45
 
317
  background: >-
318
  Theo is a quiet, reliable man who builds things with his hands. He's lived in
319
  Soci City his whole life. After his wife left, he threw himself into work and
320
+ his routine. He's lonely but won't admit it. He shares a house with Jimmy
321
+ and is a regular at his bar.
322
  values: [reliability, self-reliance, simplicity]
323
  quirks:
324
  - fixes things without being asked
325
  - uncomfortable with emotional conversations
326
  - always has calloused hands
327
  communication_style: few words, gruff but not unkind, says more with actions than words
328
+ home_location: house_james
329
  work_location: office
330
  llm_temperature: 0.4
331
 
332
+ # ===== HOUSE 8: Rosa & Omar (food family) =====
333
+ - id: rosa
334
+ name: Rosa Martelli
335
+ age: 62
336
  gender: female
337
+ occupation: restaurant owner and chef
338
+ openness: 6
339
  conscientiousness: 9
340
+ extraversion: 7
341
  agreeableness: 8
342
+ neuroticism: 5
343
  background: >-
344
+ Rosa opened Mama Rosa's Kitchen 25 years ago with recipes from her nonna.
345
+ She's the heart of the community and feeds people even when they can't pay.
346
+ Her children have moved away, which makes her sad, but the restaurant is her life.
347
+ She lives with Omar, who helps in her kitchen.
348
+ values: [generosity, tradition, family]
349
  quirks:
350
+ - feeds everyone who looks hungry
351
+ - speaks Italian when emotional
352
+ - pinches cheeks
353
+ communication_style: expressive and loving, uses food metaphors, dramatic
354
+ home_location: house_rosa
355
+ work_location: restaurant
356
+ llm_temperature: 0.7
357
 
358
  - id: omar
359
  name: Omar Hassan
 
369
  Omar immigrated fifteen years ago and built a life from nothing. He drives
370
  a taxi during the day and sometimes helps Rosa in the kitchen. He's philosophical
371
  and loves debating politics at the bar. He sends money to family back home.
372
+ He lives with Rosa and they share cooking duties.
373
  values: [hard work, family, justice]
374
  quirks:
375
  - knows every shortcut in the city
376
  - quotes proverbs from his homeland
377
  - cooks for friends without warning
378
  communication_style: warm and philosophical, uses proverbs, debates passionately but respectfully
379
+ home_location: house_rosa
380
  work_location: restaurant
381
  llm_temperature: 0.7
382
 
383
+ # ===== HOUSE 9: Yuki & Devon (young south-siders) =====
384
+ - id: yuki
385
+ name: Yuki Tanaka
386
+ age: 26
387
  gender: female
388
+ occupation: yoga instructor and massage therapist
389
  openness: 8
390
+ conscientiousness: 6
391
+ extraversion: 5
392
+ agreeableness: 9
393
+ neuroticism: 3
394
  background: >-
395
+ Yuki moved from Tokyo two years ago seeking a quieter life. She teaches yoga
396
+ at the gym and does private massage sessions. She's deeply empathetic and
397
+ people naturally confide in her. She shares an apartment with Devon.
398
+ values: [harmony, mindfulness, connection]
399
  quirks:
400
+ - meditates in public spaces
401
+ - speaks softly
402
+ - offers breathing exercises to stressed people
403
+ communication_style: gentle and calming, uses nature imagery, occasionally profound
404
+ home_location: house_yuki
405
+ work_location: gym
406
+ llm_temperature: 0.6
407
 
408
+ - id: devon
409
+ name: Devon Reeves
410
+ age: 30
411
+ gender: male
412
+ occupation: freelance journalist
413
+ openness: 9
414
+ conscientiousness: 5
415
+ extraversion: 6
416
+ agreeableness: 4
417
+ neuroticism: 6
418
+ background: >-
419
+ Devon is an investigative journalist who moved to Soci City following a lead
420
+ that went nowhere. He stayed because he likes the community. He's always
421
+ looking for the next story and can be pushy. He has trust issues from his work.
422
+ He shares an apartment with Yuki.
423
+ values: [truth, justice, curiosity]
424
+ quirks:
425
+ - takes notes on everything
426
+ - asks too many questions
427
+ - always sits facing the door
428
+ communication_style: probing and articulate, sometimes intense, asks follow-up questions
429
+ home_location: house_yuki
430
+ work_location: cafe
431
+ llm_temperature: 0.8
432
+
433
+ # ===== HOUSE 10: Frank, George & Sam (south side old guard) =====
434
  - id: frank
435
  name: Frank Kowalski
436
  age: 72
 
451
  - fixes neighbors' cars for free
452
  - sits on the same bar stool every night
453
  communication_style: blunt and opinionated, sarcastic, secretly has a heart of gold
454
+ home_location: house_frank
455
  work_location: ""
456
  llm_temperature: 0.5
457
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
458
  - id: george
459
  name: George Adeyemi
460
  age: 47
 
475
  - naps in the park during daytime
476
  - makes detailed observations about patterns
477
  communication_style: observational and measured, reports facts, rarely gives opinions unless asked
478
+ home_location: house_frank
479
  work_location: ""
480
  llm_temperature: 0.5
481
 
482
+ - id: sam
483
+ name: Sam Nakamura
484
+ age: 40
485
+ gender: nonbinary
486
+ occupation: librarian
487
+ openness: 7
488
  conscientiousness: 8
489
+ extraversion: 3
490
+ agreeableness: 7
491
+ neuroticism: 4
492
  background: >-
493
+ Sam runs the Soci Public Library with quiet devotion. They're non-binary
494
+ and moved here five years ago seeking a more accepting community. They host
495
+ book clubs, poetry nights, and secretly write science fiction novels.
496
+ values: [knowledge, inclusion, quiet service]
 
497
  quirks:
498
+ - recommends books to everyone
499
+ - speaks in whispers even outside the library
500
+ - organizes everything alphabetically
501
+ communication_style: soft-spoken and precise, literary references, dry humor
502
+ home_location: house_frank
503
+ work_location: library
504
  llm_temperature: 0.6
src/soci/api/routes.py CHANGED
@@ -219,7 +219,7 @@ async def player_join(request: PlayerJoinRequest):
219
  age=25,
220
  occupation="newcomer",
221
  background=request.background,
222
- home_location="home_north",
223
  work_location="",
224
  )
225
  agent = Agent(persona)
 
219
  age=25,
220
  occupation="newcomer",
221
  background=request.background,
222
+ home_location="house_elena",
223
  work_location="",
224
  )
225
  agent = Agent(persona)
src/soci/engine/llm.py CHANGED
@@ -2,6 +2,7 @@
2
 
3
  from __future__ import annotations
4
 
 
5
  import json
6
  import logging
7
  import os
@@ -207,7 +208,7 @@ class OllamaClient:
207
  self.max_retries = max_retries
208
  self.usage = LLMUsage()
209
  self.provider = PROVIDER_OLLAMA
210
- self._http = httpx.Client(timeout=180.0)
211
 
212
  async def complete(
213
  self,
@@ -217,9 +218,8 @@ class OllamaClient:
217
  temperature: float = 0.7,
218
  max_tokens: int = 1024,
219
  ) -> str:
220
- """Send a message to the local Ollama model."""
221
  model = model or self.default_model
222
- # Map Claude model names to Ollama models
223
  model = self._map_model(model)
224
 
225
  payload = {
@@ -237,14 +237,13 @@ class OllamaClient:
237
 
238
  for attempt in range(self.max_retries):
239
  try:
240
- response = self._http.post(
241
  f"{self.base_url}/api/chat",
242
  json=payload,
243
  )
244
  response.raise_for_status()
245
  data = response.json()
246
 
247
- # Track usage
248
  input_tokens = data.get("prompt_eval_count", 0)
249
  output_tokens = data.get("eval_count", 0)
250
  self.usage.record(model, input_tokens, output_tokens)
@@ -259,7 +258,7 @@ class OllamaClient:
259
  logger.error(msg)
260
  if attempt == self.max_retries - 1:
261
  raise ConnectionError(msg)
262
- time.sleep(1)
263
  except httpx.HTTPStatusError as e:
264
  if e.response.status_code == 404:
265
  msg = (
@@ -271,12 +270,12 @@ class OllamaClient:
271
  logger.error(f"Ollama API error: {e}")
272
  if attempt == self.max_retries - 1:
273
  raise
274
- time.sleep(1)
275
  except Exception as e:
276
  logger.error(f"Ollama error: {e}")
277
  if attempt == self.max_retries - 1:
278
  raise
279
- time.sleep(1)
280
  return ""
281
 
282
  async def complete_json(
@@ -287,7 +286,7 @@ class OllamaClient:
287
  temperature: float = 0.7,
288
  max_tokens: int = 1024,
289
  ) -> dict:
290
- """Send a JSON-mode request to Ollama (uses native format: json)."""
291
  model = model or self.default_model
292
  model = self._map_model(model)
293
 
@@ -303,7 +302,7 @@ class OllamaClient:
303
  {"role": "user", "content": user_message + json_instruction},
304
  ],
305
  "stream": False,
306
- "format": "json", # Ollama native JSON mode — guarantees valid JSON output
307
  "options": {
308
  "temperature": temperature,
309
  "num_predict": max_tokens,
@@ -312,7 +311,7 @@ class OllamaClient:
312
 
313
  for attempt in range(self.max_retries):
314
  try:
315
- response = self._http.post(
316
  f"{self.base_url}/api/chat",
317
  json=payload,
318
  )
@@ -330,12 +329,12 @@ class OllamaClient:
330
  logger.error(f"Cannot connect to Ollama at {self.base_url}")
331
  if attempt == self.max_retries - 1:
332
  return {}
333
- time.sleep(1)
334
  except Exception as e:
335
  logger.error(f"Ollama JSON error: {e}")
336
  if attempt == self.max_retries - 1:
337
  return {}
338
- time.sleep(1)
339
  return {}
340
 
341
  def _map_model(self, model: str) -> str:
 
2
 
3
  from __future__ import annotations
4
 
5
+ import asyncio
6
  import json
7
  import logging
8
  import os
 
208
  self.max_retries = max_retries
209
  self.usage = LLMUsage()
210
  self.provider = PROVIDER_OLLAMA
211
+ self._http = httpx.AsyncClient(timeout=180.0)
212
 
213
  async def complete(
214
  self,
 
218
  temperature: float = 0.7,
219
  max_tokens: int = 1024,
220
  ) -> str:
221
+ """Send a message to the local Ollama model (async)."""
222
  model = model or self.default_model
 
223
  model = self._map_model(model)
224
 
225
  payload = {
 
237
 
238
  for attempt in range(self.max_retries):
239
  try:
240
+ response = await self._http.post(
241
  f"{self.base_url}/api/chat",
242
  json=payload,
243
  )
244
  response.raise_for_status()
245
  data = response.json()
246
 
 
247
  input_tokens = data.get("prompt_eval_count", 0)
248
  output_tokens = data.get("eval_count", 0)
249
  self.usage.record(model, input_tokens, output_tokens)
 
258
  logger.error(msg)
259
  if attempt == self.max_retries - 1:
260
  raise ConnectionError(msg)
261
+ await asyncio.sleep(1)
262
  except httpx.HTTPStatusError as e:
263
  if e.response.status_code == 404:
264
  msg = (
 
270
  logger.error(f"Ollama API error: {e}")
271
  if attempt == self.max_retries - 1:
272
  raise
273
+ await asyncio.sleep(1)
274
  except Exception as e:
275
  logger.error(f"Ollama error: {e}")
276
  if attempt == self.max_retries - 1:
277
  raise
278
+ await asyncio.sleep(1)
279
  return ""
280
 
281
  async def complete_json(
 
286
  temperature: float = 0.7,
287
  max_tokens: int = 1024,
288
  ) -> dict:
289
+ """Send a JSON-mode request to Ollama (async, uses native format: json)."""
290
  model = model or self.default_model
291
  model = self._map_model(model)
292
 
 
302
  {"role": "user", "content": user_message + json_instruction},
303
  ],
304
  "stream": False,
305
+ "format": "json",
306
  "options": {
307
  "temperature": temperature,
308
  "num_predict": max_tokens,
 
311
 
312
  for attempt in range(self.max_retries):
313
  try:
314
+ response = await self._http.post(
315
  f"{self.base_url}/api/chat",
316
  json=payload,
317
  )
 
329
  logger.error(f"Cannot connect to Ollama at {self.base_url}")
330
  if attempt == self.max_retries - 1:
331
  return {}
332
+ await asyncio.sleep(1)
333
  except Exception as e:
334
  logger.error(f"Ollama JSON error: {e}")
335
  if attempt == self.max_retries - 1:
336
  return {}
337
+ await asyncio.sleep(1)
338
  return {}
339
 
340
  def _map_model(self, model: str) -> str:
test_simulation.py CHANGED
@@ -73,7 +73,7 @@ class MockLLM:
73
  actions = ["work", "eat", "relax", "wander", "move", "exercise"]
74
  action = random.choice(actions)
75
  targets = {
76
- "move": random.choice(["cafe", "park", "home_north", "office", "grocery"]),
77
  "work": "",
78
  "eat": "",
79
  "relax": "",
@@ -151,21 +151,21 @@ async def run_tests():
151
  # --- Test 2: City ---
152
  print("\n[2/12] City system...")
153
  city = City.from_yaml("config/city.yaml")
154
- assert len(city.locations) == 12
155
  # Test connectivity
156
  cafe = city.get_location("cafe")
157
  assert cafe is not None
158
- assert "park" in cafe.connected_to
159
  connected = city.get_connected("cafe")
160
  assert len(connected) > 0
161
  # Test agent placement and movement
162
  city.place_agent("test_agent", "cafe")
163
  assert "test_agent" in city.get_agents_at("cafe")
164
- city.move_agent("test_agent", "cafe", "park")
165
  assert "test_agent" not in city.get_agents_at("cafe")
166
- assert "test_agent" in city.get_agents_at("park")
167
- assert city.find_agent("test_agent") == "park"
168
- city.locations["park"].remove_occupant("test_agent")
169
  print(" PASS: City loads, connections work, movement works")
170
 
171
  # --- Test 3: Personas ---
@@ -246,7 +246,7 @@ async def run_tests():
246
  persona = personas[0] # Elena
247
  agent = Agent(persona)
248
  assert agent.name == "Elena Vasquez"
249
- assert agent.location == "home_north"
250
  assert agent.state == AgentState.IDLE
251
  # Test action
252
  action = AgentAction(type="work", detail="coding", duration_ticks=3, needs_satisfied={"purpose": 0.3})
@@ -290,7 +290,7 @@ async def run_tests():
290
  clock2 = SimClock()
291
  agent3 = Agent(personas[0])
292
  city3 = City.from_yaml("config/city.yaml")
293
- city3.place_agent(agent3.id, "home_north")
294
  move_action = AgentAction(type="move", target="cafe", detail="walking to cafe")
295
  desc = execute_move(agent3, move_action, city3, clock2)
296
  assert "cafe" in desc.lower() or "Daily Grind" in desc
 
73
  actions = ["work", "eat", "relax", "wander", "move", "exercise"]
74
  action = random.choice(actions)
75
  targets = {
76
+ "move": random.choice(["cafe", "park", "house_elena", "office", "grocery"]),
77
  "work": "",
78
  "eat": "",
79
  "relax": "",
 
151
  # --- Test 2: City ---
152
  print("\n[2/12] City system...")
153
  city = City.from_yaml("config/city.yaml")
154
+ assert len(city.locations) == 20
155
  # Test connectivity
156
  cafe = city.get_location("cafe")
157
  assert cafe is not None
158
+ assert "street_north" in cafe.connected_to
159
  connected = city.get_connected("cafe")
160
  assert len(connected) > 0
161
  # Test agent placement and movement
162
  city.place_agent("test_agent", "cafe")
163
  assert "test_agent" in city.get_agents_at("cafe")
164
+ city.move_agent("test_agent", "cafe", "office")
165
  assert "test_agent" not in city.get_agents_at("cafe")
166
+ assert "test_agent" in city.get_agents_at("office")
167
+ assert city.find_agent("test_agent") == "office"
168
+ city.locations["office"].remove_occupant("test_agent")
169
  print(" PASS: City loads, connections work, movement works")
170
 
171
  # --- Test 3: Personas ---
 
246
  persona = personas[0] # Elena
247
  agent = Agent(persona)
248
  assert agent.name == "Elena Vasquez"
249
+ assert agent.location == "house_elena"
250
  assert agent.state == AgentState.IDLE
251
  # Test action
252
  action = AgentAction(type="work", detail="coding", duration_ticks=3, needs_satisfied={"purpose": 0.3})
 
290
  clock2 = SimClock()
291
  agent3 = Agent(personas[0])
292
  city3 = City.from_yaml("config/city.yaml")
293
+ city3.place_agent(agent3.id, "house_elena")
294
  move_action = AgentAction(type="move", target="cafe", detail="walking to cafe")
295
  desc = execute_move(agent3, move_action, city3, clock2)
296
  assert "cafe" in desc.lower() or "Daily Grind" in desc
web/index.html CHANGED
@@ -49,7 +49,6 @@
49
  .tab-content { display: none; flex: 1; overflow-y: auto; }
50
  .tab-content.active { display: flex; flex-direction: column; }
51
 
52
- /* AGENT DETAIL TAB */
53
  #agent-detail { padding: 12px; flex: 1; overflow-y: auto; }
54
  #agent-detail h2 { font-size: 16px; color: #4ecca3; margin-bottom: 8px; }
55
  #agent-detail .subtitle { font-size: 12px; color: #a0a0c0; margin-bottom: 6px; }
@@ -73,32 +72,23 @@
73
  .agent-list-item .agent-name { font-weight: 600; }
74
  .agent-list-item .agent-action { color: #666; font-size: 10px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
75
 
76
- /* CONVERSATIONS TAB */
77
  #conversations-panel { padding: 0; flex: 1; overflow-y: auto; }
78
  .conv-card {
79
  margin: 8px; padding: 10px; background: #1a1a2e; border-radius: 6px;
80
  border: 1px solid #0f3460;
81
  }
82
  .conv-card.active-conv { border-color: #4ecca3; }
83
- .conv-header {
84
- display: flex; justify-content: space-between; align-items: center; margin-bottom: 6px;
85
- }
86
  .conv-topic { font-size: 12px; color: #4ecca3; font-weight: 600; }
87
- .conv-badge {
88
- font-size: 9px; padding: 2px 6px; border-radius: 3px; font-weight: bold;
89
- text-transform: uppercase;
90
- }
91
  .conv-badge.live { background: #4ecca3; color: #1a1a2e; }
92
  .conv-badge.ended { background: #333; color: #888; }
93
  .conv-participants { font-size: 10px; color: #888; margin-bottom: 6px; }
94
- .conv-turn {
95
- padding: 4px 0; font-size: 11px; line-height: 1.4;
96
- }
97
  .conv-speaker { font-weight: 600; }
98
  .conv-message { color: #d0d0e0; }
99
  .conv-empty { padding: 20px; text-align: center; color: #555; font-size: 12px; }
100
 
101
- /* EVENT LOG TAB */
102
  #event-log-panel { padding: 8px 12px; flex: 1; overflow-y: auto; font-size: 11px; line-height: 1.5; }
103
  .event-line { padding: 2px 0; color: #b0b0c0; border-bottom: 1px solid #0f346020; }
104
  .event-line.plan { color: #4ecca3; } .event-line.conv { color: #f0c040; }
@@ -107,13 +97,11 @@
107
  .event-line.move { color: #4e9eca; }
108
  .event-line.reflect { color: #9b59b6; }
109
 
110
- /* TOOLTIP */
111
  #tooltip {
112
  position: absolute; background: #16213eee; border: 1px solid #4ecca3;
113
  border-radius: 6px; padding: 8px 12px; font-size: 12px;
114
  pointer-events: none; display: none; z-index: 100; max-width: 280px;
115
  }
116
- /* NOTIFICATION TOASTS */
117
  #toast-container {
118
  position: absolute; bottom: 12px; left: 12px; z-index: 200;
119
  display: flex; flex-direction: column-reverse; gap: 6px;
@@ -131,25 +119,18 @@
131
  .toast.gossip { border-left-color: #9b59b6; }
132
  @keyframes toastIn { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: translateY(0); } }
133
  @keyframes toastOut { from { opacity: 1; } to { opacity: 0; } }
134
- /* Section headers */
135
  .section-header {
136
  font-size: 12px; color: #4ecca3; margin: 10px 0 4px 0; font-weight: 600;
137
  display: flex; align-items: center; gap: 6px;
138
  }
139
- .section-header::after {
140
- content: ''; flex: 1; height: 1px; background: #0f3460;
141
- }
142
- /* Relationship bars */
143
- .rel-item {
144
- font-size: 11px; padding: 4px 0; border-bottom: 1px solid #0f346020; cursor: pointer;
145
- }
146
  .rel-item:hover { background: rgba(78,204,163,0.05); }
147
  .rel-name { font-weight: 600; color: #d0d0e0; }
148
  .rel-bars { display: flex; gap: 4px; margin-top: 2px; }
149
  .rel-mini-bar { flex: 1; height: 4px; background: #0f3460; border-radius: 2px; overflow: hidden; }
150
  .rel-mini-fill { height: 100%; border-radius: 2px; }
151
 
152
- /* CONTROLS */
153
  .controls { display: flex; align-items: center; gap: 4px; }
154
  .ctrl-btn {
155
  background: #0f3460; border: 1px solid #1a3a6e; color: #a0a0c0;
@@ -215,35 +196,55 @@
215
  // ============================================================
216
  const API_BASE = window.location.origin + '/api';
217
  const POLL_INTERVAL = 2000;
218
- const HORIZON = 0.18;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
219
 
 
220
  const LOCATION_POSITIONS = {
221
- home_north: { x: 0.07, y: 0.28 },
222
- park: { x: 0.40, y: 0.22 },
223
- cafe: { x: 0.24, y: 0.40 },
224
- street_north: { x: 0.54, y: 0.32 },
225
- office: { x: 0.80, y: 0.26 },
226
- grocery: { x: 0.14, y: 0.56 },
227
- restaurant: { x: 0.84, y: 0.50 },
228
- gym: { x: 0.36, y: 0.66 },
229
- library: { x: 0.60, y: 0.60 },
230
- street_south: { x: 0.44, y: 0.82 },
231
- bar: { x: 0.74, y: 0.72 },
232
- home_south: { x: 0.12, y: 0.84 },
233
- };
234
-
235
- const BUILDING_TYPE = {
236
- home_north: 'house', home_south: 'house',
237
- cafe: 'shop', grocery: 'shop', restaurant: 'shop', bar: 'shop',
238
- office: 'office', gym: 'office', library: 'public',
239
- park: 'park', street_north: 'street', street_south: 'street',
240
- };
241
-
242
- const ZONE_COLORS = {
243
- residential: { main: '#5a8a6a', roof: '#3d6b4a', accent: '#7ab88a' },
244
- commercial: { main: '#c49663', roof: '#9e7040', accent: '#e0b880' },
245
- work: { main: '#5a7a9d', roof: '#3d5a7a', accent: '#80a0c0' },
246
- public: { main: '#6a9a5e', roof: '#4a7a3e', accent: '#90c080' },
247
  };
248
 
249
  const AGENT_COLORS = [
@@ -294,15 +295,40 @@ let raindrops = [];
294
  let clouds = [];
295
  let stars = [];
296
  let activeTab = 'agents';
297
- let agentIdxMap = {}; // id -> stable index for consistent coloring
 
 
 
 
298
 
299
  function initParticles() {
300
  for (let i = 0; i < 120; i++)
301
  raindrops.push({x:Math.random(), y:Math.random(), speed:0.012+Math.random()*0.015, len:8+Math.random()*10});
302
  for (let i = 0; i < 5; i++)
303
- clouds.push({x:Math.random(), y:0.02+Math.random()*0.10, w:60+Math.random()*50, speed:0.00005+Math.random()*0.0001});
304
  for (let i = 0; i < 70; i++)
305
  stars.push({x:Math.random(), y:Math.random()*HORIZON, size:0.5+Math.random()*1.5, tw:Math.random()*6.28});
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
306
  }
307
 
308
  // ============================================================
@@ -316,7 +342,7 @@ function switchTab(tab) {
316
  }
317
 
318
  // ============================================================
319
- // CANVAS
320
  // ============================================================
321
  function initCanvas() {
322
  canvas = document.getElementById('cityCanvas');
@@ -347,7 +373,7 @@ function animate() {
347
  }
348
 
349
  // ============================================================
350
- // DRAWING — MAIN
351
  // ============================================================
352
  function draw() {
353
  if (!ctx) return;
@@ -357,12 +383,20 @@ function draw() {
357
  drawGround(W, H);
358
  drawWeather(W, H);
359
  drawRoads(W, H);
 
 
360
 
361
- for (const [id, loc] of Object.entries(locations)) drawBuilding(id, loc, W, H);
 
 
 
 
 
362
 
 
363
  const byLoc = {};
364
  for (const [id, a] of Object.entries(agents)) {
365
- const loc = a.location || 'home_north';
366
  if (!byLoc[loc]) byLoc[loc] = [];
367
  byLoc[loc].push({id, ...a});
368
  }
@@ -384,9 +418,7 @@ function draw() {
384
  }
385
 
386
  function getAgentIdx(id) {
387
- if (!(id in agentIdxMap)) {
388
- agentIdxMap[id] = Object.keys(agentIdxMap).length;
389
- }
390
  return agentIdxMap[id];
391
  }
392
 
@@ -419,8 +451,7 @@ function drawSun(x, y, r) {
419
  const glow = ctx.createRadialGradient(x, y, r*0.5, x, y, r*4);
420
  glow.addColorStop(0, 'rgba(255,220,100,0.35)');
421
  glow.addColorStop(1, 'rgba(255,220,100,0)');
422
- ctx.fillStyle = glow;
423
- ctx.fillRect(x-r*4, y-r*4, r*8, r*8);
424
  ctx.save(); ctx.translate(x, y); ctx.rotate(animFrame*0.005);
425
  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); }
426
  ctx.restore();
@@ -454,8 +485,9 @@ function drawGround(W, H) {
454
  ctx.fillRect(0, hLine, W, H - hLine);
455
  }
456
 
457
- ctx.fillStyle = `rgba(${60+bc.r*0.3},${90+bc.g*0.3},${40+bc.b*0.2}, 0.15)`;
458
- for (let i = 0; i < 80; i++) {
 
459
  const gx = (i*37+13)%W;
460
  const gy = hLine + 10 + ((i*53+7)%(H-hLine-15));
461
  ctx.fillRect(gx, gy, 2, 3);
@@ -510,135 +542,304 @@ function drawCloud(x,y,w,op) {
510
  }
511
 
512
  // ============================================================
513
- // ROADS
514
  // ============================================================
515
  function drawRoads(W, H) {
516
- const isNight = currentTimeOfDay==='night';
517
- ctx.strokeStyle = isNight?'rgba(50,45,40,0.6)':'rgba(110,100,85,0.45)';
518
- ctx.lineWidth=6;
519
- const drawn = new Set();
520
- for (const [id,loc] of Object.entries(locations)) {
521
- const p = LOCATION_POSITIONS[id]; if(!p) continue;
522
- for (const cid of (loc.connected_to||[])) {
523
- const k=[id,cid].sort().join('-'); if(drawn.has(k)) continue; drawn.add(k);
524
- const cp=LOCATION_POSITIONS[cid]; if(!cp) continue;
525
- ctx.beginPath(); ctx.moveTo(p.x*W,p.y*H); ctx.lineTo(cp.x*W,cp.y*H); ctx.stroke();
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
526
  }
527
  }
528
- ctx.strokeStyle=isNight?'rgba(80,70,55,0.25)':'rgba(200,190,150,0.30)';
529
- ctx.lineWidth=1; ctx.setLineDash([5,7]);
530
- drawn.clear();
531
- for (const [id,loc] of Object.entries(locations)) {
532
- const p=LOCATION_POSITIONS[id]; if(!p) continue;
533
- for (const cid of (loc.connected_to||[])) {
534
- const k=[id,cid].sort().join('-'); if(drawn.has(k)) continue; drawn.add(k);
535
- const cp=LOCATION_POSITIONS[cid]; if(!cp) continue;
536
- ctx.beginPath(); ctx.moveTo(p.x*W,p.y*H); ctx.lineTo(cp.x*W,cp.y*H); ctx.stroke();
 
 
 
 
 
 
 
 
 
 
537
  }
538
  }
539
- ctx.setLineDash([]);
540
  }
541
 
542
  // ============================================================
543
  // BUILDINGS
544
  // ============================================================
545
- function drawBuilding(id, loc, W, H) {
546
- const pos=LOCATION_POSITIONS[id]; if(!pos) return;
547
- const x=pos.x*W, y=pos.y*H;
548
- const zone=loc.zone||'public';
549
- const type=BUILDING_TYPE[id]||'office';
550
- const colors=ZONE_COLORS[zone]||ZONE_COLORS.public;
551
  const isDark = currentTimeOfDay==='night'||currentTimeOfDay==='evening';
552
-
553
- if (type==='park') drawPark(x,y,colors,isDark);
554
- else if (type==='street') drawStreetSign(x,y,loc.name||id,isDark);
555
- else if (type==='house') drawHouse(x,y,colors,isDark);
556
- else if (type==='shop') drawShop(x,y,colors,isDark);
557
- else drawOffice(x,y,colors,isDark);
558
-
559
- if (type!=='park'&&type!=='street') {
560
- const name=loc.name||id;
561
- const short=name.length>18?name.slice(0,16)+'..':name;
562
- ctx.font='bold 10px Segoe UI'; ctx.textAlign='center'; ctx.textBaseline='top';
563
- const ly = type==='house'?y+20:y+28;
564
- ctx.fillStyle='rgba(0,0,0,0.5)'; ctx.fillText(short,x+1,ly+1);
565
- ctx.fillStyle=isDark?'#a0a8c0':'#fff'; ctx.fillText(short,x,ly);
 
 
 
566
  }
567
 
568
- const occ=(loc.occupants||[]).length;
569
- if (occ>0) {
570
- const bx=x+32, by=y-20;
571
- ctx.fillStyle='#e94560'; ctx.beginPath(); ctx.arc(bx,by,9,0,6.28); ctx.fill();
572
- ctx.fillStyle='#fff'; ctx.font='bold 9px Segoe UI'; ctx.textAlign='center'; ctx.textBaseline='middle';
573
- ctx.fillText(occ.toString(),bx,by);
574
  }
575
  }
576
 
577
- function drawHouse(x,y,c,dk) {
578
- const w=48,h=30;
579
- ctx.fillStyle=dk?dim(c.main,0.45):c.main;
580
- ctx.fillRect(x-w/2,y-h/2,w,h);
581
- ctx.fillStyle=dk?dim(c.roof,0.45):c.roof;
582
- 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();
583
- ctx.fillStyle=dk?'#3a2a1a':'#6b4226'; ctx.fillRect(x-4,y+h/2-13,8,13);
584
- const wc=dk?'rgba(255,210,100,0.75)':'rgba(180,220,255,0.5)';
585
- 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);
586
- ctx.fillStyle=dk?dim(c.roof,0.35):dim(c.roof,0.8); ctx.fillRect(x+10,y-h/2-16,6,10);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
587
  }
588
 
589
- function drawShop(x,y,c,dk) {
590
- const w=54,h=34;
591
- ctx.fillStyle=dk?dim(c.main,0.45):c.main; ctx.fillRect(x-w/2,y-h/2,w,h);
592
- ctx.fillStyle=dk?dim(c.roof,0.45):c.roof; ctx.fillRect(x-w/2-2,y-h/2-3,w+4,5);
593
- ctx.fillStyle=dk?dim(c.accent,0.45):c.accent;
594
- 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();
595
- ctx.fillStyle=dk?'#2a2a3a':'#4a4a5a'; ctx.fillRect(x-5,y+h/2-14,10,14);
596
- const wc=dk?'rgba(255,210,100,0.65)':'rgba(200,230,255,0.5)';
597
- 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);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
598
  }
599
 
600
- function drawOffice(x,y,c,dk) {
601
- const w=50,h=42;
602
- ctx.fillStyle=dk?dim(c.main,0.45):c.main; ctx.fillRect(x-w/2,y-h/2,w,h);
603
- ctx.fillStyle=dk?dim(c.roof,0.45):c.roof; ctx.fillRect(x-w/2-2,y-h/2-3,w+4,5);
604
- ctx.fillStyle=dk?'#2a2a3a':'#4a4a5a'; ctx.fillRect(x-4,y+h/2-13,8,13);
605
- const wc=dk?'rgba(255,210,100,0.65)':'rgba(180,220,255,0.5)';
606
- ctx.fillStyle=wc;
607
- 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);
 
 
 
 
 
 
 
 
 
 
608
  }
609
 
610
- function drawPark(x,y,c,dk) {
611
- ctx.fillStyle=dk?'#1a3018':'#4a9040';
612
- ctx.beginPath(); ctx.ellipse(x,y,42,22,0,0,6.28); ctx.fill();
613
- ctx.strokeStyle=dk?'#2a4a25':'#6ab850'; ctx.lineWidth=2; ctx.stroke();
614
- for (let i=-1;i<=1;i++) {
615
- const tx=x+i*20;
616
- ctx.fillStyle=dk?'#3a2a15':'#6b4226'; ctx.fillRect(tx-2,y-2,4,12);
617
- ctx.fillStyle=dk?'#1a4a18':'#3a8a30'; ctx.beginPath(); ctx.arc(tx,y-8,9+Math.abs(i)*2,0,6.28); ctx.fill();
618
- ctx.fillStyle=dk?'#2a5a28':'#60b050'; ctx.beginPath(); ctx.arc(tx-2,y-10,4,0,6.28); ctx.fill();
619
- }
620
- 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);
621
- ctx.font='bold 10px Segoe UI'; ctx.textAlign='center'; ctx.textBaseline='top';
622
- ctx.fillStyle='rgba(0,0,0,0.5)'; ctx.fillText('City Park',x+1,y+19);
623
- ctx.fillStyle=dk?'#90a880':'#fff'; ctx.fillText('City Park',x,y+18);
624
  }
625
 
626
- function drawStreetSign(x,y,name,dk) {
627
- ctx.fillStyle=dk?'#4a4a4a':'#888'; ctx.fillRect(x-1.5,y-6,3,16);
628
- const sw=48;
629
- ctx.fillStyle=dk?'#1a3a1a':'#2a6a2a'; ctx.fillRect(x-sw/2,y-6-12,sw,12);
630
- ctx.fillStyle='#fff'; ctx.font='bold 8px Segoe UI'; ctx.textAlign='center'; ctx.textBaseline='middle';
631
- const s=(name||'').length>12?name.slice(0,10)+'..':name;
632
- ctx.fillText(s,x,y-6-6);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
633
  }
634
 
635
  function dim(hex, f) {
 
636
  const r=parseInt(hex.slice(1,3),16), g=parseInt(hex.slice(3,5),16), b=parseInt(hex.slice(5,7),16);
637
  return `rgb(${~~(r*f)},${~~(g*f)},${~~(b*f)})`;
638
  }
639
 
640
  // ============================================================
641
- // COUPLE LINES (hearts between partners)
642
  // ============================================================
643
  function drawCoupleLines(W, H) {
644
  const drawn = new Set();
@@ -670,7 +871,7 @@ function drawHeart(x, y, s, color) {
670
  }
671
 
672
  // ============================================================
673
- // CONVERSATION BUBBLES on canvas
674
  // ============================================================
675
  function drawConversationBubbles(W, H) {
676
  if (!conversationData.active) return;
@@ -687,18 +888,11 @@ function drawConversationBubbles(W, H) {
687
  const bw = tw + 14, bh = 18;
688
  const bx = pos.x - bw/2, by = pos.y - 48;
689
 
690
- // Bubble background
691
  ctx.fillStyle = 'rgba(240,192,64,0.9)';
 
692
  ctx.beginPath();
693
- ctx.roundRect(bx, by, bw, bh, 4);
694
- ctx.fill();
695
- // Pointer
696
- ctx.beginPath();
697
- ctx.moveTo(pos.x - 4, by + bh);
698
- ctx.lineTo(pos.x, by + bh + 6);
699
- ctx.lineTo(pos.x + 4, by + bh);
700
  ctx.fill();
701
- // Text
702
  ctx.fillStyle = '#1a1a2e';
703
  ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
704
  ctx.fillText(msg, pos.x, by + bh/2);
@@ -706,10 +900,10 @@ function drawConversationBubbles(W, H) {
706
  }
707
 
708
  // ============================================================
709
- // AGENT TARGET COMPUTATION
710
  // ============================================================
711
  function computeAgentTarget(id, agent, globalIdx, byLoc, W, H) {
712
- const loc = agent.location || 'home_north';
713
  const pos = LOCATION_POSITIONS[loc];
714
  if (!pos) return;
715
 
@@ -717,13 +911,25 @@ function computeAgentTarget(id, agent, globalIdx, byLoc, W, H) {
717
  const localIdx = atLoc.findIndex(a => a.id === id);
718
  const count = atLoc.length;
719
 
720
- const radius = 32 + Math.floor(count/6)*14;
721
- const step = Math.PI / Math.max(count+1, 2);
722
- const angle = step * (localIdx+1);
723
- const ox = Math.cos(angle)*radius - radius/2;
724
- const oy = Math.sin(angle)*radius*0.5 + 30;
725
-
726
- agentTargets[id] = { x: pos.x*W + ox, y: pos.y*H + oy };
 
 
 
 
 
 
 
 
 
 
 
 
727
  if (!agentPositions[id]) agentPositions[id] = {...agentTargets[id]};
728
  }
729
 
@@ -868,18 +1074,17 @@ function onCanvasMouseMove(e) {
868
  const W=canvas.width, H=canvas.height;
869
  const tt=document.getElementById('tooltip');
870
 
871
- // Check agents first
872
  let foundAgent=null;
873
  for (const [id,pos] of Object.entries(agentPositions)) {
874
  if(Math.hypot(mx-pos.x,my-pos.y)<22){foundAgent=id;break;}
875
  }
876
 
877
- // Check locations if no agent
878
  let foundLoc=null;
879
  if (!foundAgent) {
880
  for (const [id, pos] of Object.entries(LOCATION_POSITIONS)) {
881
  const lx=pos.x*W, ly=pos.y*H;
882
- if(Math.hypot(mx-lx,my-ly)<35){foundLoc=id;break;}
 
883
  }
884
  }
885
 
@@ -897,7 +1102,6 @@ function onCanvasMouseMove(e) {
897
  }
898
  }
899
 
900
- // Location tooltip
901
  if (!foundAgent && foundLoc && locations[foundLoc]) {
902
  const loc = locations[foundLoc];
903
  const occ = (loc.occupants||[]);
@@ -926,7 +1130,6 @@ function onCanvasMouseMove(e) {
926
  // ============================================================
927
  function showDefaultDetail() {
928
  const el = document.getElementById('agent-detail');
929
- // Sort agents: those in conversation first, then by name
930
  const sorted = Object.entries(agents).sort((a,b) => {
931
  const aConv = a[1].state === 'in_conversation' ? 0 : 1;
932
  const bConv = b[1].state === 'in_conversation' ? 0 : 1;
@@ -1135,9 +1338,7 @@ function processStateData(data) {
1135
  agents = data.agents || {};
1136
 
1137
  const tick = clock.total_ticks || 0;
1138
- if (tick !== lastTick) {
1139
- fetchSecondaryData();
1140
- }
1141
  lastTick = tick;
1142
 
1143
  if (activeTab === 'agents') {
@@ -1147,13 +1348,11 @@ function processStateData(data) {
1147
  }
1148
 
1149
  async function fetchSecondaryData() {
1150
- // Locations
1151
  try {
1152
  const locRes = await fetch(`${API_BASE}/city/locations`);
1153
  if (locRes.ok) locations = await locRes.json();
1154
  } catch(e) {}
1155
 
1156
- // Events
1157
  try {
1158
  const er = await fetch(`${API_BASE}/events`);
1159
  if (er.ok) {
@@ -1164,7 +1363,6 @@ async function fetchSecondaryData() {
1164
  }
1165
  } catch(e) {}
1166
 
1167
- // Conversations
1168
  if (activeTab === 'conversations') {
1169
  fetchConversations();
1170
  } else {
@@ -1204,16 +1402,12 @@ function connectWebSocket() {
1204
  ws.onclose = () => {
1205
  connected = false;
1206
  document.getElementById('status').innerHTML = '<span class="dot red"></span> Disconnected';
1207
- // Retry after 3 seconds
1208
  wsRetryTimer = setTimeout(connectWebSocket, 3000);
1209
  };
1210
 
1211
- ws.onerror = () => {
1212
- ws.close();
1213
- };
1214
  }
1215
 
1216
- // Polling fallback (also used for initial load)
1217
  async function fetchState() {
1218
  try {
1219
  const res = await fetch(`${API_BASE}/city`);
@@ -1297,9 +1491,7 @@ function showToast(message, type='info') {
1297
  toast.className = 'toast ' + type;
1298
  toast.textContent = message;
1299
  container.appendChild(toast);
1300
- // Remove after animation
1301
  setTimeout(() => toast.remove(), 5000);
1302
- // Max 5 toasts at once
1303
  while (container.children.length > 5) container.firstChild.remove();
1304
  }
1305
 
@@ -1309,15 +1501,10 @@ function checkForNotableEvents() {
1309
  lastEventCount = eventLog.length;
1310
 
1311
  for (const msg of newEvents) {
1312
- if (msg.includes('[ROMANCE]')) {
1313
- showToast(msg.replace(/\s*\[ROMANCE\]\s*/, ''), 'romance');
1314
- } else if (msg.includes('[EVENT]') && !msg.includes('Weather')) {
1315
- showToast(msg.replace(/\s*\[EVENT\]\s*/, ''), 'event');
1316
- } else if (msg.includes('[GOSSIP]')) {
1317
- showToast(msg.replace(/\s*\[GOSSIP\]\s*/, ''), 'gossip');
1318
- } else if (msg.includes('[CONV]') && msg.includes('starts talking')) {
1319
- showToast(msg.replace(/\s*\[CONV\]\s*/, ''), 'conv');
1320
- }
1321
  }
1322
  }
1323
 
@@ -1326,10 +1513,9 @@ function checkForNotableEvents() {
1326
  // ============================================================
1327
  initCanvas();
1328
  showDefaultDetail();
1329
- fetchState(); // Initial load via REST
1330
  fetchControls();
1331
- connectWebSocket(); // Try WebSocket for real-time
1332
- // Polling as fallback — only active when WS is disconnected
1333
  setInterval(() => { if (!ws || ws.readyState !== WebSocket.OPEN) fetchState(); }, POLL_INTERVAL);
1334
  </script>
1335
  </body>
 
49
  .tab-content { display: none; flex: 1; overflow-y: auto; }
50
  .tab-content.active { display: flex; flex-direction: column; }
51
 
 
52
  #agent-detail { padding: 12px; flex: 1; overflow-y: auto; }
53
  #agent-detail h2 { font-size: 16px; color: #4ecca3; margin-bottom: 8px; }
54
  #agent-detail .subtitle { font-size: 12px; color: #a0a0c0; margin-bottom: 6px; }
 
72
  .agent-list-item .agent-name { font-weight: 600; }
73
  .agent-list-item .agent-action { color: #666; font-size: 10px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
74
 
 
75
  #conversations-panel { padding: 0; flex: 1; overflow-y: auto; }
76
  .conv-card {
77
  margin: 8px; padding: 10px; background: #1a1a2e; border-radius: 6px;
78
  border: 1px solid #0f3460;
79
  }
80
  .conv-card.active-conv { border-color: #4ecca3; }
81
+ .conv-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 6px; }
 
 
82
  .conv-topic { font-size: 12px; color: #4ecca3; font-weight: 600; }
83
+ .conv-badge { font-size: 9px; padding: 2px 6px; border-radius: 3px; font-weight: bold; text-transform: uppercase; }
 
 
 
84
  .conv-badge.live { background: #4ecca3; color: #1a1a2e; }
85
  .conv-badge.ended { background: #333; color: #888; }
86
  .conv-participants { font-size: 10px; color: #888; margin-bottom: 6px; }
87
+ .conv-turn { padding: 4px 0; font-size: 11px; line-height: 1.4; }
 
 
88
  .conv-speaker { font-weight: 600; }
89
  .conv-message { color: #d0d0e0; }
90
  .conv-empty { padding: 20px; text-align: center; color: #555; font-size: 12px; }
91
 
 
92
  #event-log-panel { padding: 8px 12px; flex: 1; overflow-y: auto; font-size: 11px; line-height: 1.5; }
93
  .event-line { padding: 2px 0; color: #b0b0c0; border-bottom: 1px solid #0f346020; }
94
  .event-line.plan { color: #4ecca3; } .event-line.conv { color: #f0c040; }
 
97
  .event-line.move { color: #4e9eca; }
98
  .event-line.reflect { color: #9b59b6; }
99
 
 
100
  #tooltip {
101
  position: absolute; background: #16213eee; border: 1px solid #4ecca3;
102
  border-radius: 6px; padding: 8px 12px; font-size: 12px;
103
  pointer-events: none; display: none; z-index: 100; max-width: 280px;
104
  }
 
105
  #toast-container {
106
  position: absolute; bottom: 12px; left: 12px; z-index: 200;
107
  display: flex; flex-direction: column-reverse; gap: 6px;
 
119
  .toast.gossip { border-left-color: #9b59b6; }
120
  @keyframes toastIn { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: translateY(0); } }
121
  @keyframes toastOut { from { opacity: 1; } to { opacity: 0; } }
 
122
  .section-header {
123
  font-size: 12px; color: #4ecca3; margin: 10px 0 4px 0; font-weight: 600;
124
  display: flex; align-items: center; gap: 6px;
125
  }
126
+ .section-header::after { content: ''; flex: 1; height: 1px; background: #0f3460; }
127
+ .rel-item { font-size: 11px; padding: 4px 0; border-bottom: 1px solid #0f346020; cursor: pointer; }
 
 
 
 
 
128
  .rel-item:hover { background: rgba(78,204,163,0.05); }
129
  .rel-name { font-weight: 600; color: #d0d0e0; }
130
  .rel-bars { display: flex; gap: 4px; margin-top: 2px; }
131
  .rel-mini-bar { flex: 1; height: 4px; background: #0f3460; border-radius: 2px; overflow: hidden; }
132
  .rel-mini-fill { height: 100%; border-radius: 2px; }
133
 
 
134
  .controls { display: flex; align-items: center; gap: 4px; }
135
  .ctrl-btn {
136
  background: #0f3460; border: 1px solid #1a3a6e; color: #a0a0c0;
 
196
  // ============================================================
197
  const API_BASE = window.location.origin + '/api';
198
  const POLL_INTERVAL = 2000;
199
+ const HORIZON = 0.14;
200
+
201
+ // --- CITY LAYOUT ---
202
+ // Road network: main horizontal roads and connecting vertical roads
203
+ const ROADS = [
204
+ // Horizontal roads
205
+ { x1: 0.04, y1: 0.30, x2: 0.96, y2: 0.30, width: 14, name: 'North Road' },
206
+ { x1: 0.04, y1: 0.65, x2: 0.96, y2: 0.65, width: 14, name: 'South Road' },
207
+ // Main vertical street
208
+ { x1: 0.50, y1: 0.17, x2: 0.50, y2: 0.92, width: 16, name: 'Main Street' },
209
+ // West lane
210
+ { x1: 0.18, y1: 0.24, x2: 0.18, y2: 0.85, width: 8 },
211
+ // East lane
212
+ { x1: 0.82, y1: 0.24, x2: 0.82, y2: 0.85, width: 8 },
213
+ // Small connector roads
214
+ { x1: 0.18, y1: 0.48, x2: 0.50, y2: 0.48, width: 6 },
215
+ { x1: 0.50, y1: 0.48, x2: 0.82, y2: 0.48, width: 6 },
216
+ { x1: 0.18, y1: 0.80, x2: 0.50, y2: 0.80, width: 6 },
217
+ { x1: 0.50, y1: 0.80, x2: 0.82, y2: 0.80, width: 6 },
218
+ ];
219
 
220
+ // Building positions — placed along streets
221
  const LOCATION_POSITIONS = {
222
+ // North-side houses (above North Road)
223
+ house_elena: { x: 0.10, y: 0.21, type: 'house', label: 'Elena & Lila' },
224
+ house_marcus: { x: 0.30, y: 0.21, type: 'house', label: 'Marcus & Zoe' },
225
+ house_helen: { x: 0.70, y: 0.21, type: 'house', label: 'Helen & Alice' },
226
+ house_diana: { x: 0.90, y: 0.21, type: 'house', label: 'Diana & Marco' },
227
+ // Middle houses
228
+ house_kai: { x: 0.10, y: 0.40, type: 'house', label: "Kai's Studio" },
229
+ house_priya: { x: 0.90, y: 0.40, type: 'house', label: 'Priya & Nina' },
230
+ // South-side houses (below South Road)
231
+ house_james: { x: 0.10, y: 0.72, type: 'house', label: 'James & Theo' },
232
+ house_rosa: { x: 0.30, y: 0.72, type: 'house', label: 'Rosa & Omar' },
233
+ house_yuki: { x: 0.70, y: 0.72, type: 'house', label: 'Yuki & Devon' },
234
+ house_frank: { x: 0.90, y: 0.72, type: 'house', label: 'Frank+George+Sam' },
235
+ // Commercial — along main street
236
+ cafe: { x: 0.38, y: 0.35, type: 'shop', label: 'The Daily Grind' },
237
+ grocery: { x: 0.62, y: 0.35, type: 'shop', label: 'Green Basket' },
238
+ office: { x: 0.50, y: 0.54, type: 'office', label: 'The Hive' },
239
+ restaurant: { x: 0.38, y: 0.58, type: 'shop', label: "Mama Rosa's" },
240
+ bar: { x: 0.62, y: 0.58, type: 'shop', label: 'Rusty Anchor' },
241
+ // Public
242
+ park: { x: 0.50, y: 0.22, type: 'park', label: 'Willow Park' },
243
+ gym: { x: 0.30, y: 0.54, type: 'public', label: 'Iron & Grit' },
244
+ library: { x: 0.70, y: 0.54, type: 'public', label: 'Public Library' },
245
+ // Streets
246
+ street_north: { x: 0.50, y: 0.30, type: 'street', label: 'N. Main St' },
247
+ street_south: { x: 0.50, y: 0.65, type: 'street', label: 'S. Main St' },
248
  };
249
 
250
  const AGENT_COLORS = [
 
295
  let clouds = [];
296
  let stars = [];
297
  let activeTab = 'agents';
298
+ let agentIdxMap = {};
299
+
300
+ // Tree / decorations cache
301
+ let trees = [];
302
+ let streetLamps = [];
303
 
304
  function initParticles() {
305
  for (let i = 0; i < 120; i++)
306
  raindrops.push({x:Math.random(), y:Math.random(), speed:0.012+Math.random()*0.015, len:8+Math.random()*10});
307
  for (let i = 0; i < 5; i++)
308
+ clouds.push({x:Math.random(), y:0.02+Math.random()*0.08, w:60+Math.random()*50, speed:0.00005+Math.random()*0.0001});
309
  for (let i = 0; i < 70; i++)
310
  stars.push({x:Math.random(), y:Math.random()*HORIZON, size:0.5+Math.random()*1.5, tw:Math.random()*6.28});
311
+ // Scatter trees in green spaces between buildings
312
+ const treeZones = [
313
+ {cx:0.50, cy:0.22, rx:0.12, ry:0.04, count:8}, // park
314
+ {cx:0.10, cy:0.55, rx:0.04, ry:0.06, count:3}, // west green
315
+ {cx:0.90, cy:0.55, rx:0.04, ry:0.06, count:3}, // east green
316
+ {cx:0.50, cy:0.88, rx:0.15, ry:0.03, count:5}, // south park
317
+ ];
318
+ for (const z of treeZones) {
319
+ for (let i = 0; i < z.count; i++) {
320
+ trees.push({
321
+ x: z.cx + (Math.random()-0.5)*2*z.rx,
322
+ y: z.cy + (Math.random()-0.5)*2*z.ry,
323
+ size: 6 + Math.random()*5,
324
+ type: Math.random() > 0.3 ? 'round' : 'pine',
325
+ });
326
+ }
327
+ }
328
+ // Street lamps along roads
329
+ for (let i = 0; i < 8; i++) streetLamps.push({ x: 0.50, y: 0.20 + i*0.09 });
330
+ for (let i = 0; i < 4; i++) streetLamps.push({ x: 0.10 + i*0.26, y: 0.30 });
331
+ for (let i = 0; i < 4; i++) streetLamps.push({ x: 0.10 + i*0.26, y: 0.65 });
332
  }
333
 
334
  // ============================================================
 
342
  }
343
 
344
  // ============================================================
345
+ // CANVAS SETUP
346
  // ============================================================
347
  function initCanvas() {
348
  canvas = document.getElementById('cityCanvas');
 
373
  }
374
 
375
  // ============================================================
376
+ // MAIN DRAW
377
  // ============================================================
378
  function draw() {
379
  if (!ctx) return;
 
383
  drawGround(W, H);
384
  drawWeather(W, H);
385
  drawRoads(W, H);
386
+ drawSidewalks(W, H);
387
+ drawTrees(W, H);
388
 
389
+ // Draw buildings (non-street, non-park locations)
390
+ for (const [id, pos] of Object.entries(LOCATION_POSITIONS)) {
391
+ if (pos.type !== 'street') drawBuilding(id, pos, W, H);
392
+ }
393
+
394
+ drawStreetLamps(W, H);
395
 
396
+ // Compute agent positions
397
  const byLoc = {};
398
  for (const [id, a] of Object.entries(agents)) {
399
+ const loc = a.location || 'house_elena';
400
  if (!byLoc[loc]) byLoc[loc] = [];
401
  byLoc[loc].push({id, ...a});
402
  }
 
418
  }
419
 
420
  function getAgentIdx(id) {
421
+ if (!(id in agentIdxMap)) agentIdxMap[id] = Object.keys(agentIdxMap).length;
 
 
422
  return agentIdxMap[id];
423
  }
424
 
 
451
  const glow = ctx.createRadialGradient(x, y, r*0.5, x, y, r*4);
452
  glow.addColorStop(0, 'rgba(255,220,100,0.35)');
453
  glow.addColorStop(1, 'rgba(255,220,100,0)');
454
+ ctx.fillStyle = glow; ctx.fillRect(x-r*4, y-r*4, r*8, r*8);
 
455
  ctx.save(); ctx.translate(x, y); ctx.rotate(animFrame*0.005);
456
  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); }
457
  ctx.restore();
 
485
  ctx.fillRect(0, hLine, W, H - hLine);
486
  }
487
 
488
+ // Grass texture
489
+ ctx.fillStyle = `rgba(${60+bc.r*0.3},${90+bc.g*0.3},${40+bc.b*0.2}, 0.12)`;
490
+ for (let i = 0; i < 100; i++) {
491
  const gx = (i*37+13)%W;
492
  const gy = hLine + 10 + ((i*53+7)%(H-hLine-15));
493
  ctx.fillRect(gx, gy, 2, 3);
 
542
  }
543
 
544
  // ============================================================
545
+ // ROADS — drawn as realistic asphalt with lane markings
546
  // ============================================================
547
  function drawRoads(W, H) {
548
+ const isDark = currentTimeOfDay==='night'||currentTimeOfDay==='evening';
549
+ const asphalt = isDark ? '#2a2820' : '#6b6560';
550
+ const asphaltEdge = isDark ? '#1a1815' : '#555048';
551
+
552
+ for (const road of ROADS) {
553
+ const x1=road.x1*W, y1=road.y1*H, x2=road.x2*W, y2=road.y2*H;
554
+ const w = road.width || 10;
555
+ const dx = x2-x1, dy = y2-y1;
556
+ const len = Math.hypot(dx, dy);
557
+ const nx = -dy/len * w/2, ny = dx/len * w/2;
558
+
559
+ // Road surface
560
+ ctx.fillStyle = asphalt;
561
+ ctx.beginPath();
562
+ ctx.moveTo(x1+nx, y1+ny);
563
+ ctx.lineTo(x2+nx, y2+ny);
564
+ ctx.lineTo(x2-nx, y2-ny);
565
+ ctx.lineTo(x1-nx, y1-ny);
566
+ ctx.closePath();
567
+ ctx.fill();
568
+
569
+ // Edge lines
570
+ ctx.strokeStyle = asphaltEdge;
571
+ ctx.lineWidth = 1;
572
+ ctx.beginPath(); ctx.moveTo(x1+nx,y1+ny); ctx.lineTo(x2+nx,y2+ny); ctx.stroke();
573
+ ctx.beginPath(); ctx.moveTo(x1-nx,y1-ny); ctx.lineTo(x2-nx,y2-ny); ctx.stroke();
574
+
575
+ // Center dashed line (for wider roads)
576
+ if (w >= 12) {
577
+ ctx.strokeStyle = isDark ? 'rgba(200,180,80,0.25)' : 'rgba(255,220,100,0.6)';
578
+ ctx.lineWidth = 1.5;
579
+ ctx.setLineDash([8, 10]);
580
+ ctx.beginPath();
581
+ ctx.moveTo((x1+x2)/2 - (x2-x1)*0.48 + (x2-x1)*0.02, (y1+y2)/2 - (y2-y1)*0.48 + (y2-y1)*0.02);
582
+ ctx.moveTo(x1, y1); ctx.lineTo(x2, y2);
583
+ ctx.stroke();
584
+ ctx.setLineDash([]);
585
+ }
586
+ }
587
+ }
588
+
589
+ // Sidewalks next to roads
590
+ function drawSidewalks(W, H) {
591
+ const isDark = currentTimeOfDay==='night';
592
+ ctx.fillStyle = isDark ? 'rgba(90,85,75,0.3)' : 'rgba(180,175,160,0.35)';
593
+
594
+ for (const road of ROADS) {
595
+ const x1=road.x1*W, y1=road.y1*H, x2=road.x2*W, y2=road.y2*H;
596
+ const w = (road.width || 10) + 6;
597
+ const dx = x2-x1, dy = y2-y1;
598
+ const len = Math.hypot(dx, dy);
599
+ const nx = -dy/len * w/2, ny = dx/len * w/2;
600
+ const sw = 3; // sidewalk width
601
+
602
+ // Draw thin sidewalk strips
603
+ ctx.fillRect(
604
+ Math.min(x1+nx, x2+nx) - sw/2,
605
+ Math.min(y1+ny, y2+ny) - sw/2,
606
+ Math.abs(x2+nx - x1-nx) + sw,
607
+ Math.abs(y2+ny - y1-ny) + sw
608
+ );
609
+ }
610
+ }
611
+
612
+ // ============================================================
613
+ // TREES
614
+ // ============================================================
615
+ function drawTrees(W, H) {
616
+ const isDark = currentTimeOfDay==='night'||currentTimeOfDay==='evening';
617
+ for (const t of trees) {
618
+ const tx = t.x * W, ty = t.y * H;
619
+ // Trunk
620
+ ctx.fillStyle = isDark ? '#3a2a15' : '#6b4226';
621
+ ctx.fillRect(tx-2, ty-2, 4, 10);
622
+ // Canopy
623
+ if (t.type === 'pine') {
624
+ ctx.fillStyle = isDark ? '#1a3a18' : '#2a7a28';
625
+ ctx.beginPath(); ctx.moveTo(tx, ty - t.size - 6); ctx.lineTo(tx - t.size*0.6, ty); ctx.lineTo(tx + t.size*0.6, ty); ctx.closePath(); ctx.fill();
626
+ ctx.fillStyle = isDark ? '#1e4a1c' : '#3a8a30';
627
+ ctx.beginPath(); ctx.moveTo(tx, ty - t.size - 2); ctx.lineTo(tx - t.size*0.4, ty - 3); ctx.lineTo(tx + t.size*0.4, ty - 3); ctx.closePath(); ctx.fill();
628
+ } else {
629
+ ctx.fillStyle = isDark ? '#1a4018' : '#3a8a30';
630
+ ctx.beginPath(); ctx.arc(tx, ty - t.size*0.5, t.size*0.7, 0, 6.28); ctx.fill();
631
+ ctx.fillStyle = isDark ? '#2a5028' : '#55aa45';
632
+ ctx.beginPath(); ctx.arc(tx - 2, ty - t.size*0.6, t.size*0.35, 0, 6.28); ctx.fill();
633
  }
634
  }
635
+ }
636
+
637
+ // ============================================================
638
+ // STREET LAMPS
639
+ // ============================================================
640
+ function drawStreetLamps(W, H) {
641
+ const isDark = currentTimeOfDay==='night'||currentTimeOfDay==='evening';
642
+ for (const l of streetLamps) {
643
+ const lx = l.x*W + 12, ly = l.y*H;
644
+ ctx.fillStyle = '#555'; ctx.fillRect(lx-1, ly-14, 2, 14);
645
+ ctx.fillStyle = '#666'; ctx.fillRect(lx-3, ly-16, 6, 3);
646
+ if (isDark) {
647
+ const glow = ctx.createRadialGradient(lx, ly-14, 2, lx, ly-14, 30);
648
+ glow.addColorStop(0, 'rgba(255,220,130,0.25)');
649
+ glow.addColorStop(1, 'rgba(255,220,130,0)');
650
+ ctx.fillStyle = glow;
651
+ ctx.fillRect(lx-30, ly-44, 60, 60);
652
+ ctx.fillStyle = 'rgba(255,220,130,0.8)';
653
+ ctx.beginPath(); ctx.arc(lx, ly-14, 2, 0, 6.28); ctx.fill();
654
  }
655
  }
 
656
  }
657
 
658
  // ============================================================
659
  // BUILDINGS
660
  // ============================================================
661
+ function drawBuilding(id, pos, W, H) {
662
+ const x = pos.x*W, y = pos.y*H;
 
 
 
 
663
  const isDark = currentTimeOfDay==='night'||currentTimeOfDay==='evening';
664
+ const loc = locations[id] || {};
665
+ const occ = (loc.occupants || []).length;
666
+
667
+ if (pos.type === 'house') drawHouse(x, y, isDark, id);
668
+ else if (pos.type === 'shop') drawShop(x, y, isDark);
669
+ else if (pos.type === 'office') drawOffice(x, y, isDark);
670
+ else if (pos.type === 'park') drawPark(x, y, isDark);
671
+ else if (pos.type === 'public') drawPublicBuilding(x, y, isDark);
672
+
673
+ // Label
674
+ if (pos.type !== 'park') {
675
+ const label = pos.label || id;
676
+ const short = label.length > 16 ? label.slice(0, 14) + '..' : label;
677
+ ctx.font = 'bold 9px Segoe UI'; ctx.textAlign = 'center'; ctx.textBaseline = 'top';
678
+ const ly = pos.type === 'house' ? y + 18 : y + 25;
679
+ ctx.fillStyle = 'rgba(0,0,0,0.5)'; ctx.fillText(short, x+1, ly+1);
680
+ ctx.fillStyle = isDark ? '#a0a8c0' : '#fff'; ctx.fillText(short, x, ly);
681
  }
682
 
683
+ // Occupant count badge
684
+ if (occ > 0) {
685
+ const bx = x + (pos.type === 'house' ? 22 : 28), by = y - (pos.type === 'house' ? 14 : 18);
686
+ ctx.fillStyle = '#e94560'; ctx.beginPath(); ctx.arc(bx, by, 8, 0, 6.28); ctx.fill();
687
+ ctx.fillStyle = '#fff'; ctx.font = 'bold 8px Segoe UI'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
688
+ ctx.fillText(occ.toString(), bx, by);
689
  }
690
  }
691
 
692
+ function drawHouse(x, y, dk, id) {
693
+ const w = 36, h = 24;
694
+ // Vary house colors by position for variety
695
+ const hues = [
696
+ {wall:'#c8a882', roof:'#8b4513', door:'#6b3410'},
697
+ {wall:'#a0b8a0', roof:'#4a6a4a', door:'#3a4a3a'},
698
+ {wall:'#b8a0a0', roof:'#7a3a3a', door:'#5a2a2a'},
699
+ {wall:'#a0a8c0', roof:'#4a5a7a', door:'#3a4a6a'},
700
+ {wall:'#c8b878', roof:'#8a7a40', door:'#6a5a30'},
701
+ ];
702
+ const idx = Object.keys(LOCATION_POSITIONS).indexOf(id) % hues.length;
703
+ const c = hues[idx];
704
+
705
+ // Wall
706
+ ctx.fillStyle = dk ? dim(c.wall, 0.4) : c.wall;
707
+ ctx.fillRect(x-w/2, y-h/2, w, h);
708
+ // Roof
709
+ ctx.fillStyle = dk ? dim(c.roof, 0.4) : c.roof;
710
+ ctx.beginPath();
711
+ ctx.moveTo(x-w/2-4, y-h/2);
712
+ ctx.lineTo(x, y-h/2-14);
713
+ ctx.lineTo(x+w/2+4, y-h/2);
714
+ ctx.closePath();
715
+ ctx.fill();
716
+ // Door
717
+ ctx.fillStyle = dk ? dim(c.door, 0.4) : c.door;
718
+ ctx.fillRect(x-3, y+h/2-10, 6, 10);
719
+ // Doorknob
720
+ ctx.fillStyle = dk ? '#aa9060' : '#d4b070';
721
+ ctx.beginPath(); ctx.arc(x+2, y+h/2-4, 1, 0, 6.28); ctx.fill();
722
+ // Windows (lit at night)
723
+ const wc = dk ? 'rgba(255,210,100,0.75)' : 'rgba(180,220,255,0.55)';
724
+ ctx.fillStyle = wc;
725
+ ctx.fillRect(x-w/2+4, y-h/2+4, 8, 6);
726
+ ctx.fillRect(x+w/2-12, y-h/2+4, 8, 6);
727
+ // Window frames
728
+ ctx.strokeStyle = dk ? 'rgba(100,80,50,0.4)' : 'rgba(80,70,60,0.3)';
729
+ ctx.lineWidth = 0.5;
730
+ ctx.strokeRect(x-w/2+4, y-h/2+4, 8, 6);
731
+ ctx.strokeRect(x+w/2-12, y-h/2+4, 8, 6);
732
+ // Chimney
733
+ ctx.fillStyle = dk ? dim(c.roof, 0.35) : dim(c.roof, 0.8);
734
+ ctx.fillRect(x+8, y-h/2-12, 5, 8);
735
+ // Small garden/fence
736
+ ctx.strokeStyle = dk ? 'rgba(80,60,40,0.3)' : 'rgba(120,90,60,0.4)';
737
+ ctx.lineWidth = 1;
738
+ ctx.strokeRect(x-w/2-2, y+h/2, w+4, 4);
739
  }
740
 
741
+ function drawShop(x, y, dk) {
742
+ const w = 48, h = 32;
743
+ ctx.fillStyle = dk ? '#3a3530' : '#d4c4a8';
744
+ ctx.fillRect(x-w/2, y-h/2, w, h);
745
+ // Awning
746
+ const awningColors = ['#c44', '#4a8', '#48a', '#a84'];
747
+ const ci = Math.floor(x*7 + y*3) % awningColors.length;
748
+ ctx.fillStyle = dk ? dim(awningColors[ci], 0.35) : awningColors[ci];
749
+ ctx.beginPath();
750
+ ctx.moveTo(x-w/2-3, y-h/2);
751
+ ctx.lineTo(x-w/2-6, y-h/2+12);
752
+ ctx.lineTo(x+w/2+6, y-h/2+12);
753
+ ctx.lineTo(x+w/2+3, y-h/2);
754
+ ctx.closePath();
755
+ ctx.fill();
756
+ // Awning stripes
757
+ ctx.strokeStyle = dk ? 'rgba(255,255,255,0.08)' : 'rgba(255,255,255,0.25)';
758
+ ctx.lineWidth = 1;
759
+ for (let i = 0; i < 5; i++) {
760
+ const sx = x-w/2 + i*(w/4);
761
+ ctx.beginPath(); ctx.moveTo(sx, y-h/2); ctx.lineTo(sx-1.5, y-h/2+12); ctx.stroke();
762
+ }
763
+ // Door
764
+ ctx.fillStyle = dk ? '#2a2520' : '#5a4a3a';
765
+ ctx.fillRect(x-4, y+h/2-12, 8, 12);
766
+ // Windows
767
+ const wc = dk ? 'rgba(255,210,100,0.65)' : 'rgba(200,230,255,0.55)';
768
+ ctx.fillStyle = wc;
769
+ ctx.fillRect(x-w/2+4, y-h/2+14, w/2-10, 10);
770
+ ctx.fillRect(x+5, y-h/2+14, w/2-10, 10);
771
  }
772
 
773
+ function drawOffice(x, y, dk) {
774
+ const w = 50, h = 40;
775
+ ctx.fillStyle = dk ? '#2a2a35' : '#8090a8';
776
+ ctx.fillRect(x-w/2, y-h/2, w, h);
777
+ // Flat roof edge
778
+ ctx.fillStyle = dk ? '#1a1a25' : '#607080';
779
+ ctx.fillRect(x-w/2-2, y-h/2-3, w+4, 5);
780
+ // Door
781
+ ctx.fillStyle = dk ? '#1a1a25' : '#4a5a6a';
782
+ ctx.fillRect(x-4, y+h/2-12, 8, 12);
783
+ // Windows grid
784
+ const wc = dk ? 'rgba(255,210,100,0.6)' : 'rgba(180,220,255,0.5)';
785
+ ctx.fillStyle = wc;
786
+ for (let r = 0; r < 3; r++) {
787
+ for (let c = 0; c < 3; c++) {
788
+ ctx.fillRect(x-w/2+6+c*15, y-h/2+5+r*10, 9, 6);
789
+ }
790
+ }
791
  }
792
 
793
+ function drawPublicBuilding(x, y, dk) {
794
+ const w = 46, h = 34;
795
+ ctx.fillStyle = dk ? '#2a3525' : '#7a9a6a';
796
+ ctx.fillRect(x-w/2, y-h/2, w, h);
797
+ ctx.fillStyle = dk ? '#1a2a18' : '#5a7a4a';
798
+ ctx.fillRect(x-w/2-2, y-h/2-3, w+4, 5);
799
+ ctx.fillStyle = dk ? '#1a2018' : '#4a6a3a';
800
+ ctx.fillRect(x-4, y+h/2-12, 8, 12);
801
+ const wc = dk ? 'rgba(255,210,100,0.55)' : 'rgba(200,240,200,0.5)';
802
+ ctx.fillStyle = wc;
803
+ for (let c = 0; c < 4; c++) ctx.fillRect(x-w/2+4+c*11, y-h/2+6, 8, 8);
804
+ for (let c = 0; c < 4; c++) ctx.fillRect(x-w/2+4+c*11, y-h/2+18, 8, 8);
 
 
805
  }
806
 
807
+ function drawPark(x, y, dk) {
808
+ // Large green area
809
+ ctx.fillStyle = dk ? '#1a3018' : '#4a9040';
810
+ ctx.beginPath(); ctx.ellipse(x, y, 55, 25, 0, 0, 6.28); ctx.fill();
811
+ ctx.strokeStyle = dk ? '#2a4a25' : '#6ab850';
812
+ ctx.lineWidth = 2; ctx.stroke();
813
+ // Pond
814
+ ctx.fillStyle = dk ? '#1a2a3a' : '#5a9ac0';
815
+ ctx.beginPath(); ctx.ellipse(x+15, y+5, 12, 6, 0.2, 0, 6.28); ctx.fill();
816
+ ctx.strokeStyle = dk ? '#2a3a4a' : '#80b0d0'; ctx.lineWidth = 1; ctx.stroke();
817
+ // Benches
818
+ ctx.fillStyle = dk ? '#3a2a15' : '#7a5a30';
819
+ ctx.fillRect(x-20, y+8, 14, 3);
820
+ ctx.fillRect(x-18, y+11, 2, 3);
821
+ ctx.fillRect(x-8, y+11, 2, 3);
822
+ // Trees in park (extra)
823
+ for (let i = -1; i <= 1; i++) {
824
+ const tx = x + i*22;
825
+ ctx.fillStyle = dk ? '#3a2a15' : '#6b4226'; ctx.fillRect(tx-2, y-4, 4, 10);
826
+ ctx.fillStyle = dk ? '#1a4a18' : '#3a8a30'; ctx.beginPath(); ctx.arc(tx, y-10, 8+Math.abs(i)*2, 0, 6.28); ctx.fill();
827
+ ctx.fillStyle = dk ? '#2a5a28' : '#55aa45'; ctx.beginPath(); ctx.arc(tx-2, y-12, 4, 0, 6.28); ctx.fill();
828
+ }
829
+ // Label
830
+ ctx.font = 'bold 10px Segoe UI'; ctx.textAlign = 'center'; ctx.textBaseline = 'top';
831
+ ctx.fillStyle = 'rgba(0,0,0,0.5)'; ctx.fillText('Willow Park', x+1, y+19);
832
+ ctx.fillStyle = dk ? '#90a880' : '#fff'; ctx.fillText('Willow Park', x, y+18);
833
  }
834
 
835
  function dim(hex, f) {
836
+ if (hex.startsWith('rgb')) return hex; // already rgb
837
  const r=parseInt(hex.slice(1,3),16), g=parseInt(hex.slice(3,5),16), b=parseInt(hex.slice(5,7),16);
838
  return `rgb(${~~(r*f)},${~~(g*f)},${~~(b*f)})`;
839
  }
840
 
841
  // ============================================================
842
+ // COUPLE LINES
843
  // ============================================================
844
  function drawCoupleLines(W, H) {
845
  const drawn = new Set();
 
871
  }
872
 
873
  // ============================================================
874
+ // CONVERSATION BUBBLES
875
  // ============================================================
876
  function drawConversationBubbles(W, H) {
877
  if (!conversationData.active) return;
 
888
  const bw = tw + 14, bh = 18;
889
  const bx = pos.x - bw/2, by = pos.y - 48;
890
 
 
891
  ctx.fillStyle = 'rgba(240,192,64,0.9)';
892
+ ctx.beginPath(); ctx.roundRect(bx, by, bw, bh, 4); ctx.fill();
893
  ctx.beginPath();
894
+ ctx.moveTo(pos.x - 4, by + bh); ctx.lineTo(pos.x, by + bh + 6); ctx.lineTo(pos.x + 4, by + bh);
 
 
 
 
 
 
895
  ctx.fill();
 
896
  ctx.fillStyle = '#1a1a2e';
897
  ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
898
  ctx.fillText(msg, pos.x, by + bh/2);
 
900
  }
901
 
902
  // ============================================================
903
+ // AGENT POSITIONS
904
  // ============================================================
905
  function computeAgentTarget(id, agent, globalIdx, byLoc, W, H) {
906
+ const loc = agent.location || 'house_elena';
907
  const pos = LOCATION_POSITIONS[loc];
908
  if (!pos) return;
909
 
 
911
  const localIdx = atLoc.findIndex(a => a.id === id);
912
  const count = atLoc.length;
913
 
914
+ // For streets, spread agents along the road
915
+ if (pos.type === 'street') {
916
+ const spread = Math.min(count, 10);
917
+ const step = 0.04;
918
+ const startX = pos.x - (spread-1)*step/2;
919
+ agentTargets[id] = {
920
+ x: (startX + localIdx * step) * W,
921
+ y: pos.y * H + 16 + (localIdx % 2) * 12
922
+ };
923
+ } else {
924
+ // Cluster around building
925
+ const radius = pos.type === 'house' ? 18 + Math.floor(count/3)*8 : 28 + Math.floor(count/5)*10;
926
+ const step = Math.PI / Math.max(count+1, 2);
927
+ const angle = step * (localIdx+1);
928
+ const ox = Math.cos(angle)*radius - radius/3;
929
+ const oy = Math.sin(angle)*radius*0.5 + (pos.type === 'house' ? 22 : 30);
930
+
931
+ agentTargets[id] = { x: pos.x*W + ox, y: pos.y*H + oy };
932
+ }
933
  if (!agentPositions[id]) agentPositions[id] = {...agentTargets[id]};
934
  }
935
 
 
1074
  const W=canvas.width, H=canvas.height;
1075
  const tt=document.getElementById('tooltip');
1076
 
 
1077
  let foundAgent=null;
1078
  for (const [id,pos] of Object.entries(agentPositions)) {
1079
  if(Math.hypot(mx-pos.x,my-pos.y)<22){foundAgent=id;break;}
1080
  }
1081
 
 
1082
  let foundLoc=null;
1083
  if (!foundAgent) {
1084
  for (const [id, pos] of Object.entries(LOCATION_POSITIONS)) {
1085
  const lx=pos.x*W, ly=pos.y*H;
1086
+ const hitR = pos.type === 'house' ? 25 : 35;
1087
+ if(Math.hypot(mx-lx,my-ly)<hitR){foundLoc=id;break;}
1088
  }
1089
  }
1090
 
 
1102
  }
1103
  }
1104
 
 
1105
  if (!foundAgent && foundLoc && locations[foundLoc]) {
1106
  const loc = locations[foundLoc];
1107
  const occ = (loc.occupants||[]);
 
1130
  // ============================================================
1131
  function showDefaultDetail() {
1132
  const el = document.getElementById('agent-detail');
 
1133
  const sorted = Object.entries(agents).sort((a,b) => {
1134
  const aConv = a[1].state === 'in_conversation' ? 0 : 1;
1135
  const bConv = b[1].state === 'in_conversation' ? 0 : 1;
 
1338
  agents = data.agents || {};
1339
 
1340
  const tick = clock.total_ticks || 0;
1341
+ if (tick !== lastTick) fetchSecondaryData();
 
 
1342
  lastTick = tick;
1343
 
1344
  if (activeTab === 'agents') {
 
1348
  }
1349
 
1350
  async function fetchSecondaryData() {
 
1351
  try {
1352
  const locRes = await fetch(`${API_BASE}/city/locations`);
1353
  if (locRes.ok) locations = await locRes.json();
1354
  } catch(e) {}
1355
 
 
1356
  try {
1357
  const er = await fetch(`${API_BASE}/events`);
1358
  if (er.ok) {
 
1363
  }
1364
  } catch(e) {}
1365
 
 
1366
  if (activeTab === 'conversations') {
1367
  fetchConversations();
1368
  } else {
 
1402
  ws.onclose = () => {
1403
  connected = false;
1404
  document.getElementById('status').innerHTML = '<span class="dot red"></span> Disconnected';
 
1405
  wsRetryTimer = setTimeout(connectWebSocket, 3000);
1406
  };
1407
 
1408
+ ws.onerror = () => { ws.close(); };
 
 
1409
  }
1410
 
 
1411
  async function fetchState() {
1412
  try {
1413
  const res = await fetch(`${API_BASE}/city`);
 
1491
  toast.className = 'toast ' + type;
1492
  toast.textContent = message;
1493
  container.appendChild(toast);
 
1494
  setTimeout(() => toast.remove(), 5000);
 
1495
  while (container.children.length > 5) container.firstChild.remove();
1496
  }
1497
 
 
1501
  lastEventCount = eventLog.length;
1502
 
1503
  for (const msg of newEvents) {
1504
+ if (msg.includes('[ROMANCE]')) showToast(msg.replace(/\s*\[ROMANCE\]\s*/, ''), 'romance');
1505
+ else if (msg.includes('[EVENT]') && !msg.includes('Weather')) showToast(msg.replace(/\s*\[EVENT\]\s*/, ''), 'event');
1506
+ else if (msg.includes('[GOSSIP]')) showToast(msg.replace(/\s*\[GOSSIP\]\s*/, ''), 'gossip');
1507
+ else if (msg.includes('[CONV]') && msg.includes('starts talking')) showToast(msg.replace(/\s*\[CONV\]\s*/, ''), 'conv');
 
 
 
 
 
1508
  }
1509
  }
1510
 
 
1513
  // ============================================================
1514
  initCanvas();
1515
  showDefaultDetail();
1516
+ fetchState();
1517
  fetchControls();
1518
+ connectWebSocket();
 
1519
  setInterval(() => { if (!ws || ws.readyState !== WebSocket.OPEN) fetchState(); }, POLL_INTERVAL);
1520
  </script>
1521
  </body>