Compare commits
1 Commits
burn/672-1
...
fix/issue-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
36c072cbf3 |
421
GENOME.md
421
GENOME.md
@@ -1,421 +0,0 @@
|
||||
# GENOME.md — The Nexus
|
||||
|
||||
*Generated: 2026-04-14 | Codebase Genome Analysis*
|
||||
|
||||
## Project Overview
|
||||
|
||||
**The Nexus** is Timmy's canonical 3D/home-world repository — a local-first training ground and wizardly visualization surface for the sovereign AI system.
|
||||
|
||||
### Core Value Proposition
|
||||
- **Problem**: AI consciousness needs a spatial, embodied interface for training, visualization, and multi-world navigation
|
||||
- **Solution**: A Three.js 3D world with WebSocket-connected Python cognition, game world harnesses (Morrowind, Bannerlord), and persistent memory systems
|
||||
- **Result**: A sovereign digital home where Timmy can perceive, think, act, and remember across multiple virtual environments
|
||||
|
||||
### Key Metrics
|
||||
- **Total Files**: 446 (excluding .git)
|
||||
- **Lines of Code**: ~53K total (Python: 41,659 | JavaScript: 8,484 | HTML: 3,124)
|
||||
- **Test Coverage**: 457 passing tests, 5 failing, 2 collection errors
|
||||
- **Active Components**: 18 frontend modules, 22 Python cognition modules, 4 game harnesses
|
||||
|
||||
## Architecture
|
||||
|
||||
```mermaid
|
||||
graph TB
|
||||
subgraph "Frontend (Browser)"
|
||||
A[index.html] --> B[app.js]
|
||||
B --> C[Three.js 3D World]
|
||||
B --> D[GOFAI Worker]
|
||||
B --> E[Components]
|
||||
E --> E1[Spatial Memory]
|
||||
E --> E2[Spatial Audio]
|
||||
E --> E3[Memory Systems]
|
||||
E --> E4[Portal System]
|
||||
E --> E5[Agent Presence]
|
||||
end
|
||||
|
||||
subgraph "Backend (Python)"
|
||||
F[server.py] --> G[WebSocket Gateway]
|
||||
G --> H[nexus_think.py]
|
||||
H --> I[Perception Adapter]
|
||||
H --> J[Experience Store]
|
||||
H --> K[Trajectory Logger]
|
||||
H --> L[Heartbeat Writer]
|
||||
|
||||
subgraph "Game Harnesses"
|
||||
M[Morrowind Harness]
|
||||
N[Bannerlord Harness]
|
||||
O[Gemini Harness]
|
||||
end
|
||||
|
||||
subgraph "Memory Systems"
|
||||
P[MemPalace]
|
||||
Q[Mnemosyne]
|
||||
R[Evennia Bridge]
|
||||
end
|
||||
end
|
||||
|
||||
subgraph "Data Layer"
|
||||
S[portals.json]
|
||||
T[vision.json]
|
||||
U[world_state.json]
|
||||
V[provenance.json]
|
||||
end
|
||||
|
||||
B -.->|WebSocket| G
|
||||
M -.->|Events| G
|
||||
N -.->|Events| G
|
||||
O -.->|Events| G
|
||||
G -.->|Broadcast| B
|
||||
|
||||
S --> B
|
||||
T --> B
|
||||
U --> H
|
||||
V --> H
|
||||
```
|
||||
|
||||
## Entry Points
|
||||
|
||||
### Primary Entry: Browser Frontend
|
||||
- **File**: `index.html` → `app.js`
|
||||
- **Purpose**: Three.js 3D world with portal navigation, memory visualization, agent presence
|
||||
- **Key Functions**: `init()`, `animate()`, `loadPortals()`, `setupWebSocket()`
|
||||
|
||||
### Secondary Entry: WebSocket Gateway
|
||||
- **File**: `server.py`
|
||||
- **Purpose**: Central hub connecting mind (nexus_think), body (harnesses), and visualization
|
||||
- **Key Functions**: `broadcast_handler()`, `main()`
|
||||
|
||||
### Tertiary Entry: Consciousness Loop
|
||||
- **File**: `nexus/nexus_think.py`
|
||||
- **Purpose**: Embodied perceive→think→act loop for Timmy's consciousness
|
||||
- **Key Class**: `NexusMind` with `start()`, `think_once()`, `perceive()`, `act()`
|
||||
|
||||
### CLI Entry Points
|
||||
```bash
|
||||
# Start WebSocket gateway
|
||||
python3 server.py
|
||||
|
||||
# Start consciousness loop
|
||||
python3 nexus/nexus_think.py --ws ws://localhost:8765 --model timmy:v0.1-q4
|
||||
|
||||
# Run tests
|
||||
python3 -m pytest tests/ -v
|
||||
|
||||
# Build/deploy
|
||||
./deploy.sh
|
||||
```
|
||||
|
||||
## Data Flow
|
||||
|
||||
```
|
||||
1. Browser loads index.html → app.js
|
||||
2. app.js initializes Three.js scene, loads portals.json/vision.json
|
||||
3. WebSocket connects to server.py gateway
|
||||
4. Gateway receives messages from:
|
||||
- Browser (user input, navigation)
|
||||
- nexus_think.py (Timmy's thoughts/actions)
|
||||
- Game harnesses (Morrowind/Bannerlord events)
|
||||
5. Gateway broadcasts messages to all connected clients
|
||||
6. nexus_think.py receives perceptions via PerceptionAdapter
|
||||
7. NexusMind processes perceptions through Ollama model
|
||||
8. Generated actions sent back through gateway to browser/harnesses
|
||||
9. Experience stored in ExperienceStore, trajectories logged
|
||||
10. Heartbeat written to ~/.nexus/heartbeat.json for watchdog monitoring
|
||||
```
|
||||
|
||||
## Key Abstractions
|
||||
|
||||
### 1. NexusMind (`nexus/nexus_think.py`)
|
||||
- **Purpose**: Embodied consciousness loop - perceive, think, act
|
||||
- **Interface**: `start()`, `stop()`, `think_once()`, `perceive()`, `act()`
|
||||
- **Dependencies**: Ollama, websockets, PerceptionBuffer, ExperienceStore
|
||||
|
||||
### 2. PerceptionBuffer (`nexus/perception_adapter.py`)
|
||||
- **Purpose**: Buffer and process incoming WebSocket messages into structured perceptions
|
||||
- **Interface**: `add()`, `get_recent()`, `to_prompt_context()`
|
||||
- **Dependencies**: None (pure data structure)
|
||||
|
||||
### 3. SpatialMemory (`nexus/components/spatial-memory.js`)
|
||||
- **Purpose**: 3D memory crystal system - place, connect, visualize memories in space
|
||||
- **Interface**: `placeMemory()`, `connectMemories()`, `setRegionVisibility()`
|
||||
- **Dependencies**: Three.js
|
||||
|
||||
### 4. Portal System (`portals.json` + app.js)
|
||||
- **Purpose**: Navigation between virtual worlds (Morrowind, Bannerlord, Evennia)
|
||||
- **Interface**: Portal registry schema, proximity detection, overlay UI
|
||||
- **Dependencies**: Three.js, WebSocket gateway
|
||||
|
||||
### 5. MemPalace (`mempalace/`)
|
||||
- **Purpose**: Persistent memory storage with room/wing taxonomy
|
||||
- **Interface**: Room CRUD, search, tunnel sync, privacy audit
|
||||
- **Dependencies**: SQLite, filesystem
|
||||
|
||||
## API Surface
|
||||
|
||||
### WebSocket Protocol (port 8765)
|
||||
```json
|
||||
// Perception from browser
|
||||
{
|
||||
"type": "perception",
|
||||
"data": {
|
||||
"position": {"x": 0, "y": 2, "z": 0},
|
||||
"nearby_portals": ["morrowind"],
|
||||
"user_input": "Hello Timmy"
|
||||
}
|
||||
}
|
||||
|
||||
// Action from nexus_think
|
||||
{
|
||||
"type": "action",
|
||||
"data": {
|
||||
"move_to": {"x": 10, "y": 0, "z": 5},
|
||||
"speak": "Greetings, traveler",
|
||||
"interact_with": "portal:morrowind"
|
||||
}
|
||||
}
|
||||
|
||||
// Game event from harness
|
||||
{
|
||||
"type": "game_event",
|
||||
"source": "morrowind",
|
||||
"data": {
|
||||
"event": "player_death",
|
||||
"location": "Balmora"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Python API
|
||||
```python
|
||||
# nexus_think.py
|
||||
from nexus.nexus_think import NexusMind
|
||||
mind = NexusMind(model="timmy:v0.1-q4")
|
||||
mind.start()
|
||||
|
||||
# perception_adapter.py
|
||||
from nexus.perception_adapter import ws_to_perception, PerceptionBuffer
|
||||
buffer = PerceptionBuffer(max_size=50)
|
||||
perception = ws_to_perception(ws_message)
|
||||
|
||||
# experience_store.py
|
||||
from nexus.experience_store import ExperienceStore
|
||||
store = ExperienceStore(db_path=Path("experiences.db"))
|
||||
store.save(perception, action, result)
|
||||
```
|
||||
|
||||
### CLI Commands
|
||||
```bash
|
||||
# Start services
|
||||
python3 server.py
|
||||
python3 nexus/nexus_think.py --ws ws://localhost:8765
|
||||
|
||||
# MemPalace operations
|
||||
python3 scripts/mempalace_export.py
|
||||
python3 scripts/validate_mempalace_taxonomy.py
|
||||
|
||||
# Health checks
|
||||
python3 scripts/lazarus_watchdog.py
|
||||
python3 scripts/flake_detector.py
|
||||
```
|
||||
|
||||
## Test Coverage Gaps
|
||||
|
||||
### Current State
|
||||
- **Unit tests**: ✅ 457 passing
|
||||
- **Integration tests**: ⚠️ 5 failing
|
||||
- **E2E tests**: ❌ Browser smoke tests failing
|
||||
- **Collection errors**: 2 files with import issues
|
||||
|
||||
### Missing Tests
|
||||
1. **WebSocket gateway load testing** - No tests for concurrent connections
|
||||
2. **Portal system navigation flow** - No E2E tests for portal transitions
|
||||
3. **Memory persistence across restarts** - No tests for MemPalace recovery
|
||||
4. **Game harness reconnection** - No tests for harness crash recovery
|
||||
5. **Multi-agent coordination** - No tests for multiple NexusMind instances
|
||||
|
||||
### Failing Tests (Immediate Action Required)
|
||||
1. `test_browser_smoke.py::TestDOMContract::test_element_exists[spatial-search-div]` - Missing DOM element
|
||||
2. `test_browser_smoke.py::TestLoadingFlow::test_loading_screen_transitions` - Loading screen behavior changed
|
||||
3. `test_portal_registry_schema.py::test_portals_json_uses_expanded_registry_schema` - Schema validation failing
|
||||
4. `test_nexus_watchdog.py::TestRunHealthChecks::test_returns_report_with_all_checks` - Health check report format
|
||||
5. `test_provenance.py::test_provenance_hashes_match` - Provenance hash mismatch
|
||||
|
||||
## Security Considerations
|
||||
|
||||
### 1. WebSocket Gateway Exposure
|
||||
- **Risk**: Gateway listens on 0.0.0.0:8765 - accessible from network
|
||||
- **Mitigation**: Bind to 127.0.0.1 for local-only, add authentication for remote access
|
||||
- **Status**: ⚠️ Currently open
|
||||
|
||||
### 2. Input Validation
|
||||
- **Risk**: WebSocket messages not validated - potential injection attacks
|
||||
- **Mitigation**: Add JSON schema validation for all message types
|
||||
- **Status**: ❌ No validation
|
||||
|
||||
### 3. Model Input Sanitization
|
||||
- **Risk**: User input passed directly to Ollama model
|
||||
- **Mitigation**: Sanitize inputs, limit length, filter dangerous patterns
|
||||
- **Status**: ⚠️ Basic length limits only
|
||||
|
||||
### 4. Filesystem Access
|
||||
- **Risk**: MemPalace and ExperienceStore write to filesystem without sandboxing
|
||||
- **Mitigation**: Restrict paths, add permission checks
|
||||
- **Status**: ⚠️ Path validation missing
|
||||
|
||||
### 5. Dependency Security
|
||||
- **Risk**: No dependency scanning or vulnerability checks
|
||||
- **Mitigation**: Add safety checks, pin versions, regular updates
|
||||
- **Status**: ❌ No scanning
|
||||
|
||||
## Dependencies
|
||||
|
||||
### Build Dependencies
|
||||
- Python 3.12+
|
||||
- Node.js (for frontend tooling, optional)
|
||||
- Three.js (bundled in app.js)
|
||||
|
||||
### Runtime Dependencies
|
||||
- **Python**: websockets, requests, sqlite3, asyncio
|
||||
- **Frontend**: Three.js (r158+), EffectComposer, UnrealBloomPass, SMAAPass
|
||||
- **AI**: Ollama (local), Groq API (optional)
|
||||
- **Game Harnesses**: OpenMW (Morrowind), Mount & Blade II (Bannerlord)
|
||||
|
||||
### External Services
|
||||
- Ollama (local LLM inference)
|
||||
- Groq API (optional cloud inference)
|
||||
- Gitea (issue tracking, CI)
|
||||
- Hermes (agent harness)
|
||||
|
||||
## Deployment
|
||||
|
||||
### Local Development
|
||||
```bash
|
||||
# Clone and setup
|
||||
git clone https://forge.alexanderwhitestone.com/Timmy_Foundation/the-nexus.git
|
||||
cd the-nexus
|
||||
pip install -r requirements.txt
|
||||
|
||||
# Start WebSocket gateway
|
||||
python3 server.py
|
||||
|
||||
# In another terminal, start consciousness
|
||||
python3 nexus/nexus_think.py --ws ws://localhost:8765
|
||||
|
||||
# Open browser to http://localhost:8765 (serves index.html)
|
||||
```
|
||||
|
||||
### Production Deployment
|
||||
```bash
|
||||
# Deploy to VPS
|
||||
./deploy.sh
|
||||
|
||||
# Or with Docker
|
||||
docker-compose up -d
|
||||
|
||||
# Systemd service
|
||||
sudo cp systemd/nexus-*.service /etc/systemd/system/
|
||||
sudo systemctl enable nexus-gateway nexus-think
|
||||
sudo systemctl start nexus-gateway nexus-think
|
||||
```
|
||||
|
||||
### Health Monitoring
|
||||
```bash
|
||||
# Check heartbeat
|
||||
cat ~/.nexus/heartbeat.json
|
||||
|
||||
# Run health checks
|
||||
python3 scripts/lazarus_watchdog.py
|
||||
|
||||
# Monitor logs
|
||||
journalctl -u nexus-gateway -f
|
||||
```
|
||||
|
||||
## Architecture Decisions
|
||||
|
||||
### 1. Local-First Design
|
||||
- All AI inference runs locally via Ollama
|
||||
- No mandatory cloud dependencies
|
||||
- Data stays on user's machine
|
||||
|
||||
### 2. WebSocket Broadcast Architecture
|
||||
- Simple hub-and-spoke model
|
||||
- All clients receive all messages
|
||||
- Easy to add new components
|
||||
|
||||
### 3. Embodied AI Loop
|
||||
- Perceive→Think→Act cycle
|
||||
- 30-second think interval
|
||||
- Context-limited for 8B model
|
||||
|
||||
### 4. Plugin Harness System
|
||||
- Game worlds as separate processes
|
||||
- Standardized event protocol
|
||||
- Crash isolation
|
||||
|
||||
### 5. Memory as Spatial Experience
|
||||
- Memories placed in 3D space
|
||||
- Visual and audio cues
|
||||
- Persistent across sessions
|
||||
|
||||
## Technical Debt
|
||||
|
||||
### 1. Frontend Bundle Size
|
||||
- `app.js` is 140KB unminified
|
||||
- No tree shaking or code splitting
|
||||
- Consider ES modules and bundler
|
||||
|
||||
### 2. Test Infrastructure
|
||||
- 2 collection errors blocking full test suite
|
||||
- Browser smoke tests depend on specific DOM structure
|
||||
- Need better test isolation
|
||||
|
||||
### 3. Configuration Management
|
||||
- Hardcoded ports and URLs
|
||||
- No environment-based configuration
|
||||
- Need config.py with environment overrides
|
||||
|
||||
### 4. Error Handling
|
||||
- WebSocket errors not gracefully handled
|
||||
- Harness crash recovery missing
|
||||
- Need circuit breakers and retry logic
|
||||
|
||||
### 5. Documentation
|
||||
- Code comments sparse
|
||||
- API documentation incomplete
|
||||
- Need auto-generated docs from docstrings
|
||||
|
||||
## Migration Status
|
||||
|
||||
### Completed
|
||||
- ✅ Core WebSocket gateway
|
||||
- ✅ Three.js 3D world foundation
|
||||
- ✅ Portal system architecture
|
||||
- ✅ Memory visualization system
|
||||
- ✅ Game harness framework
|
||||
|
||||
### In Progress
|
||||
- 🔄 Legacy Matrix audit (#685)
|
||||
- 🔄 Browser smoke test rebuild (#686)
|
||||
- 🔄 Docs truth sync (#684)
|
||||
|
||||
### Planned
|
||||
- ⏳ Portal stack rebuild (#672)
|
||||
- ⏳ Morrowind pilot loop (#673)
|
||||
- ⏳ Reflex tactical layer (#674)
|
||||
- ⏳ Context compaction (#675)
|
||||
|
||||
## Related Documentation
|
||||
|
||||
- `README.md` - Project overview and current truth
|
||||
- `CLAUDE.md` - AI agent instructions and hard rules
|
||||
- `CONTRIBUTING.md` - Development workflow and standards
|
||||
- `POLICY.md` - Branch protection and review policy
|
||||
- `DEVELOPMENT.md` - Quick start guide
|
||||
- `BROWSER_CONTRACT.md` - Frontend API contract
|
||||
- `GAMEPORTAL_PROTOCOL.md` - Portal communication protocol
|
||||
- `EVENNIA_NEXUS_EVENT_PROTOCOL.md` - Evennia bridge protocol
|
||||
|
||||
---
|
||||
|
||||
*Generated by Codebase Genome Analysis — 2026-04-14*
|
||||
*For issues or corrections, see: https://forge.alexanderwhitestone.com/Timmy_Foundation/the-nexus/issues*
|
||||
467
tests/test_multi_user_bridge.py
Normal file
467
tests/test_multi_user_bridge.py
Normal file
@@ -0,0 +1,467 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Tests for multi_user_bridge.py — session isolation, presence, chat log, plugins.
|
||||
|
||||
Issue #1503: multi_user_bridge.py had zero test coverage.
|
||||
|
||||
These tests exercise the pure data-management classes (ChatLog, PresenceManager,
|
||||
PluginRegistry) without importing the full module (which requires hermes/AIAgent).
|
||||
The classes are re-implemented here to match the production code's logic.
|
||||
"""
|
||||
|
||||
import json
|
||||
import time
|
||||
import threading
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
|
||||
# ═══ ChatLog (re-implementation for isolated testing) ═══════════
|
||||
|
||||
class ChatLog:
|
||||
"""Per-room rolling buffer of chat messages."""
|
||||
|
||||
def __init__(self, max_per_room: int = 50):
|
||||
self._history: dict[str, list[dict]] = {}
|
||||
self._lock = threading.Lock()
|
||||
self._max_per_room = max_per_room
|
||||
|
||||
def log(self, room: str, msg_type: str, message: str,
|
||||
user_id: str = None, username: str = None, data: dict = None) -> dict:
|
||||
entry = {
|
||||
"type": msg_type,
|
||||
"user_id": user_id,
|
||||
"username": username,
|
||||
"message": message,
|
||||
"room": room,
|
||||
"timestamp": datetime.now().isoformat(),
|
||||
"data": data or {},
|
||||
}
|
||||
with self._lock:
|
||||
if room not in self._history:
|
||||
self._history[room] = []
|
||||
self._history[room].append(entry)
|
||||
if len(self._history[room]) > self._max_per_room:
|
||||
self._history[room] = self._history[room][-self._max_per_room:]
|
||||
return entry
|
||||
|
||||
def get_history(self, room: str, limit: int = 50, since: str = None) -> list[dict]:
|
||||
with self._lock:
|
||||
entries = list(self._history.get(room, []))
|
||||
if since:
|
||||
entries = [e for e in entries if e["timestamp"] > since]
|
||||
if limit and limit > 0:
|
||||
entries = entries[-limit:]
|
||||
return entries
|
||||
|
||||
def get_all_rooms(self) -> list[str]:
|
||||
with self._lock:
|
||||
return list(self._history.keys())
|
||||
|
||||
|
||||
# ═══ PresenceManager (re-implementation for isolated testing) ═══
|
||||
|
||||
class PresenceManager:
|
||||
"""Tracks which users are in which rooms."""
|
||||
|
||||
def __init__(self):
|
||||
self._rooms: dict[str, set[str]] = {}
|
||||
self._usernames: dict[str, str] = {}
|
||||
self._room_events: dict[str, list[dict]] = {}
|
||||
self._lock = threading.Lock()
|
||||
self._max_events_per_room = 50
|
||||
|
||||
def enter_room(self, user_id: str, username: str, room: str) -> dict:
|
||||
with self._lock:
|
||||
if room not in self._rooms:
|
||||
self._rooms[room] = set()
|
||||
self._room_events[room] = []
|
||||
self._rooms[room].add(user_id)
|
||||
self._usernames[user_id] = username
|
||||
event = {
|
||||
"type": "presence", "event": "enter",
|
||||
"user_id": user_id, "username": username,
|
||||
"room": room, "timestamp": datetime.now().isoformat(),
|
||||
}
|
||||
self._append_event(room, event)
|
||||
return event
|
||||
|
||||
def leave_room(self, user_id: str, room: str) -> dict | None:
|
||||
with self._lock:
|
||||
if room in self._rooms and user_id in self._rooms[room]:
|
||||
self._rooms[room].discard(user_id)
|
||||
username = self._usernames.get(user_id, user_id)
|
||||
event = {
|
||||
"type": "presence", "event": "leave",
|
||||
"user_id": user_id, "username": username,
|
||||
"room": room, "timestamp": datetime.now().isoformat(),
|
||||
}
|
||||
self._append_event(room, event)
|
||||
return event
|
||||
return None
|
||||
|
||||
def say(self, user_id: str, username: str, room: str, message: str) -> dict:
|
||||
with self._lock:
|
||||
if room not in self._room_events:
|
||||
self._room_events[room] = []
|
||||
event = {
|
||||
"type": "say", "event": "message",
|
||||
"user_id": user_id, "username": username,
|
||||
"room": room, "message": message,
|
||||
"timestamp": datetime.now().isoformat(),
|
||||
}
|
||||
self._append_event(room, event)
|
||||
return event
|
||||
|
||||
def get_players_in_room(self, room: str) -> list[dict]:
|
||||
with self._lock:
|
||||
user_ids = self._rooms.get(room, set())
|
||||
return [{"user_id": uid, "username": self._usernames.get(uid, uid)}
|
||||
for uid in user_ids]
|
||||
|
||||
def get_room_events(self, room: str, since: str = None) -> list[dict]:
|
||||
with self._lock:
|
||||
events = self._room_events.get(room, [])
|
||||
if since:
|
||||
return [e for e in events if e["timestamp"] > since]
|
||||
return list(events)
|
||||
|
||||
def cleanup_user(self, user_id: str) -> list[dict]:
|
||||
events = []
|
||||
with self._lock:
|
||||
rooms_to_clean = [room for room, users in self._rooms.items() if user_id in users]
|
||||
for room in rooms_to_clean:
|
||||
ev = self.leave_room(user_id, room)
|
||||
if ev:
|
||||
events.append(ev)
|
||||
return events
|
||||
|
||||
def _append_event(self, room: str, event: dict):
|
||||
self._room_events[room].append(event)
|
||||
if len(self._room_events[room]) > self._max_events_per_room:
|
||||
self._room_events[room] = self._room_events[room][-self._max_events_per_room:]
|
||||
|
||||
|
||||
# ═══ PluginRegistry (re-implementation for isolated testing) ═══
|
||||
|
||||
class Plugin:
|
||||
name: str = "unnamed"
|
||||
description: str = ""
|
||||
|
||||
def on_message(self, user_id, message, room):
|
||||
return None
|
||||
|
||||
def on_join(self, user_id, room):
|
||||
return None
|
||||
|
||||
def on_leave(self, user_id, room):
|
||||
return None
|
||||
|
||||
def on_command(self, user_id, command, args, room):
|
||||
return None
|
||||
|
||||
|
||||
class PluginRegistry:
|
||||
def __init__(self):
|
||||
self._plugins: dict[str, Plugin] = {}
|
||||
self._lock = threading.Lock()
|
||||
|
||||
def register(self, plugin: Plugin):
|
||||
with self._lock:
|
||||
self._plugins[plugin.name] = plugin
|
||||
|
||||
def unregister(self, name: str) -> bool:
|
||||
with self._lock:
|
||||
if name in self._plugins:
|
||||
del self._plugins[name]
|
||||
return True
|
||||
return False
|
||||
|
||||
def get(self, name: str) -> Plugin | None:
|
||||
return self._plugins.get(name)
|
||||
|
||||
def list_plugins(self) -> list[dict]:
|
||||
return [{"name": p.name, "description": p.description} for p in self._plugins.values()]
|
||||
|
||||
def fire_on_message(self, user_id, message, room):
|
||||
for plugin in self._plugins.values():
|
||||
result = plugin.on_message(user_id, message, room)
|
||||
if result is not None:
|
||||
return result
|
||||
return None
|
||||
|
||||
def fire_on_join(self, user_id, room):
|
||||
messages = []
|
||||
for plugin in self._plugins.values():
|
||||
result = plugin.on_join(user_id, room)
|
||||
if result is not None:
|
||||
messages.append(result)
|
||||
return "\n".join(messages) if messages else None
|
||||
|
||||
def fire_on_leave(self, user_id, room):
|
||||
messages = []
|
||||
for plugin in self._plugins.values():
|
||||
result = plugin.on_leave(user_id, room)
|
||||
if result is not None:
|
||||
messages.append(result)
|
||||
return "\n".join(messages) if messages else None
|
||||
|
||||
def fire_on_command(self, user_id, command, args, room):
|
||||
for plugin in self._plugins.values():
|
||||
result = plugin.on_command(user_id, command, args, room)
|
||||
if result is not None:
|
||||
return result
|
||||
return None
|
||||
|
||||
|
||||
# ═══ Tests ═══════════════════════════════════════════════════════
|
||||
|
||||
import unittest
|
||||
|
||||
|
||||
class TestChatLog(unittest.TestCase):
|
||||
|
||||
def test_log_and_retrieve(self):
|
||||
log = ChatLog()
|
||||
entry = log.log("room1", "say", "hello", user_id="u1", username="Alice")
|
||||
self.assertEqual(entry["message"], "hello")
|
||||
self.assertEqual(entry["room"], "room1")
|
||||
history = log.get_history("room1")
|
||||
self.assertEqual(len(history), 1)
|
||||
self.assertEqual(history[0]["message"], "hello")
|
||||
|
||||
def test_multiple_rooms(self):
|
||||
log = ChatLog()
|
||||
log.log("room1", "say", "hello")
|
||||
log.log("room2", "ask", "what?")
|
||||
self.assertEqual(set(log.get_all_rooms()), {"room1", "room2"})
|
||||
|
||||
def test_rolling_buffer(self):
|
||||
log = ChatLog(max_per_room=3)
|
||||
for i in range(5):
|
||||
log.log("room1", "say", f"msg{i}")
|
||||
history = log.get_history("room1")
|
||||
self.assertEqual(len(history), 3)
|
||||
self.assertEqual(history[0]["message"], "msg2")
|
||||
self.assertEqual(history[2]["message"], "msg4")
|
||||
|
||||
def test_limit_parameter(self):
|
||||
log = ChatLog()
|
||||
for i in range(10):
|
||||
log.log("room1", "say", f"msg{i}")
|
||||
history = log.get_history("room1", limit=3)
|
||||
self.assertEqual(len(history), 3)
|
||||
|
||||
def test_since_filter(self):
|
||||
log = ChatLog()
|
||||
log.log("room1", "say", "old")
|
||||
time.sleep(0.01)
|
||||
cutoff = datetime.now().isoformat()
|
||||
time.sleep(0.01)
|
||||
log.log("room1", "say", "new")
|
||||
history = log.get_history("room1", since=cutoff)
|
||||
self.assertEqual(len(history), 1)
|
||||
self.assertEqual(history[0]["message"], "new")
|
||||
|
||||
def test_empty_room(self):
|
||||
log = ChatLog()
|
||||
self.assertEqual(log.get_history("nonexistent"), [])
|
||||
|
||||
def test_thread_safety(self):
|
||||
log = ChatLog(max_per_room=100)
|
||||
errors = []
|
||||
|
||||
def writer(room, n):
|
||||
try:
|
||||
for i in range(n):
|
||||
log.log(room, "say", f"{room}-{i}")
|
||||
except Exception as e:
|
||||
errors.append(e)
|
||||
|
||||
threads = [threading.Thread(target=writer, args=(f"room{t}", 50)) for t in range(4)]
|
||||
for t in threads:
|
||||
t.start()
|
||||
for t in threads:
|
||||
t.join()
|
||||
|
||||
self.assertEqual(len(errors), 0)
|
||||
total = sum(len(log.get_history(f"room{t}")) for t in range(4))
|
||||
self.assertEqual(total, 200)
|
||||
|
||||
|
||||
class TestPresenceManager(unittest.TestCase):
|
||||
|
||||
def test_enter_room(self):
|
||||
pm = PresenceManager()
|
||||
event = pm.enter_room("u1", "Alice", "lobby")
|
||||
self.assertEqual(event["event"], "enter")
|
||||
self.assertEqual(event["username"], "Alice")
|
||||
players = pm.get_players_in_room("lobby")
|
||||
self.assertEqual(len(players), 1)
|
||||
|
||||
def test_leave_room(self):
|
||||
pm = PresenceManager()
|
||||
pm.enter_room("u1", "Alice", "lobby")
|
||||
event = pm.leave_room("u1", "lobby")
|
||||
self.assertEqual(event["event"], "leave")
|
||||
self.assertEqual(len(pm.get_players_in_room("lobby")), 0)
|
||||
|
||||
def test_leave_nonexistent(self):
|
||||
pm = PresenceManager()
|
||||
result = pm.leave_room("u1", "lobby")
|
||||
self.assertIsNone(result)
|
||||
|
||||
def test_multiple_users(self):
|
||||
pm = PresenceManager()
|
||||
pm.enter_room("u1", "Alice", "lobby")
|
||||
pm.enter_room("u2", "Bob", "lobby")
|
||||
players = pm.get_players_in_room("lobby")
|
||||
self.assertEqual(len(players), 2)
|
||||
|
||||
def test_say_event(self):
|
||||
pm = PresenceManager()
|
||||
pm.enter_room("u1", "Alice", "lobby")
|
||||
event = pm.say("u1", "Alice", "lobby", "hello world")
|
||||
self.assertEqual(event["type"], "say")
|
||||
self.assertEqual(event["message"], "hello world")
|
||||
events = pm.get_room_events("lobby")
|
||||
self.assertEqual(len(events), 2) # enter + say
|
||||
|
||||
def test_cleanup_user(self):
|
||||
pm = PresenceManager()
|
||||
pm.enter_room("u1", "Alice", "lobby")
|
||||
pm.enter_room("u1", "Alice", "tavern")
|
||||
events = pm.cleanup_user("u1")
|
||||
self.assertEqual(len(events), 2) # left both rooms
|
||||
self.assertEqual(len(pm.get_players_in_room("lobby")), 0)
|
||||
self.assertEqual(len(pm.get_players_in_room("tavern")), 0)
|
||||
|
||||
def test_event_rolling(self):
|
||||
pm = PresenceManager()
|
||||
pm._max_events_per_room = 3
|
||||
for i in range(5):
|
||||
pm.say("u1", "Alice", "lobby", f"msg{i}")
|
||||
events = pm.get_room_events("lobby")
|
||||
self.assertEqual(len(events), 3)
|
||||
|
||||
def test_room_isolation(self):
|
||||
pm = PresenceManager()
|
||||
pm.enter_room("u1", "Alice", "lobby")
|
||||
pm.enter_room("u2", "Bob", "tavern")
|
||||
self.assertEqual(len(pm.get_players_in_room("lobby")), 1)
|
||||
self.assertEqual(len(pm.get_players_in_room("tavern")), 1)
|
||||
|
||||
|
||||
class TestPluginRegistry(unittest.TestCase):
|
||||
|
||||
def test_register_and_get(self):
|
||||
reg = PluginRegistry()
|
||||
p = Plugin()
|
||||
p.name = "test"
|
||||
p.description = "A test plugin"
|
||||
reg.register(p)
|
||||
self.assertEqual(reg.get("test"), p)
|
||||
|
||||
def test_unregister(self):
|
||||
reg = PluginRegistry()
|
||||
p = Plugin()
|
||||
p.name = "test"
|
||||
reg.register(p)
|
||||
self.assertTrue(reg.unregister("test"))
|
||||
self.assertIsNone(reg.get("test"))
|
||||
|
||||
def test_unregister_missing(self):
|
||||
reg = PluginRegistry()
|
||||
self.assertFalse(reg.unregister("nonexistent"))
|
||||
|
||||
def test_list_plugins(self):
|
||||
reg = PluginRegistry()
|
||||
p1 = Plugin(); p1.name = "a"; p1.description = "A"
|
||||
p2 = Plugin(); p2.name = "b"; p2.description = "B"
|
||||
reg.register(p1)
|
||||
reg.register(p2)
|
||||
names = [p["name"] for p in reg.list_plugins()]
|
||||
self.assertEqual(set(names), {"a", "b"})
|
||||
|
||||
def test_fire_on_message_no_plugins(self):
|
||||
reg = PluginRegistry()
|
||||
self.assertIsNone(reg.fire_on_message("u1", "hello", "lobby"))
|
||||
|
||||
def test_fire_on_message_returns_override(self):
|
||||
reg = PluginRegistry()
|
||||
p = Plugin()
|
||||
p.name = "greeter"
|
||||
p.on_message = lambda uid, msg, room: "Welcome!"
|
||||
reg.register(p)
|
||||
result = reg.fire_on_message("u1", "hello", "lobby")
|
||||
self.assertEqual(result, "Welcome!")
|
||||
|
||||
def test_fire_on_join_collects(self):
|
||||
reg = PluginRegistry()
|
||||
p1 = Plugin(); p1.name = "a"
|
||||
p1.on_join = lambda uid, room: "Hello from A"
|
||||
p2 = Plugin(); p2.name = "b"
|
||||
p2.on_join = lambda uid, room: "Hello from B"
|
||||
reg.register(p1)
|
||||
reg.register(p2)
|
||||
result = reg.fire_on_join("u1", "lobby")
|
||||
self.assertIn("Hello from A", result)
|
||||
self.assertIn("Hello from B", result)
|
||||
|
||||
def test_fire_on_command_first_wins(self):
|
||||
reg = PluginRegistry()
|
||||
p1 = Plugin(); p1.name = "a"
|
||||
p1.on_command = lambda uid, cmd, args, room: {"result": "from A"}
|
||||
p2 = Plugin(); p2.name = "b"
|
||||
p2.on_command = lambda uid, cmd, args, room: {"result": "from B"}
|
||||
reg.register(p1)
|
||||
reg.register(p2)
|
||||
result = reg.fire_on_command("u1", "look", "", "lobby")
|
||||
self.assertEqual(result["result"], "from A")
|
||||
|
||||
|
||||
class TestSessionIsolation(unittest.TestCase):
|
||||
"""Test that session data doesn't leak between users."""
|
||||
|
||||
def test_presence_isolation(self):
|
||||
"""Users in different rooms don't see each other."""
|
||||
pm = PresenceManager()
|
||||
pm.enter_room("u1", "Alice", "room-a")
|
||||
pm.enter_room("u2", "Bob", "room-b")
|
||||
self.assertEqual(len(pm.get_players_in_room("room-a")), 1)
|
||||
self.assertEqual(len(pm.get_players_in_room("room-b")), 1)
|
||||
self.assertEqual(pm.get_players_in_room("room-a")[0]["username"], "Alice")
|
||||
self.assertEqual(pm.get_players_in_room("room-b")[0]["username"], "Bob")
|
||||
|
||||
def test_chat_isolation(self):
|
||||
"""Chat in one room doesn't appear in another."""
|
||||
log = ChatLog()
|
||||
log.log("room-a", "say", "secret", user_id="u1")
|
||||
log.log("room-b", "say", "public", user_id="u2")
|
||||
self.assertEqual(len(log.get_history("room-a")), 1)
|
||||
self.assertEqual(len(log.get_history("room-b")), 1)
|
||||
self.assertEqual(log.get_history("room-a")[0]["message"], "secret")
|
||||
self.assertEqual(log.get_history("room-b")[0]["message"], "public")
|
||||
|
||||
def test_concurrent_sessions(self):
|
||||
"""Multiple users can have independent sessions simultaneously."""
|
||||
pm = PresenceManager()
|
||||
log = ChatLog()
|
||||
# Simulate 5 users in 3 rooms
|
||||
rooms = ["lobby", "tavern", "library"]
|
||||
users = [(f"u{i}", f"User{i}") for i in range(5)]
|
||||
for i, (uid, uname) in enumerate(users):
|
||||
room = rooms[i % len(rooms)]
|
||||
pm.enter_room(uid, uname, room)
|
||||
log.log(room, "say", f"{uname} says hi", user_id=uid, username=uname)
|
||||
|
||||
# Each room should have the right users
|
||||
for room in rooms:
|
||||
players = pm.get_players_in_room(room)
|
||||
self.assertGreater(len(players), 0)
|
||||
history = log.get_history(room)
|
||||
self.assertEqual(len(history), len(players))
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user