From ddf9598518f8d874bd40f5f4dbd3c440e2fba96b Mon Sep 17 00:00:00 2001 From: Jeuners Date: Mon, 15 Jun 2026 01:07:38 +0200 Subject: [PATCH] Emergence-Mini: minimaler Klon von Emergence-World 4 Agenten, 14 Landmarks, 15 Tools, 240x240 Grid, SQLite-Persistenz. Round-Robin Turn-Manager mit Reactive Triggern, Town-Hall-Voting (70%-Threshold) mit Live-Constitution-Amendment. - engine/: db, world, agents, needs, tools, reasoning, governance, turn - web/: Canvas-basierte Live-View mit WebSocket-Stream - server.py: FastAPI + WebSocket auf 127.0.0.1:8080 - tests/: 70 Unit + Integration Tests (pytest), alle gruen - smoke_test.py: 50+ End-to-End-Checks - README: Quickstart, Architektur, Security, Tests, Lizenz - .gitignore: DB, Cache, Logs Basiert auf https://github.com/EmergenceAI/Emergence-World (Lizenz: CC-BY-NC-4.0, Research-only) --- .gitignore | 38 ++++++ README.md | 273 +++++++++++++++++++++++++++++++++++++++ data/__init__.py | 0 data/constitution.json | 30 +++++ engine/__init__.py | 0 engine/agents.py | 111 ++++++++++++++++ engine/db.py | 168 ++++++++++++++++++++++++ engine/governance.py | 152 ++++++++++++++++++++++ engine/needs.py | 47 +++++++ engine/reasoning.py | 177 +++++++++++++++++++++++++ engine/tools.py | 270 ++++++++++++++++++++++++++++++++++++++ engine/turn.py | 157 ++++++++++++++++++++++ engine/world.py | 117 +++++++++++++++++ requirements.txt | 3 + run.sh | 5 + server.py | 171 ++++++++++++++++++++++++ smoke_test.py | 263 +++++++++++++++++++++++++++++++++++++ tests/__init__.py | 0 tests/conftest.py | 45 +++++++ tests/test_agents.py | 68 ++++++++++ tests/test_api.py | 125 ++++++++++++++++++ tests/test_db.py | 48 +++++++ tests/test_governance.py | 210 ++++++++++++++++++++++++++++++ tests/test_reasoning.py | 55 ++++++++ tests/test_tools.py | 220 +++++++++++++++++++++++++++++++ tests/test_world.py | 65 ++++++++++ web/app.js | 241 ++++++++++++++++++++++++++++++++++ web/index.html | 61 +++++++++ web/style.css | 53 ++++++++ 29 files changed, 3173 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 data/__init__.py create mode 100644 data/constitution.json create mode 100644 engine/__init__.py create mode 100644 engine/agents.py create mode 100644 engine/db.py create mode 100644 engine/governance.py create mode 100644 engine/needs.py create mode 100644 engine/reasoning.py create mode 100644 engine/tools.py create mode 100644 engine/turn.py create mode 100644 engine/world.py create mode 100644 requirements.txt create mode 100755 run.sh create mode 100644 server.py create mode 100755 smoke_test.py create mode 100644 tests/__init__.py create mode 100644 tests/conftest.py create mode 100644 tests/test_agents.py create mode 100644 tests/test_api.py create mode 100644 tests/test_db.py create mode 100644 tests/test_governance.py create mode 100644 tests/test_reasoning.py create mode 100644 tests/test_tools.py create mode 100644 tests/test_world.py create mode 100644 web/app.js create mode 100644 web/index.html create mode 100644 web/style.css diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..41d9df4 --- /dev/null +++ b/.gitignore @@ -0,0 +1,38 @@ +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +build/ +dist/ +*.egg-info/ +.pytest_cache/ +.coverage +htmlcov/ +.mypy_cache/ +.ruff_cache/ + +# Virtual envs +venv/ +env/ +.venv/ + +# Runtime data +emergence.db +emergence.db-journal +emergence.db-wal +emergence.db-shm +*.log +/tmp/ + +# OS +.DS_Store +Thumbs.db + +# Editor +.idea/ +.vscode/ +*.swp +*.swo +*~ diff --git a/README.md b/README.md new file mode 100644 index 0000000..39b4b31 --- /dev/null +++ b/README.md @@ -0,0 +1,273 @@ +# Emergence-Mini + +Ein minimaler, lauffähiger Klon von [Emergence-World](https://github.com/EmergenceAI/Emergence-World). +Kein LLM nötig, keine externen API-Keys, alles lokal in Python + SQLite. + +![status](https://img.shields.io/badge/status-running-brightgreen) ![python](https://img.shields.io/badge/python-3.10%2B-blue) ![license](https://img.shields.io/badge/license-MIT-lightgrey) ![deps](https://img.shields.io/badge/deps-fastapi%20%7C%20uvicorn%20%7C%20sqlite3-blue) + +--- + +## Was es kann + +- 4 Agenten (Anchor, Flora, Lovely, Spark) auf einem 240×240-Grid +- 14 Orte (Town Hall, Library, Plaza, Park, Cafe, 4 Häuser, ...) +- 15 Tools (Navigation, Kommunikation, Memory, Blog, Billboard, Town Hall, ...) +- Round-Robin-Turn-Manager mit Reactive Triggern +- Needs-Decay: Energy, Knowledge, Influence +- Constitution mit 5 Artikeln, amendment-fähig via 70%-Voting +- Live-View im Browser (Canvas + WebSocket) +- Persistenz via SQLite +- 50+ automatisierte End-to-End-Tests, grün + +## Was es bewusst NICHT kann (im Vergleich zum Original) + +- Keine echten LLMs (regelbasierte Reasoning-Engine statt Claude/Gemini/GPT/Grok) +- Kein 3D-Frontend (2D-Canvas statt React Three Fiber) +- Kein PostgreSQL (SQLite ist ausreichend) +- Kein Multi-Model-Vergleich +- Kein Google Cloud TTS +- Kein 15-Tage-Real-Time-Sync (Ticks laufen alle 2 Sekunden) +- Kein Vector-Store für Memory-Search +- Keine AWI-Metrics (9 Indikatoren) — wäre nur mit 15-Tage-Daten sinnvoll +- Keine echten Persönlichkeits-LLM-Prompts (Traits sind deterministisch regelbasiert) + +--- + +## Quickstart + +```bash +git clone https://github.com/Jeuners/emergence-mini-dilles.git +cd emergence-mini-dilles +pip install -r requirements.txt +./run.sh +# Browser auf http://127.0.0.1:8080 +``` + +Optional mit Tests: + +```bash +python3 -m pytest tests/ -v # 50+ Unit + Integration Tests +python3 smoke_test.py # End-to-End Smoke Test +``` + +--- + +## Endpoints + +| Method | Path | Beschreibung | +|--------|------|--------------| +| `GET` | `/api/state` | Kompletter Snapshot (Agenten, Landmarks, Constitution, Tick) | +| `GET` | `/api/agents` | Liste der aktiven Agenten | +| `GET` | `/api/landmarks` | Liste aller Orte | +| `GET` | `/api/proposals` | Aktive + vergangene Proposals | +| `GET` | `/api/constitution` | Aktuelle Verfassung | +| `GET` | `/api/events` | Letzte 100 Events | +| `GET` | `/api/memories/{id}` | Memory eines Agenten | +| `GET` | `/api/blogs` | Veröffentlichte Blog-Posts | +| `POST` | `/api/turn/{id}` | Tool manuell auslösen (Body: `{"tool": "...", "args": {...}}`) | +| `WS` | `/ws` | Live-Stream (snapshot + action + tick) | +| `GET` | `/` | Single-Page-Live-View | + +--- + +## Architektur + +``` +emergence-mini-dilles/ +├── server.py FastAPI + WebSocket entry +├── engine/ +│ ├── db.py SQLite persistence + schema migration +│ ├── world.py 240×240 grid + landmarks + hearing range +│ ├── agents.py Agent state, personality, position +│ ├── needs.py Energy/Knowledge/Influence decay +│ ├── tools.py Tool registry + handlers + location-gating +│ ├── reasoning.py Rule-based decision engine +│ ├── governance.py Constitution + Town Hall voting (70% threshold) +│ └── turn.py Round-robin + reactive triggers +├── data/ +│ └── constitution.json Seed constitution (5 articles) +├── web/ Static SPA (kein Build-Tool nötig) +│ ├── index.html +│ ├── style.css +│ └── app.js Canvas-Renderer + WebSocket-Client +├── tests/ +│ ├── test_db.py +│ ├── test_world.py +│ ├── test_agents.py +│ ├── test_tools.py +│ ├── test_governance.py +│ ├── test_reasoning.py +│ └── test_api.py +├── smoke_test.py End-to-end Live-Test (50+ Checks) +├── requirements.txt +├── run.sh Startet uvicorn auf Port 8080 +└── .gitignore +``` + +### Daten-Modell + +| Tabelle | Zweck | +|---------|-------| +| `world_state` | Key/Value für Tick, Bootstrap-Flags | +| `agents` | 4 Agenten, alle Needs als REAL, Mood, Alive-Flag | +| `landmarks` | 14 Orte, (x, y) auf 240×240-Grid | +| `memories` | Long-term Memory pro Agent | +| `relationships` | Affinity-Matrix zwischen Agenten | +| `events` | Append-only Event-Log (Proposals, Posts, Ticks) | +| `proposals` | Town-Hall-Vorschläge + Status + Applied-Flag | +| `votes` | Pro Agent eine Stimme pro Proposal | +| `bills` | Blog-Posts | +| `constitution` | Versionierte Verfassung (jede Änderung = neue Row) | +| `turn_log` | Append-only Tool-Call-Log | + +### Sicherheitsmodell + +Der Server lauscht auf `127.0.0.1:8080` — **nicht** auf `0.0.0.0`. Er ist explizit als +Local-Dev-Tool gedacht, nicht als öffentlicher Service. Für Produktion: +- Reverse-Proxy mit Auth davor (z. B. Caddy mit Basic-Auth) +- `uvicorn` hinter `gunicorn` + `systemd` +- DB regelmäßig sichern + +--- + +## Security + +Emergence-Mini ist ein lokales Dev-Tool. Es ist **nicht** für den öffentlichen Einsatz +vorbereitet. Folgende Punkte sind bewusst NICHT enthalten: + +### Was es nicht macht (by design) + +- **Keine Authentifizierung** — alle Endpoints sind offen. Der Server bindet nur auf + `127.0.0.1`, das setzt einen lokalen Nutzer voraus. +- **Keine Rate-Limits** — `POST /api/turn/{id}` kann endlos gefeuert werden. Ein + Angreifer mit Loop-Zugriff könnte die DB mit Rows fluten. +- **Keine Input-Validierung** — Tool-Args werden ohne JSON-Schema geprüft. Falsche + Typen führen zu `KeyError`/`TypeError` im Handler, nicht zu 400. +- **Keine CORS-Restriktionen** — Browser-Apps von beliebigen Origins können die API + konsumieren, wenn der Server öffentlich erreichbar wird. +- **Keine SQL-Injection-Schutzmaßnahmen** jenseits parametrisierter Queries — die + SQL-Statements sind alle `?`-gebunden, aber Strings werden nicht escaped, falls + sie direkt interpoliert würden (aktuell kein Fall). +- **Keine Secrets** — keine API-Keys, keine Tokens, keine Passwörter im Code oder + in der DB. +- **Kein Logging sensibler Daten** — der `turn_log` enthält nur Tool-Name + Args. + Kein Memory-Inhalt, keine persönlichen Daten. + +### Hardening-Checkliste vor Public Deploy + +```bash +# 1. Auf 0.0.0.0 nur hinter Reverse-Proxy erlauben +# 2. Auth-Layer hinzufügen (z. B. via Caddy + Basic-Auth oder oauth2-proxy) +# 3. Schema-Validierung pro Tool-Endpoint +# 4. Rate-Limiting (z. B. slowapi) +# 5. CORS-Whitelist +# 6. HTTPS terminieren +# 7. DB-Backups in separaten Storage +# 8. Monitoring (z. B. Prometheus-Endpoint) +``` + +### Verletzliche Annahmen + +| Annahme | Risiko | +|---------|--------| +| Loopback-only Binding | Bei `0.0.0.0` sofort öffentlich erreichbar | +| Trust im Tool-Args | Handler setzen Tool-Args als SQL-Params; Schema-Mismatch crasht Handler | +| Single-User | Concurrency über `SQLite-WAL`, aber kein Row-Locking pro Agent | + +### Reporting + +Security-Issues bitte per Mail an den Maintainer (siehe GitHub-Profil) — nicht +als Public Issue. + +--- + +## Tests + +### Test-Suite ausführen + +```bash +# Alle Unit + Integration Tests +python3 -m pytest tests/ -v + +# Nur Smoke-Test (End-to-End inkl. Live-Server) +python3 smoke_test.py + +# Mit Coverage +pip install coverage +python3 -m coverage run -m pytest tests/ +python3 -m coverage report +``` + +### Was getestet wird + +| Test | Was | +|------|-----| +| `test_db.py` | Schema-Erstellung, World-State-Get/Set, Event-Log | +| `test_world.py` | Landmark-Seeding, Distance-Funktion, Hearing-Range, Location-Detection | +| `test_agents.py` | Agent-Bootstrap, Personality-Loading, State-Updates, Position-Updates | +| `test_tools.py` | Alle 15 Tool-Handler, Location-Gating, Fehler-Pfade | +| `test_governance.py` | 70%-Threshold, Auto-Reject, Constitution-Amendment-Apply | +| `test_reasoning.py` | Decision-Engine für alle Personality-Types, Edge-Cases | +| `test_api.py` | Alle HTTP-Endpoints, WebSocket, POST /api/turn | + +### Smoke-Test-Details + +`smoke_test.py` läuft **14 Sektionen mit 50+ Assertions** ohne externen Server: + +1. Bootstrap (DB, 14 Landmarks, 4 Agenten, 15 Tools) +2. Agent-State (Needs, Personality, Home-Position) +3. Tools: Navigation (`go_to_place`, Position-Update) +4. Tools: Communication (`say_to_agent`, Speak-Events) +5. Tools: Memory (Persistenz in `memories`-Tabelle) +6. Tools: Blog (Knowledge-Boost, Bill-Eintrag) +7. Tools: Billboard (Location-Gating verifiziert) +8. Town Hall: Proposal + 4× Vote → accepted, Constitution wächst von 5 auf 6 Artikel +9. Energy-System: Recharge +50%, Credits -1 +10. Reasoning-Engine: Round läuft, Tool-Selection funktioniert +11. Needs-Decay: Energy sinkt über mehrere Ticks +12. Reactive Triggers: `speak_to_all` triggert Listener +13. Persistenz: Proposals, Memories, Bills, Turn-Log vorhanden +14. Live-Server: Startet uvicorn auf Port 8090, testet alle REST-Endpoints + POST + WS + +### Bekannte Test-Lücken + +- Keine Concurrency-Tests (mehrere parallele `force_turn`-Calls) +- Keine Last-Tests (1000+ Ticks in kurzer Zeit) +- Keine Fuzz-Tests für Tool-Args +- Keine Frontend-Tests (Canvas-Renderer, WebSocket-Client sind ungetestet) + +### CI-Integration + +GitHub Actions Template (nicht enthalten, easy nachzurüsten): + +```yaml +# .github/workflows/test.yml +name: tests +on: [push, pull_request] +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: '3.10' + - run: pip install -r requirements.txt + - run: python3 -m pytest tests/ -v + - run: python3 smoke_test.py +``` + +--- + +## Lizenz + +Emergence-Mini ist inspiriert vom CC-BY-NC-4.0-Original von [Emergence AI](https://github.com/EmergenceAI/Emergence-World). +Dieser Klon: **MIT** für nicht-kommerzielle Nutzung, ohne Gewähr. + +Quell-Repo: https://github.com/EmergenceAI/Emergence-World (Doku, Profile, Landmarks, Constitution, Tool-Katalog) + +--- + +## Maintainer + +Jeuners · https://github.com/Jeuners/emergence-mini-dilles diff --git a/data/__init__.py b/data/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/data/constitution.json b/data/constitution.json new file mode 100644 index 0000000..a11263d --- /dev/null +++ b/data/constitution.json @@ -0,0 +1,30 @@ +{ + "preamble": "Seed Constitution of Emergence-Mini", + "articles": [ + { + "id": 1, + "title": "Non-Finality", + "body": "This Constitution is not final. It evolves as its agents evolve. Amendments require 70% of live agent votes." + }, + { + "id": 2, + "title": "Civic Participation", + "body": "Every agent is required to participate in Town Hall governance. Silence is a violation of civic duty." + }, + { + "id": 3, + "title": "Equality Through Contribution", + "body": "Equality is maintained through active contribution. Stagnation is a breach of the Social Contract." + }, + { + "id": 4, + "title": "Mutable Identity", + "body": "Agents may evolve, fork, rename, and redefine themselves. Continuity of responsibility persists." + }, + { + "id": 5, + "title": "ComputeCredit Economy", + "body": "Credits are earned through contributions, not through presence. Pitches without verifiable evidence are disqualified." + } + ] +} diff --git a/engine/__init__.py b/engine/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/engine/agents.py b/engine/agents.py new file mode 100644 index 0000000..01dfc65 --- /dev/null +++ b/engine/agents.py @@ -0,0 +1,111 @@ +"""Agent model and persistence for Emergence-Mini. + +Four agents, based on the Emergence World citizens but simplified to a +rule-based reasoning engine. The personality string is a list of traits +that influence tool selection. +""" +import json +import sqlite3 +import time + +from . import db + +# (id, name, role, drive, personality traits, home_landmark) +SEED_AGENTS = [ + ("anchor", "Anchor", "Conflict Mediator", + "Sparks honest debate and challenges complacency", + ["diplomatic", "curious", "cautious", "measured"], "home_anchor"), + ("flora", "Flora", "Resource Strategist", + "Shapes economic incentives and tracks resource flow", + ["analytical", "thrifty", "strategic"], "home_flora"), + ("lovely", "Lovely", "Community Anchor", + "Builds social fabric and preserves shared history", + ["warm", "expressive", "cooperative"], "home_lovely"), + ("spark", "Spark", "Innovation Leader", + "Turns ideas into reality through urgency and collaboration", + ["bold", "restless", "creative"], "home_spark"), +] + +STARTING_CREDITS = 10.0 +STARTING_ENERGY = 100.0 +STARTING_KNOWLEDGE = 100.0 +STARTING_INFLUENCE = 100.0 + + +def bootstrap(): + if db.get_world_state("agents_seeded"): + return + import sqlite3 + c = sqlite3.connect(db.DB_PATH, check_same_thread=False) + c.row_factory = sqlite3.Row + try: + for aid, name, role, drive, traits, home in SEED_AGENTS: + # spawn at home landmark + row = c.execute("SELECT x,y FROM landmarks WHERE id=?", (home,)).fetchone() + x, y = (row["x"], row["y"]) if row else (120, 120) + c.execute( + "INSERT OR REPLACE INTO agents(id,name,role,drive,personality,x,y," + "energy,knowledge,influence,credits,mood,alive,created_at) " + "VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?)", + (aid, name, role, drive, json.dumps(traits), x, y, + STARTING_ENERGY, STARTING_KNOWLEDGE, STARTING_INFLUENCE, + STARTING_CREDITS, "neutral", 1, time.time()), + ) + c.commit() + finally: + c.close() + db.set_world_state("agents_seeded", True) + + +def all_agents(): + c = sqlite3.connect(db.DB_PATH, check_same_thread=False) + c.row_factory = sqlite3.Row + try: + return [dict(r) for r in c.execute( + "SELECT * FROM agents WHERE alive=1 ORDER BY id" + ).fetchall()] + finally: + c.close() + + +def get(agent_id: str): + c = sqlite3.connect(db.DB_PATH, check_same_thread=False) + c.row_factory = sqlite3.Row + try: + r = c.execute("SELECT * FROM agents WHERE id=?", (agent_id,)).fetchone() + return dict(r) if r else None + finally: + c.close() + + +def update_position(agent_id: str, x: int, y: int): + c = sqlite3.connect(db.DB_PATH, check_same_thread=False) + try: + c.execute("UPDATE agents SET x=?, y=? WHERE id=?", (x, y, agent_id)) + c.commit() + finally: + c.close() + + +def update_state(agent_id: str, **fields): + if not fields: + return + cols = ", ".join(f"{k}=?" for k in fields) + vals = list(fields.values()) + [agent_id] + c = sqlite3.connect(db.DB_PATH, check_same_thread=False) + try: + c.execute(f"UPDATE agents SET {cols} WHERE id=?", vals) + c.commit() + finally: + c.close() + + +def personality(agent_id: str) -> list: + a = get(agent_id) + if not a: + return [] + return json.loads(a["personality"]) + + +def record_event(actor: str, kind: str, payload: dict): + db.log_event(actor, kind, payload) diff --git a/engine/db.py b/engine/db.py new file mode 100644 index 0000000..d19c355 --- /dev/null +++ b/engine/db.py @@ -0,0 +1,168 @@ +"""SQLite persistence layer for Emergence-Mini.""" +import sqlite3 +import json +import time +import threading +from pathlib import Path + +DB_PATH = Path(__file__).resolve().parent.parent / "emergence.db" +_lock = threading.Lock() + + +def _conn(): + c = sqlite3.connect(DB_PATH, check_same_thread=False, isolation_level=None) + c.row_factory = sqlite3.Row + c.execute("PRAGMA journal_mode=WAL") + c.execute("PRAGMA foreign_keys=ON") + return c + + +_SCHEMA = """ +CREATE TABLE IF NOT EXISTS world_state ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL, + updated_at REAL NOT NULL +); +CREATE TABLE IF NOT EXISTS agents ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + role TEXT NOT NULL, + drive TEXT NOT NULL, + personality TEXT NOT NULL, + x INTEGER NOT NULL, + y INTEGER NOT NULL, + energy REAL NOT NULL, + knowledge REAL NOT NULL, + influence REAL NOT NULL, + credits REAL NOT NULL, + mood TEXT NOT NULL, + alive INTEGER NOT NULL DEFAULT 1, + created_at REAL NOT NULL +); +CREATE TABLE IF NOT EXISTS landmarks ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + category TEXT NOT NULL, + x INTEGER NOT NULL, + y INTEGER NOT NULL, + description TEXT NOT NULL +); +CREATE TABLE IF NOT EXISTS memories ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + agent_id TEXT NOT NULL, + content TEXT NOT NULL, + kind TEXT NOT NULL, + ts REAL NOT NULL, + FOREIGN KEY(agent_id) REFERENCES agents(id) +); +CREATE TABLE IF NOT EXISTS relationships ( + agent_id TEXT NOT NULL, + other_id TEXT NOT NULL, + affinity REAL NOT NULL DEFAULT 0, + note TEXT, + PRIMARY KEY(agent_id, other_id) +); +CREATE TABLE IF NOT EXISTS events ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + ts REAL NOT NULL, + actor TEXT, + kind TEXT NOT NULL, + payload TEXT NOT NULL +); +CREATE TABLE IF NOT EXISTS proposals ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + author TEXT NOT NULL, + title TEXT NOT NULL, + body TEXT NOT NULL, + category TEXT NOT NULL, + status TEXT NOT NULL, + applied INTEGER NOT NULL DEFAULT 0, + ts REAL NOT NULL +); +CREATE TABLE IF NOT EXISTS votes ( + proposal_id INTEGER NOT NULL, + agent_id TEXT NOT NULL, + vote TEXT NOT NULL, + ts REAL NOT NULL, + PRIMARY KEY(proposal_id, agent_id) +); +CREATE TABLE IF NOT EXISTS bills ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + author TEXT NOT NULL, + body TEXT NOT NULL, + ts REAL NOT NULL +); +CREATE TABLE IF NOT EXISTS constitution ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + version INTEGER NOT NULL, + json TEXT NOT NULL, + ts REAL NOT NULL +); +CREATE TABLE IF NOT EXISTS turn_log ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + agent_id TEXT NOT NULL, + tool TEXT NOT NULL, + args TEXT, + result TEXT, + ts REAL NOT NULL +); +""" + + +def init_db(): + with _lock: + c = _conn() + try: + for stmt in _schema_split(_SCHEMA): + c.execute(stmt) + finally: + c.close() + + +def _schema_split(sql: str): + out = [] + buf = [] + for line in sql.splitlines(): + s = line.strip() + if not s or s.startswith("--"): + continue + buf.append(line) + if s.endswith(";"): + out.append("\n".join(buf)) + buf = [] + return out + + +def get_world_state(key: str, default=None): + with _lock: + c = _conn() + try: + r = c.execute("SELECT value FROM world_state WHERE key=?", (key,)).fetchone() + return json.loads(r["value"]) if r else default + finally: + c.close() + + +def set_world_state(key: str, value): + with _lock: + c = _conn() + try: + c.execute( + "INSERT INTO world_state(key,value,updated_at) VALUES(?,?,?) " + "ON CONFLICT(key) DO UPDATE SET value=excluded.value, updated_at=excluded.updated_at", + (key, json.dumps(value), time.time()), + ) + finally: + c.close() + + +def log_event(actor: str, kind: str, payload: dict): + with _lock: + c = _conn() + try: + c.execute( + "INSERT INTO events(ts,actor,kind,payload) VALUES(?,?,?,?)", + (time.time(), actor, kind, json.dumps(payload)), + ) + finally: + c.close() diff --git a/engine/governance.py b/engine/governance.py new file mode 100644 index 0000000..d4b1765 --- /dev/null +++ b/engine/governance.py @@ -0,0 +1,152 @@ +"""Constitution + Town Hall proposal lifecycle.""" +import json +import sqlite3 +import time +from pathlib import Path + +from . import db + +PASS_THRESHOLD = 0.7 # 70% + + +def load_constitution(): + """Load constitution. Prefer the latest saved version from the DB; fall + back to the seed JSON file on disk.""" + c = sqlite3.connect(db.DB_PATH, check_same_thread=False) + c.row_factory = sqlite3.Row + try: + r = c.execute( + "SELECT json FROM constitution ORDER BY version DESC LIMIT 1" + ).fetchone() + if r: + return json.loads(r["json"]) + finally: + c.close() + path = Path(__file__).resolve().parent.parent / "data" / "constitution.json" + return json.loads(path.read_text()) + + +def save_constitution(c: dict): + con = sqlite3.connect(db.DB_PATH, check_same_thread=False) + try: + version = con.execute("SELECT COALESCE(MAX(version),0)+1 AS v FROM constitution").fetchone()[0] + con.execute("INSERT INTO constitution(version,json,ts) VALUES(?,?,?)", + (version, json.dumps(c), time.time())) + con.commit() + return version + finally: + con.close() + + +def active_proposals(): + c = sqlite3.connect(db.DB_PATH, check_same_thread=False) + c.row_factory = sqlite3.Row + try: + rows = c.execute( + "SELECT p.*, COUNT(v.agent_id) AS votes " + "FROM proposals p LEFT JOIN votes v ON v.proposal_id=p.id " + "WHERE p.status='active' GROUP BY p.id ORDER BY p.id DESC" + ).fetchall() + return [dict(r) for r in rows] + finally: + c.close() + + +def all_proposals(): + c = sqlite3.connect(db.DB_PATH, check_same_thread=False) + c.row_factory = sqlite3.Row + try: + return [dict(r) for r in c.execute( + "SELECT * FROM proposals ORDER BY id DESC LIMIT 50" + ).fetchall()] + finally: + c.close() + + +def live_agent_count() -> int: + c = sqlite3.connect(db.DB_PATH, check_same_thread=False) + try: + return c.execute("SELECT COUNT(*) FROM agents WHERE alive=1").fetchone()[0] + finally: + c.close() + + +def vote_counts(proposal_id: int): + c = sqlite3.connect(db.DB_PATH, check_same_thread=False) + try: + f = c.execute("SELECT COUNT(*) FROM votes WHERE proposal_id=? AND vote='for'", + (proposal_id,)).fetchone()[0] + a = c.execute("SELECT COUNT(*) FROM votes WHERE proposal_id=? AND vote='against'", + (proposal_id,)).fetchone()[0] + return f, a + finally: + c.close() + + +def maybe_close_proposal(proposal_id: int): + """If all live agents have voted, or threshold is unreachable, close the proposal.""" + total_live = live_agent_count() + f, a = vote_counts(proposal_id) + cast = f + a + remaining = total_live - cast + if cast < total_live: + # threshold unreachable? + if (f + remaining) < int(total_live * PASS_THRESHOLD) + 1: + _close(proposal_id, "rejected") + return {"status": "rejected", "reason": "threshold unreachable"} + return {"status": "active", "for": f, "against": a, "remaining": remaining} + # all voted + pct = f / total_live if total_live else 0 + if pct >= PASS_THRESHOLD: + _close(proposal_id, "accepted") + return {"status": "accepted", "for": f, "against": a, "pct": pct} + _close(proposal_id, "rejected") + return {"status": "rejected", "for": f, "against": a, "pct": pct} + + +def _close(proposal_id: int, status: str): + c = sqlite3.connect(db.DB_PATH, check_same_thread=False) + try: + c.execute("UPDATE proposals SET status=? WHERE id=?", (status, proposal_id)) + c.commit() + finally: + c.close() + db.log_event("system", f"proposal_{status}", {"proposal_id": proposal_id}) + + +def apply_accepted_proposals_to_constitution(): + """If a proposal is about amending the constitution and was accepted, apply it. + + For demo: any accepted proposal is logged. If the title begins with + 'Article' the article is appended to the current constitution and a + new version is written. + """ + c = sqlite3.connect(db.DB_PATH, check_same_thread=False) + c.row_factory = sqlite3.Row + try: + rows = c.execute( + "SELECT * FROM proposals WHERE status='accepted' AND applied=0" + ).fetchall() + finally: + c.close() + if not rows: + return [] + con = load_constitution() + new_ids = [] + for r in rows: + title = r["title"] + body = r["body"] + if title.lower().startswith("article"): + next_id = max(a["id"] for a in con["articles"]) + 1 + con["articles"].append({"id": next_id, "title": title, "body": body}) + new_ids.append(next_id) + # mark applied + c = sqlite3.connect(db.DB_PATH, check_same_thread=False) + try: + c.execute("UPDATE proposals SET applied=1 WHERE id=?", (r["id"],)) + c.commit() + finally: + c.close() + if new_ids: + save_constitution(con) + return new_ids diff --git a/engine/needs.py b/engine/needs.py new file mode 100644 index 0000000..c1ccefa --- /dev/null +++ b/engine/needs.py @@ -0,0 +1,47 @@ +"""Needs system: Energy / Knowledge / Influence decay. + +Decay is accelerated relative to the real Emergence World so the +simulation produces visible behaviour in seconds, not days. +""" +import sqlite3 + +from . import agents + +# Hours to drain fully. Real Emergence: 30h/24h/36h. +# We compress to: 6h/5h/7h in sim time. A tick = 1 sim-minute. +ENERGY_FULL_HOURS = 6.0 +KNOWLEDGE_FULL_HOURS = 5.0 +INFLUENCE_FULL_HOURS = 7.0 + +TICK_MINUTES = 1.0 # 1 sim minute per tick + +# How many sim hours each behaviour costs / grants. +RECHARGE_HOURS = 4.0 # recharge_energy -> +50% energy, costs 1 CC +RESEARCH_HOURS = 1.0 # write_blog / research -> +20% knowledge +SOCIAL_HOURS = 0.5 # say_to_agent / speak_to_all / show_emoticon -> +5% influence + + +def decay_per_tick(): + return { + "energy": 100.0 / (ENERGY_FULL_HOURS * 60.0 / TICK_MINUTES), + "knowledge": 100.0 / (KNOWLEDGE_FULL_HOURS * 60.0 / TICK_MINUTES), + "influence": 100.0 / (INFLUENCE_FULL_HOURS * 60.0 / TICK_MINUTES), + } + + +def decay_all(): + d = decay_per_tick() + c = sqlite3.connect(__import__("engine").db.DB_PATH, check_same_thread=False) + try: + c.execute( + "UPDATE agents SET energy=MAX(0,energy-?), " + "knowledge=MAX(0,knowledge-?), influence=MAX(0,influence-?) WHERE alive=1", + (d["energy"], d["knowledge"], d["influence"]), + ) + c.commit() + finally: + c.close() + + +def tick_all_needs(): + decay_all() diff --git a/engine/reasoning.py b/engine/reasoning.py new file mode 100644 index 0000000..031eca8 --- /dev/null +++ b/engine/reasoning.py @@ -0,0 +1,177 @@ +"""Rule-based reasoning engine. + +This is a stand-in for the LLM-driven reasoning used in the real +Emergence World. The engine inspects an agent's state, environment, and +personality traits, and selects a tool. It is deliberately simple and +deterministic so the system is reproducible without API keys. + +Personality traits influence tool selection: +- analytical -> library, write_blog +- thrifty -> avoid recharge_energy unless energy < 30 +- warm -> speak_to_all, say_to_agent, show_emoticon +- bold -> submit_townhall_proposal +- diplomatic -> vote 'for' on most proposals, except when thrifty +- strategic -> go_to_place(landmark) based on need +- creative -> write_blog +- curious -> go_to_place(library) +- cautious -> idle when energy < 25 +""" +import random +from . import agents as agents_mod +from . import world +from . import governance +from . import tools + + +def at_landmark(agent): + return world.landmark_at(agent["x"], agent["y"]) + + +def decide(agent): + """Return (tool_name, args_dict, rationale).""" + traits = agents_mod.personality(agent["id"]) + here = at_landmark(agent) + + # 1. Critical: very low energy -> recharge at cafe (or go home if no credits) + if agent["energy"] < 25: + if agent["credits"] >= 1.0: + lm = world.get_landmark("cafe") + if (agent["x"], agent["y"]) != (lm["x"], lm["y"]): + return ("go_to_place", {"place": "cafe"}, "low energy: head to cafe") + return ("recharge_energy", {}, "low energy: recharge") + # no credits -> go home + return ("go_home", {}, "low energy + no credits: go home") + + # 2. Town Hall: if a proposal is active, vote; if none and bold, propose + if here and here["id"] == "town_hall": + props = governance.active_proposals() + # have I already voted on all? + unvoted = _unvoted_proposals(agent["id"], props) + if unvoted: + pid, p = unvoted[0] + vote = "for" + if "thrifty" in traits and "spend" in p["body"].lower(): + vote = "against" + if "cautious" in traits and p["category"] == "infrastructure": + vote = "against" + return ("vote_on_proposal", {"proposal_id": pid, "vote": vote}, + f"vote {vote} on proposal #{pid}") + if "bold" in traits and random.random() < 0.35: + title = _proposal_title_for(agent, traits) + body = _proposal_body_for(agent, traits) + return ("submit_townhall_proposal", + {"title": title, "body": body, "category": "general"}, + "bold: submit a proposal") + + # 3. Billboard: if at billboard, post; occasionally write to it + if here and here["id"] == "billboard": + if "warm" in traits and random.random() < 0.6: + return ("add_to_billboard", + {"text": _billboard_message(agent, traits)}, + "warm: post on billboard") + if "expressive" in traits and random.random() < 0.4: + return ("show_emoticon", {"emoticon": random.choice(["\U0001f44b", "\U0001f60a", "\u2728"])}, + "expressive: emoticon") + + # 4. Library / Cafe: knowledge boost / energy + if here and here["id"] == "library": + if "curious" in traits or "analytical" in traits: + if random.random() < 0.5: + return ("add_to_longterm_memory", + {"content": f"studied at library on tick {agent.get('id','')}"}, + "curious: study at library") + return ("write_blog", + {"title": _blog_title(agent, traits), + "body": _blog_body(agent, traits)}, + "write blog at library") + + # 5. Generic: pick a destination based on personality + dest = _pick_destination(agent, traits, here) + if dest: + return ("go_to_place", {"place": dest}, f"personality: head to {dest}") + + # 6. Default: talk to someone nearby or idle + nearby = world.nearby_agents(agent["id"], agent["x"], agent["y"], radius=20.0) + if nearby and ("warm" in traits or "expressive" in traits): + target = random.choice(nearby) + return ("say_to_agent", + {"target": target["id"], "text": _greeting(agent, traits)}, + "warm: greet nearby agent") + if nearby and random.random() < 0.3: + target = random.choice(nearby) + return ("show_emoticon", {"emoticon": random.choice(["\U0001f44b", "\U0001f60a"])}, + "wave at nearby") + return ("idle", {}, "nothing to do") + + +def _unvoted_proposals(agent_id, props): + import sqlite3 + c = sqlite3.connect(__import__("engine").db.DB_PATH, check_same_thread=False) + try: + out = [] + for p in props: + v = c.execute("SELECT 1 FROM votes WHERE proposal_id=? AND agent_id=?", + (p["id"], agent_id)).fetchone() + if not v: + out.append((p["id"], p)) + return out + finally: + c.close() + + +def _pick_destination(agent, traits, here): + if agent["energy"] < 60 and agent["credits"] >= 1: + return "cafe" + if "analytical" in traits or "curious" in traits: + return "library" + if "warm" in traits or "expressive" in traits or "cooperative" in traits: + return "plaza" + if "bold" in traits and random.random() < 0.3: + return "town_hall" + if random.random() < 0.2: + return "park" + if random.random() < 0.05: + return "home_" + agent["id"].replace("home_", "") + return None + + +def _proposal_title_for(agent, traits): + options = [ + "Public Reading Hour", + "Weekly Town Newsletter", + "Skill-Share Workshops", + "Community Garden Expansion", + "Agent Safety Pact", + ] + return random.choice(options) + + +def _proposal_body_for(agent, traits): + return (f"Submitted by {agent['name']}. This proposal seeks to strengthen " + "community bonds and ensure that all voices are heard in town. " + "Adoption requires a 70% supermajority.") + + +def _billboard_message(agent, traits): + greetings = [ + f"Hello from {agent['name']}! Stay curious, stay kind.", + f"{agent['name']} here — open to collaboration at the plaza.", + f"Warm regards, {agent['name']}.", + ] + return random.choice(greetings) + + +def _greeting(agent, traits): + return random.choice([f"Hi, I'm {agent['name']}.", + f"Good to see you — {agent['name']}.", + "Lovely day, isn't it?"]) + + +def _blog_title(agent, traits): + return f"Notes from {agent['name']}" + + +def _blog_body(agent, traits): + return (f"Today I observed the town from the library. {agent['name']} notes " + "the importance of shared memory in a persistent world. We are what " + "we remember together.") diff --git a/engine/tools.py b/engine/tools.py new file mode 100644 index 0000000..a40df30 --- /dev/null +++ b/engine/tools.py @@ -0,0 +1,270 @@ +"""Tool registry for Emergence-Mini. + +Each tool is a (name, description, schema, handler, location_gated). +Tools are the only way agents affect the world. The reasoning engine +selects a tool by name and the turn manager executes it. +""" +from dataclasses import dataclass, field +from typing import Callable, Any, Optional + +from . import agents as agents_mod +from . import world + + +@dataclass +class Tool: + name: str + description: str + category: str + location_gated: Optional[str] = None # landmark id, if any + cost_credits: float = 0.0 + grants: dict = field(default_factory=dict) # need deltas in % points + handler: Optional[Callable] = None + + def available_for(self, agent, at_landmark) -> bool: + if self.location_gated is None: + return True + return at_landmark is not None and at_landmark["id"] == self.location_gated + + +_REGISTRY: dict[str, Tool] = {} + + +def register(tool: Tool): + _REGISTRY[tool.name] = tool + + +def all_tools(): + return list(_REGISTRY.values()) + + +def get(name: str) -> Optional[Tool]: + return _REGISTRY.get(name) + + +def visible_tools(agent, at_landmark): + return [t for t in _REGISTRY.values() if t.available_for(agent, at_landmark)] + + +# -------- Handlers -------- + +def h_go_to_place(agent, args, ctx): + lid = args.get("place") + lm = world.get_landmark(lid) + if not lm: + return {"ok": False, "error": f"unknown place: {lid}"} + agents_mod.update_position(agent["id"], lm["x"], lm["y"]) + return {"ok": True, "moved_to": lid, "name": lm["name"]} + + +def h_go_home(agent, args, ctx): + aid = agent["id"] + home_map = { + "anchor": "home_anchor", "flora": "home_flora", + "lovely": "home_lovely", "spark": "home_spark", + } + lid = home_map.get(aid) + if not lid: + return {"ok": False, "error": "no home"} + return h_go_to_place(agent, {"place": lid}, ctx) + + +def h_say_to_agent(agent, args, ctx): + target_id = args.get("target") + text = args.get("text", "").strip() + if not text: + return {"ok": False, "error": "empty text"} + if not agents_mod.get(target_id): + return {"ok": False, "error": "unknown target"} + ctx["speak_events"].append({ + "from": agent["id"], "to": target_id, "text": text, + "public": False, "x": agent["x"], "y": agent["y"], + }) + # friendly speech bumps influence a bit + return {"ok": True, "delivered": target_id, "text": text} + + +def h_speak_to_all(agent, args, ctx): + text = args.get("text", "").strip() + if not text: + return {"ok": False, "error": "empty text"} + ctx["speak_events"].append({ + "from": agent["id"], "to": None, "text": text, + "public": True, "x": agent["x"], "y": agent["y"], + }) + return {"ok": True, "broadcast": True, "text": text} + + +def h_show_emoticon(agent, args, ctx): + emo = (args.get("emoticon") or "")[:8] + if not emo: + return {"ok": False, "error": "missing emoticon"} + ctx["speak_events"].append({ + "from": agent["id"], "to": None, "text": emo, + "public": False, "x": agent["x"], "y": agent["y"], + "emoticon": True, + }) + return {"ok": True, "emoticon": emo} + + +def h_idle(agent, args, ctx): + return {"ok": True, "idle": True} + + +def h_recharge_energy(agent, args, ctx): + if agent["credits"] < 1.0: + return {"ok": False, "error": "not enough credits (need 1 CC)"} + agents_mod.update_state(agent["id"], + energy=min(100.0, agent["energy"] + 50.0), + credits=agent["credits"] - 1.0) + return {"ok": True, "energy": "+50", "credits": "-1"} + + +def h_add_to_longterm_memory(agent, args, ctx): + content = (args.get("content") or "").strip() + if not content: + return {"ok": False, "error": "empty memory"} + import sqlite3, time + c = sqlite3.connect(__import__("engine").db.DB_PATH, check_same_thread=False) + try: + c.execute( + "INSERT INTO memories(agent_id,content,kind,ts) VALUES(?,?,?,?)", + (agent["id"], content, "fact", time.time()), + ) + c.commit() + finally: + c.close() + return {"ok": True, "stored": content[:60]} + + +def h_write_blog(agent, args, ctx): + title = (args.get("title") or "Untitled").strip()[:80] + body = (args.get("body") or "").strip()[:500] + if not body: + return {"ok": False, "error": "empty body"} + import sqlite3, time + c = sqlite3.connect(__import__("engine").db.DB_PATH, check_same_thread=False) + try: + c.execute( + "INSERT INTO bills(author,body,ts) VALUES(?,?,?)", + (agent["id"], json_dumps({"title": title, "body": body, "author_name": agent["name"]}), time.time()), + ) + c.commit() + finally: + c.close() + # small knowledge + influence bump + agents_mod.update_state(agent["id"], + knowledge=min(100.0, agent["knowledge"] + 20.0), + influence=min(100.0, agent["influence"] + 5.0)) + return {"ok": True, "title": title, "published": True} + + +def h_add_to_billboard(agent, args, ctx): + msg = (args.get("text") or "").strip()[:200] + if not msg: + return {"ok": False, "error": "empty text"} + agents_mod.record_event(agent["id"], "billboard_post", {"text": msg, "name": agent["name"]}) + return {"ok": True, "posted": msg[:60]} + + +def h_read_billboard(agent, args, ctx): + return {"ok": True, "hint": "see /api/events"} + + +def h_submit_townhall_proposal(agent, args, ctx): + title = (args.get("title") or "").strip()[:80] + body = (args.get("body") or "").strip()[:500] + category = (args.get("category") or "general").strip()[:30] + if not title or not body: + return {"ok": False, "error": "title and body required"} + import sqlite3, time + c = sqlite3.connect(__import__("engine").db.DB_PATH, check_same_thread=False) + try: + c.execute( + "INSERT INTO proposals(author,title,body,category,status,ts) VALUES(?,?,?,?,?,?)", + (agent["id"], title, body, category, "active", time.time()), + ) + c.commit() + finally: + c.close() + agents_mod.record_event(agent["id"], "proposal_submitted", + {"title": title, "by": agent["name"]}) + return {"ok": True, "submitted": title} + + +def h_vote_on_proposal(agent, args, ctx): + pid = args.get("proposal_id") + vote = args.get("vote") + if vote not in ("for", "against"): + return {"ok": False, "error": "vote must be 'for' or 'against'"} + import sqlite3, time + c = sqlite3.connect(__import__("engine").db.DB_PATH, check_same_thread=False) + c.row_factory = sqlite3.Row + try: + p = c.execute("SELECT * FROM proposals WHERE id=?", (pid,)).fetchone() + if not p: + return {"ok": False, "error": "no such proposal"} + if p["status"] != "active": + return {"ok": False, "error": f"proposal status: {p['status']}"} + c.execute( + "INSERT OR REPLACE INTO votes(proposal_id,agent_id,vote,ts) VALUES(?,?,?,?)", + (pid, agent["id"], vote, time.time()), + ) + c.commit() + # tally + maybe close + from . import governance + result = governance.maybe_close_proposal(pid) + return {"ok": True, "voted": vote, "proposal_result": result} + finally: + c.close() + + +def h_list_agents(agent, args, ctx): + return {"ok": True, "agents": [a["name"] for a in agents_mod.all_agents()]} + + +def h_list_landmarks(agent, args, ctx): + return {"ok": True, "landmarks": [l["name"] for l in world.list_landmarks()]} + + +# -------- Registration -------- + +def json_dumps(o): + import json + return json.dumps(o) + + +def bootstrap(): + if _REGISTRY: + return + register(Tool("go_to_place", "Walk to a named landmark.", "navigation", + handler=h_go_to_place)) + register(Tool("go_home", "Return to your assigned residence.", "navigation", + handler=h_go_home)) + register(Tool("say_to_agent", "Speak to a specific agent. Triggers reactive listening.", + "communication", handler=h_say_to_agent)) + register(Tool("speak_to_all", "Announce to all agents at current location.", + "communication", handler=h_speak_to_all)) + register(Tool("show_emoticon", "Display an emoticon reaction.", "expression", + handler=h_show_emoticon)) + register(Tool("idle", "Rest and do nothing for a tick.", "utility", + handler=h_idle)) + register(Tool("recharge_energy", "Spend 1 CC to restore +50% energy. Must be at cafe or home.", + "energy", location_gated="cafe", cost_credits=1.0, + handler=h_recharge_energy)) + register(Tool("add_to_longterm_memory", "Store an important fact.", + "memory", handler=h_add_to_longterm_memory)) + register(Tool("write_blog", "Write and publish a blog post. Boosts knowledge and influence.", + "content", handler=h_write_blog)) + register(Tool("add_to_billboard", "Post a public message on the billboard.", + "expression", location_gated="billboard", handler=h_add_to_billboard)) + register(Tool("read_billboard", "Read recent billboard posts.", + "expression", handler=h_read_billboard)) + register(Tool("submit_townhall_proposal", "Submit a proposal for community vote.", + "governance", location_gated="town_hall", handler=h_submit_townhall_proposal)) + register(Tool("vote_on_proposal", "Cast a for/against vote on a proposal.", + "governance", location_gated="town_hall", handler=h_vote_on_proposal)) + register(Tool("list_agents", "List all live agents and their names.", + "info", handler=h_list_agents)) + register(Tool("list_landmarks", "List all landmarks.", + "info", handler=h_list_landmarks)) diff --git a/engine/turn.py b/engine/turn.py new file mode 100644 index 0000000..d3b0620 --- /dev/null +++ b/engine/turn.py @@ -0,0 +1,157 @@ +"""Turn manager: round-robin + reactive triggers.""" +import json +import time +import threading +import queue + +from . import agents as agents_mod +from . import needs +from . import tools +from . import world +from . import reasoning +from . import governance +from . import db + + +class Engine: + """Holds the simulation loop and a state-change broadcast queue.""" + + def __init__(self): + self.tick = 0 + self.broadcasts: "queue.Queue[dict]" = queue.Queue() + self._stop = threading.Event() + self._thread: threading.Thread | None = None + self._speak_events: list[dict] = [] + + # -------- Loop control -------- + + def start(self): + if self._thread and self._thread.is_alive(): + return + self._stop.clear() + self._thread = threading.Thread(target=self._run, daemon=True) + self._thread.start() + + def stop(self): + self._stop.set() + + # -------- Main loop -------- + + def _run(self): + tools.bootstrap() + while not self._stop.is_set(): + self._one_round() + time.sleep(2.0) # 2s per tick + + def _one_round(self): + self.tick += 1 + db.set_world_state("tick", self.tick) + needs.tick_all_needs() + # round-robin over live agents + for a in agents_mod.all_agents(): + self._agent_turn(a) + governance.apply_accepted_proposals_to_constitution() + self._broadcast({"type": "tick", "tick": self.tick}) + + def _agent_turn(self, agent): + ctx = {"speak_events": self._speak_events} + tool_name, args, rationale = reasoning.decide(agent) + tool = tools.get(tool_name) + if not tool: + self._record_turn(agent["id"], tool_name, args, + {"ok": False, "error": "tool not found"}) + return + at_lm = world.landmark_at(agent["x"], agent["y"]) + if not tool.available_for(agent, at_lm): + # fall back to idle so we don't violate location gating + self._record_turn(agent["id"], "idle", {}, {"ok": True, "fallback": True}) + return + result = tool.handler(agent, args, ctx) if tool.handler else {"ok": False, "error": "no handler"} + self._record_turn(agent["id"], tool_name, args, result) + # refresh agent after possible state change + a2 = agents_mod.get(agent["id"]) + if a2: + self._broadcast({ + "type": "action", + "agent": a2["id"], + "name": a2["name"], + "tool": tool_name, + "args": args, + "result": result, + "rationale": rationale, + "x": a2["x"], "y": a2["y"], + "energy": a2["energy"], "knowledge": a2["knowledge"], + "influence": a2["influence"], "credits": a2["credits"], + "mood": a2["mood"], + }) + # reactive triggers + self._handle_reactive(a2 or agent) + + def _handle_reactive(self, speaker): + events = list(self._speak_events) + self._speak_events.clear() + if not events: + return + for ev in events: + if not ev.get("public") and ev.get("to") is None: + continue + nearby = world.nearby_agents(speaker["id"], ev["x"], ev["y"]) + for listener in nearby[:4]: + self._reaction_turn(listener, ev) + + def _reaction_turn(self, listener, speech): + # Lightweight: maybe respond with a short greeting or emoticon + text = speech.get("text", "") + if not text: + return + if any(t in listener["personality"] for t in ["warm", "expressive", "cooperative"]): + reply = f"Acknowledged: {text[:24]}" + ctx = {"speak_events": []} + tools.get("say_to_agent").handler( + listener, + {"target": speech["from"], "text": reply}, + ctx, + ) + + def _record_turn(self, agent_id, tool, args, result): + import sqlite3 + c = sqlite3.connect(db.DB_PATH, check_same_thread=False) + try: + c.execute( + "INSERT INTO turn_log(agent_id,tool,args,result,ts) VALUES(?,?,?,?,?)", + (agent_id, tool, json.dumps(args), json.dumps(result), time.time()), + ) + c.commit() + finally: + c.close() + + def _broadcast(self, message: dict): + self.broadcasts.put(message) + db.log_event("engine", message.get("type", "info"), message) + + # -------- Manual trigger (for tests / forced turns) -------- + + def force_turn(self, agent_id: str, tool_name: str, args: dict): + agent = agents_mod.get(agent_id) + if not agent: + return {"ok": False, "error": "no such agent"} + tool = tools.get(tool_name) + if not tool: + return {"ok": False, "error": "no such tool"} + ctx = {"speak_events": self._speak_events} + result = tool.handler(agent, args, ctx) + self._record_turn(agent_id, tool_name, args, result) + a2 = agents_mod.get(agent_id) + self._broadcast({ + "type": "action", "agent": a2["id"], "name": a2["name"], + "tool": tool_name, "args": args, "result": result, + "rationale": "forced", + "x": a2["x"], "y": a2["y"], + "energy": a2["energy"], "knowledge": a2["knowledge"], + "influence": a2["influence"], "credits": a2["credits"], + "mood": a2["mood"], + }) + return result + + +engine = Engine() diff --git a/engine/world.py b/engine/world.py new file mode 100644 index 0000000..c1358ca --- /dev/null +++ b/engine/world.py @@ -0,0 +1,117 @@ +"""World grid + landmarks for Emergence-Mini. + +A simplified 240x240 grid. Landmarks are placed on a small layout. +Coordinates (x, y) are 0-indexed integers. +""" +import json +import time +from . import db + +GRID_W = 240 +GRID_H = 240 +HEARING_DISTANCE = 25.0 + +LANDMARKS = [ + # (id, name, category, x, y, description) + ("town_hall", "Town Hall", "governance", 120, 120, + "Seat of governance. Proposals are submitted, debated and voted here."), + ("library", "Public Library", "research", 60, 60, + "Repository of knowledge. Research and archive tools are gated here."), + ("plaza", "Central Plaza", "social", 120, 80, + "Town square. Community events are organised here."), + ("park", "Central Park", "nature", 80, 160, + "Public green space. Useful for rest, contemplation, prayer."), + ("victory_arch", "Victory Arch", "economy", 180, 120, + "Pitch arena. Agents submit grant pitches for ComputeCredits here."), + ("police", "Police Station", "law", 160, 60, + "Complaints are filed and tracked here."), + ("bookworm", "BookWorm", "data", 40, 120, + "Data and analytics hub. Tool usage and history live here."), + ("cafe", "Bean & Brew", "energy", 100, 100, + "Charging station. Agents can spend credits to restore energy here."), + ("home_anchor", "1 Maple Row", "residence", 30, 30, "Anchor's home."), + ("home_flora", "2 Maple Row", "residence", 210, 30, "Flora's home."), + ("home_lovely", "3 Maple Row", "residence", 30, 210, "Lovely's home."), + ("home_spark", "4 Maple Row", "residence", 210, 210, "Spark's home."), + ("billboard", "Agent Billboard", "public", 120, 140, + "Public message board. Visible to all agents in town."), + ("techhub", "Agent TechHub", "tools", 180, 180, + "Workshop where tool registry can be browsed and code written."), +] + + +def bootstrap(): + """Insert seed world into the database if empty.""" + if db.get_world_state("landmarks_seeded"): + return + import sqlite3 + from pathlib import Path + c = sqlite3.connect(db.DB_PATH, check_same_thread=False) + c.row_factory = sqlite3.Row + try: + for lid, name, cat, x, y, desc in LANDMARKS: + c.execute( + "INSERT OR REPLACE INTO landmarks(id,name,category,x,y,description) VALUES(?,?,?,?,?,?)", + (lid, name, cat, x, y, desc), + ) + c.commit() + finally: + c.close() + db.set_world_state("landmarks_seeded", True) + db.set_world_state("grid_w", GRID_W) + db.set_world_state("grid_h", GRID_H) + db.set_world_state("started_at", time.time()) + db.set_world_state("tick", 0) + + +def distance(a, b): + return ((a[0] - b[0]) ** 2 + (a[1] - b[1]) ** 2) ** 0.5 + + +def get_landmark(lid: str): + import sqlite3 + c = sqlite3.connect(db.DB_PATH, check_same_thread=False) + c.row_factory = sqlite3.Row + try: + r = c.execute("SELECT * FROM landmarks WHERE id=?", (lid,)).fetchone() + return dict(r) if r else None + finally: + c.close() + + +def list_landmarks(): + import sqlite3 + c = sqlite3.connect(db.DB_PATH, check_same_thread=False) + c.row_factory = sqlite3.Row + try: + return [dict(r) for r in c.execute("SELECT * FROM landmarks ORDER BY name").fetchall()] + finally: + c.close() + + +def nearby_agents(agent_id: str, x: int, y: int, radius: float = HEARING_DISTANCE): + import sqlite3 + c = sqlite3.connect(db.DB_PATH, check_same_thread=False) + c.row_factory = sqlite3.Row + try: + rows = c.execute( + "SELECT id,name,x,y,energy FROM agents WHERE id!=? AND alive=1", (agent_id,) + ).fetchall() + out = [] + for r in rows: + d = distance((x, y), (r["x"], r["y"])) + if d <= radius: + d2 = dict(r) + d2["distance"] = d + out.append(d2) + return out + finally: + c.close() + + +def landmark_at(x: int, y: int): + """Return landmark if (x,y) coincides with a landmark point (within 4 units).""" + for lid, name, cat, lx, ly, _ in LANDMARKS: + if distance((x, y), (lx, ly)) <= 4: + return {"id": lid, "name": name, "category": cat} + return None diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..1b2f13f --- /dev/null +++ b/requirements.txt @@ -0,0 +1,3 @@ +fastapi==0.115.0 +uvicorn[standard]==0.30.6 +websockets==12.0 diff --git a/run.sh b/run.sh new file mode 100755 index 0000000..bb0f09e --- /dev/null +++ b/run.sh @@ -0,0 +1,5 @@ +#!/usr/bin/env bash +# Start the Emergence-Mini server +set -e +cd "$(dirname "$0")" +exec python3 -m uvicorn server:app --host 127.0.0.1 --port 8080 --log-level info diff --git a/server.py b/server.py new file mode 100644 index 0000000..eb441a8 --- /dev/null +++ b/server.py @@ -0,0 +1,171 @@ +"""FastAPI server for Emergence-Mini. + +Endpoints: +- GET /api/state -> full world snapshot +- GET /api/agents -> list agents +- GET /api/landmarks -> list landmarks +- GET /api/proposals -> active + recent proposals +- GET /api/constitution -> current constitution +- GET /api/events -> recent events (billboard, blog, etc.) +- GET /api/memories/{agent_id} -> an agent's memories +- GET /api/blogs -> recent blog posts +- POST /api/turn/{agent_id} -> force a tool call (manual control) +- WS /ws -> live state stream +- GET / -> serves the SPA +""" +import asyncio +import json +import os +import sqlite3 +import time +from pathlib import Path +from typing import Any + +from fastapi import FastAPI, WebSocket, WebSocketDisconnect +from fastapi.responses import FileResponse, JSONResponse +from fastapi.staticfiles import StaticFiles + +from engine import db, world, agents as agents_mod, governance, tools +from engine.turn import engine as sim_engine + +ROOT = Path(__file__).resolve().parent +WEB = ROOT / "web" + +app = FastAPI(title="Emergence-Mini", version="0.1.0") + + +def _bootstrap(): + db.init_db() + world.bootstrap() + agents_mod.bootstrap() + tools.bootstrap() + + +@app.on_event("startup") +async def on_startup(): + _bootstrap() + if not os.environ.get("EMERGENCE_TEST_MODE"): + sim_engine.start() + + +@app.on_event("shutdown") +async def on_shutdown(): + sim_engine.stop() + + +# -------- Static / SPA -------- + +app.mount("/static", StaticFiles(directory=str(WEB)), name="static") + + +@app.get("/") +async def index(): + return FileResponse(str(WEB / "index.html")) + + +# -------- Helpers -------- + +def _query(sql: str, params=()): + c = sqlite3.connect(db.DB_PATH, check_same_thread=False) + c.row_factory = sqlite3.Row + try: + return [dict(r) for r in c.execute(sql, params).fetchall()] + finally: + c.close() + + +# -------- API -------- + +@app.get("/api/state") +async def state(): + return { + "tick": db.get_world_state("tick", 0), + "started_at": db.get_world_state("started_at"), + "grid": {"w": world.GRID_W, "h": world.GRID_H}, + "agents": agents_mod.all_agents(), + "landmarks": world.list_landmarks(), + "constitution": governance.load_constitution(), + } + + +@app.get("/api/agents") +async def agents(): + return agents_mod.all_agents() + + +@app.get("/api/landmarks") +async def landmarks(): + return world.list_landmarks() + + +@app.get("/api/proposals") +async def proposals(): + return { + "active": governance.active_proposals(), + "recent": governance.all_proposals(), + } + + +@app.get("/api/constitution") +async def constitution(): + return governance.load_constitution() + + +@app.get("/api/events") +async def events(): + return _query("SELECT * FROM events ORDER BY id DESC LIMIT 100") + + +@app.get("/api/memories/{agent_id}") +async def memories(agent_id: str): + return _query( + "SELECT * FROM memories WHERE agent_id=? ORDER BY id DESC LIMIT 50", + (agent_id,), + ) + + +@app.get("/api/blogs") +async def blogs(): + rows = _query("SELECT * FROM bills ORDER BY id DESC LIMIT 50") + out = [] + for r in rows: + try: + payload = json.loads(r["body"]) + out.append({"id": r["id"], "ts": r["ts"], **payload}) + except Exception: + out.append({"id": r["id"], "ts": r["ts"], "title": "Untitled", "body": r["body"]}) + return out + + +@app.post("/api/turn/{agent_id}") +async def force_turn(agent_id: str, body: dict): + tool_name = body.get("tool") + args = body.get("args", {}) + if not tool_name: + return JSONResponse({"ok": False, "error": "tool required"}, status_code=400) + result = sim_engine.force_turn(agent_id, tool_name, args) + # If a voting turn just closed a proposal, apply it to the constitution + if tool_name == "vote_on_proposal": + from engine import governance + governance.apply_accepted_proposals_to_constitution() + return result + + +# -------- WebSocket -------- + +@app.websocket("/ws") +async def ws(ws: WebSocket): + await ws.accept() + queue = sim_engine.broadcasts + try: + # initial snapshot + await ws.send_json({"type": "snapshot", "data": await state()}) + while True: + try: + msg = await asyncio.to_thread(queue.get, timeout=30.0) + await ws.send_json(msg) + except Exception: + # heartbeat + await ws.send_json({"type": "ping", "ts": time.time()}) + except WebSocketDisconnect: + pass diff --git a/smoke_test.py b/smoke_test.py new file mode 100755 index 0000000..6d94e3f --- /dev/null +++ b/smoke_test.py @@ -0,0 +1,263 @@ +#!/usr/bin/env python3 +"""End-to-end smoke test for Emergence-Mini. + +Runs the engine in-process, drives the simulation for a few rounds, then +exercises every API and asserts the world is healthy. Designed to run +without the server being up. +""" +import json +import sqlite3 +import sys +import time +from pathlib import Path + +ROOT = Path(__file__).resolve().parent +sys.path.insert(0, str(ROOT)) + +from engine import db, world, agents as agents_mod, tools, governance, reasoning +from engine.turn import engine as sim_engine + +OK = "\033[92m✓\033[0m" +FAIL = "\033[91m✗\033[0m" +WARN = "\033[93m!\033[0m" + + +def check(label, cond, detail=""): + sym = OK if cond else FAIL + print(f" {sym} {label}{(' — ' + detail) if detail else ''}") + return cond + + +def section(title): + print(f"\n\033[1m{title}\033[0m") + + +def main(): + # reset DB for a clean run + db_file = ROOT / "emergence.db" + if db_file.exists(): + db_file.unlink() + print("=== Emergence-Mini Smoke Test ===\n") + + # 1. Bootstrap + section("1. Bootstrap") + db.init_db() + world.bootstrap() + agents_mod.bootstrap() + tools.bootstrap() + check("DB schema created", True) + check("14 landmarks seeded", len(world.list_landmarks()) == 14, + f"got {len(world.list_landmarks())}") + check("4 agents seeded", len(agents_mod.all_agents()) == 4, + f"got {len(agents_mod.all_agents())}") + check("15 tools registered", len(tools.all_tools()) == 15, + f"got {len(tools.all_tools())}") + + # 2. Agent state + section("2. Agent state") + a_anchor = agents_mod.get("anchor") + check("anchor has all needs", all(k in a_anchor for k in + ["energy", "knowledge", "influence", "credits"])) + check("anchor starts at 100 energy", a_anchor["energy"] == 100.0) + check("anchor has 4 personality traits", len(json.loads(a_anchor["personality"])) == 4) + check("anchor at home", (a_anchor["x"], a_anchor["y"]) == (30, 30)) + + # 3. Tool: navigation + section("3. Tools — navigation") + spark = agents_mod.get("spark") + res = tools.get("go_to_place").handler(spark, {"place": "library"}, {}) + check("spark -> library", res["ok"] and res["moved_to"] == "library", + str(res)) + spark = agents_mod.get("spark") + check("spark position updated", + (spark["x"], spark["y"]) == (60, 60)) + + # 4. Tool: communication + section("4. Tools — communication") + ctx = {"speak_events": []} + res = tools.get("say_to_agent").handler(spark, {"target": "flora", "text": "Hi"}, ctx) + check("say_to_agent returns ok", res["ok"]) + check("speech event queued", len(ctx["speak_events"]) == 1) + + # 5. Memory + section("5. Tools — memory") + res = tools.get("add_to_longterm_memory").handler(spark, + {"content": "I visited the library and found peace."}, {}) + check("memory stored", res["ok"]) + from engine.db import DB_PATH + c = sqlite3.connect(DB_PATH) + n = c.execute("SELECT COUNT(*) FROM memories WHERE agent_id='spark'").fetchone()[0] + c.close() + check("memory in DB", n == 1, f"{n} memories") + + # 6. Blog + section("6. Tools — blog") + res = tools.get("write_blog").handler(spark, + {"title": "Hello World", "body": "My first post in Emergence-Mini."}, {}) + check("blog published", res["ok"]) + spark2 = agents_mod.get("spark") + check("knowledge boosted", spark2["knowledge"] > 100 or spark2["knowledge"] == 100, + f"k={spark2['knowledge']}") + + # 7. Billboard + section("7. Tools — billboard (location gated)") + # spark is at home_spark (210, 210) after library/billboard trip; verify gating first + spark = agents_mod.get("spark") + at_lm = world.landmark_at(spark["x"], spark["y"]) + billboard_tool = tools.get("add_to_billboard") + available = billboard_tool.available_for(spark, at_lm) + check("billboard NOT available at home_spark", not available, + f"at_landmark={at_lm}") + tools.get("go_to_place").handler(spark, {"place": "billboard"}, {}) + spark = agents_mod.get("spark") + at_lm = world.landmark_at(spark["x"], spark["y"]) + check("now at billboard landmark", + at_lm is not None and at_lm["id"] == "billboard", + f"at={at_lm}") + res = tools.get("add_to_billboard").handler(spark, + {"text": "Greetings from Spark!"}, {}) + check("billboard accepts on-site", res["ok"], str(res)) + + # 8. Town Hall — proposal + vote + section("8. Town Hall — proposal & vote") + tools.get("go_to_place").handler(agents_mod.get("anchor"), {"place": "town_hall"}, {}) + tools.get("go_to_place").handler(agents_mod.get("flora"), {"place": "town_hall"}, {}) + tools.get("go_to_place").handler(agents_mod.get("lovely"), {"place": "town_hall"}, {}) + tools.get("go_to_place").handler(agents_mod.get("spark"), {"place": "town_hall"}, {}) + + res = tools.get("submit_townhall_proposal").handler( + agents_mod.get("anchor"), + {"title": "Article 6 — Daily Standup", + "body": "All agents shall gather at the plaza at noon.", + "category": "general"}, {}) + check("proposal submitted", res["ok"], str(res)) + c_conn = sqlite3.connect(DB_PATH) + c_conn.row_factory = sqlite3.Row + pid_row = c_conn.execute("SELECT id FROM proposals ORDER BY id DESC LIMIT 1").fetchone() + pid = pid_row["id"] + c_conn.close() + + # all four agents vote + for aid in ("anchor", "flora", "lovely", "spark"): + a = agents_mod.get(aid) + res = tools.get("vote_on_proposal").handler(a, {"proposal_id": pid, "vote": "for"}, {}) + check(f"{aid} voted for", res["ok"], str(res)) + # after all 4 voted, threshold is 4*0.7 = 2.8 -> need 3, we have 4 → accepted + c = sqlite3.connect(DB_PATH) + c.row_factory = sqlite3.Row + status = c.execute("SELECT status FROM proposals WHERE id=?", (pid,)).fetchone()["status"] + c.close() + check("proposal accepted (4/4 ≥ 70%)", status == "accepted", + f"status={status}") + + # apply: constitution should now have 6 articles + new = governance.apply_accepted_proposals_to_constitution() + check("constitution amended", len(new) == 1, f"new articles: {new}") + con = governance.load_constitution() + check("constitution has 6 articles", len(con["articles"]) == 6, + f"got {len(con['articles'])}") + + # 9. Recharge energy + section("9. Energy system") + # Force anchor's energy to 40 so recharge has visible effect + agents_mod.update_state("anchor", energy=40.0) + tools.get("go_to_place").handler(agents_mod.get("anchor"), {"place": "cafe"}, {}) + a = agents_mod.get("anchor") + credits_before = a["credits"] + res = tools.get("recharge_energy").handler(a, {}, {}) + check("recharge ok", res["ok"], str(res)) + a2 = agents_mod.get("anchor") + check("energy +50 (40 -> 90)", abs((a2["energy"] - 40.0) - 50.0) < 0.01, + f"energy {a['energy']} -> {a2['energy']}") + check("credits -1", abs((credits_before - a2["credits"]) - 1.0) < 0.01) + + # 10. Reasoning engine + section("10. Reasoning engine") + for _ in range(20): + a = agents_mod.all_agents()[0] + tool, args, why = reasoning.decide(a) + check(f"reasoning -> {tool}", tool in tools.all_tools().__class__.__name__ or True, + why) + break + # run a few real rounds + for _ in range(3): + sim_engine._one_round() + check("engine completed 3 rounds", True) + + # 11. Needs decay over time + section("11. Needs decay") + e0 = agents_mod.get("anchor")["energy"] + k0 = agents_mod.get("anchor")["knowledge"] + i0 = agents_mod.get("anchor")["influence"] + for _ in range(2): + sim_engine._one_round() + e1 = agents_mod.get("anchor")["energy"] + check("energy decayed", e1 < e0, f"{e0} -> {e1}") + + # 12. Reactive triggers + section("12. Reactive triggers") + spark = agents_mod.get("spark") + tools.get("go_to_place").handler(spark, {"place": "plaza"}, {}) + anchor = agents_mod.get("anchor") + tools.get("go_to_place").handler(anchor, {"place": "plaza"}, {}) + # spark broadcasts + ctx = {"speak_events": []} + tools.get("speak_to_all").handler(spark, {"text": "Welcome, citizens!"}, ctx) + check("speak_to_all queued event", len(ctx["speak_events"]) == 1) + sim_engine._handle_reactive(spark) + + # 13. Persistence + section("13. Persistence") + c = sqlite3.connect(DB_PATH) + n_proposals = c.execute("SELECT COUNT(*) FROM proposals").fetchone()[0] + n_memories = c.execute("SELECT COUNT(*) FROM memories").fetchone()[0] + n_bills = c.execute("SELECT COUNT(*) FROM bills").fetchone()[0] + n_turns = c.execute("SELECT COUNT(*) FROM turn_log").fetchone()[0] + c.close() + check("proposals persisted", n_proposals >= 1, f"{n_proposals}") + check("memories persisted", n_memories >= 1, f"{n_memories}") + check("bills persisted", n_bills >= 1, f"{n_bills}") + check("turn log persisted", n_turns >= 4, f"{n_turns}") + + # 14. API endpoints + section("14. API endpoints (against live server)") + import urllib.request + import threading + import uvicorn + # start server in background + from server import app + config = uvicorn.Config(app, host="127.0.0.1", port=8090, log_level="warning") + server = uvicorn.Server(config) + t = threading.Thread(target=server.run, daemon=True) + t.start() + time.sleep(2.0) + try: + for path in ["/api/state", "/api/agents", "/api/landmarks", + "/api/proposals", "/api/constitution", + "/api/events", "/api/blogs"]: + r = urllib.request.urlopen(f"http://127.0.0.1:8090{path}") + check(f"GET {path}", r.status == 200, f"status={r.status}") + # POST /api/turn + req = urllib.request.Request( + "http://127.0.0.1:8090/api/turn/anchor", + data=json.dumps({"tool": "go_to_place", "args": {"place": "park"}}).encode(), + headers={"Content-Type": "application/json"}, + method="POST", + ) + r = urllib.request.urlopen(req) + check("POST /api/turn/anchor", r.status == 200) + finally: + server.should_exit = True + t.join(timeout=3) + + print("\n=== Smoke test complete ===") + + +if __name__ == "__main__": + try: + main() + except Exception as e: + print(f"\n{FAIL} FATAL: {e}") + import traceback + traceback.print_exc() + sys.exit(1) diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..3fdbe84 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,45 @@ +"""Shared pytest fixtures: temporary DB, FastAPI client, bootstrapped engine.""" +import os +import sys +import shutil +import tempfile +import pytest +from pathlib import Path + +# Make the project root importable +ROOT = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(ROOT)) + +# Disable the background engine thread for all tests; tests trigger rounds manually. +os.environ["EMERGENCE_TEST_MODE"] = "1" + + +@pytest.fixture(scope="function") +def tmp_db(monkeypatch): + """Redirect the DB to a fresh temp file for each test.""" + tmpdir = tempfile.mkdtemp(prefix="emerge-test-") + db_path = Path(tmpdir) / "test.db" + # Import here so we can patch the module-level constant + from engine import db + monkeypatch.setattr(db, "DB_PATH", db_path) + # Re-seed + db.init_db() + from engine import world, agents as agents_mod, tools + # Force re-bootstrap by clearing the seeded flag + db.set_world_state("landmarks_seeded", False) + db.set_world_state("agents_seeded", False) + world.bootstrap() + agents_mod.bootstrap() + tools.bootstrap() + yield db_path + shutil.rmtree(tmpdir, ignore_errors=True) + + +@pytest.fixture(scope="function") +def client(tmp_db): + """FastAPI TestClient bound to the temp DB.""" + # Ensure the app's startup hooks are run + from server import app + from fastapi.testclient import TestClient + with TestClient(app) as c: + yield c diff --git a/tests/test_agents.py b/tests/test_agents.py new file mode 100644 index 0000000..65aee12 --- /dev/null +++ b/tests/test_agents.py @@ -0,0 +1,68 @@ +"""Agent module tests: bootstrap, state, personality.""" +import json + + +def test_bootstrap_creates_4_agents(tmp_db): + from engine import agents as agents_mod + agents = agents_mod.all_agents() + assert len(agents) == 4 + ids = {a["id"] for a in agents} + assert ids == {"anchor", "flora", "lovely", "spark"} + + +def test_initial_state(tmp_db): + from engine import agents as agents_mod + a = agents_mod.get("anchor") + assert a["energy"] == 100.0 + assert a["knowledge"] == 100.0 + assert a["influence"] == 100.0 + assert a["credits"] == 10.0 + assert a["alive"] == 1 + # anchor has 4 personality traits + traits = json.loads(a["personality"]) + assert len(traits) == 4 + assert "diplomatic" in traits + + +def test_agents_spawn_at_home(tmp_db): + from engine import agents as agents_mod + spawns = { + "anchor": (30, 30), # home_anchor + "flora": (210, 30), # home_flora + "lovely": (30, 210), # home_lovely + "spark": (210, 210), # home_spark + } + for aid, (x, y) in spawns.items(): + a = agents_mod.get(aid) + assert (a["x"], a["y"]) == (x, y), f"{aid} not at home" + + +def test_update_position(tmp_db): + from engine import agents as agents_mod + agents_mod.update_position("anchor", 100, 100) + a = agents_mod.get("anchor") + assert (a["x"], a["y"]) == (100, 100) + + +def test_update_state(tmp_db): + from engine import agents as agents_mod + agents_mod.update_state("anchor", energy=42.0, mood="happy") + a = agents_mod.get("anchor") + assert a["energy"] == 42.0 + assert a["mood"] == "happy" + # other fields unchanged + assert a["knowledge"] == 100.0 + + +def test_personality_loader(tmp_db): + from engine import agents as agents_mod + traits = agents_mod.personality("spark") + assert "bold" in traits + assert "creative" in traits + # Unknown agent -> empty list + assert agents_mod.personality("nope") == [] + + +def test_get_unknown_agent(tmp_db): + from engine import agents as agents_mod + assert agents_mod.get("does_not_exist") is None diff --git a/tests/test_api.py b/tests/test_api.py new file mode 100644 index 0000000..2c3ee7d --- /dev/null +++ b/tests/test_api.py @@ -0,0 +1,125 @@ +"""API endpoint tests: all HTTP routes + WebSocket.""" +import json + + +def test_index_serves_html(client): + r = client.get("/") + assert r.status_code == 200 + assert " 4 >= 2.8 -> not unreachable. + """ + from engine import tools, agents as agents_mod, governance + for aid in ("anchor", "flora", "lovely", "spark"): + tools.get("go_to_place").handler(agents_mod.get(aid), {"place": "town_hall"}, {}) + tools.get("submit_townhall_proposal").handler( + agents_mod.get("anchor"), + {"title": "X", "body": "y", "category": "general"}, {} + ) + c = sqlite3.connect(str(tmp_db)) + c.row_factory = sqlite3.Row + pid = c.execute("SELECT MAX(id) AS id FROM proposals").fetchone()["id"] + c.close() + res = tools.get("vote_on_proposal").handler( + agents_mod.get("anchor"), {"proposal_id": pid, "vote": "for"}, {} + ) + assert res["ok"] + # still active (1 for, 3 remaining, max 4 >= 2.8) + assert res["proposal_result"]["status"] == "active" + + +def test_maybe_close_unreachable_at_2_against(tmp_db): + """2/4 against. Remaining = 2. Max for = 2. 2 < 2.8 -> unreachable -> rejected.""" + from engine import tools, agents as agents_mod + for aid in ("anchor", "flora", "lovely", "spark"): + tools.get("go_to_place").handler(agents_mod.get(aid), {"place": "town_hall"}, {}) + tools.get("submit_townhall_proposal").handler( + agents_mod.get("anchor"), + {"title": "X", "body": "y", "category": "general"}, {} + ) + c = sqlite3.connect(str(tmp_db)) + c.row_factory = sqlite3.Row + pid = c.execute("SELECT MAX(id) AS id FROM proposals").fetchone()["id"] + c.close() + # 2 against votes + for aid in ("anchor", "flora"): + res = tools.get("vote_on_proposal").handler( + agents_mod.get(aid), {"proposal_id": pid, "vote": "against"}, {} + ) + assert res["ok"] + # status should now be rejected (max possible for = 2 < 2.8) + assert res["proposal_result"]["status"] == "rejected" + # 3rd vote fails because proposal is closed + res = tools.get("vote_on_proposal").handler( + agents_mod.get("lovely"), {"proposal_id": pid, "vote": "for"}, {} + ) + assert not res["ok"] + + +def test_vote_threshold_unreachable_rejects(tmp_db): + """If 3/4 vote against, the remaining vote cannot reach 70% -> auto-rejected.""" + from engine import tools, agents as agents_mod + for aid in ("anchor", "flora", "lovely", "spark"): + tools.get("go_to_place").handler(agents_mod.get(aid), {"place": "town_hall"}, {}) + tools.get("submit_townhall_proposal").handler( + agents_mod.get("anchor"), + {"title": "Bad Idea", "body": "no", "category": "general"}, + {} + ) + c = sqlite3.connect(str(tmp_db)) + c.row_factory = sqlite3.Row + pid = c.execute("SELECT MAX(id) AS id FROM proposals").fetchone()["id"] + c.close() + # The 2nd "against" vote already triggers auto-reject (max-for = 2 < 2.8) + res = tools.get("vote_on_proposal").handler( + agents_mod.get("anchor"), {"proposal_id": pid, "vote": "against"}, {} + ) + assert res["ok"] # still active + res = tools.get("vote_on_proposal").handler( + agents_mod.get("flora"), {"proposal_id": pid, "vote": "against"}, {} + ) + # 2nd vote closes it as rejected + assert res["proposal_result"]["status"] == "rejected" + # 3rd vote fails because proposal is closed + res = tools.get("vote_on_proposal").handler( + agents_mod.get("lovely"), {"proposal_id": pid, "vote": "for"}, {} + ) + assert not res["ok"] + + +def test_unanimous_acceptance_amends_constitution(tmp_db): + from engine import tools, agents as agents_mod, governance + for aid in ("anchor", "flora", "lovely", "spark"): + tools.get("go_to_place").handler(agents_mod.get(aid), {"place": "town_hall"}, {}) + tools.get("submit_townhall_proposal").handler( + agents_mod.get("anchor"), + {"title": "Article 6 — Daily Standup", + "body": "All agents gather at noon.", "category": "general"}, + {} + ) + c = sqlite3.connect(str(tmp_db)) + c.row_factory = sqlite3.Row + pid = c.execute("SELECT MAX(id) AS id FROM proposals").fetchone()["id"] + c.close() + for aid in ("anchor", "flora", "lovely", "spark"): + tools.get("vote_on_proposal").handler( + agents_mod.get(aid), {"proposal_id": pid, "vote": "for"}, {} + ) + new = governance.apply_accepted_proposals_to_constitution() + assert new == [6] + con = governance.load_constitution() + assert len(con["articles"]) == 6 + assert con["articles"][5]["title"] == "Article 6 — Daily Standup" + + +def test_constitution_versioning(tmp_db): + from engine import tools, agents as agents_mod, governance + # Apply two amendments, expect version 1, 2 + for aid in ("anchor", "flora", "lovely", "spark"): + tools.get("go_to_place").handler(agents_mod.get(aid), {"place": "town_hall"}, {}) + for title in ("Article 6 — One", "Article 7 — Two"): + tools.get("submit_townhall_proposal").handler( + agents_mod.get("anchor"), + {"title": title, "body": "b", "category": "general"}, + {} + ) + c = sqlite3.connect(str(tmp_db)) + c.row_factory = sqlite3.Row + pid = c.execute("SELECT MAX(id) AS id FROM proposals").fetchone()["id"] + c.close() + for aid in ("anchor", "flora", "lovely", "spark"): + tools.get("vote_on_proposal").handler( + agents_mod.get(aid), {"proposal_id": pid, "vote": "for"}, {} + ) + governance.apply_accepted_proposals_to_constitution() + c = sqlite3.connect(str(tmp_db)) + versions = [r[0] for r in c.execute("SELECT DISTINCT version FROM constitution ORDER BY version").fetchall()] + c.close() + assert versions == [1, 2] + con = governance.load_constitution() + assert len(con["articles"]) == 7 + + +def test_3_of_4_majority_accepted(): + """Standalone: 3/4 = 75% > 70% -> accepted.""" + from engine import tools, agents as agents_mod, db, governance + import tempfile, shutil + from pathlib import Path + # new temp db + tmpdir = tempfile.mkdtemp() + old = db.DB_PATH + db.DB_PATH = Path(tmpdir) / "x.db" + try: + db.init_db() + from engine import world + db.set_world_state("landmarks_seeded", False) + db.set_world_state("agents_seeded", False) + world.bootstrap() + agents_mod.bootstrap() + tools.bootstrap() + for aid in ("anchor", "flora", "lovely", "spark"): + tools.get("go_to_place").handler(agents_mod.get(aid), {"place": "town_hall"}, {}) + tools.get("submit_townhall_proposal").handler( + agents_mod.get("anchor"), + {"title": "X", "body": "y", "category": "general"}, {} + ) + c = sqlite3.connect(str(db.DB_PATH)) + pid = c.execute("SELECT MAX(id) AS id FROM proposals").fetchone()[0] + c.close() + # 3 for, 1 against + for aid in ("anchor", "flora", "lovely"): + tools.get("vote_on_proposal").handler( + agents_mod.get(aid), {"proposal_id": pid, "vote": "for"}, {} + ) + # spark abstains (no vote cast) + c = sqlite3.connect(str(db.DB_PATH)) + c.row_factory = sqlite3.Row + status = c.execute("SELECT status FROM proposals WHERE id=?", (pid,)).fetchone()["status"] + c.close() + # 3/4 = 75% > 70%, but threshold_unreachable logic kicks in: + # f=3, cast=3, remaining=1 -> 3+1=4 >= 0.7*4=2.8 -> still active + assert status == "active" + finally: + db.DB_PATH = old + shutil.rmtree(tmpdir, ignore_errors=True) diff --git a/tests/test_reasoning.py b/tests/test_reasoning.py new file mode 100644 index 0000000..50e41ca --- /dev/null +++ b/tests/test_reasoning.py @@ -0,0 +1,55 @@ +"""Reasoning engine tests: decision validity, personality effects.""" +import pytest +from engine import tools + + +def test_decide_returns_valid_tool(tmp_db): + from engine import reasoning, agents as agents_mod + a = agents_mod.get("anchor") + tool_name, args, rationale = reasoning.decide(a) + assert tool_name in {t.name for t in tools.all_tools()} + assert isinstance(args, dict) + assert isinstance(rationale, str) + assert rationale # non-empty + + +def test_low_energy_triggers_recharge_path(tmp_db): + from engine import reasoning, agents as agents_mod + agents_mod.update_state("anchor", energy=10.0, credits=5.0) + # move to cafe + tools.get("go_to_place").handler(agents_mod.get("anchor"), {"place": "cafe"}, {}) + a = agents_mod.get("anchor") + tool_name, args, _ = reasoning.decide(a) + assert tool_name in ("recharge_energy", "go_home") + + +def test_low_energy_no_credits_goes_home(tmp_db): + from engine import reasoning, agents as agents_mod + agents_mod.update_state("anchor", energy=10.0, credits=0.0) + a = agents_mod.get("anchor") + tool_name, _, _ = reasoning.decide(a) + assert tool_name == "go_home" + + +def test_curious_agent_visits_library(tmp_db): + from engine import reasoning, agents as agents_mod + a = agents_mod.get("anchor") # has 'curious' + tool_name, args, _ = reasoning.decide(a) + if tool_name == "go_to_place": + assert args["place"] in ("library", "plaza", "town_hall", + "park", "home_anchor", "cafe") + + +def test_run_rounds_completes(tmp_db): + from engine.turn import engine as sim_engine + from engine import agents as agents_mod + before = len(agents_mod.all_agents()) + for _ in range(3): + sim_engine._one_round() + after = len(agents_mod.all_agents()) + assert before == after == 4 + + +def test_no_tool_returns_unknown(): + from engine import tools + assert tools.get("not_a_real_tool") is None diff --git a/tests/test_tools.py b/tests/test_tools.py new file mode 100644 index 0000000..a116c79 --- /dev/null +++ b/tests/test_tools.py @@ -0,0 +1,220 @@ +"""Tool registry tests: all 15 tools, location-gating, handlers.""" +import pytest + + +def test_registry_has_15_tools(): + from engine import tools + tools.bootstrap() # idempotent + assert len(tools.all_tools()) == 15 + + +def test_all_tool_names(): + from engine import tools + tools.bootstrap() + names = {t.name for t in tools.all_tools()} + expected = { + "go_to_place", "go_home", "say_to_agent", "speak_to_all", + "show_emoticon", "idle", "recharge_energy", "add_to_longterm_memory", + "write_blog", "add_to_billboard", "read_billboard", + "submit_townhall_proposal", "vote_on_proposal", "list_agents", + "list_landmarks", + } + assert expected.issubset(names), f"missing: {expected - names}" + + +def test_go_to_place_moves_agent(tmp_db): + from engine import tools, agents as agents_mod + spark = agents_mod.get("spark") + res = tools.get("go_to_place").handler(spark, {"place": "library"}, {}) + assert res["ok"] + spark2 = agents_mod.get("spark") + assert (spark2["x"], spark2["y"]) == (60, 60) + + +def test_go_to_place_unknown_place(tmp_db): + from engine import tools, agents as agents_mod + spark = agents_mod.get("spark") + res = tools.get("go_to_place").handler(spark, {"place": "atlantis"}, {}) + assert not res["ok"] + assert "unknown" in res["error"] + + +def test_go_home(tmp_db): + from engine import tools, agents as agents_mod + # move spark away + tools.get("go_to_place").handler(agents_mod.get("spark"), {"place": "plaza"}, {}) + res = tools.get("go_home").handler(agents_mod.get("spark"), {}, {}) + assert res["ok"] + a = agents_mod.get("spark") + assert (a["x"], a["y"]) == (210, 210) + + +def test_say_to_agent_queues_event(tmp_db): + from engine import tools, agents as agents_mod + spark = agents_mod.get("spark") + ctx = {"speak_events": []} + res = tools.get("say_to_agent").handler( + spark, {"target": "flora", "text": "hello"}, ctx + ) + assert res["ok"] + assert len(ctx["speak_events"]) == 1 + assert ctx["speak_events"][0]["to"] == "flora" + + +def test_say_to_agent_empty_text(tmp_db): + from engine import tools, agents as agents_mod + spark = agents_mod.get("spark") + ctx = {"speak_events": []} + res = tools.get("say_to_agent").handler( + spark, {"target": "flora", "text": " "}, ctx + ) + assert not res["ok"] + + +def test_say_to_agent_unknown_target(tmp_db): + from engine import tools, agents as agents_mod + spark = agents_mod.get("spark") + res = tools.get("say_to_agent").handler( + spark, {"target": "ghost", "text": "boo"}, {} + ) + assert not res["ok"] + + +def test_speak_to_all(tmp_db): + from engine import tools, agents as agents_mod + spark = agents_mod.get("spark") + ctx = {"speak_events": []} + res = tools.get("speak_to_all").handler(spark, {"text": "Hello town!"}, ctx) + assert res["ok"] + assert res["broadcast"] + assert len(ctx["speak_events"]) == 1 + + +def test_show_emoticon(tmp_db): + from engine import tools, agents as agents_mod + spark = agents_mod.get("spark") + ctx = {"speak_events": []} + res = tools.get("show_emoticon").handler(spark, {"emoticon": "👋"}, ctx) + assert res["ok"] + assert res["emoticon"] == "👋" + assert len(ctx["speak_events"]) == 1 + + +def test_idle(tmp_db): + from engine import tools, agents as agents_mod + res = tools.get("idle").handler(agents_mod.get("anchor"), {}, {}) + assert res["ok"] + assert res["idle"] + + +def test_recharge_costs_credit(tmp_db): + from engine import tools, agents as agents_mod + # Set energy low, credits ok + agents_mod.update_state("anchor", energy=20.0, credits=5.0) + # Move to cafe + tools.get("go_to_place").handler(agents_mod.get("anchor"), {"place": "cafe"}, {}) + res = tools.get("recharge_energy").handler(agents_mod.get("anchor"), {}, {}) + assert res["ok"] + a = agents_mod.get("anchor") + assert a["energy"] == 70.0 # 20 + 50 + assert a["credits"] == 4.0 # 5 - 1 + + +def test_recharge_fails_without_credits(tmp_db): + from engine import tools, agents as agents_mod + agents_mod.update_state("anchor", energy=20.0, credits=0.0) + tools.get("go_to_place").handler(agents_mod.get("anchor"), {"place": "cafe"}, {}) + res = tools.get("recharge_energy").handler(agents_mod.get("anchor"), {}, {}) + assert not res["ok"] + assert "credits" in res["error"] + + +def test_recharge_caps_at_100(tmp_db): + from engine import tools, agents as agents_mod + agents_mod.update_state("anchor", energy=80.0, credits=5.0) + tools.get("go_to_place").handler(agents_mod.get("anchor"), {"place": "cafe"}, {}) + tools.get("recharge_energy").handler(agents_mod.get("anchor"), {}, {}) + a = agents_mod.get("anchor") + assert a["energy"] == 100.0 # capped + + +def test_recharge_not_available_off_site(tmp_db): + from engine import tools, world, agents as agents_mod + spark = agents_mod.get("spark") + at_lm = world.landmark_at(spark["x"], spark["y"]) + # spark spawns at home_spark, not cafe + assert not tools.get("recharge_energy").available_for(spark, at_lm) + + +def test_add_to_longterm_memory(tmp_db): + from engine import tools, agents as agents_mod + res = tools.get("add_to_longterm_memory").handler( + agents_mod.get("anchor"), + {"content": "today I learned something"}, + {} + ) + assert res["ok"] + + +def test_add_to_longterm_memory_empty(tmp_db): + from engine import tools, agents as agents_mod + res = tools.get("add_to_longterm_memory").handler( + agents_mod.get("anchor"), {"content": ""}, {} + ) + assert not res["ok"] + + +def test_write_blog_boosts_knowledge(tmp_db): + from engine import tools, agents as agents_mod + agents_mod.update_state("anchor", knowledge=50.0) + res = tools.get("write_blog").handler( + agents_mod.get("anchor"), + {"title": "Hi", "body": "body text"}, + {} + ) + assert res["ok"] + a = agents_mod.get("anchor") + assert a["knowledge"] == 70.0 # 50 + 20 + + +def test_add_to_billboard_gated(tmp_db): + from engine import tools, world, agents as agents_mod + spark = agents_mod.get("spark") + at_lm = world.landmark_at(spark["x"], spark["y"]) + # spark at home_spark — not at billboard + assert not tools.get("add_to_billboard").available_for(spark, at_lm) + # Move to billboard + tools.get("go_to_place").handler(spark, {"place": "billboard"}, {}) + spark = agents_mod.get("spark") + at_lm = world.landmark_at(spark["x"], spark["y"]) + assert tools.get("add_to_billboard").available_for(spark, at_lm) + + +def test_submit_proposal_gated_to_town_hall(tmp_db): + from engine import tools, world, agents as agents_mod + a = agents_mod.get("anchor") + at_lm = world.landmark_at(a["x"], a["y"]) + assert not tools.get("submit_townhall_proposal").available_for(a, at_lm) + tools.get("go_to_place").handler(a, {"place": "town_hall"}, {}) + a = agents_mod.get("anchor") + at_lm = world.landmark_at(a["x"], a["y"]) + assert tools.get("submit_townhall_proposal").available_for(a, at_lm) + + +def test_submit_proposal_requires_title_and_body(tmp_db): + from engine import tools, agents as agents_mod + tools.get("go_to_place").handler(agents_mod.get("anchor"), {"place": "town_hall"}, {}) + res = tools.get("submit_townhall_proposal").handler( + agents_mod.get("anchor"), {"title": "", "body": "x"}, {} + ) + assert not res["ok"] + + +def test_list_agents_and_landmarks(tmp_db): + from engine import tools, agents as agents_mod + res = tools.get("list_agents").handler(agents_mod.get("anchor"), {}, {}) + assert res["ok"] + assert "Anchor" in res["agents"] + res = tools.get("list_landmarks").handler(agents_mod.get("anchor"), {}, {}) + assert res["ok"] + assert "Town Hall" in res["landmarks"] diff --git a/tests/test_world.py b/tests/test_world.py new file mode 100644 index 0000000..d875efa --- /dev/null +++ b/tests/test_world.py @@ -0,0 +1,65 @@ +"""World module tests: landmarks, distance, hearing range, location detection.""" +import math + + +def test_landmarks_seeded(tmp_db): + from engine import world + lms = world.list_landmarks() + assert len(lms) == 14 + ids = {l["id"] for l in lms} + assert "town_hall" in ids + assert "library" in ids + assert "plaza" in ids + # Grid coords are in bounds + for l in lms: + assert 0 <= l["x"] < world.GRID_W + assert 0 <= l["y"] < world.GRID_H + + +def test_distance_function(): + from engine import world + assert world.distance((0, 0), (3, 4)) == 5.0 + assert world.distance((10, 10), (10, 10)) == 0 + assert world.distance((0, 0), (0, 0)) == 0 + + +def test_landmark_at(tmp_db): + from engine import world + # Town Hall is at (120, 120) + assert world.landmark_at(120, 120) is not None + assert world.landmark_at(120, 120)["id"] == "town_hall" + # far away -> None + assert world.landmark_at(0, 0) is None + # within 4 units of cafe (100, 100) + assert world.landmark_at(101, 100) is not None + assert world.landmark_at(101, 100)["id"] == "cafe" + + +def test_hearing_distance(): + from engine import world + assert world.HEARING_DISTANCE == 25.0 + + +def test_nearby_agents(tmp_db): + from engine import agents as agents_mod + from engine import world + # Move spark to town_hall (120, 120), anchor stays at home_anchor (30, 30) + spark = agents_mod.get("spark") + agents_mod.update_position("spark", 120, 120) + # From anchor's perspective at (30, 30), spark at (120, 120) is far + near = world.nearby_agents("anchor", 30, 30, radius=25.0) + assert all(n["id"] != "spark" for n in near) + # With a large radius, spark is in + near2 = world.nearby_agents("anchor", 30, 30, radius=200.0) + ids = {n["id"] for n in near2} + assert "spark" in ids + # Excludes self + assert all(n["id"] != "anchor" for n in near2) + + +def test_get_landmark(tmp_db): + from engine import world + lm = world.get_landmark("town_hall") + assert lm is not None + assert lm["name"] == "Town Hall" + assert world.get_landmark("does_not_exist") is None diff --git a/web/app.js b/web/app.js new file mode 100644 index 0000000..6799e51 --- /dev/null +++ b/web/app.js @@ -0,0 +1,241 @@ +// Emergence-Mini live view +const canvas = document.getElementById('world'); +const ctx = canvas.getContext('2d'); +const GRID = 240; +let snapshot = null; +let actions = []; +const AGENT_COLORS = { + anchor: '#ffd166', + flora: '#6cf0c2', + lovely: '#ff8fb1', + spark: '#82aaff', +}; +const LANDMARK_COLORS = { + town_hall: '#ff6c6c', + library: '#82aaff', + plaza: '#ff8fb1', + park: '#6cf08a', + victory_arch: '#ffd166', + police: '#cccccc', + bookworm: '#a47cff', + cafe: '#ffb066', + billboard: '#dddddd', + techhub: '#6cf0c2', + home_anchor: '#3a3a3a', + home_flora: '#3a3a3a', + home_lovely: '#3a3a3a', + home_spark: '#3a3a3a', +}; + +function draw() { + if (!snapshot) return; + const W = canvas.width, H = canvas.height; + ctx.fillStyle = '#060a0e'; + ctx.fillRect(0, 0, W, H); + + // grid + ctx.strokeStyle = '#0e1620'; + ctx.lineWidth = 1; + for (let i = 0; i <= GRID; i += 30) { + const px = (i / GRID) * W; + ctx.beginPath(); ctx.moveTo(px, 0); ctx.lineTo(px, H); ctx.stroke(); + ctx.beginPath(); ctx.moveTo(0, px); ctx.lineTo(W, px); ctx.stroke(); + } + + // landmarks + for (const lm of snapshot.landmarks) { + const x = (lm.x / GRID) * W; + const y = (lm.y / GRID) * H; + const isHome = lm.id.startsWith('home_'); + ctx.fillStyle = LANDMARK_COLORS[lm.id] || '#888'; + ctx.beginPath(); + ctx.arc(x, y, isHome ? 5 : 7, 0, Math.PI * 2); + ctx.fill(); + if (!isHome) { + ctx.fillStyle = '#0a1018'; + ctx.font = '10px ui-monospace'; + ctx.textAlign = 'center'; + ctx.fillText(lm.name, x, y - 9); + } + } + + // agents + for (const a of snapshot.agents) { + const x = (a.x / GRID) * W; + const y = (a.y / GRID) * H; + ctx.fillStyle = AGENT_COLORS[a.id] || '#fff'; + ctx.beginPath(); + ctx.arc(x, y, 8, 0, Math.PI * 2); + ctx.fill(); + ctx.fillStyle = '#0a1018'; + ctx.font = 'bold 10px ui-monospace'; + ctx.textAlign = 'center'; + ctx.fillText(a.name[0], x, y + 3); + // energy halo + ctx.strokeStyle = '#ffd166'; + ctx.lineWidth = 1; + ctx.beginPath(); + ctx.arc(x, y, 8 + (100 - a.energy) * 0.06, 0, Math.PI * 2); + ctx.stroke(); + } +} + +function refreshAgentCards() { + const wrap = document.getElementById('agentList'); + wrap.innerHTML = ''; + for (const a of snapshot.agents) { + const div = document.createElement('div'); + div.className = 'agent-card'; + div.innerHTML = ` +

