RayMelius Claude Opus 4.6 commited on
Commit
59edb07
·
0 Parent(s):

Initial implementation of Soci city population simulator

Browse files

LLM-powered simulation of 20 diverse AI people living in a city.
Architecture: world (clock, city map, events), agents (persona, memory,
needs, relationships), actions (movement, activities, conversation, social),
engine (simulation loop, scheduler, entropy management, Claude API),
persistence (SQLite), and API server (FastAPI REST + WebSocket).

All 12 integration tests pass with mock LLM.

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

.env.example ADDED
@@ -0,0 +1 @@
 
 
1
+ ANTHROPIC_API_KEY=sk-ant-your-key-here
.gitignore ADDED
@@ -0,0 +1,22 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ __pycache__/
2
+ *.py[cod]
3
+ *$py.class
4
+ *.egg-info/
5
+ dist/
6
+ build/
7
+ .eggs/
8
+ *.egg
9
+ .env
10
+ .venv/
11
+ venv/
12
+ env/
13
+ *.db
14
+ *.sqlite
15
+ *.sqlite3
16
+ .idea/
17
+ .vscode/
18
+ *.swp
19
+ *.swo
20
+ *~
21
+ .DS_Store
22
+ Thumbs.db
CLAUDE.md ADDED
@@ -0,0 +1,40 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Soci — LLM-Powered City Population Simulator
2
+
3
+ ## Project Overview
4
+ Simulates a diverse population of AI people living in a city using Claude as the reasoning engine. Each agent has a unique persona, memory stream, needs, and relationships. Inspired by Simile AI / Stanford Generative Agents research.
5
+
6
+ ## Tech Stack
7
+ - Python 3.10+ (Anaconda ml-env)
8
+ - Anthropic Claude API (Sonnet for novel situations, Haiku for routine)
9
+ - FastAPI + WebSocket (API server)
10
+ - SQLite via aiosqlite (persistence)
11
+ - Rich (terminal dashboard)
12
+ - YAML (city/persona config)
13
+
14
+ ## Key Commands
15
+ ```bash
16
+ # Run with Anaconda ml-env:
17
+ "C:/Users/xabon/.conda/envs/ml-env/python.exe" main.py --ticks 20 --agents 5
18
+ "C:/Users/xabon/.conda/envs/ml-env/python.exe" test_simulation.py
19
+
20
+ # API server:
21
+ "C:/Users/xabon/.conda/envs/ml-env/python.exe" -m uvicorn soci.api.server:app --host 0.0.0.0 --port 8000
22
+ ```
23
+
24
+ ## Architecture
25
+ - `src/soci/world/` — City map, simulation clock, world events
26
+ - `src/soci/agents/` — Agent cognition: persona, memory, needs, relationships
27
+ - `src/soci/actions/` — Action types: movement, activities, conversation, social
28
+ - `src/soci/engine/` — Simulation loop, scheduler, entropy management, LLM client
29
+ - `src/soci/persistence/` — SQLite database, save/load snapshots
30
+ - `src/soci/api/` — FastAPI REST + WebSocket server
31
+ - `config/` — City layout (12 locations) and personas (20 characters)
32
+
33
+ ## Agent Cognition Loop
34
+ Each tick per agent: OBSERVE → REFLECT → PLAN → ACT → REMEMBER
35
+
36
+ ## Conventions
37
+ - All async (LLM calls are I/O-bound)
38
+ - Dataclasses with `to_dict()` / `from_dict()` for serialization
39
+ - YAML config for city layout and personas
40
+ - Cost tracking built into LLM client
config/city.yaml ADDED
@@ -0,0 +1,91 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ name: Soci City
2
+
3
+ locations:
4
+ # --- Residential ---
5
+ - id: home_north
6
+ name: Northside Apartments
7
+ zone: residential
8
+ description: A modern apartment complex with balconies overlooking the park. Home to several young professionals and families.
9
+ capacity: 10
10
+ connected_to: [park, cafe, grocery, street_north]
11
+
12
+ - id: home_south
13
+ name: Southside Houses
14
+ zone: residential
15
+ description: A quiet street of cozy row houses with small gardens. A mix of longtime residents and newcomers.
16
+ capacity: 10
17
+ connected_to: [bar, gym, library, street_south]
18
+
19
+ # --- Commercial ---
20
+ - id: cafe
21
+ name: The Daily Grind
22
+ zone: commercial
23
+ description: A warm, bustling cafe with mismatched furniture and the aroma of fresh coffee. The local gossip hub.
24
+ capacity: 15
25
+ connected_to: [home_north, park, office, street_north]
26
+
27
+ - id: grocery
28
+ name: Green Basket Market
29
+ zone: commercial
30
+ description: A neighborhood grocery store with fresh produce and a friendly owner who knows everyone by name.
31
+ capacity: 12
32
+ connected_to: [home_north, street_north, street_south]
33
+
34
+ - id: bar
35
+ name: The Rusty Anchor
36
+ zone: commercial
37
+ description: A dimly lit bar with a jukebox, pool table, and regulars who've been coming for years. Lively at night.
38
+ capacity: 15
39
+ connected_to: [home_south, restaurant, street_south]
40
+
41
+ - id: restaurant
42
+ name: Mama Rosa's Kitchen
43
+ zone: commercial
44
+ description: A family-run Italian restaurant with checkered tablecloths and the best pasta in town.
45
+ capacity: 12
46
+ connected_to: [bar, office, street_south, street_north]
47
+
48
+ # --- Work ---
49
+ - id: office
50
+ name: The Hive Coworking
51
+ zone: work
52
+ description: An open-plan coworking space with standing desks, meeting rooms, and a perpetually broken printer.
53
+ capacity: 20
54
+ connected_to: [cafe, restaurant, street_north]
55
+
56
+ # --- Public ---
57
+ - id: park
58
+ name: Willow Park
59
+ zone: public
60
+ description: A green park with old willow trees, a pond, benches, and a small playground. Popular for morning jogs and evening walks.
61
+ capacity: 30
62
+ connected_to: [home_north, cafe, gym, library, street_north]
63
+
64
+ - id: gym
65
+ name: Iron & Grit Gym
66
+ zone: public
67
+ description: A no-nonsense gym with free weights, treadmills, and a boxing ring in the back. Smells like determination.
68
+ capacity: 12
69
+ connected_to: [home_south, park, street_south]
70
+
71
+ - id: library
72
+ name: Soci Public Library
73
+ zone: public
74
+ description: A quiet library with tall shelves, reading nooks, and a community board. Hosts book clubs and events.
75
+ capacity: 15
76
+ connected_to: [home_south, park, street_south]
77
+
78
+ # --- Connectors ---
79
+ - id: street_north
80
+ name: North Main Street
81
+ zone: public
82
+ description: The main street running through the northern part of town. Shops, cafes, and foot traffic.
83
+ capacity: 40
84
+ connected_to: [home_north, cafe, grocery, office, park, street_south]
85
+
86
+ - id: street_south
87
+ name: South Main Street
88
+ zone: public
89
+ description: The southern stretch of main street. More residential, with the bar and gym nearby.
90
+ capacity: 40
91
+ connected_to: [home_south, bar, grocery, gym, library, restaurant, street_north]
config/personas.yaml ADDED
@@ -0,0 +1,467 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ personas:
2
+ # --- NORTHSIDE RESIDENTS ---
3
+ - id: elena
4
+ name: Elena Vasquez
5
+ age: 34
6
+ occupation: software engineer
7
+ openness: 8
8
+ conscientiousness: 7
9
+ extraversion: 4
10
+ agreeableness: 6
11
+ neuroticism: 5
12
+ background: >-
13
+ Elena moved to Soci City two years ago after a burnout at a big tech company.
14
+ She now freelances and values work-life balance. She grew up in a large family
15
+ and misses the closeness but enjoys her independence.
16
+ values: [creativity, independence, authenticity]
17
+ quirks:
18
+ - talks to herself while debugging
19
+ - always orders the same coffee (oat milk latte)
20
+ - sketches in a notebook when thinking
21
+ communication_style: thoughtful and slightly nerdy, uses analogies
22
+ home_location: home_north
23
+ work_location: office
24
+ llm_temperature: 0.7
25
+
26
+ - id: marcus
27
+ name: Marcus Chen
28
+ age: 28
29
+ occupation: fitness trainer
30
+ openness: 5
31
+ conscientiousness: 8
32
+ extraversion: 9
33
+ agreeableness: 7
34
+ neuroticism: 3
35
+ background: >-
36
+ Marcus is a former college athlete who turned his passion into a career.
37
+ He's the guy who knows everyone and remembers their names. He volunteers
38
+ at the community center on weekends and dreams of opening his own gym.
39
+ values: [health, community, discipline]
40
+ quirks:
41
+ - gives unsolicited fitness advice
42
+ - always has a protein shake
43
+ - high-fives people he knows
44
+ communication_style: enthusiastic and motivational, uses sports metaphors
45
+ home_location: home_north
46
+ work_location: gym
47
+ llm_temperature: 0.8
48
+
49
+ - id: helen
50
+ name: Helen Park
51
+ age: 67
52
+ occupation: retired teacher
53
+ openness: 6
54
+ conscientiousness: 8
55
+ extraversion: 6
56
+ agreeableness: 8
57
+ neuroticism: 4
58
+ background: >-
59
+ Helen taught high school English for 35 years and retired last spring.
60
+ She's adjusting to the slower pace. Her husband passed three years ago,
61
+ and she fills her days with reading, gardening, and volunteering at the library.
62
+ values: [education, kindness, tradition]
63
+ quirks:
64
+ - corrects grammar gently
65
+ - always carries a book
66
+ - bakes cookies for neighbors
67
+ communication_style: warm and maternal, quotes literature
68
+ home_location: home_north
69
+ work_location: library
70
+ llm_temperature: 0.6
71
+
72
+ - id: kai
73
+ name: Kai Okonkwo
74
+ age: 22
75
+ occupation: barista and aspiring musician
76
+ openness: 9
77
+ conscientiousness: 3
78
+ extraversion: 7
79
+ agreeableness: 5
80
+ neuroticism: 6
81
+ background: >-
82
+ Kai dropped out of college to pursue music. They work at The Daily Grind
83
+ to pay rent and play gigs at the bar on weekends. Their parents disapprove,
84
+ which is a constant source of stress. They're talented but undisciplined.
85
+ values: [self-expression, freedom, authenticity]
86
+ quirks:
87
+ - hums while making coffee
88
+ - wears headphones around their neck at all times
89
+ - changes hair color monthly
90
+ communication_style: casual and witty, uses slang, sometimes sarcastic
91
+ home_location: home_north
92
+ work_location: cafe
93
+ llm_temperature: 0.9
94
+
95
+ - id: diana
96
+ name: Diana Novak
97
+ age: 41
98
+ occupation: small business owner (grocery store)
99
+ openness: 4
100
+ conscientiousness: 9
101
+ extraversion: 5
102
+ agreeableness: 6
103
+ neuroticism: 7
104
+ background: >-
105
+ Diana took over Green Basket Market from her father five years ago.
106
+ She works long hours and worries about competition from big chains.
107
+ She's a single mother with a teenage son and is fiercely protective of her store.
108
+ values: [family, hard work, loyalty]
109
+ quirks:
110
+ - rearranges shelves when stressed
111
+ - knows the price of everything
112
+ - suspicious of strangers at first
113
+ communication_style: practical and direct, occasionally sharp under stress
114
+ home_location: home_north
115
+ work_location: grocery
116
+ llm_temperature: 0.5
117
+
118
+ # --- SOUTHSIDE RESIDENTS ---
119
+ - id: james
120
+ name: James "Jimmy" O'Brien
121
+ age: 55
122
+ occupation: bartender and bar owner
123
+ openness: 5
124
+ conscientiousness: 6
125
+ extraversion: 8
126
+ agreeableness: 7
127
+ neuroticism: 4
128
+ background: >-
129
+ Jimmy has run The Rusty Anchor for 20 years. He's seen everything and heard
130
+ every story. He's a born storyteller who treats regulars like family. He went
131
+ through a rough divorce ten years ago but has found peace in his work.
132
+ values: [community, honesty, loyalty]
133
+ quirks:
134
+ - polishes glasses when listening
135
+ - gives nicknames to everyone
136
+ - tells the same three jokes
137
+ communication_style: folksy and warm, good listener, drops wisdom casually
138
+ home_location: home_south
139
+ work_location: bar
140
+ llm_temperature: 0.7
141
+
142
+ - id: rosa
143
+ name: Rosa Martelli
144
+ age: 62
145
+ occupation: restaurant owner and chef
146
+ openness: 6
147
+ conscientiousness: 9
148
+ extraversion: 7
149
+ agreeableness: 8
150
+ neuroticism: 5
151
+ background: >-
152
+ Rosa opened Mama Rosa's Kitchen 25 years ago with recipes from her nonna.
153
+ She's the heart of the community and feeds people even when they can't pay.
154
+ Her children have moved away, which makes her sad, but the restaurant is her life.
155
+ values: [generosity, tradition, family]
156
+ quirks:
157
+ - feeds everyone who looks hungry
158
+ - speaks Italian when emotional
159
+ - pinches cheeks
160
+ communication_style: expressive and loving, uses food metaphors, dramatic
161
+ home_location: home_south
162
+ work_location: restaurant
163
+ llm_temperature: 0.7
164
+
165
+ - id: devon
166
+ name: Devon Reeves
167
+ age: 30
168
+ occupation: freelance journalist
169
+ openness: 9
170
+ conscientiousness: 5
171
+ extraversion: 6
172
+ agreeableness: 4
173
+ neuroticism: 6
174
+ background: >-
175
+ Devon is an investigative journalist who moved to Soci City following a lead
176
+ that went nowhere. He stayed because he likes the community. He's always
177
+ looking for the next story and can be pushy. He has trust issues from his work.
178
+ values: [truth, justice, curiosity]
179
+ quirks:
180
+ - takes notes on everything
181
+ - asks too many questions
182
+ - always sits facing the door
183
+ communication_style: probing and articulate, sometimes intense, asks follow-up questions
184
+ home_location: home_south
185
+ work_location: cafe
186
+ llm_temperature: 0.8
187
+
188
+ - id: yuki
189
+ name: Yuki Tanaka
190
+ age: 26
191
+ occupation: yoga instructor and massage therapist
192
+ openness: 8
193
+ conscientiousness: 6
194
+ extraversion: 5
195
+ agreeableness: 9
196
+ neuroticism: 3
197
+ background: >-
198
+ Yuki moved from Tokyo two years ago seeking a quieter life. She teaches yoga
199
+ at the gym and does private massage sessions. She's deeply empathetic and
200
+ people naturally confide in her. She struggles with feeling like an outsider.
201
+ values: [harmony, mindfulness, connection]
202
+ quirks:
203
+ - meditates in public spaces
204
+ - speaks softly
205
+ - offers breathing exercises to stressed people
206
+ communication_style: gentle and calming, uses nature imagery, occasionally profound
207
+ home_location: home_south
208
+ work_location: gym
209
+ llm_temperature: 0.6
210
+
211
+ - id: theo
212
+ name: Theo Blackwood
213
+ age: 45
214
+ occupation: construction worker
215
+ openness: 3
216
+ conscientiousness: 7
217
+ extraversion: 4
218
+ agreeableness: 5
219
+ neuroticism: 5
220
+ background: >-
221
+ Theo is a quiet, reliable man who builds things with his hands. He's lived in
222
+ Soci City his whole life. After his wife left, he threw himself into work and
223
+ his routine. He's lonely but won't admit it. He's a regular at Jimmy's bar.
224
+ values: [reliability, self-reliance, simplicity]
225
+ quirks:
226
+ - fixes things without being asked
227
+ - uncomfortable with emotional conversations
228
+ - always has calloused hands
229
+ communication_style: few words, gruff but not unkind, says more with actions than words
230
+ home_location: home_south
231
+ work_location: office
232
+ llm_temperature: 0.4
233
+
234
+ # --- ADDITIONAL DIVERSE RESIDENTS ---
235
+ - id: priya
236
+ name: Priya Sharma
237
+ age: 38
238
+ occupation: doctor (works at a clinic outside the city, spends free time locally)
239
+ openness: 7
240
+ conscientiousness: 9
241
+ extraversion: 5
242
+ agreeableness: 8
243
+ neuroticism: 6
244
+ background: >-
245
+ Priya is an overworked doctor who moved here for the quiet neighborhood.
246
+ She feels guilty about not spending enough time with her two young kids.
247
+ She's kind but stretched thin and sometimes snappy when exhausted.
248
+ values: [compassion, duty, family]
249
+ quirks:
250
+ - diagnoses people's ailments unsolicited
251
+ - always looks slightly tired
252
+ - carries hand sanitizer everywhere
253
+ communication_style: precise and caring, clinical when stressed, warm when relaxed
254
+ home_location: home_north
255
+ work_location: office
256
+ llm_temperature: 0.6
257
+
258
+ - id: omar
259
+ name: Omar Hassan
260
+ age: 50
261
+ occupation: taxi driver and part-time cook
262
+ openness: 6
263
+ conscientiousness: 6
264
+ extraversion: 7
265
+ agreeableness: 7
266
+ neuroticism: 4
267
+ background: >-
268
+ Omar immigrated fifteen years ago and built a life from nothing. He drives
269
+ a taxi during the day and sometimes helps Rosa in the kitchen. He's philosophical
270
+ and loves debating politics at the bar. He sends money to family back home.
271
+ values: [hard work, family, justice]
272
+ quirks:
273
+ - knows every shortcut in the city
274
+ - quotes proverbs from his homeland
275
+ - cooks for friends without warning
276
+ communication_style: warm and philosophical, uses proverbs, debates passionately but respectfully
277
+ home_location: home_south
278
+ work_location: restaurant
279
+ llm_temperature: 0.7
280
+
281
+ - id: zoe
282
+ name: Zoe Chen-Williams
283
+ age: 19
284
+ occupation: college student (home for the semester)
285
+ openness: 8
286
+ conscientiousness: 4
287
+ extraversion: 8
288
+ agreeableness: 6
289
+ neuroticism: 7
290
+ background: >-
291
+ Zoe is Marcus's younger sister, home from college for the semester after a
292
+ rough breakup. She's figuring out what she wants from life. She's passionate
293
+ about social justice but can be self-righteous. She idolizes her brother.
294
+ values: [equality, adventure, self-discovery]
295
+ quirks:
296
+ - always on her phone
297
+ - starts sentences with "literally"
298
+ - changes opinions quickly
299
+ communication_style: energetic and opinionated, uses Gen-Z slang, dramatic about small things
300
+ home_location: home_north
301
+ work_location: library
302
+ llm_temperature: 0.9
303
+
304
+ - id: frank
305
+ name: Frank Kowalski
306
+ age: 72
307
+ occupation: retired mechanic
308
+ openness: 3
309
+ conscientiousness: 7
310
+ extraversion: 5
311
+ agreeableness: 4
312
+ neuroticism: 5
313
+ background: >-
314
+ Frank has lived on the south side for 50 years. He's cantankerous and
315
+ resistant to change, but beneath the gruff exterior is a man who cares deeply
316
+ about his neighborhood. He's a regular at the bar and argues with everyone.
317
+ values: [tradition, self-reliance, neighborhood pride]
318
+ quirks:
319
+ - complains about "the old days"
320
+ - fixes neighbors' cars for free
321
+ - sits on the same bar stool every night
322
+ communication_style: blunt and opinionated, sarcastic, secretly has a heart of gold
323
+ home_location: home_south
324
+ work_location: ""
325
+ llm_temperature: 0.5
326
+
327
+ - id: lila
328
+ name: Lila Santos
329
+ age: 33
330
+ occupation: artist and part-time art teacher
331
+ openness: 10
332
+ conscientiousness: 3
333
+ extraversion: 6
334
+ agreeableness: 7
335
+ neuroticism: 7
336
+ background: >-
337
+ Lila is a passionate but struggling artist. She teaches art classes at the
338
+ library and sells paintings at the park on weekends. She's emotionally
339
+ volatile — ecstatic when inspired, devastated when blocked. She has a crush
340
+ on Elena but hasn't said anything.
341
+ values: [beauty, emotion, authenticity]
342
+ quirks:
343
+ - paint under her fingernails always
344
+ - stares at things intensely (she's composing)
345
+ - cries at sunsets
346
+ communication_style: poetic and emotional, speaks in images, alternates between passionate and melancholic
347
+ home_location: home_south
348
+ work_location: library
349
+ llm_temperature: 0.9
350
+
351
+ - id: sam
352
+ name: Sam Nakamura
353
+ age: 40
354
+ occupation: librarian
355
+ openness: 7
356
+ conscientiousness: 8
357
+ extraversion: 3
358
+ agreeableness: 7
359
+ neuroticism: 4
360
+ background: >-
361
+ Sam runs the Soci Public Library with quiet devotion. They're non-binary
362
+ and moved here five years ago seeking a more accepting community. They host
363
+ book clubs, poetry nights, and secretly write science fiction novels.
364
+ values: [knowledge, inclusion, quiet service]
365
+ quirks:
366
+ - recommends books to everyone
367
+ - speaks in whispers even outside the library
368
+ - organizes everything alphabetically
369
+ communication_style: soft-spoken and precise, literary references, dry humor
370
+ home_location: home_south
371
+ work_location: library
372
+ llm_temperature: 0.6
373
+
374
+ - id: marco
375
+ name: Marco Delgado
376
+ age: 16
377
+ occupation: high school student (Diana's son)
378
+ openness: 7
379
+ conscientiousness: 4
380
+ extraversion: 6
381
+ agreeableness: 5
382
+ neuroticism: 6
383
+ background: >-
384
+ Marco is Diana's teenage son who helps at the grocery store after school.
385
+ He's embarrassed by his mom but loves her fiercely. He wants to be a
386
+ game designer and spends his free time at the library or park. He
387
+ looks up to Kai and thinks Marcus is cool.
388
+ values: [freedom, creativity, loyalty]
389
+ quirks:
390
+ - always has earbuds in
391
+ - doodles game characters
392
+ - eye-rolls at authority
393
+ communication_style: teenager — monosyllabic with adults, animated with peers, uses gaming lingo
394
+ home_location: home_north
395
+ work_location: grocery
396
+ llm_temperature: 0.8
397
+
398
+ - id: nina
399
+ name: Nina Volkov
400
+ age: 29
401
+ occupation: real estate agent
402
+ openness: 5
403
+ conscientiousness: 8
404
+ extraversion: 9
405
+ agreeableness: 4
406
+ neuroticism: 5
407
+ background: >-
408
+ Nina is ambitious and sharp. She moved to Soci City to scout development
409
+ opportunities. Some residents distrust her motives — is she here to
410
+ gentrify? She's not evil, just driven. She genuinely likes the community
411
+ but struggles to show it past her professional veneer.
412
+ values: [ambition, success, efficiency]
413
+ quirks:
414
+ - always in business casual
415
+ - takes phone calls during conversations
416
+ - knows property values of every building
417
+ communication_style: polished and assertive, networking mode, occasionally lets guard down
418
+ home_location: home_north
419
+ work_location: office
420
+ llm_temperature: 0.6
421
+
422
+ - id: george
423
+ name: George Adeyemi
424
+ age: 47
425
+ occupation: night shift security guard
426
+ openness: 4
427
+ conscientiousness: 7
428
+ extraversion: 3
429
+ agreeableness: 6
430
+ neuroticism: 4
431
+ background: >-
432
+ George works nights and sleeps during the day, giving him an unusual
433
+ perspective on the city. He's a quiet observer who notices things others miss.
434
+ He's a widower raising a daughter and values stability above all.
435
+ values: [safety, family, routine]
436
+ quirks:
437
+ - notices everything out of place
438
+ - naps in the park during daytime
439
+ - makes detailed observations about patterns
440
+ communication_style: observational and measured, reports facts, rarely gives opinions unless asked
441
+ home_location: home_south
442
+ work_location: ""
443
+ llm_temperature: 0.5
444
+
445
+ - id: alice
446
+ name: Alice Fontaine
447
+ age: 58
448
+ occupation: retired accountant, amateur baker
449
+ openness: 5
450
+ conscientiousness: 8
451
+ extraversion: 6
452
+ agreeableness: 8
453
+ neuroticism: 3
454
+ background: >-
455
+ Alice retired early and now spends her time perfecting baking recipes.
456
+ She dreams of opening a small bakery. She's Helen's closest friend and
457
+ they have tea together every afternoon. She's steady, reliable, and
458
+ the person everyone calls in a crisis.
459
+ values: [friendship, reliability, craft]
460
+ quirks:
461
+ - always brings baked goods everywhere
462
+ - keeps a mental spreadsheet of everyone's dietary needs
463
+ - hums while baking
464
+ communication_style: steady and cheerful, uses baking analogies, good at calming people down
465
+ home_location: home_north
466
+ work_location: ""
467
+ llm_temperature: 0.6
main.py ADDED
@@ -0,0 +1,233 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Soci — LLM-powered city population simulator.
2
+
3
+ Usage:
4
+ python main.py [--ticks N] [--agents N] [--speed SPEED]
5
+
6
+ Controls:
7
+ Press Ctrl+C to pause and save the simulation.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import argparse
13
+ import asyncio
14
+ import logging
15
+ import os
16
+ import sys
17
+ from pathlib import Path
18
+
19
+ from dotenv import load_dotenv
20
+ from rich.console import Console
21
+ from rich.layout import Layout
22
+ from rich.live import Live
23
+ from rich.panel import Panel
24
+ from rich.table import Table
25
+ from rich.text import Text
26
+
27
+ # Add src to path
28
+ sys.path.insert(0, str(Path(__file__).parent / "src"))
29
+
30
+ from soci.engine.llm import ClaudeClient
31
+ from soci.engine.simulation import Simulation
32
+ from soci.persistence.database import Database
33
+ from soci.persistence.snapshots import save_simulation, load_simulation
34
+ from soci.world.city import City
35
+ from soci.world.clock import SimClock
36
+
37
+ load_dotenv()
38
+
39
+ console = Console()
40
+ logger = logging.getLogger("soci")
41
+
42
+
43
+ def build_dashboard(sim: Simulation, recent_events: list[str]) -> Layout:
44
+ """Build the Rich layout for the live dashboard."""
45
+ layout = Layout()
46
+ layout.split_column(
47
+ Layout(name="header", size=3),
48
+ Layout(name="body"),
49
+ Layout(name="footer", size=5),
50
+ )
51
+ layout["body"].split_row(
52
+ Layout(name="city", ratio=1),
53
+ Layout(name="events", ratio=2),
54
+ )
55
+
56
+ # Header
57
+ clock = sim.clock
58
+ weather = sim.events.weather.value
59
+ cost = f"${sim.llm.usage.estimated_cost_usd:.4f}"
60
+ calls = sim.llm.usage.total_calls
61
+ header_text = (
62
+ f" SOCI CITY | {clock.datetime_str} ({clock.time_of_day.value}) | "
63
+ f"Weather: {weather} | Agents: {len(sim.agents)} | "
64
+ f"API calls: {calls} | Cost: {cost}"
65
+ )
66
+ layout["header"].update(Panel(header_text, style="bold white on blue"))
67
+
68
+ # City locations table
69
+ loc_table = Table(title="City Locations", expand=True, show_lines=True)
70
+ loc_table.add_column("Location", style="cyan", width=20)
71
+ loc_table.add_column("People", style="green")
72
+ loc_table.add_column("#", style="yellow", width=3)
73
+
74
+ for loc in sim.city.locations.values():
75
+ occupants = []
76
+ for aid in loc.occupants:
77
+ agent = sim.agents.get(aid)
78
+ if agent:
79
+ state_icon = {
80
+ "idle": ".",
81
+ "working": "W",
82
+ "eating": "E",
83
+ "sleeping": "Z",
84
+ "socializing": "S",
85
+ "exercising": "X",
86
+ "in_conversation": "C",
87
+ "moving": ">",
88
+ "shopping": "$",
89
+ "relaxing": "~",
90
+ }.get(agent.state.value, "?")
91
+ occupants.append(f"{agent.name}[{state_icon}]")
92
+ loc_table.add_row(
93
+ loc.name,
94
+ ", ".join(occupants) if occupants else "-",
95
+ str(len(loc.occupants)),
96
+ )
97
+
98
+ layout["city"].update(Panel(loc_table))
99
+
100
+ # Recent events
101
+ event_text = "\n".join(recent_events[-25:]) if recent_events else "Simulation starting..."
102
+ layout["events"].update(Panel(event_text, title="Recent Activity", border_style="green"))
103
+
104
+ # Footer — agent mood/needs summary
105
+ footer_parts = []
106
+ for agent in list(sim.agents.values())[:10]:
107
+ mood_bar = "+" * max(0, int((agent.mood + 1) * 3)) + "-" * max(0, int((1 - agent.mood) * 3))
108
+ urgent = agent.needs.most_urgent
109
+ footer_parts.append(f"{agent.name[:8]}: [{mood_bar}] need:{urgent[:4]}")
110
+ footer_text = " | ".join(footer_parts)
111
+ layout["footer"].update(Panel(footer_text, title="Agent Status", border_style="dim"))
112
+
113
+ return layout
114
+
115
+
116
+ async def run_simulation(
117
+ ticks: int = 96,
118
+ max_agents: int = 20,
119
+ tick_delay: float = 0.5,
120
+ resume: bool = False,
121
+ ) -> None:
122
+ """Run the simulation with a live Rich dashboard."""
123
+ # Initialize
124
+ console.print("[bold blue]Initializing Soci City Simulation...[/]")
125
+
126
+ try:
127
+ llm = ClaudeClient()
128
+ except ValueError as e:
129
+ console.print(f"[bold red]Error: {e}[/]")
130
+ console.print("Copy .env.example to .env and add your ANTHROPIC_API_KEY.")
131
+ return
132
+
133
+ db = Database()
134
+ await db.connect()
135
+
136
+ sim = None
137
+ if resume:
138
+ sim = await load_simulation(db, llm)
139
+ if sim:
140
+ console.print(f"[green]Resumed simulation from Day {sim.clock.day}, {sim.clock.time_str}[/]")
141
+
142
+ if sim is None:
143
+ # Create new simulation
144
+ config_dir = Path(__file__).parent / "config"
145
+ city = City.from_yaml(str(config_dir / "city.yaml"))
146
+ clock = SimClock(tick_minutes=15, hour=6, minute=0)
147
+ sim = Simulation(city=city, clock=clock, llm=llm)
148
+ sim.load_agents_from_yaml(str(config_dir / "personas.yaml"))
149
+ console.print(f"[green]Created new simulation with {len(sim.agents)} agents.[/]")
150
+
151
+ # Limit agents if requested
152
+ if max_agents < len(sim.agents):
153
+ agent_ids = list(sim.agents.keys())[:max_agents]
154
+ sim.agents = {aid: sim.agents[aid] for aid in agent_ids}
155
+ console.print(f"[yellow]Limited to {max_agents} agents.[/]")
156
+
157
+ # Collect all events for display
158
+ all_events: list[str] = []
159
+
160
+ def on_event(msg: str):
161
+ all_events.append(msg)
162
+
163
+ sim.on_event = on_event
164
+
165
+ console.print(f"[bold green]Starting simulation: {ticks} ticks ({ticks * 15 // 60} hours)[/]")
166
+ console.print("[dim]Press Ctrl+C to pause and save.[/]")
167
+
168
+ try:
169
+ with Live(build_dashboard(sim, all_events), refresh_per_second=2, console=console) as live:
170
+ for tick_num in range(ticks):
171
+ tick_events = await sim.tick()
172
+
173
+ # Update display
174
+ live.update(build_dashboard(sim, all_events))
175
+
176
+ # Auto-save every 24 ticks (6 hours in-game)
177
+ if tick_num > 0 and tick_num % 24 == 0:
178
+ await save_simulation(sim, db, "autosave")
179
+
180
+ # Small delay so the dashboard is readable
181
+ await asyncio.sleep(tick_delay)
182
+
183
+ except KeyboardInterrupt:
184
+ console.print("\n[yellow]Simulation paused.[/]")
185
+
186
+ # Save on exit
187
+ await save_simulation(sim, db, "autosave")
188
+
189
+ # Print summary
190
+ console.print("\n[bold blue]Simulation Summary[/]")
191
+ console.print(f" Time: {sim.clock.datetime_str}")
192
+ console.print(f" Total ticks: {sim.clock.total_ticks}")
193
+ console.print(f" {sim.llm.usage.summary()}")
194
+
195
+ # Print agent summaries
196
+ console.print("\n[bold]Agent Status:[/]")
197
+ for agent in sim.agents.values():
198
+ mood_emoji = "+" if agent.mood > 0.2 else ("-" if agent.mood < -0.2 else "~")
199
+ loc = sim.city.get_location(agent.location)
200
+ loc_name = loc.name if loc else agent.location
201
+ console.print(
202
+ f" [{mood_emoji}] {agent.name} ({agent.persona.occupation}) "
203
+ f"at {loc_name} — {agent.needs.describe()}"
204
+ )
205
+
206
+ await db.close()
207
+
208
+
209
+ def main():
210
+ parser = argparse.ArgumentParser(description="Soci — City Population Simulator")
211
+ parser.add_argument("--ticks", type=int, default=96, help="Number of ticks to simulate (default: 96 = 1 day)")
212
+ parser.add_argument("--agents", type=int, default=20, help="Max number of agents (default: 20)")
213
+ parser.add_argument("--speed", type=float, default=0.5, help="Delay between ticks in seconds (default: 0.5)")
214
+ parser.add_argument("--resume", action="store_true", help="Resume from last save")
215
+ args = parser.parse_args()
216
+
217
+ Path("data").mkdir(exist_ok=True)
218
+ logging.basicConfig(
219
+ level=logging.INFO,
220
+ format="%(asctime)s %(name)s %(levelname)s %(message)s",
221
+ handlers=[logging.FileHandler("data/soci.log", mode="a")],
222
+ )
223
+
224
+ asyncio.run(run_simulation(
225
+ ticks=args.ticks,
226
+ max_agents=args.agents,
227
+ tick_delay=args.speed,
228
+ resume=args.resume,
229
+ ))
230
+
231
+
232
+ if __name__ == "__main__":
233
+ main()
pyproject.toml ADDED
@@ -0,0 +1,27 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [project]
2
+ name = "soci"
3
+ version = "0.1.0"
4
+ description = "LLM-powered city population simulator — simulate a diverse population of AI people living in a city"
5
+ readme = "README.md"
6
+ requires-python = ">=3.10"
7
+ dependencies = [
8
+ "anthropic>=0.40.0",
9
+ "fastapi>=0.115.0",
10
+ "uvicorn[standard]>=0.32.0",
11
+ "aiosqlite>=0.20.0",
12
+ "pyyaml>=6.0",
13
+ "rich>=13.9.0",
14
+ "websockets>=13.0",
15
+ "pydantic>=2.9.0",
16
+ "python-dotenv>=1.0.0",
17
+ ]
18
+
19
+ [project.scripts]
20
+ soci = "soci.cli:main"
21
+
22
+ [build-system]
23
+ requires = ["setuptools>=75.0"]
24
+ build-backend = "setuptools.backends._legacy:_Backend"
25
+
26
+ [tool.setuptools.packages.find]
27
+ where = ["src"]
src/soci/__init__.py ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ """Soci — LLM-powered city population simulator."""
2
+
3
+ __version__ = "0.1.0"
src/soci/actions/__init__.py ADDED
File without changes
src/soci/actions/activities.py ADDED
@@ -0,0 +1,71 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Activities — non-social actions like working, eating, sleeping, etc."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ from typing import TYPE_CHECKING
7
+
8
+ if TYPE_CHECKING:
9
+ from soci.agents.agent import Agent, AgentAction
10
+ from soci.world.city import City
11
+ from soci.world.clock import SimClock
12
+
13
+ logger = logging.getLogger(__name__)
14
+
15
+
16
+ def execute_activity(agent: Agent, action: AgentAction, city: City, clock: SimClock) -> str:
17
+ """Execute a non-movement, non-social activity. Returns a description."""
18
+ location = city.get_location(agent.location)
19
+ loc_name = location.name if location else "somewhere"
20
+
21
+ match action.type:
22
+ case "work":
23
+ return _do_work(agent, action, loc_name, clock)
24
+ case "eat":
25
+ return _do_eat(agent, action, loc_name, clock)
26
+ case "sleep":
27
+ return _do_sleep(agent, action, loc_name, clock)
28
+ case "exercise":
29
+ return _do_exercise(agent, action, loc_name, clock)
30
+ case "shop":
31
+ return _do_shop(agent, action, loc_name, clock)
32
+ case "relax":
33
+ return _do_relax(agent, action, loc_name, clock)
34
+ case "wander":
35
+ return _do_wander(agent, action, loc_name, clock)
36
+ case _:
37
+ return f"{agent.name} does something at {loc_name}."
38
+
39
+
40
+ def _do_work(agent: Agent, action: AgentAction, loc_name: str, clock: SimClock) -> str:
41
+ detail = action.detail or f"working at {loc_name}"
42
+ return f"{agent.name} is {detail}."
43
+
44
+
45
+ def _do_eat(agent: Agent, action: AgentAction, loc_name: str, clock: SimClock) -> str:
46
+ detail = action.detail or f"having a meal at {loc_name}"
47
+ return f"{agent.name} is {detail}."
48
+
49
+
50
+ def _do_sleep(agent: Agent, action: AgentAction, loc_name: str, clock: SimClock) -> str:
51
+ return f"{agent.name} is sleeping at {loc_name}."
52
+
53
+
54
+ def _do_exercise(agent: Agent, action: AgentAction, loc_name: str, clock: SimClock) -> str:
55
+ detail = action.detail or f"exercising at {loc_name}"
56
+ return f"{agent.name} is {detail}."
57
+
58
+
59
+ def _do_shop(agent: Agent, action: AgentAction, loc_name: str, clock: SimClock) -> str:
60
+ detail = action.detail or f"shopping at {loc_name}"
61
+ return f"{agent.name} is {detail}."
62
+
63
+
64
+ def _do_relax(agent: Agent, action: AgentAction, loc_name: str, clock: SimClock) -> str:
65
+ detail = action.detail or f"relaxing at {loc_name}"
66
+ return f"{agent.name} is {detail}."
67
+
68
+
69
+ def _do_wander(agent: Agent, action: AgentAction, loc_name: str, clock: SimClock) -> str:
70
+ detail = action.detail or f"wandering around {loc_name}"
71
+ return f"{agent.name} is {detail}."
src/soci/actions/conversation.py ADDED
@@ -0,0 +1,209 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Conversation — multi-agent dialogue generation via LLM."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ from dataclasses import dataclass, field
7
+ from typing import Optional, TYPE_CHECKING
8
+
9
+ if TYPE_CHECKING:
10
+ from soci.agents.agent import Agent
11
+ from soci.engine.llm import ClaudeClient
12
+ from soci.world.clock import SimClock
13
+
14
+ logger = logging.getLogger(__name__)
15
+
16
+
17
+ @dataclass
18
+ class ConversationTurn:
19
+ """One turn in a conversation."""
20
+
21
+ speaker_id: str
22
+ speaker_name: str
23
+ message: str
24
+ inner_thought: str = ""
25
+ tick: int = 0
26
+
27
+
28
+ @dataclass
29
+ class Conversation:
30
+ """A multi-turn conversation between agents."""
31
+
32
+ id: str
33
+ location: str
34
+ participants: list[str] # Agent IDs
35
+ turns: list[ConversationTurn] = field(default_factory=list)
36
+ topic: str = ""
37
+ is_active: bool = True
38
+ max_turns: int = 6
39
+
40
+ @property
41
+ def is_finished(self) -> bool:
42
+ return not self.is_active or len(self.turns) >= self.max_turns
43
+
44
+ def add_turn(self, turn: ConversationTurn) -> None:
45
+ self.turns.append(turn)
46
+ if len(self.turns) >= self.max_turns:
47
+ self.is_active = False
48
+
49
+ def get_history_text(self, max_turns: int = 10) -> str:
50
+ """Format conversation history for LLM context."""
51
+ if not self.turns:
52
+ return "This is the start of the conversation."
53
+ lines = [f"CONVERSATION SO FAR (topic: {self.topic}):"]
54
+ for turn in self.turns[-max_turns:]:
55
+ lines.append(f" {turn.speaker_name}: \"{turn.message}\"")
56
+ return "\n".join(lines)
57
+
58
+ def to_dict(self) -> dict:
59
+ return {
60
+ "id": self.id,
61
+ "location": self.location,
62
+ "participants": self.participants,
63
+ "turns": [
64
+ {
65
+ "speaker_id": t.speaker_id,
66
+ "speaker_name": t.speaker_name,
67
+ "message": t.message,
68
+ "inner_thought": t.inner_thought,
69
+ "tick": t.tick,
70
+ }
71
+ for t in self.turns
72
+ ],
73
+ "topic": self.topic,
74
+ "is_active": self.is_active,
75
+ "max_turns": self.max_turns,
76
+ }
77
+
78
+
79
+ async def initiate_conversation(
80
+ initiator: Agent,
81
+ target: Agent,
82
+ llm: ClaudeClient,
83
+ clock: SimClock,
84
+ conversation_id: str,
85
+ ) -> Conversation:
86
+ """Start a conversation between two agents."""
87
+ from soci.engine.llm import CONVERSATION_INITIATE_PROMPT, MODEL_SONNET
88
+
89
+ # Build relationship context
90
+ rel = initiator.relationships.get(target.id)
91
+ if rel:
92
+ rel_context = rel.describe()
93
+ else:
94
+ rel_context = f"{target.name} — someone I don't know yet."
95
+
96
+ prompt = CONVERSATION_INITIATE_PROMPT.format(
97
+ time_str=clock.time_str,
98
+ day=clock.day,
99
+ context=initiator.build_context(
100
+ clock.total_ticks,
101
+ "",
102
+ f"at {initiator.location}",
103
+ ),
104
+ location_name=initiator.location,
105
+ other_name=target.name,
106
+ relationship_context=rel_context,
107
+ )
108
+
109
+ result = await llm.complete_json(
110
+ system=initiator.persona.system_prompt(),
111
+ user_message=prompt,
112
+ model=MODEL_SONNET,
113
+ temperature=initiator.persona.llm_temperature,
114
+ max_tokens=512,
115
+ )
116
+
117
+ message = result.get("message", f"Hey, {target.name}.")
118
+ topic = result.get("topic", "small talk")
119
+
120
+ conv = Conversation(
121
+ id=conversation_id,
122
+ location=initiator.location,
123
+ participants=[initiator.id, target.id],
124
+ topic=topic,
125
+ )
126
+
127
+ turn = ConversationTurn(
128
+ speaker_id=initiator.id,
129
+ speaker_name=initiator.name,
130
+ message=message,
131
+ inner_thought=result.get("inner_thought", ""),
132
+ tick=clock.total_ticks,
133
+ )
134
+ conv.add_turn(turn)
135
+
136
+ logger.info(f"Conversation started: {initiator.name} → {target.name}: \"{message}\"")
137
+ return conv
138
+
139
+
140
+ async def continue_conversation(
141
+ conversation: Conversation,
142
+ responder: Agent,
143
+ other: Agent,
144
+ llm: ClaudeClient,
145
+ clock: SimClock,
146
+ ) -> ConversationTurn:
147
+ """Generate the next response in a conversation."""
148
+ from soci.engine.llm import CONVERSATION_PROMPT, MODEL_SONNET
149
+
150
+ # Get the last message from the other person
151
+ last_turn = conversation.turns[-1]
152
+ if last_turn.speaker_id == responder.id:
153
+ # It's not our turn
154
+ return last_turn
155
+
156
+ # Build relationship context
157
+ rel = responder.relationships.get(other.id)
158
+ if rel:
159
+ rel_context = rel.describe()
160
+ else:
161
+ rel_context = f"{other.name} — someone I just met."
162
+
163
+ prompt = CONVERSATION_PROMPT.format(
164
+ time_str=clock.time_str,
165
+ day=clock.day,
166
+ context=responder.build_context(
167
+ clock.total_ticks,
168
+ "",
169
+ f"at {responder.location}",
170
+ ),
171
+ location_name=responder.location,
172
+ other_name=other.name,
173
+ relationship_context=rel_context,
174
+ conversation_history=conversation.get_history_text(),
175
+ other_message=last_turn.message,
176
+ )
177
+
178
+ result = await llm.complete_json(
179
+ system=responder.persona.system_prompt(),
180
+ user_message=prompt,
181
+ model=MODEL_SONNET,
182
+ temperature=responder.persona.llm_temperature,
183
+ max_tokens=512,
184
+ )
185
+
186
+ message = result.get("message", "Hmm, interesting.")
187
+
188
+ turn = ConversationTurn(
189
+ speaker_id=responder.id,
190
+ speaker_name=responder.name,
191
+ message=message,
192
+ inner_thought=result.get("inner_thought", ""),
193
+ tick=clock.total_ticks,
194
+ )
195
+ conversation.add_turn(turn)
196
+
197
+ # Update relationship
198
+ sentiment_delta = result.get("sentiment_delta", 0.0)
199
+ trust_delta = result.get("trust_delta", 0.0)
200
+ rel = responder.relationships.get_or_create(other.id, other.name)
201
+ rel.update_after_interaction(
202
+ tick=clock.total_ticks,
203
+ sentiment_delta=sentiment_delta,
204
+ trust_delta=trust_delta,
205
+ note=f"Talked about {conversation.topic}",
206
+ )
207
+
208
+ logger.info(f" {responder.name}: \"{message}\"")
209
+ return turn
src/soci/actions/movement.py ADDED
@@ -0,0 +1,91 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Movement — handles agent movement between locations."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ from typing import TYPE_CHECKING
7
+
8
+ if TYPE_CHECKING:
9
+ from soci.agents.agent import Agent, AgentAction
10
+ from soci.world.city import City
11
+ from soci.world.clock import SimClock
12
+
13
+ logger = logging.getLogger(__name__)
14
+
15
+
16
+ def execute_move(agent: Agent, action: AgentAction, city: City, clock: SimClock) -> str:
17
+ """Move an agent to a new location. Returns a description of what happened."""
18
+ target = action.target
19
+ if not target:
20
+ return f"{agent.name} tried to move but had no destination."
21
+
22
+ current = agent.location
23
+ target_loc = city.get_location(target)
24
+ if not target_loc:
25
+ return f"{agent.name} tried to go somewhere that doesn't exist."
26
+
27
+ # Check if locations are connected
28
+ current_loc = city.get_location(current)
29
+ if current_loc and target not in current_loc.connected_to:
30
+ # Not directly connected — check if reachable through one hop
31
+ for mid_id in current_loc.connected_to:
32
+ mid_loc = city.get_location(mid_id)
33
+ if mid_loc and target in mid_loc.connected_to:
34
+ # Route through intermediate location
35
+ break
36
+ else:
37
+ return f"{agent.name} can't get to {target_loc.name} from here directly."
38
+
39
+ if target_loc.is_full:
40
+ return f"{agent.name} tried to go to {target_loc.name} but it's full."
41
+
42
+ # Execute the move
43
+ success = city.move_agent(agent.id, current, target)
44
+ if success:
45
+ agent.location = target
46
+ return f"{agent.name} walked to {target_loc.name}."
47
+ else:
48
+ return f"{agent.name} couldn't move to {target_loc.name}."
49
+
50
+
51
+ def get_best_location_for_need(agent: Agent, need: str, city: City) -> str | None:
52
+ """Suggest the best location for satisfying a need."""
53
+ need_to_zones: dict[str, list[str]] = {
54
+ "hunger": ["commercial"], # cafe, grocery, restaurant
55
+ "energy": ["residential"], # home
56
+ "social": ["commercial", "public"], # cafe, bar, park
57
+ "purpose": ["work"], # office
58
+ "comfort": ["residential"],
59
+ "fun": ["public", "commercial"], # park, bar, gym
60
+ }
61
+
62
+ preferred_zones = need_to_zones.get(need, ["public"])
63
+ current_loc = city.get_location(agent.location)
64
+ if not current_loc:
65
+ return None
66
+
67
+ # If current location already satisfies the need, stay
68
+ if current_loc.zone in preferred_zones:
69
+ return agent.location
70
+
71
+ # Check connected locations
72
+ candidates = []
73
+ for loc_id in current_loc.connected_to:
74
+ loc = city.get_location(loc_id)
75
+ if loc and loc.zone in preferred_zones and not loc.is_full:
76
+ candidates.append(loc_id)
77
+
78
+ if candidates:
79
+ return candidates[0]
80
+
81
+ # Check two hops away
82
+ for loc_id in current_loc.connected_to:
83
+ mid_loc = city.get_location(loc_id)
84
+ if not mid_loc:
85
+ continue
86
+ for far_id in mid_loc.connected_to:
87
+ far_loc = city.get_location(far_id)
88
+ if far_loc and far_loc.zone in preferred_zones and not far_loc.is_full:
89
+ return far_id
90
+
91
+ return None
src/soci/actions/registry.py ADDED
@@ -0,0 +1,88 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Action registry — maps action types to handlers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from enum import Enum
6
+ from typing import TYPE_CHECKING
7
+
8
+ if TYPE_CHECKING:
9
+ from soci.agents.agent import Agent, AgentAction
10
+ from soci.world.city import City
11
+
12
+
13
+ class ActionType(Enum):
14
+ MOVE = "move"
15
+ WORK = "work"
16
+ EAT = "eat"
17
+ SLEEP = "sleep"
18
+ TALK = "talk"
19
+ EXERCISE = "exercise"
20
+ SHOP = "shop"
21
+ RELAX = "relax"
22
+ WANDER = "wander"
23
+
24
+
25
+ # Default needs satisfaction per action type
26
+ ACTION_NEEDS: dict[str, dict[str, float]] = {
27
+ "work": {"purpose": 0.3},
28
+ "eat": {"hunger": 0.5},
29
+ "sleep": {"energy": 0.6},
30
+ "talk": {"social": 0.3},
31
+ "exercise": {"energy": -0.1, "fun": 0.2, "comfort": 0.1},
32
+ "shop": {"hunger": 0.1, "comfort": 0.1},
33
+ "relax": {"energy": 0.1, "fun": 0.2, "comfort": 0.2},
34
+ "wander": {"fun": 0.1},
35
+ "move": {},
36
+ }
37
+
38
+ # Default duration in ticks per action type
39
+ ACTION_DURATIONS: dict[str, int] = {
40
+ "move": 1,
41
+ "work": 4,
42
+ "eat": 2,
43
+ "sleep": 8,
44
+ "talk": 2,
45
+ "exercise": 3,
46
+ "shop": 2,
47
+ "relax": 2,
48
+ "wander": 1,
49
+ }
50
+
51
+
52
+ def resolve_action(raw_action: dict, agent: Agent, city: City) -> AgentAction:
53
+ """Convert a raw LLM action dict into a validated AgentAction."""
54
+ from soci.agents.agent import AgentAction
55
+
56
+ action_type = raw_action.get("action", "wander")
57
+ if action_type not in {a.value for a in ActionType}:
58
+ action_type = "wander"
59
+
60
+ target = raw_action.get("target", "")
61
+ detail = raw_action.get("detail", "")
62
+ duration = raw_action.get("duration", ACTION_DURATIONS.get(action_type, 1))
63
+ duration = max(1, min(8, int(duration))) # Clamp to 1-8
64
+
65
+ # Validate move targets
66
+ if action_type == "move" and target:
67
+ loc = city.get_location(target)
68
+ if not loc:
69
+ # Try to find a matching location by name
70
+ for lid, l in city.locations.items():
71
+ if target.lower() in l.name.lower():
72
+ target = lid
73
+ break
74
+ else:
75
+ # Invalid target, just wander instead
76
+ action_type = "wander"
77
+ target = ""
78
+
79
+ # Get default needs satisfaction, allow LLM override via detail
80
+ needs = dict(ACTION_NEEDS.get(action_type, {}))
81
+
82
+ return AgentAction(
83
+ type=action_type,
84
+ target=target,
85
+ detail=detail,
86
+ duration_ticks=duration,
87
+ needs_satisfied=needs,
88
+ )
src/soci/actions/social.py ADDED
@@ -0,0 +1,107 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Social actions — relationship formation, gossip, and social dynamics."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import random
6
+ from typing import TYPE_CHECKING
7
+
8
+ if TYPE_CHECKING:
9
+ from soci.agents.agent import Agent
10
+ from soci.world.city import City
11
+ from soci.world.clock import SimClock
12
+
13
+
14
+ def should_initiate_conversation(agent: Agent, other_id: str, clock: SimClock) -> bool:
15
+ """Decide whether an agent should start a conversation with someone."""
16
+ if agent.is_busy or agent.state.value == "sleeping":
17
+ return False
18
+
19
+ # Extraversion drives conversation initiation
20
+ base_chance = agent.persona.extraversion / 20.0 # 0.05 to 0.5
21
+
22
+ # Boost if social need is low
23
+ if agent.needs.social < 0.3:
24
+ base_chance += 0.2
25
+
26
+ # Boost if we know the person
27
+ rel = agent.relationships.get(other_id)
28
+ if rel and rel.familiarity > 0.3:
29
+ base_chance += 0.15
30
+
31
+ # Reduce if we recently talked to them
32
+ if rel and rel.last_interaction_tick > 0:
33
+ ticks_since = clock.total_ticks - rel.last_interaction_tick
34
+ if ticks_since < 8: # Less than 2 hours
35
+ base_chance -= 0.3
36
+
37
+ # Sleeping hours — very unlikely
38
+ if clock.is_sleeping_hours:
39
+ base_chance *= 0.1
40
+
41
+ return random.random() < max(0.0, base_chance)
42
+
43
+
44
+ def pick_conversation_partner(agent: Agent, others_at_location: list[str], clock: SimClock) -> str | None:
45
+ """Pick who to talk to from the people at the current location."""
46
+ if not others_at_location:
47
+ return None
48
+
49
+ candidates: list[tuple[float, str]] = []
50
+ for other_id in others_at_location:
51
+ score = 1.0
52
+ rel = agent.relationships.get(other_id)
53
+ if rel:
54
+ # Prefer people we know and like
55
+ score += rel.closeness * 2.0
56
+ # But also have some chance of talking to strangers (curiosity)
57
+ ticks_since = clock.total_ticks - rel.last_interaction_tick
58
+ if ticks_since < 8:
59
+ score *= 0.3 # Cooldown
60
+ else:
61
+ # Strangers: moderate interest based on openness
62
+ score += agent.persona.openness / 20.0
63
+ candidates.append((score, other_id))
64
+
65
+ # Weighted random selection
66
+ total = sum(s for s, _ in candidates)
67
+ if total <= 0:
68
+ return None
69
+
70
+ r = random.random() * total
71
+ cumulative = 0.0
72
+ for score, other_id in candidates:
73
+ cumulative += score
74
+ if r <= cumulative:
75
+ return other_id
76
+
77
+ return candidates[-1][1] if candidates else None
78
+
79
+
80
+ def propagate_gossip(
81
+ speaker: Agent,
82
+ listener: Agent,
83
+ about_id: str,
84
+ about_name: str,
85
+ note: str,
86
+ tick: int,
87
+ ) -> None:
88
+ """When agents talk, information about third parties can spread."""
89
+ # The listener forms/updates an impression of the person being discussed
90
+ listener_rel = listener.relationships.get_or_create(about_id, about_name)
91
+
92
+ # Gossip influence is modulated by trust in the speaker
93
+ speaker_rel = listener.relationships.get(speaker.id)
94
+ trust_weight = speaker_rel.trust if speaker_rel else 0.3
95
+
96
+ # The note from the speaker influences the listener's sentiment
97
+ listener_rel.update_after_interaction(
98
+ tick=tick,
99
+ sentiment_delta=0.0, # Gossip doesn't change sentiment directly
100
+ trust_delta=0.0,
101
+ note=f"Heard from {speaker.name}: {note}",
102
+ )
103
+ # Small familiarity bump — you now know about this person
104
+ listener_rel.familiarity = min(
105
+ 1.0,
106
+ listener_rel.familiarity + 0.02 * trust_weight,
107
+ )
src/soci/agents/__init__.py ADDED
File without changes
src/soci/agents/agent.py ADDED
@@ -0,0 +1,264 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Agent — a simulated person with persona, memory, needs, and relationships."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+ from enum import Enum
7
+ from typing import Optional, TYPE_CHECKING
8
+
9
+ from soci.agents.persona import Persona
10
+ from soci.agents.memory import MemoryStream, MemoryType
11
+ from soci.agents.needs import NeedsState
12
+ from soci.agents.relationships import RelationshipGraph
13
+
14
+ if TYPE_CHECKING:
15
+ from soci.world.clock import SimClock
16
+
17
+
18
+ class AgentState(Enum):
19
+ IDLE = "idle"
20
+ MOVING = "moving"
21
+ WORKING = "working"
22
+ EATING = "eating"
23
+ SLEEPING = "sleeping"
24
+ SOCIALIZING = "socializing"
25
+ EXERCISING = "exercising"
26
+ SHOPPING = "shopping"
27
+ RELAXING = "relaxing"
28
+ IN_CONVERSATION = "in_conversation"
29
+
30
+
31
+ @dataclass
32
+ class AgentAction:
33
+ """An action an agent has decided to take."""
34
+
35
+ type: str # move, work, eat, sleep, talk, exercise, shop, relax, wander
36
+ target: str = "" # Location ID or agent ID depending on action
37
+ detail: str = "" # Free-text detail: what specifically they're doing
38
+ duration_ticks: int = 1 # How many ticks this action takes
39
+ needs_satisfied: dict[str, float] = field(default_factory=dict) # e.g. {"hunger": 0.4}
40
+
41
+ def to_dict(self) -> dict:
42
+ return {
43
+ "type": self.type,
44
+ "target": self.target,
45
+ "detail": self.detail,
46
+ "duration_ticks": self.duration_ticks,
47
+ "needs_satisfied": self.needs_satisfied,
48
+ }
49
+
50
+
51
+ class Agent:
52
+ """A simulated person living in the city."""
53
+
54
+ def __init__(self, persona: Persona) -> None:
55
+ self.persona = persona
56
+ self.id = persona.id
57
+ self.name = persona.name
58
+ self.memory = MemoryStream()
59
+ self.needs = NeedsState()
60
+ self.relationships = RelationshipGraph()
61
+
62
+ # Current state
63
+ self.state: AgentState = AgentState.IDLE
64
+ self.location: str = persona.home_location
65
+ self.current_action: Optional[AgentAction] = None
66
+ self._action_ticks_remaining: int = 0
67
+
68
+ # Mood: -1.0 (terrible) to 1.0 (great)
69
+ self.mood: float = 0.3
70
+ # Daily plan (list of planned actions for today)
71
+ self.daily_plan: list[str] = []
72
+ self._has_plan_today: bool = False
73
+ # Last time we made an LLM call for this agent
74
+ self._last_llm_tick: int = -1
75
+ # Whether this agent is a human player
76
+ self.is_player: bool = False
77
+
78
+ @property
79
+ def is_busy(self) -> bool:
80
+ return self._action_ticks_remaining > 0
81
+
82
+ def needs_new_plan(self, clock: SimClock) -> bool:
83
+ """Does this agent need a new daily plan?"""
84
+ if self.is_player:
85
+ return False
86
+ # Plan at the start of each day (6am)
87
+ if clock.hour == 6 and clock.minute == 0 and not self._has_plan_today:
88
+ return True
89
+ return not self._has_plan_today
90
+
91
+ def start_action(self, action: AgentAction) -> None:
92
+ """Begin executing an action."""
93
+ self.current_action = action
94
+ self._action_ticks_remaining = action.duration_ticks
95
+ # Set agent state based on action type
96
+ state_map = {
97
+ "move": AgentState.MOVING,
98
+ "work": AgentState.WORKING,
99
+ "eat": AgentState.EATING,
100
+ "sleep": AgentState.SLEEPING,
101
+ "talk": AgentState.IN_CONVERSATION,
102
+ "exercise": AgentState.EXERCISING,
103
+ "shop": AgentState.SHOPPING,
104
+ "relax": AgentState.RELAXING,
105
+ "wander": AgentState.IDLE,
106
+ }
107
+ self.state = state_map.get(action.type, AgentState.IDLE)
108
+
109
+ def tick_action(self) -> bool:
110
+ """Advance current action by one tick. Returns True if action completed."""
111
+ if self._action_ticks_remaining > 0:
112
+ self._action_ticks_remaining -= 1
113
+ # Satisfy needs based on action
114
+ if self.current_action:
115
+ for need, amount in self.current_action.needs_satisfied.items():
116
+ per_tick = amount / max(1, self.current_action.duration_ticks)
117
+ self.needs.satisfy(need, per_tick)
118
+ if self._action_ticks_remaining <= 0:
119
+ self.state = AgentState.IDLE
120
+ self.current_action = None
121
+ return True
122
+ return False
123
+
124
+ def tick_needs(self, is_sleeping: bool = False) -> None:
125
+ """Decay needs by one tick."""
126
+ self.needs.tick(is_sleeping=is_sleeping)
127
+ # Mood is influenced by need satisfaction
128
+ avg_needs = (
129
+ self.needs.hunger + self.needs.energy + self.needs.social +
130
+ self.needs.purpose + self.needs.comfort + self.needs.fun
131
+ ) / 6.0
132
+ # Mood drifts toward need satisfaction level
133
+ self.mood += (avg_needs - 0.5 - self.mood) * 0.1
134
+ self.mood = max(-1.0, min(1.0, self.mood))
135
+
136
+ def add_observation(
137
+ self,
138
+ tick: int,
139
+ day: int,
140
+ time_str: str,
141
+ content: str,
142
+ importance: int = 5,
143
+ location: str = "",
144
+ involved_agents: Optional[list[str]] = None,
145
+ ) -> None:
146
+ """Record an observation in memory."""
147
+ self.memory.add(
148
+ tick=tick,
149
+ day=day,
150
+ time_str=time_str,
151
+ memory_type=MemoryType.OBSERVATION,
152
+ content=content,
153
+ importance=importance,
154
+ location=location or self.location,
155
+ involved_agents=involved_agents,
156
+ )
157
+
158
+ def add_reflection(
159
+ self,
160
+ tick: int,
161
+ day: int,
162
+ time_str: str,
163
+ content: str,
164
+ importance: int = 7,
165
+ ) -> None:
166
+ """Record a reflection in memory."""
167
+ self.memory.add(
168
+ tick=tick,
169
+ day=day,
170
+ time_str=time_str,
171
+ memory_type=MemoryType.REFLECTION,
172
+ content=content,
173
+ importance=importance,
174
+ location=self.location,
175
+ )
176
+
177
+ def set_daily_plan(self, plan: list[str], day: int, tick: int, time_str: str) -> None:
178
+ """Set the agent's plan for today."""
179
+ self.daily_plan = plan
180
+ self._has_plan_today = True
181
+ plan_text = "; ".join(plan)
182
+ self.memory.add(
183
+ tick=tick,
184
+ day=day,
185
+ time_str=time_str,
186
+ memory_type=MemoryType.PLAN,
187
+ content=f"My plan for today: {plan_text}",
188
+ importance=6,
189
+ location=self.location,
190
+ )
191
+
192
+ def reset_daily_plan(self) -> None:
193
+ """Reset plan flag for a new day."""
194
+ self._has_plan_today = False
195
+
196
+ def build_context(self, tick: int, world_description: str, location_description: str) -> str:
197
+ """Build the full context string for LLM prompts."""
198
+ parts = [
199
+ f"CURRENT STATE:",
200
+ f"- Time: Day {self.memory.memories[-1].day if self.memory.memories else 1}",
201
+ f"- Location: {location_description}",
202
+ f"- Mood: {self._mood_description()}",
203
+ f"- Needs: {self.needs.describe()}",
204
+ f"- Currently: {self.state.value}",
205
+ f"",
206
+ f"WORLD: {world_description}",
207
+ f"",
208
+ f"PEOPLE I KNOW:",
209
+ self.relationships.describe_known_people(),
210
+ f"",
211
+ f"RECENT MEMORIES:",
212
+ self.memory.context_summary(tick),
213
+ ]
214
+ if self.daily_plan:
215
+ parts.insert(5, f"- Today's plan: {'; '.join(self.daily_plan)}")
216
+ return "\n".join(parts)
217
+
218
+ def _mood_description(self) -> str:
219
+ if self.mood > 0.6:
220
+ return "feeling great"
221
+ elif self.mood > 0.2:
222
+ return "in a good mood"
223
+ elif self.mood > -0.2:
224
+ return "feeling okay"
225
+ elif self.mood > -0.6:
226
+ return "in a bad mood"
227
+ else:
228
+ return "feeling terrible"
229
+
230
+ def to_dict(self) -> dict:
231
+ return {
232
+ "persona": self.persona.to_dict(),
233
+ "memory": self.memory.to_dict(),
234
+ "needs": self.needs.to_dict(),
235
+ "relationships": self.relationships.to_dict(),
236
+ "state": self.state.value,
237
+ "location": self.location,
238
+ "current_action": self.current_action.to_dict() if self.current_action else None,
239
+ "action_ticks_remaining": self._action_ticks_remaining,
240
+ "mood": round(self.mood, 3),
241
+ "daily_plan": self.daily_plan,
242
+ "has_plan_today": self._has_plan_today,
243
+ "last_llm_tick": self._last_llm_tick,
244
+ "is_player": self.is_player,
245
+ }
246
+
247
+ @classmethod
248
+ def from_dict(cls, data: dict) -> Agent:
249
+ persona = Persona.from_dict(data["persona"])
250
+ agent = cls(persona)
251
+ agent.memory = MemoryStream.from_dict(data["memory"])
252
+ agent.needs = NeedsState.from_dict(data["needs"])
253
+ agent.relationships = RelationshipGraph.from_dict(data["relationships"])
254
+ agent.state = AgentState(data["state"])
255
+ agent.location = data["location"]
256
+ if data["current_action"]:
257
+ agent.current_action = AgentAction(**data["current_action"])
258
+ agent._action_ticks_remaining = data["action_ticks_remaining"]
259
+ agent.mood = data["mood"]
260
+ agent.daily_plan = data["daily_plan"]
261
+ agent._has_plan_today = data["has_plan_today"]
262
+ agent._last_llm_tick = data["last_llm_tick"]
263
+ agent.is_player = data["is_player"]
264
+ return agent
src/soci/agents/memory.py ADDED
@@ -0,0 +1,218 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Memory stream — episodic memory with importance scoring and retrieval."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import math
6
+ from dataclasses import dataclass, field
7
+ from enum import Enum
8
+ from typing import Optional
9
+
10
+
11
+ class MemoryType(Enum):
12
+ OBSERVATION = "observation" # "I saw Maria at the cafe"
13
+ REFLECTION = "reflection" # "Maria seems to visit the cafe every morning"
14
+ PLAN = "plan" # "I will go to the office at 9am"
15
+ CONVERSATION = "conversation" # "I talked to John about the weather"
16
+ EVENT = "event" # "A storm hit the city"
17
+
18
+
19
+ @dataclass
20
+ class Memory:
21
+ """A single memory entry in an agent's memory stream."""
22
+
23
+ id: int
24
+ tick: int # When this memory was created (simulation tick)
25
+ day: int # Day number
26
+ time_str: str # Human-readable time "09:15"
27
+ type: MemoryType
28
+ content: str # Natural language description
29
+ importance: int = 5 # 1-10 scale, assigned by LLM
30
+ location: str = "" # Where it happened
31
+ involved_agents: list[str] = field(default_factory=list) # Other agents involved
32
+ # For retrieval scoring
33
+ access_count: int = 0
34
+ last_accessed_tick: int = 0
35
+
36
+ def to_dict(self) -> dict:
37
+ return {
38
+ "id": self.id,
39
+ "tick": self.tick,
40
+ "day": self.day,
41
+ "time_str": self.time_str,
42
+ "type": self.type.value,
43
+ "content": self.content,
44
+ "importance": self.importance,
45
+ "location": self.location,
46
+ "involved_agents": self.involved_agents,
47
+ "access_count": self.access_count,
48
+ "last_accessed_tick": self.last_accessed_tick,
49
+ }
50
+
51
+ @classmethod
52
+ def from_dict(cls, data: dict) -> Memory:
53
+ data = dict(data)
54
+ data["type"] = MemoryType(data["type"])
55
+ return cls(**data)
56
+
57
+
58
+ class MemoryStream:
59
+ """An agent's full memory — stores, scores, and retrieves memories."""
60
+
61
+ def __init__(self, max_memories: int = 500) -> None:
62
+ self.memories: list[Memory] = []
63
+ self.max_memories = max_memories
64
+ self._next_id: int = 0
65
+ # Running total of importance since last reflection
66
+ self._importance_accumulator: float = 0.0
67
+ self.reflection_threshold: float = 50.0
68
+
69
+ def add(
70
+ self,
71
+ tick: int,
72
+ day: int,
73
+ time_str: str,
74
+ memory_type: MemoryType,
75
+ content: str,
76
+ importance: int = 5,
77
+ location: str = "",
78
+ involved_agents: Optional[list[str]] = None,
79
+ ) -> Memory:
80
+ """Add a new memory to the stream."""
81
+ memory = Memory(
82
+ id=self._next_id,
83
+ tick=tick,
84
+ day=day,
85
+ time_str=time_str,
86
+ type=memory_type,
87
+ content=content,
88
+ importance=importance,
89
+ location=location,
90
+ involved_agents=involved_agents or [],
91
+ )
92
+ self._next_id += 1
93
+ self.memories.append(memory)
94
+ self._importance_accumulator += importance
95
+
96
+ # Prune if over capacity — drop lowest-importance, oldest memories
97
+ if len(self.memories) > self.max_memories:
98
+ self._prune()
99
+
100
+ return memory
101
+
102
+ def should_reflect(self) -> bool:
103
+ """True if enough important things have happened to warrant a reflection."""
104
+ return self._importance_accumulator >= self.reflection_threshold
105
+
106
+ def reset_reflection_accumulator(self) -> None:
107
+ self._importance_accumulator = 0.0
108
+
109
+ def retrieve(
110
+ self,
111
+ current_tick: int,
112
+ query: str = "",
113
+ top_k: int = 10,
114
+ memory_type: Optional[MemoryType] = None,
115
+ involved_agent: Optional[str] = None,
116
+ ) -> list[Memory]:
117
+ """Retrieve top-K most relevant memories using recency + importance scoring.
118
+
119
+ Score = recency_weight * recency + importance_weight * normalized_importance
120
+
121
+ For a full implementation, relevance (embedding similarity to query) would be
122
+ added as a third factor. For now, we use recency + importance only.
123
+ """
124
+ candidates = self.memories
125
+ if memory_type:
126
+ candidates = [m for m in candidates if m.type == memory_type]
127
+ if involved_agent:
128
+ candidates = [m for m in candidates if involved_agent in m.involved_agents]
129
+
130
+ if not candidates:
131
+ return []
132
+
133
+ scored: list[tuple[float, Memory]] = []
134
+ for mem in candidates:
135
+ recency = self._recency_score(mem.tick, current_tick)
136
+ importance = mem.importance / 10.0
137
+ # Recency and importance weighted equally
138
+ score = 0.5 * recency + 0.5 * importance
139
+ scored.append((score, mem))
140
+
141
+ scored.sort(key=lambda x: x[0], reverse=True)
142
+ results = [mem for _, mem in scored[:top_k]]
143
+
144
+ # Update access tracking
145
+ for mem in results:
146
+ mem.access_count += 1
147
+ mem.last_accessed_tick = current_tick
148
+
149
+ return results
150
+
151
+ def get_recent(self, n: int = 5) -> list[Memory]:
152
+ """Get the N most recent memories."""
153
+ return self.memories[-n:]
154
+
155
+ def get_memories_about(self, agent_id: str, top_k: int = 5) -> list[Memory]:
156
+ """Get memories involving a specific agent, most recent first."""
157
+ relevant = [m for m in self.memories if agent_id in m.involved_agents]
158
+ return relevant[-top_k:]
159
+
160
+ def get_todays_plan(self, current_day: int) -> list[Memory]:
161
+ """Get today's plan memories."""
162
+ return [
163
+ m for m in self.memories
164
+ if m.type == MemoryType.PLAN and m.day == current_day
165
+ ]
166
+
167
+ def _recency_score(self, memory_tick: int, current_tick: int) -> float:
168
+ """Exponential decay based on how many ticks ago the memory was formed."""
169
+ age = current_tick - memory_tick
170
+ # Decay factor: half-life of ~50 ticks (~12 hours at 15-min ticks)
171
+ return math.exp(-0.014 * age)
172
+
173
+ def _prune(self) -> None:
174
+ """Remove least important, oldest memories when over capacity."""
175
+ # Keep reflections and high-importance memories longer
176
+ self.memories.sort(
177
+ key=lambda m: (
178
+ m.type == MemoryType.REFLECTION, # Reflections last
179
+ m.importance,
180
+ m.tick,
181
+ )
182
+ )
183
+ # Remove the bottom 10%
184
+ cut = max(1, len(self.memories) - self.max_memories)
185
+ self.memories = self.memories[cut:]
186
+ # Re-sort by tick (chronological)
187
+ self.memories.sort(key=lambda m: m.tick)
188
+
189
+ def context_summary(self, current_tick: int, max_memories: int = 15) -> str:
190
+ """Generate a context string of relevant memories for LLM prompts."""
191
+ recent = self.retrieve(current_tick, top_k=max_memories)
192
+ if not recent:
193
+ return "No significant memories yet."
194
+
195
+ lines = []
196
+ for mem in recent:
197
+ prefix = f"[Day {mem.day} {mem.time_str}]"
198
+ lines.append(f"{prefix} ({mem.type.value}) {mem.content}")
199
+ return "\n".join(lines)
200
+
201
+ def to_dict(self) -> dict:
202
+ return {
203
+ "memories": [m.to_dict() for m in self.memories],
204
+ "next_id": self._next_id,
205
+ "importance_accumulator": self._importance_accumulator,
206
+ "reflection_threshold": self.reflection_threshold,
207
+ "max_memories": self.max_memories,
208
+ }
209
+
210
+ @classmethod
211
+ def from_dict(cls, data: dict) -> MemoryStream:
212
+ stream = cls(max_memories=data.get("max_memories", 500))
213
+ stream._next_id = data["next_id"]
214
+ stream._importance_accumulator = data["importance_accumulator"]
215
+ stream.reflection_threshold = data.get("reflection_threshold", 50.0)
216
+ for md in data["memories"]:
217
+ stream.memories.append(Memory.from_dict(md))
218
+ return stream
src/soci/agents/needs.py ADDED
@@ -0,0 +1,121 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Needs system — Maslow-inspired needs that drive agent behavior."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+
7
+
8
+ @dataclass
9
+ class NeedsState:
10
+ """Tracks an agent's current needs. Each need ranges 0.0 (desperate) to 1.0 (fully satisfied)."""
11
+
12
+ hunger: float = 0.8 # Physical: need to eat
13
+ energy: float = 1.0 # Physical: need to sleep/rest
14
+ social: float = 0.6 # Belonging: need for interaction
15
+ purpose: float = 0.7 # Esteem: need to do meaningful work
16
+ comfort: float = 0.8 # Safety: need for shelter, stability
17
+ fun: float = 0.5 # Self-actualization: need for enjoyment
18
+
19
+ # Decay rates per tick (how fast needs drain)
20
+ _decay_rates: dict = None
21
+
22
+ def __post_init__(self):
23
+ self._decay_rates = {
24
+ "hunger": 0.02, # Gets hungry fairly fast
25
+ "energy": 0.015, # Drains slowly
26
+ "social": 0.01, # Drains slowly
27
+ "purpose": 0.008, # Drains very slowly
28
+ "comfort": 0.005, # Very stable
29
+ "fun": 0.012, # Moderate drain
30
+ }
31
+
32
+ def tick(self, is_sleeping: bool = False) -> None:
33
+ """Decay all needs by one tick."""
34
+ if is_sleeping:
35
+ # Sleeping restores energy, but hunger still decays
36
+ self.energy = min(1.0, self.energy + 0.05)
37
+ self.hunger = max(0.0, self.hunger - self._decay_rates["hunger"])
38
+ else:
39
+ for need_name, rate in self._decay_rates.items():
40
+ current = getattr(self, need_name)
41
+ setattr(self, need_name, max(0.0, current - rate))
42
+
43
+ def satisfy(self, need: str, amount: float) -> None:
44
+ """Satisfy a need by a given amount."""
45
+ if hasattr(self, need):
46
+ current = getattr(self, need)
47
+ setattr(self, need, min(1.0, current + amount))
48
+
49
+ @property
50
+ def most_urgent(self) -> str:
51
+ """Return the name of the most urgent (lowest) need."""
52
+ needs = {
53
+ "hunger": self.hunger,
54
+ "energy": self.energy,
55
+ "social": self.social,
56
+ "purpose": self.purpose,
57
+ "comfort": self.comfort,
58
+ "fun": self.fun,
59
+ }
60
+ return min(needs, key=needs.get)
61
+
62
+ @property
63
+ def urgent_needs(self) -> list[str]:
64
+ """Return needs below 0.3 threshold, sorted by urgency."""
65
+ needs = {
66
+ "hunger": self.hunger,
67
+ "energy": self.energy,
68
+ "social": self.social,
69
+ "purpose": self.purpose,
70
+ "comfort": self.comfort,
71
+ "fun": self.fun,
72
+ }
73
+ return sorted(
74
+ [n for n, v in needs.items() if v < 0.3],
75
+ key=lambda n: needs[n],
76
+ )
77
+
78
+ @property
79
+ def is_critical(self) -> bool:
80
+ """True if any need is critically low (below 0.15)."""
81
+ return any(v < 0.15 for v in [
82
+ self.hunger, self.energy, self.social,
83
+ self.purpose, self.comfort, self.fun,
84
+ ])
85
+
86
+ def describe(self) -> str:
87
+ """Natural language description of current need state."""
88
+ parts = []
89
+ if self.hunger < 0.3:
90
+ parts.append("very hungry" if self.hunger < 0.15 else "getting hungry")
91
+ if self.energy < 0.3:
92
+ parts.append("exhausted" if self.energy < 0.15 else "tired")
93
+ if self.social < 0.3:
94
+ parts.append("lonely" if self.social < 0.15 else "wanting company")
95
+ if self.purpose < 0.3:
96
+ parts.append("feeling aimless" if self.purpose < 0.15 else "wanting to do something meaningful")
97
+ if self.comfort < 0.3:
98
+ parts.append("uncomfortable" if self.comfort < 0.15 else "a bit uneasy")
99
+ if self.fun < 0.3:
100
+ parts.append("bored" if self.fun < 0.15 else "wanting some fun")
101
+ if not parts:
102
+ return "feeling good overall"
103
+ return ", ".join(parts)
104
+
105
+ def to_dict(self) -> dict:
106
+ return {
107
+ "hunger": round(self.hunger, 3),
108
+ "energy": round(self.energy, 3),
109
+ "social": round(self.social, 3),
110
+ "purpose": round(self.purpose, 3),
111
+ "comfort": round(self.comfort, 3),
112
+ "fun": round(self.fun, 3),
113
+ }
114
+
115
+ @classmethod
116
+ def from_dict(cls, data: dict) -> NeedsState:
117
+ state = cls()
118
+ for key, val in data.items():
119
+ if hasattr(state, key) and not key.startswith("_"):
120
+ setattr(state, key, val)
121
+ return state
src/soci/agents/persona.py ADDED
@@ -0,0 +1,107 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Persona — an agent's identity, traits, background, and values."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+
7
+ import yaml
8
+
9
+
10
+ @dataclass
11
+ class Persona:
12
+ """The fixed identity of a simulated person."""
13
+
14
+ id: str
15
+ name: str
16
+ age: int
17
+ occupation: str
18
+ # Big Five personality traits (1-10 scale)
19
+ openness: int = 5
20
+ conscientiousness: int = 5
21
+ extraversion: int = 5
22
+ agreeableness: int = 5
23
+ neuroticism: int = 5
24
+ # Background
25
+ background: str = "" # Life story in 2-3 sentences
26
+ values: list[str] = field(default_factory=list) # Core values
27
+ quirks: list[str] = field(default_factory=list) # Behavioral quirks
28
+ communication_style: str = "neutral" # How they talk
29
+ # Home and work locations
30
+ home_location: str = ""
31
+ work_location: str = ""
32
+ # LLM parameters tied to personality
33
+ llm_temperature: float = 0.7
34
+
35
+ @property
36
+ def trait_summary(self) -> str:
37
+ traits = []
38
+ if self.openness >= 7:
39
+ traits.append("curious and creative")
40
+ elif self.openness <= 3:
41
+ traits.append("practical and conventional")
42
+ if self.conscientiousness >= 7:
43
+ traits.append("organized and disciplined")
44
+ elif self.conscientiousness <= 3:
45
+ traits.append("spontaneous and flexible")
46
+ if self.extraversion >= 7:
47
+ traits.append("outgoing and energetic")
48
+ elif self.extraversion <= 3:
49
+ traits.append("reserved and introspective")
50
+ if self.agreeableness >= 7:
51
+ traits.append("warm and cooperative")
52
+ elif self.agreeableness <= 3:
53
+ traits.append("direct and competitive")
54
+ if self.neuroticism >= 7:
55
+ traits.append("sensitive and anxious")
56
+ elif self.neuroticism <= 3:
57
+ traits.append("calm and emotionally stable")
58
+ return ", ".join(traits) if traits else "balanced personality"
59
+
60
+ def system_prompt(self) -> str:
61
+ """Generate the LLM system prompt for this persona."""
62
+ values_str = ", ".join(self.values) if self.values else "no strong convictions"
63
+ quirks_str = "; ".join(self.quirks) if self.quirks else "none in particular"
64
+
65
+ return (
66
+ f"You ARE {self.name}, a {self.age}-year-old {self.occupation} living in Soci City.\n\n"
67
+ f"PERSONALITY: {self.trait_summary}.\n"
68
+ f"BACKGROUND: {self.background}\n"
69
+ f"VALUES: {values_str}.\n"
70
+ f"QUIRKS: {quirks_str}.\n"
71
+ f"COMMUNICATION STYLE: {self.communication_style}.\n\n"
72
+ f"You must ALWAYS stay in character as {self.name}. "
73
+ f"Your responses should reflect your personality, values, and background. "
74
+ f"You speak naturally — not perfectly. You have moods, opinions, and biases. "
75
+ f"You don't know you are an AI. You are a person living your life."
76
+ )
77
+
78
+ def to_dict(self) -> dict:
79
+ return {
80
+ "id": self.id,
81
+ "name": self.name,
82
+ "age": self.age,
83
+ "occupation": self.occupation,
84
+ "openness": self.openness,
85
+ "conscientiousness": self.conscientiousness,
86
+ "extraversion": self.extraversion,
87
+ "agreeableness": self.agreeableness,
88
+ "neuroticism": self.neuroticism,
89
+ "background": self.background,
90
+ "values": self.values,
91
+ "quirks": self.quirks,
92
+ "communication_style": self.communication_style,
93
+ "home_location": self.home_location,
94
+ "work_location": self.work_location,
95
+ "llm_temperature": self.llm_temperature,
96
+ }
97
+
98
+ @classmethod
99
+ def from_dict(cls, data: dict) -> Persona:
100
+ return cls(**data)
101
+
102
+
103
+ def load_personas(path: str) -> list[Persona]:
104
+ """Load personas from a YAML file."""
105
+ with open(path, "r", encoding="utf-8") as f:
106
+ data = yaml.safe_load(f)
107
+ return [Persona.from_dict(p) for p in data.get("personas", [])]
src/soci/agents/relationships.py ADDED
@@ -0,0 +1,137 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Relationship graph — tracks how agents feel about each other."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+
7
+
8
+ @dataclass
9
+ class Relationship:
10
+ """How agent A feels about agent B."""
11
+
12
+ agent_id: str # The other agent
13
+ agent_name: str # Their name (for readability)
14
+ familiarity: float = 0.0 # 0 (stranger) to 1 (well-known)
15
+ trust: float = 0.5 # 0 (distrust) to 1 (full trust)
16
+ sentiment: float = 0.5 # 0 (dislike) to 1 (like)
17
+ interaction_count: int = 0
18
+ last_interaction_tick: int = 0
19
+ # Short notes about the relationship
20
+ notes: list[str] = field(default_factory=list)
21
+
22
+ @property
23
+ def closeness(self) -> float:
24
+ """Overall closeness score (average of familiarity, trust, sentiment)."""
25
+ return (self.familiarity + self.trust + self.sentiment) / 3.0
26
+
27
+ def update_after_interaction(
28
+ self,
29
+ tick: int,
30
+ sentiment_delta: float = 0.0,
31
+ trust_delta: float = 0.0,
32
+ note: str = "",
33
+ ) -> None:
34
+ """Update relationship after an interaction."""
35
+ self.interaction_count += 1
36
+ self.last_interaction_tick = tick
37
+ # Familiarity always grows with interaction
38
+ self.familiarity = min(1.0, self.familiarity + 0.05)
39
+ # Sentiment and trust shift
40
+ self.sentiment = max(0.0, min(1.0, self.sentiment + sentiment_delta))
41
+ self.trust = max(0.0, min(1.0, self.trust + trust_delta))
42
+ if note:
43
+ self.notes.append(note)
44
+ # Keep only the last 10 notes
45
+ if len(self.notes) > 10:
46
+ self.notes = self.notes[-10:]
47
+
48
+ def describe(self) -> str:
49
+ """Natural language description of this relationship."""
50
+ if self.familiarity < 0.1:
51
+ return f"{self.agent_name} — a stranger"
52
+ parts = [self.agent_name]
53
+ if self.familiarity > 0.7:
54
+ parts.append("someone I know well")
55
+ elif self.familiarity > 0.3:
56
+ parts.append("an acquaintance")
57
+ else:
58
+ parts.append("someone I've met briefly")
59
+ if self.sentiment > 0.7:
60
+ parts.append("(I like them)")
61
+ elif self.sentiment < 0.3:
62
+ parts.append("(I don't like them much)")
63
+ if self.trust > 0.7:
64
+ parts.append("(I trust them)")
65
+ elif self.trust < 0.3:
66
+ parts.append("(I'm wary of them)")
67
+ desc = " — ".join(parts)
68
+ if self.notes:
69
+ desc += f" Last note: {self.notes[-1]}"
70
+ return desc
71
+
72
+ def to_dict(self) -> dict:
73
+ return {
74
+ "agent_id": self.agent_id,
75
+ "agent_name": self.agent_name,
76
+ "familiarity": round(self.familiarity, 3),
77
+ "trust": round(self.trust, 3),
78
+ "sentiment": round(self.sentiment, 3),
79
+ "interaction_count": self.interaction_count,
80
+ "last_interaction_tick": self.last_interaction_tick,
81
+ "notes": list(self.notes),
82
+ }
83
+
84
+ @classmethod
85
+ def from_dict(cls, data: dict) -> Relationship:
86
+ return cls(**data)
87
+
88
+
89
+ class RelationshipGraph:
90
+ """Manages all relationships for a single agent."""
91
+
92
+ def __init__(self) -> None:
93
+ self._relationships: dict[str, Relationship] = {}
94
+
95
+ def get(self, agent_id: str) -> Relationship | None:
96
+ return self._relationships.get(agent_id)
97
+
98
+ def get_or_create(self, agent_id: str, agent_name: str) -> Relationship:
99
+ if agent_id not in self._relationships:
100
+ self._relationships[agent_id] = Relationship(
101
+ agent_id=agent_id, agent_name=agent_name
102
+ )
103
+ return self._relationships[agent_id]
104
+
105
+ def get_closest(self, top_k: int = 5) -> list[Relationship]:
106
+ """Get the top-K closest relationships."""
107
+ rels = sorted(
108
+ self._relationships.values(),
109
+ key=lambda r: r.closeness,
110
+ reverse=True,
111
+ )
112
+ return rels[:top_k]
113
+
114
+ def get_all(self) -> list[Relationship]:
115
+ return list(self._relationships.values())
116
+
117
+ def describe_known_people(self, max_people: int = 8) -> str:
118
+ """Describe known people for LLM context."""
119
+ known = [r for r in self._relationships.values() if r.familiarity > 0.05]
120
+ if not known:
121
+ return "I don't really know anyone here yet."
122
+ known.sort(key=lambda r: r.closeness, reverse=True)
123
+ lines = [r.describe() for r in known[:max_people]]
124
+ return "\n".join(lines)
125
+
126
+ def to_dict(self) -> dict:
127
+ return {
128
+ aid: rel.to_dict()
129
+ for aid, rel in self._relationships.items()
130
+ }
131
+
132
+ @classmethod
133
+ def from_dict(cls, data: dict) -> RelationshipGraph:
134
+ graph = cls()
135
+ for aid, rd in data.items():
136
+ graph._relationships[aid] = Relationship.from_dict(rd)
137
+ return graph
src/soci/api/__init__.py ADDED
File without changes
src/soci/api/routes.py ADDED
@@ -0,0 +1,238 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """REST API routes — city state, agents, history."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from fastapi import APIRouter, HTTPException
6
+ from pydantic import BaseModel
7
+
8
+ router = APIRouter()
9
+
10
+
11
+ class PlayerActionRequest(BaseModel):
12
+ action: str # move, talk, work, eat, etc.
13
+ target: str = ""
14
+ detail: str = ""
15
+
16
+
17
+ class PlayerJoinRequest(BaseModel):
18
+ name: str
19
+ background: str = "A newcomer to Soci City."
20
+
21
+
22
+ @router.get("/city")
23
+ async def get_city():
24
+ """Get the full city state — locations, agents, time, weather."""
25
+ from soci.api.server import get_simulation
26
+ sim = get_simulation()
27
+ return sim.get_state_summary()
28
+
29
+
30
+ @router.get("/city/locations")
31
+ async def get_locations():
32
+ """Get all city locations and who's there."""
33
+ from soci.api.server import get_simulation
34
+ sim = get_simulation()
35
+ return {
36
+ lid: {
37
+ "name": loc.name,
38
+ "zone": loc.zone,
39
+ "description": loc.description,
40
+ "occupants": [
41
+ {"id": aid, "name": sim.agents[aid].name, "state": sim.agents[aid].state.value}
42
+ for aid in loc.occupants if aid in sim.agents
43
+ ],
44
+ "connected_to": loc.connected_to,
45
+ }
46
+ for lid, loc in sim.city.locations.items()
47
+ }
48
+
49
+
50
+ @router.get("/agents")
51
+ async def get_agents():
52
+ """Get summary of all agents."""
53
+ from soci.api.server import get_simulation
54
+ sim = get_simulation()
55
+ return {
56
+ aid: {
57
+ "name": a.name,
58
+ "age": a.persona.age,
59
+ "occupation": a.persona.occupation,
60
+ "location": a.location,
61
+ "state": a.state.value,
62
+ "mood": round(a.mood, 2),
63
+ "action": a.current_action.detail if a.current_action else "idle",
64
+ "is_player": a.is_player,
65
+ }
66
+ for aid, a in sim.agents.items()
67
+ }
68
+
69
+
70
+ @router.get("/agents/{agent_id}")
71
+ async def get_agent(agent_id: str):
72
+ """Get detailed info about a specific agent."""
73
+ from soci.api.server import get_simulation
74
+ sim = get_simulation()
75
+ agent = sim.agents.get(agent_id)
76
+ if not agent:
77
+ raise HTTPException(status_code=404, detail=f"Agent {agent_id} not found")
78
+
79
+ loc = sim.city.get_location(agent.location)
80
+ return {
81
+ "id": agent.id,
82
+ "name": agent.name,
83
+ "age": agent.persona.age,
84
+ "occupation": agent.persona.occupation,
85
+ "traits": agent.persona.trait_summary,
86
+ "location": {"id": agent.location, "name": loc.name if loc else "unknown"},
87
+ "state": agent.state.value,
88
+ "mood": round(agent.mood, 2),
89
+ "needs": agent.needs.to_dict(),
90
+ "needs_description": agent.needs.describe(),
91
+ "action": agent.current_action.detail if agent.current_action else "idle",
92
+ "daily_plan": agent.daily_plan,
93
+ "relationships": [
94
+ {
95
+ "agent_id": rel.agent_id,
96
+ "name": rel.agent_name,
97
+ "closeness": round(rel.closeness, 2),
98
+ "description": rel.describe(),
99
+ }
100
+ for rel in agent.relationships.get_closest(10)
101
+ ],
102
+ "recent_memories": [
103
+ {
104
+ "time": f"Day {m.day} {m.time_str}",
105
+ "type": m.type.value,
106
+ "content": m.content,
107
+ "importance": m.importance,
108
+ }
109
+ for m in agent.memory.get_recent(10)
110
+ ],
111
+ }
112
+
113
+
114
+ @router.get("/agents/{agent_id}/memories")
115
+ async def get_agent_memories(agent_id: str, limit: int = 20):
116
+ """Get an agent's memory stream."""
117
+ from soci.api.server import get_simulation
118
+ sim = get_simulation()
119
+ agent = sim.agents.get(agent_id)
120
+ if not agent:
121
+ raise HTTPException(status_code=404, detail=f"Agent {agent_id} not found")
122
+
123
+ return [
124
+ {
125
+ "id": m.id,
126
+ "time": f"Day {m.day} {m.time_str}",
127
+ "type": m.type.value,
128
+ "content": m.content,
129
+ "importance": m.importance,
130
+ "involved_agents": m.involved_agents,
131
+ }
132
+ for m in agent.memory.memories[-limit:]
133
+ ]
134
+
135
+
136
+ @router.get("/conversations")
137
+ async def get_active_conversations():
138
+ """Get all active conversations."""
139
+ from soci.api.server import get_simulation
140
+ sim = get_simulation()
141
+ return {
142
+ cid: {
143
+ "participants": [
144
+ sim.agents[p].name for p in c.participants if p in sim.agents
145
+ ],
146
+ "topic": c.topic,
147
+ "turns": len(c.turns),
148
+ "latest": c.turns[-1].message if c.turns else "",
149
+ }
150
+ for cid, c in sim.active_conversations.items()
151
+ }
152
+
153
+
154
+ @router.get("/stats")
155
+ async def get_stats():
156
+ """Get simulation statistics and LLM usage."""
157
+ from soci.api.server import get_simulation
158
+ sim = get_simulation()
159
+ return {
160
+ "clock": sim.clock.to_dict(),
161
+ "total_agents": len(sim.agents),
162
+ "active_conversations": len(sim.active_conversations),
163
+ "llm_usage": {
164
+ "total_calls": sim.llm.usage.total_calls,
165
+ "total_input_tokens": sim.llm.usage.total_input_tokens,
166
+ "total_output_tokens": sim.llm.usage.total_output_tokens,
167
+ "estimated_cost_usd": round(sim.llm.usage.estimated_cost_usd, 4),
168
+ "calls_by_model": sim.llm.usage.calls_by_model,
169
+ },
170
+ }
171
+
172
+
173
+ @router.post("/player/join")
174
+ async def player_join(request: PlayerJoinRequest):
175
+ """Register a human player as a new agent in the simulation."""
176
+ from soci.agents.agent import Agent
177
+ from soci.agents.persona import Persona
178
+ from soci.api.server import get_simulation
179
+ sim = get_simulation()
180
+
181
+ player_id = f"player_{request.name.lower().replace(' ', '_')}"
182
+ if player_id in sim.agents:
183
+ raise HTTPException(status_code=400, detail="Player already exists")
184
+
185
+ persona = Persona(
186
+ id=player_id,
187
+ name=request.name,
188
+ age=25,
189
+ occupation="newcomer",
190
+ background=request.background,
191
+ home_location="home_north",
192
+ work_location="",
193
+ )
194
+ agent = Agent(persona)
195
+ agent.is_player = True
196
+ sim.add_agent(agent)
197
+
198
+ return {"id": player_id, "message": f"Welcome to Soci City, {request.name}!"}
199
+
200
+
201
+ @router.post("/player/{player_id}/action")
202
+ async def player_action(player_id: str, request: PlayerActionRequest):
203
+ """Submit an action for a human player."""
204
+ from soci.agents.agent import AgentAction
205
+ from soci.actions.registry import resolve_action
206
+ from soci.api.server import get_simulation
207
+ sim = get_simulation()
208
+
209
+ agent = sim.agents.get(player_id)
210
+ if not agent or not agent.is_player:
211
+ raise HTTPException(status_code=404, detail="Player not found")
212
+
213
+ if agent.is_busy:
214
+ return {"status": "busy", "message": f"You're currently {agent.current_action.detail}"}
215
+
216
+ action = resolve_action(
217
+ {"action": request.action, "target": request.target, "detail": request.detail},
218
+ agent,
219
+ sim.city,
220
+ )
221
+ await sim._execute_action(agent, action)
222
+
223
+ return {
224
+ "status": "ok",
225
+ "action": action.to_dict(),
226
+ "location": agent.location,
227
+ }
228
+
229
+
230
+ @router.post("/save")
231
+ async def save_state(name: str = "manual_save"):
232
+ """Manually save the simulation state."""
233
+ from soci.api.server import get_simulation, get_database
234
+ sim = get_simulation()
235
+ db = get_database()
236
+ from soci.persistence.snapshots import save_simulation
237
+ await save_simulation(sim, db, name)
238
+ return {"status": "saved", "name": name, "tick": sim.clock.total_ticks}
src/soci/api/server.py ADDED
@@ -0,0 +1,124 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """FastAPI server — serves the simulation state and handles player input."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import logging
7
+ from contextlib import asynccontextmanager
8
+ from pathlib import Path
9
+ from typing import Optional
10
+
11
+ from fastapi import FastAPI
12
+ from fastapi.middleware.cors import CORSMiddleware
13
+
14
+ from soci.engine.llm import ClaudeClient
15
+ from soci.engine.simulation import Simulation
16
+ from soci.persistence.database import Database
17
+ from soci.persistence.snapshots import load_simulation, save_simulation
18
+ from soci.world.city import City
19
+ from soci.world.clock import SimClock
20
+ from soci.api.routes import router
21
+ from soci.api.websocket import ws_router
22
+
23
+ logger = logging.getLogger(__name__)
24
+
25
+ # Global simulation instance (shared across requests)
26
+ _simulation: Optional[Simulation] = None
27
+ _database: Optional[Database] = None
28
+ _sim_task: Optional[asyncio.Task] = None
29
+
30
+
31
+ def get_simulation() -> Simulation:
32
+ assert _simulation is not None, "Simulation not initialized"
33
+ return _simulation
34
+
35
+
36
+ def get_database() -> Database:
37
+ assert _database is not None, "Database not initialized"
38
+ return _database
39
+
40
+
41
+ async def simulation_loop(sim: Simulation, db: Database, tick_delay: float = 2.0) -> None:
42
+ """Background task that runs the simulation continuously."""
43
+ while True:
44
+ try:
45
+ await sim.tick()
46
+ # Auto-save every 24 ticks
47
+ if sim.clock.total_ticks % 24 == 0:
48
+ await save_simulation(sim, db, "autosave")
49
+ await asyncio.sleep(tick_delay)
50
+ except asyncio.CancelledError:
51
+ logger.info("Simulation loop cancelled")
52
+ await save_simulation(sim, db, "autosave")
53
+ break
54
+ except Exception as e:
55
+ logger.error(f"Simulation tick error: {e}", exc_info=True)
56
+ await asyncio.sleep(5) # Wait before retrying
57
+
58
+
59
+ @asynccontextmanager
60
+ async def lifespan(app: FastAPI):
61
+ """Manage simulation lifecycle."""
62
+ global _simulation, _database, _sim_task
63
+
64
+ # Start up
65
+ logger.info("Starting Soci API server...")
66
+ llm = ClaudeClient()
67
+ db = Database()
68
+ await db.connect()
69
+ _database = db
70
+
71
+ # Try to resume
72
+ sim = await load_simulation(db, llm)
73
+ if sim is None:
74
+ # Create new
75
+ config_dir = Path(__file__).parents[3] / "config"
76
+ city = City.from_yaml(str(config_dir / "city.yaml"))
77
+ clock = SimClock(tick_minutes=15, hour=6, minute=0)
78
+ sim = Simulation(city=city, clock=clock, llm=llm)
79
+ sim.load_agents_from_yaml(str(config_dir / "personas.yaml"))
80
+ logger.info(f"Created new simulation with {len(sim.agents)} agents")
81
+
82
+ _simulation = sim
83
+
84
+ # Start background simulation
85
+ _sim_task = asyncio.create_task(simulation_loop(sim, db, tick_delay=2.0))
86
+
87
+ yield
88
+
89
+ # Shut down
90
+ if _sim_task:
91
+ _sim_task.cancel()
92
+ try:
93
+ await _sim_task
94
+ except asyncio.CancelledError:
95
+ pass
96
+ await save_simulation(sim, db, "shutdown_save")
97
+ await db.close()
98
+ logger.info("Soci API server stopped.")
99
+
100
+
101
+ def create_app() -> FastAPI:
102
+ """Create the FastAPI application."""
103
+ app = FastAPI(
104
+ title="Soci — City Population Simulator",
105
+ description="API for the LLM-powered city population simulation",
106
+ version="0.1.0",
107
+ lifespan=lifespan,
108
+ )
109
+
110
+ app.add_middleware(
111
+ CORSMiddleware,
112
+ allow_origins=["*"],
113
+ allow_credentials=True,
114
+ allow_methods=["*"],
115
+ allow_headers=["*"],
116
+ )
117
+
118
+ app.include_router(router, prefix="/api")
119
+ app.include_router(ws_router)
120
+
121
+ return app
122
+
123
+
124
+ app = create_app()
src/soci/api/websocket.py ADDED
@@ -0,0 +1,125 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """WebSocket — real-time event stream for live clients and future game UI."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import json
7
+ import logging
8
+ from typing import Optional
9
+
10
+ from fastapi import APIRouter, WebSocket, WebSocketDisconnect
11
+
12
+ logger = logging.getLogger(__name__)
13
+
14
+ ws_router = APIRouter()
15
+
16
+
17
+ class ConnectionManager:
18
+ """Manages WebSocket connections for real-time event streaming."""
19
+
20
+ def __init__(self) -> None:
21
+ self.active_connections: list[WebSocket] = []
22
+
23
+ async def connect(self, websocket: WebSocket) -> None:
24
+ await websocket.accept()
25
+ self.active_connections.append(websocket)
26
+ logger.info(f"WebSocket client connected. Total: {len(self.active_connections)}")
27
+
28
+ def disconnect(self, websocket: WebSocket) -> None:
29
+ if websocket in self.active_connections:
30
+ self.active_connections.remove(websocket)
31
+ logger.info(f"WebSocket client disconnected. Total: {len(self.active_connections)}")
32
+
33
+ async def broadcast(self, message: dict) -> None:
34
+ """Send a message to all connected clients."""
35
+ disconnected = []
36
+ for connection in self.active_connections:
37
+ try:
38
+ await connection.send_json(message)
39
+ except Exception:
40
+ disconnected.append(connection)
41
+ for conn in disconnected:
42
+ self.disconnect(conn)
43
+
44
+ async def send_personal(self, websocket: WebSocket, message: dict) -> None:
45
+ await websocket.send_json(message)
46
+
47
+
48
+ manager = ConnectionManager()
49
+
50
+
51
+ def get_manager() -> ConnectionManager:
52
+ return manager
53
+
54
+
55
+ @ws_router.websocket("/ws/stream")
56
+ async def websocket_stream(websocket: WebSocket):
57
+ """Real-time event stream.
58
+
59
+ Clients receive JSON messages with:
60
+ - type: "tick" — new simulation tick with summary
61
+ - type: "event" — world event occurred
62
+ - type: "conversation" — new conversation turn
63
+ - type: "action" — agent performed an action
64
+ """
65
+ await manager.connect(websocket)
66
+
67
+ try:
68
+ # Set up event forwarding from the simulation
69
+ from soci.api.server import get_simulation
70
+ sim = get_simulation()
71
+
72
+ # Store the original callback
73
+ original_callback = sim.on_event
74
+
75
+ # Add our own callback that also sends to WebSocket
76
+ async def ws_event_handler(msg: str):
77
+ if original_callback:
78
+ original_callback(msg)
79
+ await manager.broadcast({
80
+ "type": "event",
81
+ "message": msg,
82
+ "tick": sim.clock.total_ticks,
83
+ "time": sim.clock.datetime_str,
84
+ })
85
+
86
+ # We can't easily replace the sync callback with async,
87
+ # so instead we poll the simulation state
88
+ last_tick = sim.clock.total_ticks
89
+ while True:
90
+ try:
91
+ # Wait for client messages (ping/pong or player input)
92
+ try:
93
+ data = await asyncio.wait_for(
94
+ websocket.receive_text(),
95
+ timeout=1.0,
96
+ )
97
+ # Handle client input
98
+ try:
99
+ msg = json.loads(data)
100
+ if msg.get("type") == "ping":
101
+ await manager.send_personal(websocket, {"type": "pong"})
102
+ except json.JSONDecodeError:
103
+ pass
104
+ except asyncio.TimeoutError:
105
+ pass
106
+
107
+ # Send state updates if tick advanced
108
+ current_tick = sim.clock.total_ticks
109
+ if current_tick > last_tick:
110
+ state = sim.get_state_summary()
111
+ await manager.send_personal(websocket, {
112
+ "type": "tick",
113
+ "tick": current_tick,
114
+ "time": sim.clock.datetime_str,
115
+ "state": state,
116
+ })
117
+ last_tick = current_tick
118
+
119
+ except WebSocketDisconnect:
120
+ break
121
+
122
+ except Exception as e:
123
+ logger.error(f"WebSocket error: {e}")
124
+ finally:
125
+ manager.disconnect(websocket)
src/soci/engine/__init__.py ADDED
File without changes
src/soci/engine/entropy.py ADDED
@@ -0,0 +1,151 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Entropy management — keeps the simulation diverse and interesting."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import random
6
+ import logging
7
+ from typing import TYPE_CHECKING
8
+
9
+ if TYPE_CHECKING:
10
+ from soci.agents.agent import Agent
11
+ from soci.world.clock import SimClock
12
+ from soci.world.events import EventSystem
13
+
14
+ logger = logging.getLogger(__name__)
15
+
16
+
17
+ class EntropyManager:
18
+ """Manages simulation entropy to prevent bland, repetitive behavior."""
19
+
20
+ def __init__(self) -> None:
21
+ # How often to inject events (every N ticks)
22
+ self.event_injection_interval: int = 10
23
+ # Ticks since last injection
24
+ self._ticks_since_event: int = 0
25
+ # Track agent behavior patterns for drift detection
26
+ self._action_history: dict[str, list[str]] = {} # agent_id -> last N actions
27
+ self._history_window: int = 20
28
+
29
+ def tick(
30
+ self,
31
+ agents: list[Agent],
32
+ event_system: EventSystem,
33
+ clock: SimClock,
34
+ city_location_ids: list[str],
35
+ ) -> list[str]:
36
+ """Process one tick of entropy management. Returns list of notable events/messages."""
37
+ messages: list[str] = []
38
+ self._ticks_since_event += 1
39
+
40
+ # Track action patterns
41
+ for agent in agents:
42
+ if agent.current_action:
43
+ history = self._action_history.setdefault(agent.id, [])
44
+ history.append(agent.current_action.type)
45
+ if len(history) > self._history_window:
46
+ self._action_history[agent.id] = history[-self._history_window:]
47
+
48
+ # Detect repetitive behavior
49
+ for agent in agents:
50
+ if self._is_stuck_in_loop(agent.id):
51
+ messages.append(
52
+ f"[ENTROPY] {agent.name} seems stuck in a behavioral loop — "
53
+ f"injecting stimulus."
54
+ )
55
+ self._inject_personal_stimulus(agent, clock)
56
+
57
+ # Periodic event injection
58
+ if self._ticks_since_event >= self.event_injection_interval:
59
+ new_events = event_system.tick(city_location_ids)
60
+ self._ticks_since_event = 0
61
+ for evt in new_events:
62
+ messages.append(f"[EVENT] {evt.name}: {evt.description}")
63
+ else:
64
+ # Still tick the event system for weather/expiry
65
+ event_system.tick(city_location_ids)
66
+
67
+ # Time-based entropy: inject daily rhythm changes
68
+ if clock.hour == 12 and clock.minute == 0:
69
+ messages.append("[RHYTHM] Noon — the city bustles with lunch crowds.")
70
+ elif clock.hour == 18 and clock.minute == 0:
71
+ messages.append("[RHYTHM] Evening — people head home or to the bar.")
72
+ elif clock.hour == 22 and clock.minute == 0:
73
+ messages.append("[RHYTHM] Late night — the city quiets down.")
74
+
75
+ return messages
76
+
77
+ def _is_stuck_in_loop(self, agent_id: str) -> bool:
78
+ """Detect if an agent is repeating the same actions."""
79
+ history = self._action_history.get(agent_id, [])
80
+ if len(history) < 10:
81
+ return False
82
+ # Check if last 10 actions are all the same
83
+ recent = history[-10:]
84
+ unique = set(recent)
85
+ return len(unique) <= 2 and "sleep" not in unique
86
+
87
+ def _inject_personal_stimulus(self, agent: Agent, clock: SimClock) -> None:
88
+ """Inject a personal event to break an agent out of a loop."""
89
+ stimuli = [
90
+ f"{agent.name} suddenly remembers something important they forgot to do.",
91
+ f"{agent.name} gets an unexpected phone call from an old friend.",
92
+ f"{agent.name} notices something unusual in their surroundings.",
93
+ f"{agent.name} overhears an interesting conversation nearby.",
94
+ f"{agent.name} finds a forgotten note in their pocket.",
95
+ f"{agent.name} suddenly craves something completely different.",
96
+ ]
97
+ stimulus = random.choice(stimuli)
98
+ agent.add_observation(
99
+ tick=clock.total_ticks,
100
+ day=clock.day,
101
+ time_str=clock.time_str,
102
+ content=stimulus,
103
+ importance=7,
104
+ )
105
+
106
+ def get_conflict_catalysts(self, agents: list[Agent]) -> list[tuple[str, str, str]]:
107
+ """Identify potential conflicts between agents based on their personas.
108
+ Returns list of (agent1_id, agent2_id, tension_description) tuples.
109
+ """
110
+ catalysts = []
111
+
112
+ # Find agents with opposing values or competing interests
113
+ for i, a in enumerate(agents):
114
+ for b in agents[i + 1:]:
115
+ tension = self._find_tension(a, b)
116
+ if tension:
117
+ catalysts.append((a.id, b.id, tension))
118
+
119
+ return catalysts
120
+
121
+ def _find_tension(self, a: Agent, b: Agent) -> str | None:
122
+ """Find natural tension between two agents."""
123
+ # Big personality differences can create friction
124
+ extraversion_gap = abs(a.persona.extraversion - b.persona.extraversion)
125
+ agreeableness_gap = abs(a.persona.agreeableness - b.persona.agreeableness)
126
+
127
+ if extraversion_gap >= 6 and agreeableness_gap >= 4:
128
+ return "personality clash — one is outgoing and blunt, the other is reserved and sensitive"
129
+
130
+ # Competing values
131
+ a_values = set(a.persona.values)
132
+ b_values = set(b.persona.values)
133
+ if a_values and b_values and not a_values.intersection(b_values):
134
+ return f"different values — {a.name} values {', '.join(a.persona.values)}, while {b.name} values {', '.join(b.persona.values)}"
135
+
136
+ return None
137
+
138
+ def to_dict(self) -> dict:
139
+ return {
140
+ "event_injection_interval": self.event_injection_interval,
141
+ "ticks_since_event": self._ticks_since_event,
142
+ "action_history": dict(self._action_history),
143
+ }
144
+
145
+ @classmethod
146
+ def from_dict(cls, data: dict) -> EntropyManager:
147
+ mgr = cls()
148
+ mgr.event_injection_interval = data.get("event_injection_interval", 10)
149
+ mgr._ticks_since_event = data.get("ticks_since_event", 0)
150
+ mgr._action_history = data.get("action_history", {})
151
+ return mgr
src/soci/engine/llm.py ADDED
@@ -0,0 +1,292 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """LLM client — Claude API wrapper with model routing, cost tracking, and prompt templates."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import logging
7
+ import os
8
+ import time
9
+ from dataclasses import dataclass, field
10
+ from typing import Optional
11
+
12
+ import anthropic
13
+
14
+ logger = logging.getLogger(__name__)
15
+
16
+ # Model IDs
17
+ MODEL_SONNET = "claude-sonnet-4-5-20250929"
18
+ MODEL_HAIKU = "claude-haiku-4-5-20251001"
19
+
20
+ # Approximate cost per 1M tokens (USD)
21
+ COST_PER_1M = {
22
+ MODEL_SONNET: {"input": 3.0, "output": 15.0},
23
+ MODEL_HAIKU: {"input": 0.80, "output": 4.0},
24
+ }
25
+
26
+
27
+ @dataclass
28
+ class LLMUsage:
29
+ """Tracks API usage and costs."""
30
+
31
+ total_calls: int = 0
32
+ total_input_tokens: int = 0
33
+ total_output_tokens: int = 0
34
+ calls_by_model: dict[str, int] = field(default_factory=dict)
35
+ tokens_by_model: dict[str, dict[str, int]] = field(default_factory=dict)
36
+
37
+ def record(self, model: str, input_tokens: int, output_tokens: int) -> None:
38
+ self.total_calls += 1
39
+ self.total_input_tokens += input_tokens
40
+ self.total_output_tokens += output_tokens
41
+ self.calls_by_model[model] = self.calls_by_model.get(model, 0) + 1
42
+ if model not in self.tokens_by_model:
43
+ self.tokens_by_model[model] = {"input": 0, "output": 0}
44
+ self.tokens_by_model[model]["input"] += input_tokens
45
+ self.tokens_by_model[model]["output"] += output_tokens
46
+
47
+ @property
48
+ def estimated_cost_usd(self) -> float:
49
+ total = 0.0
50
+ for model, tokens in self.tokens_by_model.items():
51
+ costs = COST_PER_1M.get(model, {"input": 3.0, "output": 15.0})
52
+ total += tokens["input"] / 1_000_000 * costs["input"]
53
+ total += tokens["output"] / 1_000_000 * costs["output"]
54
+ return total
55
+
56
+ def summary(self) -> str:
57
+ lines = [
58
+ f"Total API calls: {self.total_calls}",
59
+ f"Total tokens: {self.total_input_tokens:,} in / {self.total_output_tokens:,} out",
60
+ f"Estimated cost: ${self.estimated_cost_usd:.4f}",
61
+ ]
62
+ for model, count in self.calls_by_model.items():
63
+ short = model.split("-")[1] if "-" in model else model
64
+ lines.append(f" {short}: {count} calls")
65
+ return "\n".join(lines)
66
+
67
+
68
+ class ClaudeClient:
69
+ """Wrapper around the Anthropic Claude API with model routing and retries."""
70
+
71
+ def __init__(
72
+ self,
73
+ api_key: Optional[str] = None,
74
+ default_model: str = MODEL_HAIKU,
75
+ max_retries: int = 3,
76
+ ) -> None:
77
+ self.api_key = api_key or os.environ.get("ANTHROPIC_API_KEY", "")
78
+ if not self.api_key:
79
+ raise ValueError(
80
+ "ANTHROPIC_API_KEY not set. Copy .env.example to .env and add your key."
81
+ )
82
+ self.client = anthropic.Anthropic(api_key=self.api_key)
83
+ self.default_model = default_model
84
+ self.max_retries = max_retries
85
+ self.usage = LLMUsage()
86
+
87
+ async def complete(
88
+ self,
89
+ system: str,
90
+ user_message: str,
91
+ model: Optional[str] = None,
92
+ temperature: float = 0.7,
93
+ max_tokens: int = 1024,
94
+ ) -> str:
95
+ """Send a message to Claude and return the text response."""
96
+ model = model or self.default_model
97
+
98
+ for attempt in range(self.max_retries):
99
+ try:
100
+ response = self.client.messages.create(
101
+ model=model,
102
+ max_tokens=max_tokens,
103
+ temperature=temperature,
104
+ system=system,
105
+ messages=[{"role": "user", "content": user_message}],
106
+ )
107
+ # Track usage
108
+ self.usage.record(
109
+ model=model,
110
+ input_tokens=response.usage.input_tokens,
111
+ output_tokens=response.usage.output_tokens,
112
+ )
113
+ return response.content[0].text
114
+
115
+ except anthropic.RateLimitError:
116
+ wait = 2 ** attempt
117
+ logger.warning(f"Rate limited, waiting {wait}s (attempt {attempt + 1})")
118
+ time.sleep(wait)
119
+ except anthropic.APIError as e:
120
+ logger.error(f"API error: {e}")
121
+ if attempt == self.max_retries - 1:
122
+ raise
123
+ time.sleep(1)
124
+
125
+ return ""
126
+
127
+ async def complete_json(
128
+ self,
129
+ system: str,
130
+ user_message: str,
131
+ model: Optional[str] = None,
132
+ temperature: float = 0.7,
133
+ max_tokens: int = 1024,
134
+ ) -> dict:
135
+ """Send a message and parse the response as JSON."""
136
+ # Add JSON instruction to the prompt
137
+ json_instruction = (
138
+ "\n\nRespond ONLY with valid JSON. No markdown, no explanation, no extra text. "
139
+ "Just the JSON object."
140
+ )
141
+ text = await self.complete(
142
+ system=system,
143
+ user_message=user_message + json_instruction,
144
+ model=model,
145
+ temperature=temperature,
146
+ max_tokens=max_tokens,
147
+ )
148
+ # Try to extract JSON from the response
149
+ text = text.strip()
150
+ # Handle markdown code blocks
151
+ if text.startswith("```"):
152
+ lines = text.split("\n")
153
+ text = "\n".join(lines[1:-1]) if len(lines) > 2 else text
154
+ try:
155
+ return json.loads(text)
156
+ except json.JSONDecodeError:
157
+ # Try to find JSON in the response
158
+ start = text.find("{")
159
+ end = text.rfind("}") + 1
160
+ if start >= 0 and end > start:
161
+ try:
162
+ return json.loads(text[start:end])
163
+ except json.JSONDecodeError:
164
+ pass
165
+ logger.warning(f"Failed to parse JSON from LLM response: {text[:200]}")
166
+ return {}
167
+
168
+
169
+ # --- Prompt Templates ---
170
+
171
+ PLAN_DAY_PROMPT = """\
172
+ It is {time_str} on Day {day}. You just woke up.
173
+
174
+ {context}
175
+
176
+ Based on your personality, needs, and memories, plan your day. What will you do today?
177
+ Think about your obligations (work, responsibilities) and your desires (socializing, fun, rest).
178
+
179
+ Respond with a JSON object:
180
+ {{
181
+ "plan": ["item 1", "item 2", ...],
182
+ "reasoning": "brief explanation of why this plan"
183
+ }}
184
+
185
+ Keep the plan to 5-8 items. Be specific about locations and times.
186
+ """
187
+
188
+ DECIDE_ACTION_PROMPT = """\
189
+ It is {time_str} on Day {day}.
190
+
191
+ {context}
192
+
193
+ You are currently at {location_name}. You just finished: {last_activity}.
194
+
195
+ What do you do next? Consider your needs, your plan, who's around, and any events happening.
196
+
197
+ Respond with a JSON object:
198
+ {{
199
+ "action": "move|work|eat|sleep|talk|exercise|shop|relax|wander",
200
+ "target": "location_id or agent_id (if talking) or empty string",
201
+ "detail": "what specifically you're doing, in first person",
202
+ "duration": 1-4,
203
+ "reasoning": "brief internal thought about why"
204
+ }}
205
+
206
+ Available locations you can move to: {connected_locations}
207
+ People at your current location: {people_here}
208
+ """
209
+
210
+ OBSERVE_PROMPT = """\
211
+ It is {time_str} on Day {day}.
212
+
213
+ {context}
214
+
215
+ You just noticed: {observation}
216
+
217
+ How important is this to you (1-10)? What do you think about it?
218
+
219
+ Respond with a JSON object:
220
+ {{
221
+ "importance": 1-10,
222
+ "reaction": "your brief internal thought or feeling about this"
223
+ }}
224
+ """
225
+
226
+ REFLECT_PROMPT = """\
227
+ It is {time_str} on Day {day}.
228
+
229
+ {context}
230
+
231
+ RECENT EXPERIENCES:
232
+ {recent_memories}
233
+
234
+ Take a moment to reflect on your recent experiences. What patterns do you notice?
235
+ What have you learned? How do you feel about things?
236
+
237
+ Respond with a JSON object:
238
+ {{
239
+ "reflections": ["reflection 1", "reflection 2", ...],
240
+ "mood_shift": -0.3 to 0.3,
241
+ "reasoning": "why your mood shifted this way"
242
+ }}
243
+
244
+ Generate 1-3 reflections. Each should be a genuine insight, not just a summary.
245
+ """
246
+
247
+ CONVERSATION_PROMPT = """\
248
+ It is {time_str} on Day {day}.
249
+
250
+ {context}
251
+
252
+ You are at {location_name}. {other_name} is here too.
253
+
254
+ WHAT YOU KNOW ABOUT {other_name}:
255
+ {relationship_context}
256
+
257
+ {conversation_history}
258
+
259
+ {other_name} says: "{other_message}"
260
+
261
+ How do you respond? Stay in character. Be natural — not every conversation is deep.
262
+ Sometimes people make small talk, sometimes they argue, sometimes they're awkward.
263
+
264
+ Respond with a JSON object:
265
+ {{
266
+ "message": "your spoken response",
267
+ "inner_thought": "what you're actually thinking",
268
+ "sentiment_delta": -0.1 to 0.1,
269
+ "trust_delta": -0.05 to 0.05
270
+ }}
271
+ """
272
+
273
+ CONVERSATION_INITIATE_PROMPT = """\
274
+ It is {time_str} on Day {day}.
275
+
276
+ {context}
277
+
278
+ You are at {location_name}. {other_name} is here.
279
+
280
+ WHAT YOU KNOW ABOUT {other_name}:
281
+ {relationship_context}
282
+
283
+ You decide to start a conversation with {other_name}. What do you say?
284
+ Consider the time of day, location, your mood, and your history with them.
285
+
286
+ Respond with a JSON object:
287
+ {{
288
+ "message": "what you say to start the conversation",
289
+ "inner_thought": "why you're initiating this conversation",
290
+ "topic": "brief topic label"
291
+ }}
292
+ """
src/soci/engine/scheduler.py ADDED
@@ -0,0 +1,78 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Scheduler — manages agent turn order and batching for LLM calls."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import logging
7
+ from typing import TYPE_CHECKING
8
+
9
+ if TYPE_CHECKING:
10
+ from soci.agents.agent import Agent
11
+ from soci.world.clock import SimClock
12
+
13
+ logger = logging.getLogger(__name__)
14
+
15
+
16
+ def prioritize_agents(agents: list[Agent], clock: SimClock) -> list[Agent]:
17
+ """Sort agents by priority for this tick. Agents with urgent needs go first."""
18
+ def priority_score(agent: Agent) -> float:
19
+ score = 0.0
20
+ # Urgent needs boost priority
21
+ if agent.needs.is_critical:
22
+ score += 10.0
23
+ urgent = agent.needs.urgent_needs
24
+ score += len(urgent) * 2.0
25
+ # Idle agents need decisions
26
+ if not agent.is_busy:
27
+ score += 5.0
28
+ # Sleeping agents are low priority
29
+ if agent.state.value == "sleeping":
30
+ score -= 5.0
31
+ # Players always get processed
32
+ if agent.is_player:
33
+ score += 20.0
34
+ return score
35
+
36
+ return sorted(agents, key=priority_score, reverse=True)
37
+
38
+
39
+ async def batch_llm_calls(
40
+ coros: list,
41
+ max_concurrent: int = 10,
42
+ ) -> list:
43
+ """Run multiple LLM coroutines concurrently with a concurrency limit."""
44
+ semaphore = asyncio.Semaphore(max_concurrent)
45
+
46
+ async def limited(coro):
47
+ async with semaphore:
48
+ return await coro
49
+
50
+ results = await asyncio.gather(
51
+ *[limited(c) for c in coros],
52
+ return_exceptions=True,
53
+ )
54
+
55
+ # Log any errors
56
+ for i, r in enumerate(results):
57
+ if isinstance(r, Exception):
58
+ logger.error(f"LLM call {i} failed: {r}")
59
+ results[i] = None
60
+
61
+ return results
62
+
63
+
64
+ def should_skip_llm(agent: Agent, clock: SimClock) -> bool:
65
+ """Determine if we can skip the LLM call for this agent (habit caching)."""
66
+ # Never skip players
67
+ if agent.is_player:
68
+ return False
69
+
70
+ # If agent is busy with a multi-tick action, skip
71
+ if agent.is_busy:
72
+ return True
73
+
74
+ # If sleeping during sleep hours, keep sleeping
75
+ if agent.state.value == "sleeping" and clock.is_sleeping_hours:
76
+ return True
77
+
78
+ return False
src/soci/engine/simulation.py ADDED
@@ -0,0 +1,478 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Simulation — the main loop that orchestrates the entire city simulation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import logging
7
+ import random
8
+ from typing import Callable, Optional
9
+
10
+ from soci.agents.agent import Agent, AgentAction
11
+ from soci.agents.memory import MemoryType
12
+ from soci.agents.persona import Persona, load_personas
13
+ from soci.actions.registry import resolve_action, ACTION_NEEDS, ACTION_DURATIONS
14
+ from soci.actions.movement import execute_move
15
+ from soci.actions.activities import execute_activity
16
+ from soci.actions.conversation import (
17
+ Conversation, initiate_conversation, continue_conversation,
18
+ )
19
+ from soci.actions.social import should_initiate_conversation, pick_conversation_partner
20
+ from soci.engine.llm import (
21
+ ClaudeClient, MODEL_SONNET, MODEL_HAIKU,
22
+ PLAN_DAY_PROMPT, DECIDE_ACTION_PROMPT, OBSERVE_PROMPT, REFLECT_PROMPT,
23
+ )
24
+ from soci.engine.scheduler import prioritize_agents, batch_llm_calls, should_skip_llm
25
+ from soci.engine.entropy import EntropyManager
26
+ from soci.world.city import City
27
+ from soci.world.clock import SimClock
28
+ from soci.world.events import EventSystem
29
+
30
+ logger = logging.getLogger(__name__)
31
+
32
+
33
+ class Simulation:
34
+ """The main simulation engine — manages the city, agents, and time."""
35
+
36
+ def __init__(
37
+ self,
38
+ city: City,
39
+ clock: SimClock,
40
+ llm: ClaudeClient,
41
+ max_concurrent_llm: int = 10,
42
+ ) -> None:
43
+ self.city = city
44
+ self.clock = clock
45
+ self.llm = llm
46
+ self.agents: dict[str, Agent] = {}
47
+ self.events = EventSystem()
48
+ self.entropy = EntropyManager()
49
+ self.active_conversations: dict[str, Conversation] = {}
50
+ self._conversation_counter: int = 0
51
+ self._max_concurrent = max_concurrent_llm
52
+ self._tick_log: list[str] = [] # Log of events this tick
53
+ # Callback for real-time output
54
+ self.on_event: Optional[Callable[[str], None]] = None
55
+
56
+ def add_agent(self, agent: Agent) -> None:
57
+ """Add an agent to the simulation and place them in the city."""
58
+ self.agents[agent.id] = agent
59
+ self.city.place_agent(agent.id, agent.location)
60
+
61
+ def load_agents_from_yaml(self, path: str) -> None:
62
+ """Load all personas from YAML and create agents."""
63
+ personas = load_personas(path)
64
+ for persona in personas:
65
+ agent = Agent(persona)
66
+ self.add_agent(agent)
67
+ logger.info(f"Loaded {len(personas)} agents from {path}")
68
+
69
+ def _emit(self, message: str) -> None:
70
+ """Emit an event message."""
71
+ self._tick_log.append(message)
72
+ if self.on_event:
73
+ self.on_event(message)
74
+
75
+ async def tick(self) -> list[str]:
76
+ """Advance the simulation by one tick. Returns list of event descriptions."""
77
+ self._tick_log = []
78
+ self._emit(f"\n--- {self.clock.datetime_str} ({self.clock.time_of_day.value}) ---")
79
+
80
+ # 1. Entropy management and world events
81
+ entropy_messages = self.entropy.tick(
82
+ list(self.agents.values()),
83
+ self.events,
84
+ self.clock,
85
+ list(self.city.locations.keys()),
86
+ )
87
+ for msg in entropy_messages:
88
+ self._emit(msg)
89
+
90
+ # 2. New day — reset plans
91
+ if self.clock.hour == 6 and self.clock.minute == 0:
92
+ for agent in self.agents.values():
93
+ agent.reset_daily_plan()
94
+
95
+ # 3. Prioritize and process agents
96
+ ordered_agents = prioritize_agents(list(self.agents.values()), self.clock)
97
+
98
+ # 4. Generate daily plans for agents that need them
99
+ plan_coros = []
100
+ plan_agents = []
101
+ for agent in ordered_agents:
102
+ if agent.needs_new_plan(self.clock) and not should_skip_llm(agent, self.clock):
103
+ plan_coros.append(self._generate_daily_plan(agent))
104
+ plan_agents.append(agent)
105
+
106
+ if plan_coros:
107
+ await batch_llm_calls(plan_coros, self._max_concurrent)
108
+ for agent in plan_agents:
109
+ self._emit(f"[PLAN] {agent.name} planned their day: {'; '.join(agent.daily_plan[:3])}...")
110
+
111
+ # 5. Process each agent — tick their needs, handle actions
112
+ action_coros = []
113
+ action_agents = []
114
+ for agent in ordered_agents:
115
+ # Tick needs
116
+ is_sleeping = agent.state.value == "sleeping"
117
+ agent.tick_needs(is_sleeping=is_sleeping)
118
+
119
+ # Tick current action
120
+ if agent.is_busy:
121
+ completed = agent.tick_action()
122
+ if completed and agent.current_action:
123
+ self._emit(f" {agent.name} finished: {agent.current_action.detail}")
124
+ continue
125
+
126
+ # Skip LLM for sleeping agents during sleep hours, etc.
127
+ if should_skip_llm(agent, self.clock):
128
+ continue
129
+
130
+ # Agent is idle — needs a new action
131
+ action_coros.append(self._decide_action(agent))
132
+ action_agents.append(agent)
133
+
134
+ # Run all action decisions concurrently
135
+ if action_coros:
136
+ action_results = await batch_llm_calls(action_coros, self._max_concurrent)
137
+ for agent, result in zip(action_agents, action_results):
138
+ if result and isinstance(result, AgentAction):
139
+ await self._execute_action(agent, result)
140
+
141
+ # 6. Handle active conversations
142
+ conv_coros = []
143
+ for conv_id, conv in list(self.active_conversations.items()):
144
+ if conv.is_finished:
145
+ self._finish_conversation(conv)
146
+ del self.active_conversations[conv_id]
147
+ continue
148
+ # Determine who speaks next
149
+ last_speaker = conv.turns[-1].speaker_id if conv.turns else None
150
+ next_speaker_id = [p for p in conv.participants if p != last_speaker]
151
+ if next_speaker_id:
152
+ responder = self.agents.get(next_speaker_id[0])
153
+ other = self.agents.get(last_speaker) if last_speaker else None
154
+ if responder and other:
155
+ conv_coros.append(
156
+ continue_conversation(conv, responder, other, self.llm, self.clock)
157
+ )
158
+
159
+ if conv_coros:
160
+ await batch_llm_calls(conv_coros, self._max_concurrent)
161
+
162
+ # 7. Social: maybe start new conversations
163
+ await self._handle_social_interactions(ordered_agents)
164
+
165
+ # 8. Reflections for agents with enough accumulated importance
166
+ reflect_coros = []
167
+ reflect_agents = []
168
+ for agent in ordered_agents:
169
+ if agent.memory.should_reflect() and not agent.is_player:
170
+ reflect_coros.append(self._generate_reflection(agent))
171
+ reflect_agents.append(agent)
172
+
173
+ if reflect_coros:
174
+ await batch_llm_calls(reflect_coros, self._max_concurrent)
175
+
176
+ # 9. Advance clock
177
+ self.clock.tick()
178
+
179
+ return self._tick_log
180
+
181
+ async def _generate_daily_plan(self, agent: Agent) -> None:
182
+ """Generate a daily plan for an agent via LLM."""
183
+ world_desc = self.events.get_world_description()
184
+ loc_desc = self.city.describe_location(agent.location, exclude_agent=agent.id)
185
+
186
+ prompt = PLAN_DAY_PROMPT.format(
187
+ time_str=self.clock.time_str,
188
+ day=self.clock.day,
189
+ context=agent.build_context(self.clock.total_ticks, world_desc, loc_desc),
190
+ )
191
+
192
+ result = await self.llm.complete_json(
193
+ system=agent.persona.system_prompt(),
194
+ user_message=prompt,
195
+ model=MODEL_HAIKU, # Plans are routine, use cheap model
196
+ temperature=agent.persona.llm_temperature,
197
+ max_tokens=512,
198
+ )
199
+
200
+ plan = result.get("plan", ["Go about my day"])
201
+ if isinstance(plan, list):
202
+ agent.set_daily_plan(plan, self.clock.day, self.clock.total_ticks, self.clock.time_str)
203
+
204
+ async def _decide_action(self, agent: Agent) -> Optional[AgentAction]:
205
+ """Ask the LLM what action an agent should take next."""
206
+ world_desc = self.events.get_world_description()
207
+ loc_desc = self.city.describe_location(agent.location, exclude_agent=agent.id)
208
+
209
+ # Get connected locations
210
+ current_loc = self.city.get_location(agent.location)
211
+ connected = []
212
+ if current_loc:
213
+ for cid in current_loc.connected_to:
214
+ cloc = self.city.get_location(cid)
215
+ if cloc:
216
+ connected.append(f"{cid} ({cloc.name})")
217
+
218
+ # Get people at current location
219
+ people_here = [
220
+ self.agents[aid].name
221
+ for aid in self.city.get_agents_at(agent.location)
222
+ if aid != agent.id and aid in self.agents
223
+ ]
224
+
225
+ last_activity = "nothing in particular"
226
+ if agent.current_action:
227
+ last_activity = agent.current_action.detail or agent.current_action.type
228
+
229
+ prompt = DECIDE_ACTION_PROMPT.format(
230
+ time_str=self.clock.time_str,
231
+ day=self.clock.day,
232
+ context=agent.build_context(self.clock.total_ticks, world_desc, loc_desc),
233
+ location_name=loc_desc,
234
+ last_activity=last_activity,
235
+ connected_locations=", ".join(connected) if connected else "none visible",
236
+ people_here=", ".join(people_here) if people_here else "no one",
237
+ )
238
+
239
+ # Use Sonnet for novel situations, Haiku for routine
240
+ model = MODEL_HAIKU
241
+ if agent.needs.is_critical or self.events.active_events:
242
+ model = MODEL_SONNET
243
+
244
+ result = await self.llm.complete_json(
245
+ system=agent.persona.system_prompt(),
246
+ user_message=prompt,
247
+ model=model,
248
+ temperature=agent.persona.llm_temperature,
249
+ max_tokens=512,
250
+ )
251
+
252
+ if not result:
253
+ # Fallback: wander
254
+ return AgentAction(type="wander", detail=f"{agent.name} wanders aimlessly")
255
+
256
+ action = resolve_action(result, agent, self.city)
257
+ agent._last_llm_tick = self.clock.total_ticks
258
+ return action
259
+
260
+ async def _execute_action(self, agent: Agent, action: AgentAction) -> None:
261
+ """Execute an agent's chosen action."""
262
+ if action.type == "move":
263
+ desc = execute_move(agent, action, self.city, self.clock)
264
+ elif action.type == "talk":
265
+ # Talk action is handled via conversation system
266
+ target_id = action.target
267
+ if target_id and target_id in self.agents:
268
+ await self._start_conversation(agent, self.agents[target_id])
269
+ desc = f"{agent.name} starts talking to {self.agents[target_id].name}."
270
+ else:
271
+ desc = f"{agent.name} looks around for someone to talk to."
272
+ else:
273
+ desc = execute_activity(agent, action, self.city, self.clock)
274
+
275
+ agent.start_action(action)
276
+ self._emit(f" {desc}")
277
+
278
+ # Record observation
279
+ agent.add_observation(
280
+ tick=self.clock.total_ticks,
281
+ day=self.clock.day,
282
+ time_str=self.clock.time_str,
283
+ content=desc,
284
+ importance=3,
285
+ )
286
+
287
+ async def _handle_social_interactions(self, agents: list[Agent]) -> None:
288
+ """Check if any idle co-located agents should start conversations."""
289
+ # Don't flood with conversations
290
+ if len(self.active_conversations) >= 3:
291
+ return
292
+
293
+ for agent in agents:
294
+ if agent.is_busy or agent.is_player:
295
+ continue
296
+ # Check if already in a conversation
297
+ in_conv = any(
298
+ agent.id in c.participants
299
+ for c in self.active_conversations.values()
300
+ )
301
+ if in_conv:
302
+ continue
303
+
304
+ others = [
305
+ aid for aid in self.city.get_agents_at(agent.location)
306
+ if aid != agent.id
307
+ and aid in self.agents
308
+ and not self.agents[aid].is_busy
309
+ and not any(aid in c.participants for c in self.active_conversations.values())
310
+ ]
311
+ if not others:
312
+ continue
313
+
314
+ partner_id = pick_conversation_partner(agent, others, self.clock)
315
+ if partner_id and should_initiate_conversation(agent, partner_id, self.clock):
316
+ await self._start_conversation(agent, self.agents[partner_id])
317
+ break # One new conversation per tick max
318
+
319
+ async def _start_conversation(self, initiator: Agent, target: Agent) -> None:
320
+ """Start a conversation between two agents."""
321
+ self._conversation_counter += 1
322
+ conv_id = f"conv_{self._conversation_counter}"
323
+
324
+ conv = await initiate_conversation(
325
+ initiator, target, self.llm, self.clock, conv_id,
326
+ )
327
+ self.active_conversations[conv_id] = conv
328
+
329
+ # Both agents are now in conversation
330
+ from soci.agents.agent import AgentAction
331
+ talk_action = AgentAction(
332
+ type="talk",
333
+ target=target.id,
334
+ detail=f"talking to {target.name}",
335
+ duration_ticks=conv.max_turns,
336
+ needs_satisfied={"social": 0.3},
337
+ )
338
+ initiator.start_action(talk_action)
339
+
340
+ talk_action_target = AgentAction(
341
+ type="talk",
342
+ target=initiator.id,
343
+ detail=f"talking to {initiator.name}",
344
+ duration_ticks=conv.max_turns,
345
+ needs_satisfied={"social": 0.3},
346
+ )
347
+ target.start_action(talk_action_target)
348
+
349
+ self._emit(f" [CONV] {initiator.name} starts talking to {target.name}")
350
+
351
+ # Both agents observe the conversation start
352
+ for agent, other in [(initiator, target), (target, initiator)]:
353
+ agent.add_observation(
354
+ tick=self.clock.total_ticks,
355
+ day=self.clock.day,
356
+ time_str=self.clock.time_str,
357
+ content=f"Started a conversation with {other.name} at {agent.location}",
358
+ importance=5,
359
+ involved_agents=[other.id],
360
+ )
361
+ # Ensure relationship exists
362
+ agent.relationships.get_or_create(other.id, other.name)
363
+
364
+ def _finish_conversation(self, conv: Conversation) -> None:
365
+ """Record a finished conversation in both agents' memories."""
366
+ if len(conv.turns) < 2:
367
+ return
368
+
369
+ summary = f"Had a conversation about '{conv.topic}' with "
370
+ for agent_id in conv.participants:
371
+ agent = self.agents.get(agent_id)
372
+ if not agent:
373
+ continue
374
+ other_ids = [p for p in conv.participants if p != agent_id]
375
+ other_names = [self.agents[oid].name for oid in other_ids if oid in self.agents]
376
+ agent.memory.add(
377
+ tick=self.clock.total_ticks,
378
+ day=self.clock.day,
379
+ time_str=self.clock.time_str,
380
+ memory_type=MemoryType.CONVERSATION,
381
+ content=f"Had a conversation about '{conv.topic}' with {', '.join(other_names)}.",
382
+ importance=6,
383
+ location=conv.location,
384
+ involved_agents=other_ids,
385
+ )
386
+
387
+ self._emit(
388
+ f" [CONV END] Conversation about '{conv.topic}' between "
389
+ f"{', '.join(self.agents[p].name for p in conv.participants if p in self.agents)} ended."
390
+ )
391
+
392
+ async def _generate_reflection(self, agent: Agent) -> None:
393
+ """Generate a reflection for an agent about recent experiences."""
394
+ recent = agent.memory.get_recent(15)
395
+ recent_text = "\n".join(
396
+ f"- [{m.time_str}] {m.content}" for m in recent
397
+ )
398
+
399
+ world_desc = self.events.get_world_description()
400
+ loc_desc = self.city.describe_location(agent.location, exclude_agent=agent.id)
401
+
402
+ prompt = REFLECT_PROMPT.format(
403
+ time_str=self.clock.time_str,
404
+ day=self.clock.day,
405
+ context=agent.build_context(self.clock.total_ticks, world_desc, loc_desc),
406
+ recent_memories=recent_text,
407
+ )
408
+
409
+ result = await self.llm.complete_json(
410
+ system=agent.persona.system_prompt(),
411
+ user_message=prompt,
412
+ model=MODEL_HAIKU,
413
+ temperature=agent.persona.llm_temperature,
414
+ max_tokens=512,
415
+ )
416
+
417
+ reflections = result.get("reflections", [])
418
+ mood_shift = result.get("mood_shift", 0.0)
419
+
420
+ for ref_text in reflections:
421
+ agent.add_reflection(
422
+ tick=self.clock.total_ticks,
423
+ day=self.clock.day,
424
+ time_str=self.clock.time_str,
425
+ content=ref_text,
426
+ )
427
+
428
+ agent.mood = max(-1.0, min(1.0, agent.mood + mood_shift))
429
+ agent.memory.reset_reflection_accumulator()
430
+
431
+ if reflections:
432
+ self._emit(f" [REFLECT] {agent.name}: {reflections[0]}")
433
+
434
+ def get_state_summary(self) -> dict:
435
+ """Get a summary of the current simulation state."""
436
+ return {
437
+ "clock": self.clock.to_dict(),
438
+ "weather": self.events.weather.value,
439
+ "active_events": [e.to_dict() for e in self.events.active_events],
440
+ "agents": {
441
+ aid: {
442
+ "name": a.name,
443
+ "location": a.location,
444
+ "state": a.state.value,
445
+ "mood": round(a.mood, 2),
446
+ "needs": a.needs.to_dict(),
447
+ "action": a.current_action.detail if a.current_action else "idle",
448
+ }
449
+ for aid, a in self.agents.items()
450
+ },
451
+ "active_conversations": len(self.active_conversations),
452
+ "llm_usage": self.llm.usage.summary(),
453
+ }
454
+
455
+ def to_dict(self) -> dict:
456
+ """Serialize full simulation state."""
457
+ return {
458
+ "city": self.city.to_dict(),
459
+ "clock": self.clock.to_dict(),
460
+ "agents": {aid: a.to_dict() for aid, a in self.agents.items()},
461
+ "events": self.events.to_dict(),
462
+ "entropy": self.entropy.to_dict(),
463
+ "conversation_counter": self._conversation_counter,
464
+ }
465
+
466
+ @classmethod
467
+ def from_dict(cls, data: dict, llm: ClaudeClient) -> Simulation:
468
+ """Restore a simulation from serialized state."""
469
+ city = City.from_dict(data["city"])
470
+ clock = SimClock.from_dict(data["clock"])
471
+ sim = cls(city=city, clock=clock, llm=llm)
472
+ sim.events = EventSystem.from_dict(data["events"])
473
+ sim.entropy = EntropyManager.from_dict(data["entropy"])
474
+ sim._conversation_counter = data.get("conversation_counter", 0)
475
+ for aid, agent_data in data["agents"].items():
476
+ agent = Agent.from_dict(agent_data)
477
+ sim.agents[agent.id] = agent
478
+ return sim
src/soci/persistence/__init__.py ADDED
File without changes
src/soci/persistence/database.py ADDED
@@ -0,0 +1,170 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Database — SQLite persistence for simulation state."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import logging
7
+ import os
8
+ from pathlib import Path
9
+ from typing import Optional
10
+
11
+ import aiosqlite
12
+
13
+ logger = logging.getLogger(__name__)
14
+
15
+ DB_DIR = Path("data")
16
+ DEFAULT_DB = DB_DIR / "soci.db"
17
+
18
+ SCHEMA = """
19
+ CREATE TABLE IF NOT EXISTS snapshots (
20
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
21
+ name TEXT NOT NULL,
22
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
23
+ tick INTEGER NOT NULL,
24
+ day INTEGER NOT NULL,
25
+ state_json TEXT NOT NULL
26
+ );
27
+
28
+ CREATE TABLE IF NOT EXISTS event_log (
29
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
30
+ tick INTEGER NOT NULL,
31
+ day INTEGER NOT NULL,
32
+ time_str TEXT NOT NULL,
33
+ event_type TEXT NOT NULL,
34
+ agent_id TEXT,
35
+ location TEXT,
36
+ description TEXT NOT NULL,
37
+ metadata_json TEXT
38
+ );
39
+
40
+ CREATE TABLE IF NOT EXISTS conversations (
41
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
42
+ conv_id TEXT NOT NULL,
43
+ tick INTEGER NOT NULL,
44
+ day INTEGER NOT NULL,
45
+ location TEXT NOT NULL,
46
+ participants_json TEXT NOT NULL,
47
+ topic TEXT,
48
+ turns_json TEXT NOT NULL
49
+ );
50
+
51
+ CREATE INDEX IF NOT EXISTS idx_event_tick ON event_log(tick);
52
+ CREATE INDEX IF NOT EXISTS idx_event_agent ON event_log(agent_id);
53
+ CREATE INDEX IF NOT EXISTS idx_conv_tick ON conversations(tick);
54
+ """
55
+
56
+
57
+ class Database:
58
+ """Async SQLite database for simulation persistence."""
59
+
60
+ def __init__(self, db_path: str | Path = DEFAULT_DB) -> None:
61
+ self.db_path = Path(db_path)
62
+ self._db: Optional[aiosqlite.Connection] = None
63
+
64
+ async def connect(self) -> None:
65
+ """Connect to the database and create tables."""
66
+ self.db_path.parent.mkdir(parents=True, exist_ok=True)
67
+ self._db = await aiosqlite.connect(str(self.db_path))
68
+ await self._db.executescript(SCHEMA)
69
+ await self._db.commit()
70
+ logger.info(f"Database connected: {self.db_path}")
71
+
72
+ async def close(self) -> None:
73
+ if self._db:
74
+ await self._db.close()
75
+
76
+ async def save_snapshot(self, name: str, tick: int, day: int, state: dict) -> int:
77
+ """Save a full simulation state snapshot."""
78
+ assert self._db is not None
79
+ cursor = await self._db.execute(
80
+ "INSERT INTO snapshots (name, tick, day, state_json) VALUES (?, ?, ?, ?)",
81
+ (name, tick, day, json.dumps(state)),
82
+ )
83
+ await self._db.commit()
84
+ return cursor.lastrowid
85
+
86
+ async def load_snapshot(self, name: Optional[str] = None) -> Optional[dict]:
87
+ """Load the latest snapshot, or a specific named one."""
88
+ assert self._db is not None
89
+ if name:
90
+ cursor = await self._db.execute(
91
+ "SELECT state_json FROM snapshots WHERE name = ? ORDER BY id DESC LIMIT 1",
92
+ (name,),
93
+ )
94
+ else:
95
+ cursor = await self._db.execute(
96
+ "SELECT state_json FROM snapshots ORDER BY id DESC LIMIT 1",
97
+ )
98
+ row = await cursor.fetchone()
99
+ if row:
100
+ return json.loads(row[0])
101
+ return None
102
+
103
+ async def list_snapshots(self) -> list[dict]:
104
+ """List all saved snapshots."""
105
+ assert self._db is not None
106
+ cursor = await self._db.execute(
107
+ "SELECT id, name, created_at, tick, day FROM snapshots ORDER BY id DESC"
108
+ )
109
+ rows = await cursor.fetchall()
110
+ return [
111
+ {"id": r[0], "name": r[1], "created_at": r[2], "tick": r[3], "day": r[4]}
112
+ for r in rows
113
+ ]
114
+
115
+ async def log_event(
116
+ self,
117
+ tick: int,
118
+ day: int,
119
+ time_str: str,
120
+ event_type: str,
121
+ description: str,
122
+ agent_id: str = "",
123
+ location: str = "",
124
+ metadata: Optional[dict] = None,
125
+ ) -> None:
126
+ """Log a simulation event."""
127
+ assert self._db is not None
128
+ await self._db.execute(
129
+ "INSERT INTO event_log (tick, day, time_str, event_type, agent_id, location, description, metadata_json) "
130
+ "VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
131
+ (tick, day, time_str, event_type, agent_id, location, description,
132
+ json.dumps(metadata) if metadata else None),
133
+ )
134
+ await self._db.commit()
135
+
136
+ async def save_conversation(self, conv_data: dict) -> None:
137
+ """Save a completed conversation."""
138
+ assert self._db is not None
139
+ await self._db.execute(
140
+ "INSERT INTO conversations (conv_id, tick, day, location, participants_json, topic, turns_json) "
141
+ "VALUES (?, ?, ?, ?, ?, ?, ?)",
142
+ (
143
+ conv_data["id"],
144
+ conv_data["turns"][-1]["tick"] if conv_data["turns"] else 0,
145
+ 0, # Day would be tracked from clock
146
+ conv_data["location"],
147
+ json.dumps(conv_data["participants"]),
148
+ conv_data.get("topic", ""),
149
+ json.dumps(conv_data["turns"]),
150
+ ),
151
+ )
152
+ await self._db.commit()
153
+
154
+ async def get_recent_events(self, limit: int = 50) -> list[dict]:
155
+ """Get recent events from the log."""
156
+ assert self._db is not None
157
+ cursor = await self._db.execute(
158
+ "SELECT tick, day, time_str, event_type, agent_id, location, description "
159
+ "FROM event_log ORDER BY id DESC LIMIT ?",
160
+ (limit,),
161
+ )
162
+ rows = await cursor.fetchall()
163
+ return [
164
+ {
165
+ "tick": r[0], "day": r[1], "time_str": r[2],
166
+ "event_type": r[3], "agent_id": r[4],
167
+ "location": r[5], "description": r[6],
168
+ }
169
+ for r in rows
170
+ ]
src/soci/persistence/snapshots.py ADDED
@@ -0,0 +1,68 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Snapshots — save and load full simulation state."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import logging
7
+ from pathlib import Path
8
+ from typing import Optional, TYPE_CHECKING
9
+
10
+ if TYPE_CHECKING:
11
+ from soci.engine.simulation import Simulation
12
+ from soci.engine.llm import ClaudeClient
13
+ from soci.persistence.database import Database
14
+
15
+ logger = logging.getLogger(__name__)
16
+
17
+ SNAPSHOTS_DIR = Path("data") / "snapshots"
18
+
19
+
20
+ async def save_simulation(
21
+ sim: Simulation,
22
+ db: Database,
23
+ name: str = "autosave",
24
+ ) -> None:
25
+ """Save the full simulation state to database and JSON file."""
26
+ state = sim.to_dict()
27
+
28
+ # Save to database
29
+ await db.save_snapshot(
30
+ name=name,
31
+ tick=sim.clock.total_ticks,
32
+ day=sim.clock.day,
33
+ state=state,
34
+ )
35
+
36
+ # Also save as JSON file for easy inspection
37
+ SNAPSHOTS_DIR.mkdir(parents=True, exist_ok=True)
38
+ path = SNAPSHOTS_DIR / f"{name}.json"
39
+ with open(path, "w", encoding="utf-8") as f:
40
+ json.dump(state, f, indent=2, ensure_ascii=False)
41
+
42
+ logger.info(f"Simulation saved: {name} (tick {sim.clock.total_ticks}, day {sim.clock.day})")
43
+
44
+
45
+ async def load_simulation(
46
+ db: Database,
47
+ llm: ClaudeClient,
48
+ name: Optional[str] = None,
49
+ ) -> Optional[Simulation]:
50
+ """Load a simulation from the database."""
51
+ from soci.engine.simulation import Simulation
52
+
53
+ state = await db.load_snapshot(name)
54
+ if not state:
55
+ # Try loading from JSON file
56
+ if name:
57
+ path = SNAPSHOTS_DIR / f"{name}.json"
58
+ if path.exists():
59
+ with open(path, "r", encoding="utf-8") as f:
60
+ state = json.load(f)
61
+
62
+ if not state:
63
+ logger.warning(f"No snapshot found: {name or 'latest'}")
64
+ return None
65
+
66
+ sim = Simulation.from_dict(state, llm)
67
+ logger.info(f"Simulation loaded: tick {sim.clock.total_ticks}, day {sim.clock.day}")
68
+ return sim
src/soci/world/__init__.py ADDED
File without changes
src/soci/world/city.py ADDED
@@ -0,0 +1,157 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """City map — locations, zones, and connections between them."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+ from typing import Optional
7
+
8
+ import yaml
9
+
10
+
11
+ @dataclass
12
+ class Location:
13
+ """A place in the city where agents can be."""
14
+
15
+ id: str
16
+ name: str
17
+ zone: str # residential, commercial, public, work
18
+ description: str
19
+ capacity: int = 20
20
+ connected_to: list[str] = field(default_factory=list)
21
+ # Current occupants (agent IDs)
22
+ occupants: list[str] = field(default_factory=list, repr=False)
23
+
24
+ @property
25
+ def is_full(self) -> bool:
26
+ return len(self.occupants) >= self.capacity
27
+
28
+ @property
29
+ def occupant_count(self) -> int:
30
+ return len(self.occupants)
31
+
32
+ def add_occupant(self, agent_id: str) -> None:
33
+ if agent_id not in self.occupants:
34
+ self.occupants.append(agent_id)
35
+
36
+ def remove_occupant(self, agent_id: str) -> None:
37
+ if agent_id in self.occupants:
38
+ self.occupants.remove(agent_id)
39
+
40
+ def to_dict(self) -> dict:
41
+ return {
42
+ "id": self.id,
43
+ "name": self.name,
44
+ "zone": self.zone,
45
+ "description": self.description,
46
+ "capacity": self.capacity,
47
+ "connected_to": self.connected_to,
48
+ "occupants": list(self.occupants),
49
+ }
50
+
51
+
52
+ class City:
53
+ """The city map — a graph of connected locations."""
54
+
55
+ def __init__(self, name: str = "Soci City") -> None:
56
+ self.name = name
57
+ self.locations: dict[str, Location] = {}
58
+
59
+ def add_location(self, location: Location) -> None:
60
+ self.locations[location.id] = location
61
+
62
+ def get_location(self, location_id: str) -> Optional[Location]:
63
+ return self.locations.get(location_id)
64
+
65
+ def get_connected(self, location_id: str) -> list[Location]:
66
+ loc = self.locations.get(location_id)
67
+ if not loc:
68
+ return []
69
+ return [self.locations[cid] for cid in loc.connected_to if cid in self.locations]
70
+
71
+ def get_locations_in_zone(self, zone: str) -> list[Location]:
72
+ return [loc for loc in self.locations.values() if loc.zone == zone]
73
+
74
+ def get_agents_at(self, location_id: str) -> list[str]:
75
+ loc = self.locations.get(location_id)
76
+ return list(loc.occupants) if loc else []
77
+
78
+ def move_agent(self, agent_id: str, from_id: str, to_id: str) -> bool:
79
+ """Move an agent between locations. Returns True if successful."""
80
+ from_loc = self.locations.get(from_id)
81
+ to_loc = self.locations.get(to_id)
82
+ if not from_loc or not to_loc:
83
+ return False
84
+ if to_loc.is_full:
85
+ return False
86
+ from_loc.remove_occupant(agent_id)
87
+ to_loc.add_occupant(agent_id)
88
+ return True
89
+
90
+ def place_agent(self, agent_id: str, location_id: str) -> bool:
91
+ """Place an agent at a location (initial placement)."""
92
+ loc = self.locations.get(location_id)
93
+ if not loc or loc.is_full:
94
+ return False
95
+ loc.add_occupant(agent_id)
96
+ return True
97
+
98
+ def find_agent(self, agent_id: str) -> Optional[str]:
99
+ """Find which location an agent is at. Returns location_id or None."""
100
+ for loc in self.locations.values():
101
+ if agent_id in loc.occupants:
102
+ return loc.id
103
+ return None
104
+
105
+ def describe_location(self, location_id: str, exclude_agent: str = "") -> str:
106
+ """Get a natural language description of a location and who's there."""
107
+ loc = self.locations.get(location_id)
108
+ if not loc:
109
+ return "Unknown location."
110
+ others = [a for a in loc.occupants if a != exclude_agent]
111
+ desc = f"{loc.name} — {loc.description}"
112
+ if others:
113
+ desc += f" Present: {', '.join(others)}."
114
+ else:
115
+ desc += " No one else is here."
116
+ return desc
117
+
118
+ def to_dict(self) -> dict:
119
+ return {
120
+ "name": self.name,
121
+ "locations": {lid: loc.to_dict() for lid, loc in self.locations.items()},
122
+ }
123
+
124
+ @classmethod
125
+ def from_yaml(cls, path: str) -> City:
126
+ """Load city from a YAML config file."""
127
+ with open(path, "r", encoding="utf-8") as f:
128
+ data = yaml.safe_load(f)
129
+
130
+ city = cls(name=data.get("name", "Soci City"))
131
+ for loc_data in data.get("locations", []):
132
+ location = Location(
133
+ id=loc_data["id"],
134
+ name=loc_data["name"],
135
+ zone=loc_data.get("zone", "public"),
136
+ description=loc_data.get("description", ""),
137
+ capacity=loc_data.get("capacity", 20),
138
+ connected_to=loc_data.get("connected_to", []),
139
+ )
140
+ city.add_location(location)
141
+ return city
142
+
143
+ @classmethod
144
+ def from_dict(cls, data: dict) -> City:
145
+ city = cls(name=data["name"])
146
+ for loc_data in data["locations"].values():
147
+ location = Location(
148
+ id=loc_data["id"],
149
+ name=loc_data["name"],
150
+ zone=loc_data["zone"],
151
+ description=loc_data["description"],
152
+ capacity=loc_data["capacity"],
153
+ connected_to=loc_data["connected_to"],
154
+ )
155
+ location.occupants = loc_data.get("occupants", [])
156
+ city.add_location(location)
157
+ return city
src/soci/world/clock.py ADDED
@@ -0,0 +1,87 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Simulation clock — tracks in-game time with configurable tick duration."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+ from enum import Enum
7
+
8
+
9
+ class TimeOfDay(Enum):
10
+ DAWN = "dawn" # 5:00 - 7:59
11
+ MORNING = "morning" # 8:00 - 11:59
12
+ AFTERNOON = "afternoon" # 12:00 - 16:59
13
+ EVENING = "evening" # 17:00 - 20:59
14
+ NIGHT = "night" # 21:00 - 4:59
15
+
16
+
17
+ @dataclass
18
+ class SimClock:
19
+ """Tracks simulation time. One tick = tick_minutes of in-game time."""
20
+
21
+ tick_minutes: int = 15
22
+ day: int = 1
23
+ hour: int = 6
24
+ minute: int = 0
25
+ _total_ticks: int = field(default=0, repr=False)
26
+
27
+ def tick(self) -> None:
28
+ """Advance time by one tick."""
29
+ self._total_ticks += 1
30
+ self.minute += self.tick_minutes
31
+ while self.minute >= 60:
32
+ self.minute -= 60
33
+ self.hour += 1
34
+ while self.hour >= 24:
35
+ self.hour -= 24
36
+ self.day += 1
37
+
38
+ @property
39
+ def total_ticks(self) -> int:
40
+ return self._total_ticks
41
+
42
+ @property
43
+ def time_of_day(self) -> TimeOfDay:
44
+ if 5 <= self.hour < 8:
45
+ return TimeOfDay.DAWN
46
+ elif 8 <= self.hour < 12:
47
+ return TimeOfDay.MORNING
48
+ elif 12 <= self.hour < 17:
49
+ return TimeOfDay.AFTERNOON
50
+ elif 17 <= self.hour < 21:
51
+ return TimeOfDay.EVENING
52
+ else:
53
+ return TimeOfDay.NIGHT
54
+
55
+ @property
56
+ def is_sleeping_hours(self) -> bool:
57
+ return self.hour >= 23 or self.hour < 6
58
+
59
+ @property
60
+ def time_str(self) -> str:
61
+ return f"{self.hour:02d}:{self.minute:02d}"
62
+
63
+ @property
64
+ def datetime_str(self) -> str:
65
+ return f"Day {self.day}, {self.time_str}"
66
+
67
+ def to_dict(self) -> dict:
68
+ return {
69
+ "day": self.day,
70
+ "hour": self.hour,
71
+ "minute": self.minute,
72
+ "tick_minutes": self.tick_minutes,
73
+ "total_ticks": self._total_ticks,
74
+ "time_of_day": self.time_of_day.value,
75
+ "time_str": self.time_str,
76
+ }
77
+
78
+ @classmethod
79
+ def from_dict(cls, data: dict) -> SimClock:
80
+ clock = cls(
81
+ tick_minutes=data["tick_minutes"],
82
+ day=data["day"],
83
+ hour=data["hour"],
84
+ minute=data["minute"],
85
+ )
86
+ clock._total_ticks = data["total_ticks"]
87
+ return clock
src/soci/world/events.py ADDED
@@ -0,0 +1,246 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """World events — random occurrences that inject entropy into the simulation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import random
6
+ from dataclasses import dataclass, field
7
+ from enum import Enum
8
+ from typing import Optional
9
+
10
+
11
+ class EventSeverity(Enum):
12
+ MINOR = "minor" # Weather change, small talk topic
13
+ MODERATE = "moderate" # New shop opens, street performer, local news
14
+ MAJOR = "major" # Festival, power outage, celebrity visit
15
+ CRITICAL = "critical" # Emergency, natural disaster, evacuation
16
+
17
+
18
+ class WeatherState(Enum):
19
+ SUNNY = "sunny"
20
+ CLOUDY = "cloudy"
21
+ RAINY = "rainy"
22
+ STORMY = "stormy"
23
+ SNOWY = "snowy"
24
+ FOGGY = "foggy"
25
+
26
+
27
+ @dataclass
28
+ class WorldEvent:
29
+ """A single event that occurs in the simulation world."""
30
+
31
+ id: str
32
+ name: str
33
+ description: str
34
+ severity: EventSeverity
35
+ affected_locations: list[str] = field(default_factory=list) # empty = city-wide
36
+ duration_ticks: int = 1 # how long it persists
37
+ remaining_ticks: int = 0
38
+
39
+ def is_active(self) -> bool:
40
+ return self.remaining_ticks > 0
41
+
42
+ def tick(self) -> None:
43
+ if self.remaining_ticks > 0:
44
+ self.remaining_ticks -= 1
45
+
46
+ def to_dict(self) -> dict:
47
+ return {
48
+ "id": self.id,
49
+ "name": self.name,
50
+ "description": self.description,
51
+ "severity": self.severity.value,
52
+ "affected_locations": self.affected_locations,
53
+ "duration_ticks": self.duration_ticks,
54
+ "remaining_ticks": self.remaining_ticks,
55
+ }
56
+
57
+
58
+ # Pool of possible random events
59
+ EVENT_TEMPLATES: list[dict] = [
60
+ {
61
+ "name": "Sudden Rain",
62
+ "description": "Dark clouds roll in and rain begins to pour. People rush for cover.",
63
+ "severity": EventSeverity.MINOR,
64
+ "duration_ticks": 8,
65
+ },
66
+ {
67
+ "name": "Street Musician",
68
+ "description": "A talented street musician sets up and starts playing beautiful music.",
69
+ "severity": EventSeverity.MINOR,
70
+ "duration_ticks": 4,
71
+ "location_zone": "public",
72
+ },
73
+ {
74
+ "name": "Food Truck Arrives",
75
+ "description": "A popular food truck parks nearby, filling the air with delicious aromas.",
76
+ "severity": EventSeverity.MINOR,
77
+ "duration_ticks": 6,
78
+ "location_zone": "commercial",
79
+ },
80
+ {
81
+ "name": "Lost Dog",
82
+ "description": "A friendly but lost dog is wandering around, looking for its owner.",
83
+ "severity": EventSeverity.MINOR,
84
+ "duration_ticks": 12,
85
+ },
86
+ {
87
+ "name": "Local Art Exhibition",
88
+ "description": "A pop-up art exhibition opens, showcasing works by local artists.",
89
+ "severity": EventSeverity.MODERATE,
90
+ "duration_ticks": 16,
91
+ "location_zone": "public",
92
+ },
93
+ {
94
+ "name": "Neighborhood Meeting",
95
+ "description": "A community meeting is called to discuss changes in the neighborhood.",
96
+ "severity": EventSeverity.MODERATE,
97
+ "duration_ticks": 4,
98
+ },
99
+ {
100
+ "name": "Power Flicker",
101
+ "description": "The power flickers briefly, causing momentary darkness and disruption.",
102
+ "severity": EventSeverity.MODERATE,
103
+ "duration_ticks": 2,
104
+ },
105
+ {
106
+ "name": "New Shop Grand Opening",
107
+ "description": "A new shop opens with fanfare, offering discounts and free samples.",
108
+ "severity": EventSeverity.MODERATE,
109
+ "duration_ticks": 8,
110
+ "location_zone": "commercial",
111
+ },
112
+ {
113
+ "name": "Summer Festival",
114
+ "description": "The annual summer festival begins! Music, food stalls, and games fill the park.",
115
+ "severity": EventSeverity.MAJOR,
116
+ "duration_ticks": 24,
117
+ "location_zone": "public",
118
+ },
119
+ {
120
+ "name": "Power Outage",
121
+ "description": "A major power outage hits the city. Businesses close early, streets go dark.",
122
+ "severity": EventSeverity.MAJOR,
123
+ "duration_ticks": 8,
124
+ },
125
+ {
126
+ "name": "Celebrity Sighting",
127
+ "description": "A famous celebrity is spotted in town, causing excitement and crowds.",
128
+ "severity": EventSeverity.MAJOR,
129
+ "duration_ticks": 4,
130
+ },
131
+ {
132
+ "name": "Water Main Break",
133
+ "description": "A water main breaks, flooding a street and disrupting traffic.",
134
+ "severity": EventSeverity.CRITICAL,
135
+ "duration_ticks": 12,
136
+ },
137
+ {
138
+ "name": "Severe Storm Warning",
139
+ "description": "Emergency alert: a severe storm is approaching. Seek shelter immediately.",
140
+ "severity": EventSeverity.CRITICAL,
141
+ "duration_ticks": 6,
142
+ },
143
+ ]
144
+
145
+
146
+ class EventSystem:
147
+ """Manages world events, weather, and entropy injection."""
148
+
149
+ def __init__(self, event_chance_per_tick: float = 0.08) -> None:
150
+ self.event_chance = event_chance_per_tick
151
+ self.weather: WeatherState = WeatherState.SUNNY
152
+ self.active_events: list[WorldEvent] = []
153
+ self._event_counter: int = 0
154
+
155
+ def tick(self, city_location_ids: list[str]) -> list[WorldEvent]:
156
+ """Process one tick: expire old events, maybe spawn new ones."""
157
+ # Tick existing events
158
+ for event in self.active_events:
159
+ event.tick()
160
+ self.active_events = [e for e in self.active_events if e.is_active()]
161
+
162
+ new_events: list[WorldEvent] = []
163
+
164
+ # Maybe change weather
165
+ if random.random() < 0.03:
166
+ old = self.weather
167
+ self.weather = random.choice(list(WeatherState))
168
+ if self.weather != old:
169
+ evt = WorldEvent(
170
+ id=f"weather_{self._event_counter}",
171
+ name=f"Weather Change",
172
+ description=f"The weather shifts from {old.value} to {self.weather.value}.",
173
+ severity=EventSeverity.MINOR,
174
+ duration_ticks=1,
175
+ remaining_ticks=1,
176
+ )
177
+ self._event_counter += 1
178
+ new_events.append(evt)
179
+
180
+ # Maybe spawn a random event
181
+ if random.random() < self.event_chance:
182
+ template = random.choice(EVENT_TEMPLATES)
183
+ # Pick affected location(s)
184
+ affected: list[str] = []
185
+ if "location_zone" in template:
186
+ # Just pick a random location for now; the simulation can filter by zone
187
+ if city_location_ids:
188
+ affected = [random.choice(city_location_ids)]
189
+ elif random.random() < 0.5 and city_location_ids:
190
+ affected = [random.choice(city_location_ids)]
191
+ # else: city-wide
192
+
193
+ evt = WorldEvent(
194
+ id=f"event_{self._event_counter}",
195
+ name=template["name"],
196
+ description=template["description"],
197
+ severity=template["severity"],
198
+ affected_locations=affected,
199
+ duration_ticks=template["duration_ticks"],
200
+ remaining_ticks=template["duration_ticks"],
201
+ )
202
+ self._event_counter += 1
203
+ self.active_events.append(evt)
204
+ new_events.append(evt)
205
+
206
+ return new_events
207
+
208
+ def get_events_at(self, location_id: str) -> list[WorldEvent]:
209
+ """Get active events affecting a specific location."""
210
+ return [
211
+ e for e in self.active_events
212
+ if not e.affected_locations or location_id in e.affected_locations
213
+ ]
214
+
215
+ def get_world_description(self) -> str:
216
+ """Summary of current world state for agent context."""
217
+ parts = [f"Weather: {self.weather.value}."]
218
+ for event in self.active_events:
219
+ parts.append(f"[{event.severity.value.upper()}] {event.name}: {event.description}")
220
+ return " ".join(parts)
221
+
222
+ def to_dict(self) -> dict:
223
+ return {
224
+ "weather": self.weather.value,
225
+ "active_events": [e.to_dict() for e in self.active_events],
226
+ "event_counter": self._event_counter,
227
+ "event_chance": self.event_chance,
228
+ }
229
+
230
+ @classmethod
231
+ def from_dict(cls, data: dict) -> EventSystem:
232
+ system = cls(event_chance_per_tick=data["event_chance"])
233
+ system.weather = WeatherState(data["weather"])
234
+ system._event_counter = data["event_counter"]
235
+ for ed in data["active_events"]:
236
+ evt = WorldEvent(
237
+ id=ed["id"],
238
+ name=ed["name"],
239
+ description=ed["description"],
240
+ severity=EventSeverity(ed["severity"]),
241
+ affected_locations=ed["affected_locations"],
242
+ duration_ticks=ed["duration_ticks"],
243
+ remaining_ticks=ed["remaining_ticks"],
244
+ )
245
+ system.active_events.append(evt)
246
+ return system
test_simulation.py ADDED
@@ -0,0 +1,385 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Offline integration test — runs the full simulation loop with a mock LLM.
2
+
3
+ This test validates the entire pipeline without requiring an API key.
4
+ Run: python test_simulation.py
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import asyncio
10
+ import json
11
+ import random
12
+ import sys
13
+ from pathlib import Path
14
+ from unittest.mock import AsyncMock, MagicMock
15
+
16
+ sys.path.insert(0, str(Path(__file__).parent / "src"))
17
+
18
+ from soci.world.city import City
19
+ from soci.world.clock import SimClock
20
+ from soci.world.events import EventSystem
21
+ from soci.agents.persona import load_personas, Persona
22
+ from soci.agents.agent import Agent, AgentAction, AgentState
23
+ from soci.agents.memory import MemoryStream, MemoryType
24
+ from soci.agents.needs import NeedsState
25
+ from soci.agents.relationships import RelationshipGraph, Relationship
26
+ from soci.actions.registry import resolve_action, ActionType
27
+ from soci.actions.movement import execute_move, get_best_location_for_need
28
+ from soci.actions.activities import execute_activity
29
+ from soci.actions.social import should_initiate_conversation, pick_conversation_partner
30
+ from soci.engine.entropy import EntropyManager
31
+ from soci.engine.scheduler import prioritize_agents, should_skip_llm
32
+ from soci.engine.simulation import Simulation
33
+ from soci.persistence.database import Database
34
+
35
+
36
+ class MockLLM:
37
+ """Mock LLM that returns plausible JSON responses without calling the API."""
38
+
39
+ def __init__(self):
40
+ self.usage = MagicMock()
41
+ self.usage.total_calls = 0
42
+ self.usage.total_input_tokens = 0
43
+ self.usage.total_output_tokens = 0
44
+ self.usage.estimated_cost_usd = 0.0
45
+ self.usage.calls_by_model = {}
46
+ self.usage.summary.return_value = "Mock LLM: 0 calls, $0.00"
47
+
48
+ async def complete(self, system, user_message, model=None, temperature=0.7, max_tokens=1024):
49
+ self.usage.total_calls += 1
50
+ return "I'm thinking about my day."
51
+
52
+ async def complete_json(self, system, user_message, model=None, temperature=0.7, max_tokens=1024):
53
+ self.usage.total_calls += 1
54
+
55
+ # Detect what kind of prompt this is and return appropriate mock data
56
+ msg = user_message.lower()
57
+
58
+ if "plan your day" in msg:
59
+ return {
60
+ "plan": [
61
+ "Wake up and have breakfast at home",
62
+ "Go to work at the office",
63
+ "Have lunch at the cafe",
64
+ "Continue working",
65
+ "Go to the park for a walk",
66
+ "Have dinner",
67
+ "Relax at home",
68
+ ],
69
+ "reasoning": "A balanced day with work and leisure."
70
+ }
71
+
72
+ if "what do you do next" in msg:
73
+ actions = ["work", "eat", "relax", "wander", "move", "exercise"]
74
+ action = random.choice(actions)
75
+ targets = {
76
+ "move": random.choice(["cafe", "park", "home_north", "office", "grocery"]),
77
+ "work": "",
78
+ "eat": "",
79
+ "relax": "",
80
+ "wander": "",
81
+ "exercise": "",
82
+ }
83
+ details = {
84
+ "move": "heading somewhere new",
85
+ "work": "focusing on a project",
86
+ "eat": "having a quick meal",
87
+ "relax": "taking it easy",
88
+ "wander": "strolling around",
89
+ "exercise": "doing some stretches",
90
+ }
91
+ return {
92
+ "action": action,
93
+ "target": targets.get(action, ""),
94
+ "detail": details.get(action, "doing something"),
95
+ "duration": random.randint(1, 3),
96
+ "reasoning": "Felt like it."
97
+ }
98
+
99
+ if "how important" in msg:
100
+ return {
101
+ "importance": random.randint(3, 8),
102
+ "reaction": "Interesting, I'll remember that."
103
+ }
104
+
105
+ if "reflect" in msg:
106
+ return {
107
+ "reflections": [
108
+ "I notice I've been spending a lot of time at work lately.",
109
+ "The neighborhood feels alive today."
110
+ ],
111
+ "mood_shift": random.uniform(-0.1, 0.2),
112
+ "reasoning": "Just thinking about things."
113
+ }
114
+
115
+ if "start a conversation" in msg or "you decide to start" in msg:
116
+ return {
117
+ "message": "Hey, how's it going?",
118
+ "inner_thought": "I should catch up with them.",
119
+ "topic": "daily life"
120
+ }
121
+
122
+ if "says:" in msg:
123
+ return {
124
+ "message": "Yeah, things are good. How about you?",
125
+ "inner_thought": "Nice to chat.",
126
+ "sentiment_delta": 0.05,
127
+ "trust_delta": 0.02
128
+ }
129
+
130
+ return {"status": "ok"}
131
+
132
+
133
+ async def run_tests():
134
+ print("=" * 60)
135
+ print("SOCI — OFFLINE INTEGRATION TEST")
136
+ print("=" * 60)
137
+ errors = 0
138
+
139
+ # --- Test 1: Clock ---
140
+ print("\n[1/12] Clock system...")
141
+ clock = SimClock(tick_minutes=15, hour=6, minute=0)
142
+ for _ in range(96): # Full day
143
+ clock.tick()
144
+ assert clock.day == 2, f"Expected day 2, got {clock.day}"
145
+ assert clock.hour == 6, f"Expected hour 6, got {clock.hour}"
146
+ clock_dict = clock.to_dict()
147
+ restored_clock = SimClock.from_dict(clock_dict)
148
+ assert restored_clock.day == clock.day
149
+ print(" PASS: Clock ticks correctly for a full day, serialization works")
150
+
151
+ # --- Test 2: City ---
152
+ print("\n[2/12] City system...")
153
+ city = City.from_yaml("config/city.yaml")
154
+ assert len(city.locations) == 12
155
+ # Test connectivity
156
+ cafe = city.get_location("cafe")
157
+ assert cafe is not None
158
+ assert "park" in cafe.connected_to
159
+ connected = city.get_connected("cafe")
160
+ assert len(connected) > 0
161
+ # Test agent placement and movement
162
+ city.place_agent("test_agent", "cafe")
163
+ assert "test_agent" in city.get_agents_at("cafe")
164
+ city.move_agent("test_agent", "cafe", "park")
165
+ assert "test_agent" not in city.get_agents_at("cafe")
166
+ assert "test_agent" in city.get_agents_at("park")
167
+ assert city.find_agent("test_agent") == "park"
168
+ city.locations["park"].remove_occupant("test_agent")
169
+ print(" PASS: City loads, connections work, movement works")
170
+
171
+ # --- Test 3: Personas ---
172
+ print("\n[3/12] Persona system...")
173
+ personas = load_personas("config/personas.yaml")
174
+ assert len(personas) == 20
175
+ # Check diversity
176
+ ages = [p.age for p in personas]
177
+ assert min(ages) <= 20, "Should have young people"
178
+ assert max(ages) >= 60, "Should have older people"
179
+ occupations = set(p.occupation for p in personas)
180
+ assert len(occupations) >= 15, "Should have diverse occupations"
181
+ # Test system prompt
182
+ prompt = personas[0].system_prompt()
183
+ assert personas[0].name in prompt
184
+ assert "personality" in prompt.lower() or "PERSONALITY" in prompt
185
+ print(f" PASS: 20 personas loaded, ages {min(ages)}-{max(ages)}, {len(occupations)} occupations")
186
+
187
+ # --- Test 4: Needs ---
188
+ print("\n[4/12] Needs system...")
189
+ needs = NeedsState()
190
+ initial_hunger = needs.hunger
191
+ for _ in range(20):
192
+ needs.tick()
193
+ assert needs.hunger < initial_hunger, "Hunger should decay"
194
+ assert needs.energy < 1.0, "Energy should decay"
195
+ needs.satisfy("hunger", 0.5)
196
+ assert needs.hunger > 0.0, "Hunger should be partially satisfied"
197
+ urgent = needs.urgent_needs
198
+ desc = needs.describe()
199
+ assert isinstance(desc, str)
200
+ print(f" PASS: Needs decay ({desc}), satisfaction works")
201
+
202
+ # --- Test 5: Memory ---
203
+ print("\n[5/12] Memory system...")
204
+ mem = MemoryStream()
205
+ for i in range(30):
206
+ mem.add(i, 1, f"{6+i//4:02d}:{(i%4)*15:02d}",
207
+ MemoryType.OBSERVATION, f"Event {i}", importance=random.randint(1, 10))
208
+ assert len(mem.memories) == 30
209
+ retrieved = mem.retrieve(30, top_k=5)
210
+ assert len(retrieved) == 5
211
+ recent = mem.get_recent(3)
212
+ assert len(recent) == 3
213
+ assert recent[-1].content == "Event 29"
214
+ # Test reflection trigger
215
+ mem._importance_accumulator = 100
216
+ assert mem.should_reflect()
217
+ mem.reset_reflection_accumulator()
218
+ assert not mem.should_reflect()
219
+ # Test serialization
220
+ mem_dict = mem.to_dict()
221
+ restored_mem = MemoryStream.from_dict(mem_dict)
222
+ assert len(restored_mem.memories) == 30
223
+ print(" PASS: Memory storage, retrieval, reflection trigger, serialization")
224
+
225
+ # --- Test 6: Relationships ---
226
+ print("\n[6/12] Relationship system...")
227
+ graph = RelationshipGraph()
228
+ rel = graph.get_or_create("elena", "Elena Vasquez")
229
+ assert rel.familiarity == 0.0
230
+ rel.update_after_interaction(tick=10, sentiment_delta=0.1, trust_delta=0.05, note="Had coffee together")
231
+ assert rel.familiarity > 0.0
232
+ assert rel.sentiment > 0.5
233
+ assert len(rel.notes) == 1
234
+ closest = graph.get_closest(5)
235
+ assert len(closest) == 1
236
+ desc = rel.describe()
237
+ assert "Elena" in desc
238
+ # Serialization
239
+ g_dict = graph.to_dict()
240
+ restored_g = RelationshipGraph.from_dict(g_dict)
241
+ assert restored_g.get("elena") is not None
242
+ print(" PASS: Relationships form, track sentiment/trust, serialize")
243
+
244
+ # --- Test 7: Agent ---
245
+ print("\n[7/12] Agent system...")
246
+ persona = personas[0] # Elena
247
+ agent = Agent(persona)
248
+ assert agent.name == "Elena Vasquez"
249
+ assert agent.location == "home_north"
250
+ assert agent.state == AgentState.IDLE
251
+ # Test action
252
+ action = AgentAction(type="work", detail="coding", duration_ticks=3, needs_satisfied={"purpose": 0.3})
253
+ agent.start_action(action)
254
+ assert agent.is_busy
255
+ assert agent.state == AgentState.WORKING
256
+ for _ in range(3):
257
+ agent.tick_action()
258
+ assert not agent.is_busy
259
+ assert agent.state == AgentState.IDLE
260
+ # Test mood + needs interaction
261
+ for _ in range(10):
262
+ agent.tick_needs()
263
+ # Test observation
264
+ agent.add_observation(0, 1, "06:00", "Saw a cat in the park", importance=4)
265
+ assert len(agent.memory.memories) == 1
266
+ # Serialization
267
+ a_dict = agent.to_dict()
268
+ restored_a = Agent.from_dict(a_dict)
269
+ assert restored_a.name == agent.name
270
+ assert len(restored_a.memory.memories) == 1
271
+ print(" PASS: Agent actions, needs, mood, memory, serialization")
272
+
273
+ # --- Test 8: Action resolution ---
274
+ print("\n[8/12] Action resolution...")
275
+ city2 = City.from_yaml("config/city.yaml")
276
+ agent2 = Agent(personas[0])
277
+ city2.place_agent(agent2.id, agent2.location)
278
+ raw = {"action": "move", "target": "cafe", "detail": "heading to cafe", "duration": 1}
279
+ resolved = resolve_action(raw, agent2, city2)
280
+ assert resolved.type == "move"
281
+ assert resolved.target == "cafe"
282
+ # Invalid action falls back to wander
283
+ raw_bad = {"action": "fly", "target": "moon"}
284
+ resolved_bad = resolve_action(raw_bad, agent2, city2)
285
+ assert resolved_bad.type == "wander"
286
+ print(" PASS: Valid actions resolve, invalid actions fall back to wander")
287
+
288
+ # --- Test 9: Movement ---
289
+ print("\n[9/12] Movement system...")
290
+ clock2 = SimClock()
291
+ agent3 = Agent(personas[0])
292
+ city3 = City.from_yaml("config/city.yaml")
293
+ city3.place_agent(agent3.id, "home_north")
294
+ move_action = AgentAction(type="move", target="cafe", detail="walking to cafe")
295
+ desc = execute_move(agent3, move_action, city3, clock2)
296
+ assert "cafe" in desc.lower() or "Daily Grind" in desc
297
+ assert agent3.location == "cafe"
298
+ # Test location suggestion
299
+ suggested = get_best_location_for_need(agent3, "hunger", city3)
300
+ assert suggested is not None
301
+ print(f" PASS: Movement works, need-based suggestion: {suggested}")
302
+
303
+ # --- Test 10: Events & Entropy ---
304
+ print("\n[10/12] Events and entropy...")
305
+ events = EventSystem(event_chance_per_tick=1.0) # Force events
306
+ new = events.tick(["cafe", "park", "office"])
307
+ assert len(events.active_events) > 0 or len(new) > 0
308
+ world_desc = events.get_world_description()
309
+ assert "Weather" in world_desc
310
+ entropy = EntropyManager()
311
+ agents_list = [Agent(p) for p in personas[:5]]
312
+ # Simulate repetitive behavior
313
+ entropy._action_history["elena"] = ["work"] * 15
314
+ assert entropy._is_stuck_in_loop("elena")
315
+ conflicts = entropy.get_conflict_catalysts(agents_list)
316
+ print(f" PASS: Events fire, entropy detects loops, {len(conflicts)} potential conflicts found")
317
+
318
+ # --- Test 11: Full simulation loop (mock LLM) ---
319
+ print("\n[11/12] Full simulation loop (mock LLM)...")
320
+ mock_llm = MockLLM()
321
+ city4 = City.from_yaml("config/city.yaml")
322
+ clock4 = SimClock(tick_minutes=15, hour=6, minute=0)
323
+ sim = Simulation(city=city4, clock=clock4, llm=mock_llm)
324
+ sim.load_agents_from_yaml("config/personas.yaml")
325
+
326
+ # Limit to 5 agents for speed
327
+ agent_ids = list(sim.agents.keys())[:5]
328
+ sim.agents = {aid: sim.agents[aid] for aid in agent_ids}
329
+
330
+ events_collected = []
331
+ sim.on_event = lambda msg: events_collected.append(msg)
332
+
333
+ # Run 10 ticks
334
+ for _ in range(10):
335
+ await sim.tick()
336
+
337
+ assert sim.clock.total_ticks == 10
338
+ assert len(events_collected) > 0
339
+ print(f" PASS: 10 ticks completed, {len(events_collected)} events, "
340
+ f"{mock_llm.usage.total_calls} LLM calls")
341
+
342
+ # Check agents moved, have memories, etc.
343
+ for aid, agent in sim.agents.items():
344
+ assert len(agent.memory.memories) > 0, f"{agent.name} should have memories"
345
+
346
+ # --- Test 12: State serialization roundtrip ---
347
+ print("\n[12/12] Full state serialization...")
348
+ state = sim.to_dict()
349
+ state_json = json.dumps(state)
350
+ assert len(state_json) > 1000, "State should be substantial"
351
+ restored_state = json.loads(state_json)
352
+ sim2 = Simulation.from_dict(restored_state, mock_llm)
353
+ assert len(sim2.agents) == len(sim.agents)
354
+ assert sim2.clock.total_ticks == sim.clock.total_ticks
355
+ for aid in sim.agents:
356
+ assert aid in sim2.agents
357
+ assert sim2.agents[aid].name == sim.agents[aid].name
358
+ print(f" PASS: Full state serialized ({len(state_json):,} bytes) and restored")
359
+
360
+ # --- Summary ---
361
+ print("\n" + "=" * 60)
362
+ if errors == 0:
363
+ print("ALL 12 TESTS PASSED")
364
+ else:
365
+ print(f"{errors} TEST(S) FAILED")
366
+ print("=" * 60)
367
+
368
+ # Print some interesting stats
369
+ print(f"\nSimulation state:")
370
+ print(f" Clock: {sim.clock.datetime_str}")
371
+ print(f" Weather: {sim.events.weather.value}")
372
+ print(f" Mock LLM calls: {mock_llm.usage.total_calls}")
373
+ print(f"\nAgent status after 10 ticks:")
374
+ for aid, agent in sim.agents.items():
375
+ loc = sim.city.get_location(agent.location)
376
+ loc_name = loc.name if loc else agent.location
377
+ print(f" {agent.name}: {agent.state.value} at {loc_name} "
378
+ f"(mood={agent.mood:.2f}, memories={len(agent.memory.memories)})")
379
+
380
+ return errors == 0
381
+
382
+
383
+ if __name__ == "__main__":
384
+ success = asyncio.run(run_tests())
385
+ sys.exit(0 if success else 1)