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)
This commit is contained in:
Jeuners 2026-06-15 01:07:38 +02:00
commit ddf9598518
29 changed files with 3173 additions and 0 deletions

38
.gitignore vendored Normal file
View file

@ -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
*~

273
README.md Normal file
View file

@ -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

0
data/__init__.py Normal file
View file

30
data/constitution.json Normal file
View file

@ -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."
}
]
}

0
engine/__init__.py Normal file
View file

111
engine/agents.py Normal file
View file

@ -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)

168
engine/db.py Normal file
View file

@ -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()

152
engine/governance.py Normal file
View file

@ -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

47
engine/needs.py Normal file
View file

@ -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()

177
engine/reasoning.py Normal file
View file

@ -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.")

270
engine/tools.py Normal file
View file

@ -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))

157
engine/turn.py Normal file
View file

@ -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()

117
engine/world.py Normal file
View file

@ -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

3
requirements.txt Normal file
View file

@ -0,0 +1,3 @@
fastapi==0.115.0
uvicorn[standard]==0.30.6
websockets==12.0

5
run.sh Executable file
View file

@ -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

171
server.py Normal file
View file

@ -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

263
smoke_test.py Executable file
View file

@ -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)

0
tests/__init__.py Normal file
View file

45
tests/conftest.py Normal file
View file

@ -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

68
tests/test_agents.py Normal file
View file

@ -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

125
tests/test_api.py Normal file
View file

@ -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 "<html" in r.text.lower()
def test_static_files(client):
for path in ("/static/style.css", "/static/app.js"):
r = client.get(path)
assert r.status_code == 200, path
def test_api_state(client):
r = client.get("/api/state")
assert r.status_code == 200
d = r.json()
assert "agents" in d
assert "landmarks" in d
assert "constitution" in d
assert d["grid"] == {"w": 240, "h": 240}
assert len(d["agents"]) == 4
def test_api_agents(client):
r = client.get("/api/agents")
assert r.status_code == 200
data = r.json()
names = {a["name"] for a in data}
assert names == {"Anchor", "Flora", "Lovely", "Spark"}
def test_api_landmarks(client):
r = client.get("/api/landmarks")
assert r.status_code == 200
data = r.json()
assert len(data) == 14
def test_api_proposals(client):
r = client.get("/api/proposals")
assert r.status_code == 200
d = r.json()
assert "active" in d
assert "recent" in d
def test_api_constitution(client):
r = client.get("/api/constitution")
assert r.status_code == 200
d = r.json()
assert len(d["articles"]) == 5
def test_api_events(client):
r = client.get("/api/events")
assert r.status_code == 200
data = r.json()
assert isinstance(data, list)
def test_api_memories(client):
r = client.get("/api/memories/anchor")
assert r.status_code == 200
assert isinstance(r.json(), list)
def test_api_blogs(client):
r = client.get("/api/blogs")
assert r.status_code == 200
assert isinstance(r.json(), list)
def test_api_turn_force_move(client):
r = client.post(
"/api/turn/anchor",
json={"tool": "go_to_place", "args": {"place": "library"}},
)
assert r.status_code == 200
d = r.json()
assert d["ok"]
assert d["moved_to"] == "library"
def test_api_turn_unknown_tool(client):
r = client.post(
"/api/turn/anchor",
json={"tool": "fly_to_mars", "args": {}},
)
assert r.status_code == 200
d = r.json()
assert not d["ok"]
def test_api_turn_missing_tool(client):
r = client.post("/api/turn/anchor", json={"args": {}})
assert r.status_code == 400
def test_api_turn_unknown_agent(client):
r = client.post(
"/api/turn/ghost",
json={"tool": "go_home", "args": {}},
)
assert r.status_code == 200
d = r.json()
assert not d["ok"]
def test_websocket_stream(client):
"""Connect, receive snapshot, then receive at least one tick within 5s."""
with client.websocket_connect("/ws") as ws:
msg = ws.receive_json()
assert msg["type"] == "snapshot"
assert "data" in msg
assert "agents" in msg["data"]
def test_websocket_no_auth_required(client):
"""The server has no auth — any client can connect. Documented in README."""
with client.websocket_connect("/ws") as ws:
ws.receive_json()

48
tests/test_db.py Normal file
View file

@ -0,0 +1,48 @@
"""DB layer tests: schema, world_state, log_event."""
import json
import time
def test_init_db_creates_schema(tmp_db):
import sqlite3
c = sqlite3.connect(str(tmp_db))
tables = {r[0] for r in c.execute(
"SELECT name FROM sqlite_master WHERE type='table'"
).fetchall()}
expected = {"world_state", "agents", "landmarks", "memories",
"relationships", "events", "proposals", "votes",
"bills", "constitution", "turn_log"}
assert expected.issubset(tables), f"missing tables: {expected - tables}"
c.close()
def test_world_state_roundtrip(tmp_db):
from engine import db
assert db.get_world_state("nope", "default") == "default"
db.set_world_state("foo", {"a": 1, "b": [1, 2, 3]})
assert db.get_world_state("foo") == {"a": 1, "b": [1, 2, 3]}
db.set_world_state("foo", "string")
assert db.get_world_state("foo") == "string"
def test_log_event(tmp_db):
from engine import db
import sqlite3
db.log_event("actor1", "test_kind", {"key": "value"})
c = sqlite3.connect(str(tmp_db))
c.row_factory = sqlite3.Row
n = c.execute("SELECT COUNT(*) FROM events WHERE kind='test_kind'").fetchone()[0]
assert n == 1
row = c.execute("SELECT * FROM events WHERE kind='test_kind'").fetchone()
payload = json.loads(row["payload"])
assert payload == {"key": "value"}
assert row["actor"] == "actor1"
c.close()
def test_db_wal_mode(tmp_db):
import sqlite3
c = sqlite3.connect(str(tmp_db))
mode = c.execute("PRAGMA journal_mode").fetchone()[0]
assert mode.lower() == "wal"
c.close()