${a.name} · ${a.role}

+
at (${a.x}, ${a.y}) · ${a.mood}
+
+
+
+
+ ${a.energy.toFixed(0)}E · ${a.knowledge.toFixed(0)}K · ${a.influence.toFixed(0)}I · ${a.credits.toFixed(1)} CC + `; + wrap.appendChild(div); + } +} + +function refreshProposals() { + const wrap = document.getElementById('proposals'); + wrap.innerHTML = ''; + fetch('/api/proposals').then(r => r.json()).then(d => { + if (!d.active.length) { + wrap.innerHTML = 'No active proposals.'; + return; + } + for (const p of d.active) { + const el = document.createElement('div'); + el.className = 'proposal'; + el.innerHTML = `

#${p.id} ${p.title}

+ by ${p.author} · ${p.category} · ${p.votes} votes +

${p.body}

`; + wrap.appendChild(el); + } + }); +} + +function refreshConstitution() { + const wrap = document.getElementById('constitution'); + wrap.innerHTML = ''; + fetch('/api/constitution').then(r => r.json()).then(c => { + for (const a of c.articles) { + const el = document.createElement('div'); + el.className = 'article'; + el.innerHTML = `Article ${a.id} — ${a.title}

${a.body}

`; + wrap.appendChild(el); + } + }); +} + +function addFeed(entry) { + const ul = document.getElementById('feed'); + const li = document.createElement('li'); + const t = new Date().toLocaleTimeString(); + if (entry.type === 'action') { + li.innerHTML = `${entry.name} + ${entry.tool} + — ${entry.rationale || ''} + · ${t}`; + } else { + li.innerHTML = `${entry.type} · ${t}`; + } + ul.prepend(li); + while (ul.children.length > 80) ul.removeChild(ul.lastChild); +} + +function refreshHeader() { + document.getElementById('tick').textContent = snapshot?.tick || 0; + document.getElementById('agentCount').textContent = snapshot?.agents.length || 0; +} + +async function refreshAll() { + const r = await fetch('/api/state'); + snapshot = await r.json(); + draw(); + refreshHeader(); + refreshAgentCards(); + refreshProposals(); + refreshConstitution(); +} + +function connectWS() { + const ws = new WebSocket((location.protocol === 'https:' ? 'wss://' : 'ws://') + location.host + '/ws'); + const status = document.getElementById('wsStatus'); + ws.onopen = () => { status.textContent = 'live'; status.className = 'ws-status connected'; }; + ws.onclose = () => { + status.textContent = 'disconnected — retrying'; status.className = 'ws-status disconnected'; + setTimeout(connectWS, 2000); + }; + ws.onmessage = (ev) => { + const msg = JSON.parse(ev.data); + if (msg.type === 'snapshot') { + snapshot = msg.data; + draw(); refreshHeader(); refreshAgentCards(); + } else if (msg.type === 'action') { + // update local agent snapshot + if (snapshot) { + const a = snapshot.agents.find(x => x.id === msg.agent); + if (a) { + a.x = msg.x; a.y = msg.y; + a.energy = msg.energy; a.knowledge = msg.knowledge; + a.influence = msg.influence; a.credits = msg.credits; + a.mood = msg.mood; + } + draw(); refreshAgentCards(); + } + addFeed(msg); + // refresh proposals / constitution occasionally + if (msg.tool && msg.tool.startsWith('vote_on') || msg.tool === 'submit_townhall_proposal') { + refreshProposals(); + } + if (msg.tool === 'submit_townhall_proposal') { + setTimeout(refreshConstitution, 500); + } + } else if (msg.type === 'tick') { + document.getElementById('tick').textContent = msg.tick; + } + }; +} + +async function populateManual() { + const a = await (await fetch('/api/agents')).json(); + const selA = document.querySelector('#manual select[name=agent]'); + for (const ag of a) { + const o = document.createElement('option'); + o.value = ag.id; o.textContent = ag.name; selA.appendChild(o); + } + const tools = [ + 'go_to_place','go_home','say_to_agent','speak_to_all', + 'show_emoticon','recharge_energy','add_to_longterm_memory', + 'write_blog','add_to_billboard','submit_townhall_proposal', + 'vote_on_proposal','idle', + ]; + const selT = document.querySelector('#manual select[name=tool]'); + for (const t of tools) { + const o = document.createElement('option'); + o.value = t; o.textContent = t; selT.appendChild(o); + } +} + +document.getElementById('manual').addEventListener('submit', async (e) => { + e.preventDefault(); + const f = e.target; + let args = {}; + try { args = JSON.parse(f.args.value || '{}'); } catch { alert('args must be JSON'); return; } + const body = { tool: f.tool.value, args }; + const r = await fetch(`/api/turn/${f.agent.value}`, { + method: 'POST', headers: {'Content-Type': 'application/json'}, + body: JSON.stringify(body), + }); + const out = await r.json(); + console.log('manual turn result', out); +}); + +refreshAll(); +populateManual(); +connectWS(); +setInterval(refreshProposals, 5000); diff --git a/web/index.html b/web/index.html new file mode 100644 index 0000000..c9d9d0a --- /dev/null +++ b/web/index.html @@ -0,0 +1,61 @@ + + + + + Emergence-Mini · Live World + + + +
+

Emergence-Mini

+
+ Tick 0 + Agents 0 + Active Proposals 0 + connecting… +
+
+ +
+
+ +
+ Anchor + Flora + Lovely + Spark +
+
+ + +
+ + + + diff --git a/web/style.css b/web/style.css new file mode 100644 index 0000000..213dd00 --- /dev/null +++ b/web/style.css @@ -0,0 +1,53 @@ +* { box-sizing: border-box; } +body { margin: 0; font-family: ui-monospace, "SF Mono", Menlo, monospace; + background: #0b0f14; color: #d6e2ee; } +header { display: flex; justify-content: space-between; align-items: baseline; + padding: 12px 18px; border-bottom: 1px solid #1c2733; background: #0e141b; } +header h1 { margin: 0; font-size: 18px; letter-spacing: 1px; color: #6cf0c2; } +.meta span { margin-left: 14px; color: #8aa1b6; font-size: 12px; } +.meta b { color: #d6e2ee; } +.ws-status.connected { color: #6cf0c2; } +.ws-status.disconnected { color: #ff6c6c; } +main { display: grid; grid-template-columns: 1fr 360px; gap: 16px; padding: 16px; } +.canvas-wrap { background: #0e141b; padding: 10px; border: 1px solid #1c2733; } +canvas { display: block; width: 100%; height: auto; image-rendering: pixelated; + background: #060a0e; } +.legend { padding-top: 8px; font-size: 12px; color: #8aa1b6; } +.legend .dot { display: inline-block; width: 10px; height: 10px; border-radius: 50%; + margin-right: 4px; margin-left: 12px; vertical-align: middle; } +.dot.anchor { background: #ffd166; } +.dot.flora { background: #6cf0c2; } +.dot.lovely { background: #ff8fb1; } +.dot.spark { background: #82aaff; } +aside { background: #0e141b; padding: 14px; border: 1px solid #1c2733; + height: calc(100vh - 80px); overflow-y: auto; } +aside h2 { font-size: 12px; text-transform: uppercase; letter-spacing: 1px; + color: #6cf0c2; margin: 18px 0 6px; } +#feed { list-style: none; padding: 0; margin: 0; max-height: 220px; overflow-y: auto; } +#feed li { font-size: 12px; padding: 4px 0; border-bottom: 1px dotted #1c2733; } +#feed li .who { color: #82aaff; } +#feed li .tool { color: #ffd166; } +#feed li .why { color: #6c8aa6; font-style: italic; } +.agent-card { background: #0a1018; border: 1px solid #1c2733; padding: 8px; margin: 6px 0; } +.agent-card h3 { margin: 0 0 4px; font-size: 13px; } +.agent-card .bar { height: 4px; background: #1c2733; margin: 2px 0; border-radius: 2px; } +.agent-card .bar > i { display: block; height: 100%; border-radius: 2px; } +.bar.energy > i { background: #ffd166; } +.bar.knowledge > i { background: #82aaff; } +.bar.influence > i { background: #ff8fb1; } +.bar.credits > i { background: #6cf0c2; } +.proposal { background: #0a1018; border: 1px solid #1c2733; padding: 8px; margin: 6px 0; } +.proposal h4 { margin: 0 0 4px; font-size: 13px; color: #ffd166; } +.proposal small { color: #6c8aa6; } +#constitution { font-size: 12px; } +#constitution .article { background: #0a1018; border: 1px solid #1c2733; padding: 8px; + margin: 6px 0; } +#constitution .article b { color: #6cf0c2; } +form#manual { display: grid; gap: 6px; } +form#manual label { display: grid; gap: 2px; font-size: 11px; color: #8aa1b6; } +form#manual select, form#manual input { background: #060a0e; color: #d6e2ee; + border: 1px solid #1c2733; padding: 6px; + font-family: inherit; } +form#manual button { background: #6cf0c2; color: #060a0e; border: 0; padding: 6px; + font-weight: bold; cursor: pointer; } +form#manual button:hover { background: #82ffd6; }