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:
commit
ddf9598518
29 changed files with 3173 additions and 0 deletions
38
.gitignore
vendored
Normal file
38
.gitignore
vendored
Normal 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
273
README.md
Normal 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.
|
||||
|
||||
   
|
||||
|
||||
---
|
||||
|
||||
## 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
0
data/__init__.py
Normal file
30
data/constitution.json
Normal file
30
data/constitution.json
Normal 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
0
engine/__init__.py
Normal file
111
engine/agents.py
Normal file
111
engine/agents.py
Normal 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
168
engine/db.py
Normal 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
152
engine/governance.py
Normal 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
47
engine/needs.py
Normal 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
177
engine/reasoning.py
Normal 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
270
engine/tools.py
Normal 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
157
engine/turn.py
Normal 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
117
engine/world.py
Normal 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
3
requirements.txt
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
fastapi==0.115.0
|
||||
uvicorn[standard]==0.30.6
|
||||
websockets==12.0
|
||||
5
run.sh
Executable file
5
run.sh
Executable 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
171
server.py
Normal 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
263
smoke_test.py
Executable 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
0
tests/__init__.py
Normal file
45
tests/conftest.py
Normal file
45
tests/conftest.py
Normal 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
68
tests/test_agents.py
Normal 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
125
tests/test_api.py
Normal 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
48
tests/test_db.py
Normal 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
210
tests/test_governance.py
Normal 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
55
tests/test_reasoning.py
Normal 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
220
tests/test_tools.py
Normal 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
65
tests/test_world.py
Normal 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
241
web/app.js
Normal 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
61
web/index.html
Normal 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
53
web/style.css
Normal 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; }
|
||||
Loading…
Reference in a new issue