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 +92 -26
- config/personas.yaml +277 -260
- src/soci/api/routes.py +1 -1
- src/soci/engine/llm.py +12 -13
- test_simulation.py +9 -9
- web/index.html +395 -209
config/city.yaml
CHANGED
|
@@ -1,91 +1,157 @@
|
|
| 1 |
name: Soci City
|
| 2 |
|
| 3 |
locations:
|
| 4 |
-
#
|
| 5 |
-
|
| 6 |
-
|
|
|
|
|
|
|
| 7 |
zone: residential
|
| 8 |
-
description: A
|
| 9 |
-
capacity:
|
| 10 |
-
connected_to: [
|
| 11 |
|
| 12 |
-
- id:
|
| 13 |
-
name:
|
| 14 |
zone: residential
|
| 15 |
-
description: A
|
| 16 |
-
capacity:
|
| 17 |
-
connected_to: [
|
| 18 |
|
| 19 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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: [
|
| 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: [
|
| 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: [
|
| 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
|
| 47 |
|
| 48 |
-
#
|
|
|
|
|
|
|
| 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 |
-
#
|
|
|
|
|
|
|
| 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: [
|
| 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.
|
| 68 |
capacity: 12
|
| 69 |
-
connected_to: [
|
| 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: [
|
| 77 |
|
| 78 |
-
#
|
|
|
|
|
|
|
| 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: [
|
| 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: [
|
|
|
|
| 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 |
-
#
|
| 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:
|
| 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:
|
| 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:
|
| 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:
|
| 96 |
work_location: cafe
|
| 97 |
llm_temperature: 0.9
|
| 98 |
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
|
|
|
| 102 |
gender: female
|
| 103 |
-
occupation:
|
| 104 |
-
openness:
|
| 105 |
conscientiousness: 9
|
| 106 |
extraversion: 5
|
| 107 |
-
agreeableness:
|
| 108 |
-
neuroticism:
|
| 109 |
background: >-
|
| 110 |
-
|
| 111 |
-
She
|
| 112 |
-
She's
|
| 113 |
-
|
|
|
|
| 114 |
quirks:
|
| 115 |
-
-
|
| 116 |
-
-
|
| 117 |
-
-
|
| 118 |
-
communication_style:
|
| 119 |
-
home_location:
|
| 120 |
-
work_location:
|
| 121 |
-
llm_temperature: 0.
|
| 122 |
|
| 123 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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:
|
| 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
|
|
|
|
| 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:
|
| 241 |
work_location: office
|
| 242 |
llm_temperature: 0.4
|
| 243 |
|
| 244 |
-
#
|
| 245 |
-
- id:
|
| 246 |
-
name:
|
| 247 |
-
age:
|
| 248 |
gender: female
|
| 249 |
-
occupation:
|
| 250 |
-
openness:
|
| 251 |
conscientiousness: 9
|
| 252 |
-
extraversion:
|
| 253 |
agreeableness: 8
|
| 254 |
-
neuroticism:
|
| 255 |
background: >-
|
| 256 |
-
|
| 257 |
-
She
|
| 258 |
-
|
| 259 |
-
|
|
|
|
| 260 |
quirks:
|
| 261 |
-
-
|
| 262 |
-
-
|
| 263 |
-
-
|
| 264 |
-
communication_style:
|
| 265 |
-
home_location:
|
| 266 |
-
work_location:
|
| 267 |
-
llm_temperature: 0.
|
| 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:
|
| 290 |
work_location: restaurant
|
| 291 |
llm_temperature: 0.7
|
| 292 |
|
| 293 |
-
|
| 294 |
-
|
| 295 |
-
|
|
|
|
| 296 |
gender: female
|
| 297 |
-
occupation:
|
| 298 |
openness: 8
|
| 299 |
-
conscientiousness:
|
| 300 |
-
extraversion:
|
| 301 |
-
agreeableness:
|
| 302 |
-
neuroticism:
|
| 303 |
background: >-
|
| 304 |
-
|
| 305 |
-
|
| 306 |
-
|
| 307 |
-
values: [
|
| 308 |
quirks:
|
| 309 |
-
-
|
| 310 |
-
-
|
| 311 |
-
-
|
| 312 |
-
communication_style:
|
| 313 |
-
home_location:
|
| 314 |
-
work_location:
|
| 315 |
-
llm_temperature: 0.
|
| 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:
|
| 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:
|
| 461 |
work_location: ""
|
| 462 |
llm_temperature: 0.5
|
| 463 |
|
| 464 |
-
- id:
|
| 465 |
-
name:
|
| 466 |
-
age:
|
| 467 |
-
gender:
|
| 468 |
-
occupation:
|
| 469 |
-
openness:
|
| 470 |
conscientiousness: 8
|
| 471 |
-
extraversion:
|
| 472 |
-
agreeableness:
|
| 473 |
-
neuroticism:
|
| 474 |
background: >-
|
| 475 |
-
|
| 476 |
-
|
| 477 |
-
|
| 478 |
-
|
| 479 |
-
values: [friendship, reliability, craft]
|
| 480 |
quirks:
|
| 481 |
-
-
|
| 482 |
-
-
|
| 483 |
-
-
|
| 484 |
-
communication_style:
|
| 485 |
-
home_location:
|
| 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="
|
| 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.
|
| 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 |
-
|
| 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 |
-
|
| 275 |
except Exception as e:
|
| 276 |
logger.error(f"Ollama error: {e}")
|
| 277 |
if attempt == self.max_retries - 1:
|
| 278 |
raise
|
| 279 |
-
|
| 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",
|
| 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 |
-
|
| 334 |
except Exception as e:
|
| 335 |
logger.error(f"Ollama JSON error: {e}")
|
| 336 |
if attempt == self.max_retries - 1:
|
| 337 |
return {}
|
| 338 |
-
|
| 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", "
|
| 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) ==
|
| 155 |
# Test connectivity
|
| 156 |
cafe = city.get_location("cafe")
|
| 157 |
assert cafe is not None
|
| 158 |
-
assert "
|
| 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", "
|
| 165 |
assert "test_agent" not in city.get_agents_at("cafe")
|
| 166 |
-
assert "test_agent" in city.get_agents_at("
|
| 167 |
-
assert city.find_agent("test_agent") == "
|
| 168 |
-
city.locations["
|
| 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 == "
|
| 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, "
|
| 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 |
-
|
| 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.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 219 |
|
|
|
|
| 220 |
const LOCATION_POSITIONS = {
|
| 221 |
-
|
| 222 |
-
|
| 223 |
-
|
| 224 |
-
|
| 225 |
-
|
| 226 |
-
|
| 227 |
-
|
| 228 |
-
|
| 229 |
-
|
| 230 |
-
|
| 231 |
-
|
| 232 |
-
|
| 233 |
-
}
|
| 234 |
-
|
| 235 |
-
|
| 236 |
-
|
| 237 |
-
|
| 238 |
-
|
| 239 |
-
|
| 240 |
-
|
| 241 |
-
|
| 242 |
-
|
| 243 |
-
|
| 244 |
-
|
| 245 |
-
|
| 246 |
-
|
| 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 = {};
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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.
|
| 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 |
-
//
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 362 |
|
|
|
|
| 363 |
const byLoc = {};
|
| 364 |
for (const [id, a] of Object.entries(agents)) {
|
| 365 |
-
const loc = a.location || '
|
| 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 |
-
|
| 458 |
-
|
|
|
|
| 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
|
| 517 |
-
|
| 518 |
-
|
| 519 |
-
|
| 520 |
-
for (const
|
| 521 |
-
const
|
| 522 |
-
|
| 523 |
-
|
| 524 |
-
|
| 525 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 526 |
}
|
| 527 |
}
|
| 528 |
-
|
| 529 |
-
|
| 530 |
-
|
| 531 |
-
|
| 532 |
-
|
| 533 |
-
|
| 534 |
-
|
| 535 |
-
|
| 536 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 537 |
}
|
| 538 |
}
|
| 539 |
-
ctx.setLineDash([]);
|
| 540 |
}
|
| 541 |
|
| 542 |
// ============================================================
|
| 543 |
// BUILDINGS
|
| 544 |
// ============================================================
|
| 545 |
-
function drawBuilding(id,
|
| 546 |
-
const
|
| 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 |
-
|
| 554 |
-
|
| 555 |
-
|
| 556 |
-
else if (type==='shop') drawShop(x,y,
|
| 557 |
-
else drawOffice(x,y,
|
| 558 |
-
|
| 559 |
-
if (type
|
| 560 |
-
|
| 561 |
-
|
| 562 |
-
|
| 563 |
-
const
|
| 564 |
-
|
| 565 |
-
ctx.
|
|
|
|
|
|
|
|
|
|
| 566 |
}
|
| 567 |
|
| 568 |
-
|
| 569 |
-
if (occ>0) {
|
| 570 |
-
const bx=x+
|
| 571 |
-
ctx.fillStyle='#e94560'; ctx.beginPath(); ctx.arc(bx,by,
|
| 572 |
-
ctx.fillStyle='#fff'; ctx.font='bold
|
| 573 |
-
ctx.fillText(occ.toString(),bx,by);
|
| 574 |
}
|
| 575 |
}
|
| 576 |
|
| 577 |
-
function drawHouse(x,y,
|
| 578 |
-
const w=
|
| 579 |
-
|
| 580 |
-
|
| 581 |
-
|
| 582 |
-
|
| 583 |
-
|
| 584 |
-
|
| 585 |
-
|
| 586 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 587 |
}
|
| 588 |
|
| 589 |
-
function drawShop(x,y,
|
| 590 |
-
const w=
|
| 591 |
-
ctx.fillStyle=dk?
|
| 592 |
-
ctx.
|
| 593 |
-
|
| 594 |
-
|
| 595 |
-
|
| 596 |
-
|
| 597 |
-
ctx.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 598 |
}
|
| 599 |
|
| 600 |
-
function drawOffice(x,y,
|
| 601 |
-
const w=50,h=
|
| 602 |
-
ctx.fillStyle=dk?
|
| 603 |
-
ctx.
|
| 604 |
-
|
| 605 |
-
|
| 606 |
-
ctx.
|
| 607 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 608 |
}
|
| 609 |
|
| 610 |
-
function
|
| 611 |
-
|
| 612 |
-
ctx.
|
| 613 |
-
ctx.
|
| 614 |
-
|
| 615 |
-
|
| 616 |
-
|
| 617 |
-
|
| 618 |
-
|
| 619 |
-
|
| 620 |
-
|
| 621 |
-
|
| 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
|
| 627 |
-
|
| 628 |
-
|
| 629 |
-
ctx.
|
| 630 |
-
ctx.
|
| 631 |
-
|
| 632 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 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
|
| 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.
|
| 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
|
| 710 |
// ============================================================
|
| 711 |
function computeAgentTarget(id, agent, globalIdx, byLoc, W, H) {
|
| 712 |
-
const loc = agent.location || '
|
| 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 |
-
|
| 721 |
-
|
| 722 |
-
|
| 723 |
-
|
| 724 |
-
|
| 725 |
-
|
| 726 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
| 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 |
-
|
| 1314 |
-
|
| 1315 |
-
|
| 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();
|
| 1330 |
fetchControls();
|
| 1331 |
-
connectWebSocket();
|
| 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>
|