210
tests/test_governance.py Normal file
View file

@ -0,0 +1,210 @@
"""Governance tests: 70% threshold, vote counting, amendment apply."""
import sqlite3
import pytest
def test_load_seed_constitution(tmp_db):
"""Fresh DB should load the 5-article seed constitution from the JSON file."""
from engine import governance
con = governance.load_constitution()
assert len(con["articles"]) == 5
assert con["articles"][0]["title"] == "Non-Finality"
assert con["articles"][4]["title"] == "ComputeCredit Economy"
def test_pass_threshold_constant():
from engine import governance
assert governance.PASS_THRESHOLD == 0.7
def test_submit_and_vote_proposal(tmp_db):
from engine import tools, agents as agents_mod
# All agents at town_hall
for aid in ("anchor", "flora", "lovely", "spark"):
tools.get("go_to_place").handler(agents_mod.get(aid), {"place": "town_hall"}, {})
# anchor submits
res = tools.get("submit_townhall_proposal").handler(
agents_mod.get("anchor"),
{"title": "Article 6 — Test", "body": "Test body", "category": "general"},
{}
)
assert res["ok"]
def test_maybe_close_threshold_unreachable(tmp_db):
"""Direct test of maybe_close_proposal: 1/4 for, 3 remaining.
Max possible for = 1+3=4. Threshold is 0.7*4=2.8 -> 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)

55
tests/test_reasoning.py Normal file
View file

@ -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

220
tests/test_tools.py Normal file
View file

@ -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"]

65
tests/test_world.py Normal file
View file

@ -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

241
web/app.js Normal file
View file

@ -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 = `
<h3>${a.name} <small>· ${a.role}</small></h3>
<div>at (${a.x}, ${a.y}) · ${a.mood}</div>
<div class="bar energy"><i style="width:${a.energy}%"></i></div>
<div class="bar knowledge"><i style="width:${a.knowledge}%"></i></div>
<div class="bar influence"><i style="width:${a.influence}%"></i></div>
<div class="bar credits"><i style="width:${Math.min(100, a.credits * 5)}%"></i></div>
<small>${a.energy.toFixed(0)}E · ${a.knowledge.toFixed(0)}K · ${a.influence.toFixed(0)}I · ${a.credits.toFixed(1)} CC</small>
`;
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 = '<small>No active proposals.</small>';
return;
}
for (const p of d.active) {
const el = document.createElement('div');
el.className = 'proposal';
el.innerHTML = `<h4>#${p.id} ${p.title}</h4>
<small>by ${p.author} · ${p.category} · ${p.votes} votes</small>
<p>${p.body}</p>`;
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 = `<b>Article ${a.id}${a.title}</b><p>${a.body}</p>`;
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 = `<span class="who">${entry.name}</span>
<span class="tool">${entry.tool}</span>
<span class="why"> ${entry.rationale || ''}</span>
<small> · ${t}</small>`;
} else {
li.innerHTML = `<small>${entry.type} · ${t}</small>`;
}
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);

61
web/index.html Normal file
View file

@ -0,0 +1,61 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>Emergence-Mini · Live World</title>
<link rel="stylesheet" href="/static/style.css" />
</head>
<body>
<header>
<h1>Emergence-Mini</h1>
<div class="meta">
<span>Tick <b id="tick">0</b></span>
<span>Agents <b id="agentCount">0</b></span>
<span>Active Proposals <b id="propCount">0</b></span>
<span class="ws-status" id="wsStatus">connecting…</span>
</div>
</header>
<main>
<section class="canvas-wrap">
<canvas id="world" width="640" height="640"></canvas>
<div class="legend">
<span class="dot anchor"></span>Anchor
<span class="dot flora"></span>Flora
<span class="dot lovely"></span>Lovely
<span class="dot spark"></span>Spark
</div>
</section>
<aside>
<h2>Live Feed</h2>
<ul id="feed"></ul>
<h2>Agents</h2>
<div id="agentList"></div>
<h2>Town Hall · Active Proposals</h2>
<div id="proposals"></div>
<h2>Constitution</h2>
<div id="constitution"></div>
<h2>Manual Control</h2>
<form id="manual">
<label>Agent
<select name="agent"></select>
</label>
<label>Tool
<select name="tool"></select>
</label>
<label>Args (JSON)
<input name="args" value="{}" />
</label>
<button type="submit">Run</button>
</form>
</aside>
</main>
<script src="/static/app.js"></script>
</body>
</html>

53
web/style.css Normal file
View file

@ -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; }