Commit ·
59edb07
0
Parent(s):
Initial implementation of Soci city population simulator
Browse filesLLM-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 +1 -0
- .gitignore +22 -0
- CLAUDE.md +40 -0
- config/city.yaml +91 -0
- config/personas.yaml +467 -0
- main.py +233 -0
- pyproject.toml +27 -0
- src/soci/__init__.py +3 -0
- src/soci/actions/__init__.py +0 -0
- src/soci/actions/activities.py +71 -0
- src/soci/actions/conversation.py +209 -0
- src/soci/actions/movement.py +91 -0
- src/soci/actions/registry.py +88 -0
- src/soci/actions/social.py +107 -0
- src/soci/agents/__init__.py +0 -0
- src/soci/agents/agent.py +264 -0
- src/soci/agents/memory.py +218 -0
- src/soci/agents/needs.py +121 -0
- src/soci/agents/persona.py +107 -0
- src/soci/agents/relationships.py +137 -0
- src/soci/api/__init__.py +0 -0
- src/soci/api/routes.py +238 -0
- src/soci/api/server.py +124 -0
- src/soci/api/websocket.py +125 -0
- src/soci/engine/__init__.py +0 -0
- src/soci/engine/entropy.py +151 -0
- src/soci/engine/llm.py +292 -0
- src/soci/engine/scheduler.py +78 -0
- src/soci/engine/simulation.py +478 -0
- src/soci/persistence/__init__.py +0 -0
- src/soci/persistence/database.py +170 -0
- src/soci/persistence/snapshots.py +68 -0
- src/soci/world/__init__.py +0 -0
- src/soci/world/city.py +157 -0
- src/soci/world/clock.py +87 -0
- src/soci/world/events.py +246 -0
- test_simulation.py +385 -0
.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)
|