diff --git a/EVENNIA_NEXUS_EVENT_PROTOCOL.md b/EVENNIA_NEXUS_EVENT_PROTOCOL.md new file mode 100644 index 0000000..e67ad47 --- /dev/null +++ b/EVENNIA_NEXUS_EVENT_PROTOCOL.md @@ -0,0 +1,107 @@ +# Evennia → Nexus Event Protocol + +This is the thin semantic adapter between Timmy's persistent Evennia world and +Timmy's Nexus-facing world model. + +Principle: +- Evennia owns persistent world truth. +- Nexus owns visualization and operator legibility. +- The adapter owns only translation, not storage or game logic. + +## Canonical event families + +### 1. `evennia.session_bound` +Binds a Hermes session to a world interaction run. + +```json +{ + "type": "evennia.session_bound", + "hermes_session_id": "20260328_132016_7ea250", + "evennia_account": "Timmy", + "evennia_character": "Timmy", + "timestamp": "2026-03-28T20:00:00Z" +} +``` + +### 2. `evennia.actor_located` +Declares where Timmy currently is. + +```json +{ + "type": "evennia.actor_located", + "actor_id": "Timmy", + "room_id": "Gate", + "room_key": "Gate", + "room_name": "Gate", + "timestamp": "2026-03-28T20:00:01Z" +} +``` + +### 3. `evennia.room_snapshot` +The main room-state payload Nexus should render. + +```json +{ + "type": "evennia.room_snapshot", + "room_id": "Chapel", + "room_key": "Chapel", + "title": "Chapel", + "desc": "A quiet room set apart for prayer, conscience, grief, and right alignment.", + "exits": [ + {"key": "courtyard", "destination_id": "Courtyard", "destination_key": "Courtyard"} + ], + "objects": [ + {"id": "Book of the Soul", "key": "Book of the Soul", "short_desc": "A doctrinal anchor."}, + {"id": "Prayer Wall", "key": "Prayer Wall", "short_desc": "A place for names and remembered burdens."} + ], + "occupants": [], + "timestamp": "2026-03-28T20:00:02Z" +} +``` + +### 4. `evennia.command_issued` +Records what Timmy attempted. + +```json +{ + "type": "evennia.command_issued", + "hermes_session_id": "20260328_132016_7ea250", + "actor_id": "Timmy", + "command_text": "look Book of the Soul", + "timestamp": "2026-03-28T20:00:03Z" +} +``` + +### 5. `evennia.command_result` +Records what the world returned. + +```json +{ + "type": "evennia.command_result", + "hermes_session_id": "20260328_132016_7ea250", + "actor_id": "Timmy", + "command_text": "look Book of the Soul", + "output_text": "Book of the Soul. A doctrinal anchor. It is not decorative; it is a reference point.", + "success": true, + "timestamp": "2026-03-28T20:00:04Z" +} +``` + +## What Nexus should care about + +For first renderability, Nexus only needs: +- current room title/description +- exits +- visible objects +- actor location +- latest command/result + +It does *not* need raw telnet noise or internal Evennia database structure. + +## Ownership boundary + +Do not build a second world model in Nexus. +Do not make Nexus authoritative over persistent state. +Do not make Evennia care about Three.js internals. + +Own only this translation layer. diff --git a/nexus/__init__.py b/nexus/__init__.py index 0e4da6b..595218b 100644 --- a/nexus/__init__.py +++ b/nexus/__init__.py @@ -14,7 +14,11 @@ from nexus.perception_adapter import ( ) from nexus.experience_store import ExperienceStore from nexus.trajectory_logger import TrajectoryLogger -from nexus.nexus_think import NexusMind + +try: + from nexus.nexus_think import NexusMind +except Exception: + NexusMind = None __all__ = [ "ws_to_perception", diff --git a/nexus/evennia_event_adapter.py b/nexus/evennia_event_adapter.py new file mode 100644 index 0000000..e7dffd9 --- /dev/null +++ b/nexus/evennia_event_adapter.py @@ -0,0 +1,66 @@ +"""Thin Evennia -> Nexus event normalization helpers.""" + +from __future__ import annotations + +from datetime import datetime, timezone + + +def _ts(value: str | None = None) -> str: + return value or datetime.now(timezone.utc).isoformat() + + +def session_bound(hermes_session_id: str, evennia_account: str = "Timmy", evennia_character: str = "Timmy", timestamp: str | None = None) -> dict: + return { + "type": "evennia.session_bound", + "hermes_session_id": hermes_session_id, + "evennia_account": evennia_account, + "evennia_character": evennia_character, + "timestamp": _ts(timestamp), + } + + +def actor_located(actor_id: str, room_key: str, room_name: str | None = None, timestamp: str | None = None) -> dict: + return { + "type": "evennia.actor_located", + "actor_id": actor_id, + "room_id": room_key, + "room_key": room_key, + "room_name": room_name or room_key, + "timestamp": _ts(timestamp), + } + + +def room_snapshot(room_key: str, title: str, desc: str, exits: list[dict] | None = None, objects: list[dict] | None = None, occupants: list[dict] | None = None, timestamp: str | None = None) -> dict: + return { + "type": "evennia.room_snapshot", + "room_id": room_key, + "room_key": room_key, + "title": title, + "desc": desc, + "exits": exits or [], + "objects": objects or [], + "occupants": occupants or [], + "timestamp": _ts(timestamp), + } + + +def command_issued(hermes_session_id: str, actor_id: str, command_text: str, timestamp: str | None = None) -> dict: + return { + "type": "evennia.command_issued", + "hermes_session_id": hermes_session_id, + "actor_id": actor_id, + "command_text": command_text, + "timestamp": _ts(timestamp), + } + + +def command_result(hermes_session_id: str, actor_id: str, command_text: str, output_text: str, success: bool = True, timestamp: str | None = None) -> dict: + return { + "type": "evennia.command_result", + "hermes_session_id": hermes_session_id, + "actor_id": actor_id, + "command_text": command_text, + "output_text": output_text, + "success": success, + "timestamp": _ts(timestamp), + } diff --git a/nexus/perception_adapter.py b/nexus/perception_adapter.py index 37a1e49..998a98f 100644 --- a/nexus/perception_adapter.py +++ b/nexus/perception_adapter.py @@ -199,6 +199,56 @@ def perceive_action_result(data: dict) -> Optional[Perception]: ) +def perceive_evennia_actor_located(data: dict) -> Optional[Perception]: + actor = data.get("actor_id", "Timmy") + room = data.get("room_name") or data.get("room_key") or data.get("room_id") + if not room: + return None + return Perception( + timestamp=time.time(), + raw_type="evennia.actor_located", + description=f"{actor} is now in {room}.", + salience=0.7, + ) + + +def perceive_evennia_room_snapshot(data: dict) -> Optional[Perception]: + title = data.get("title") or data.get("room_key") or data.get("room_id") + desc = data.get("desc", "") + exits = ", ".join(exit.get("key", "") for exit in data.get("exits", []) if exit.get("key")) + objects = ", ".join(obj.get("key", "") for obj in data.get("objects", []) if obj.get("key")) + if not title: + return None + parts = [f"You are in {title}."] + if desc: + parts.append(desc) + if exits: + parts.append(f"Exits: {exits}.") + if objects: + parts.append(f"You see: {objects}.") + return Perception( + timestamp=time.time(), + raw_type="evennia.room_snapshot", + description=" ".join(parts), + salience=0.85, + ) + + +def perceive_evennia_command_result(data: dict) -> Optional[Perception]: + success = data.get("success", True) + command = data.get("command_text", "your command") + output = data.get("output_text", "") + desc = f"Your world command {'succeeded' if success else 'failed'}: {command}." + if output: + desc += f" {output[:240]}" + return Perception( + timestamp=time.time(), + raw_type="evennia.command_result", + description=desc, + salience=0.8, + ) + + # Registry of WS type → perception function PERCEPTION_MAP = { "agent_state": perceive_agent_state, @@ -212,6 +262,9 @@ PERCEPTION_MAP = { "action_result": perceive_action_result, "heartbeat": lambda _: None, # Ignore "dual_brain": lambda _: None, # Internal — not part of sensorium + "evennia.actor_located": perceive_evennia_actor_located, + "evennia.room_snapshot": perceive_evennia_room_snapshot, + "evennia.command_result": perceive_evennia_command_result, } diff --git a/tests/test_evennia_event_adapter.py b/tests/test_evennia_event_adapter.py new file mode 100644 index 0000000..6951e72 --- /dev/null +++ b/tests/test_evennia_event_adapter.py @@ -0,0 +1,56 @@ +from nexus.evennia_event_adapter import actor_located, command_issued, command_result, room_snapshot, session_bound +from nexus.perception_adapter import ws_to_perception + + +def test_session_bound_schema(): + event = session_bound("sess-1") + assert event["type"] == "evennia.session_bound" + assert event["hermes_session_id"] == "sess-1" + assert event["evennia_account"] == "Timmy" + + +def test_room_snapshot_schema(): + event = room_snapshot( + room_key="Chapel", + title="Chapel", + desc="Quiet room.", + exits=[{"key": "courtyard", "destination_id": "Courtyard", "destination_key": "Courtyard"}], + objects=[{"id": "Book of the Soul", "key": "Book of the Soul", "short_desc": "A doctrinal anchor."}], + ) + assert event["type"] == "evennia.room_snapshot" + assert event["title"] == "Chapel" + assert event["objects"][0]["key"] == "Book of the Soul" + + +def test_evennia_room_snapshot_becomes_perception(): + perception = ws_to_perception( + room_snapshot( + room_key="Workshop", + title="Workshop", + desc="Tools everywhere.", + exits=[{"key": "courtyard", "destination_id": "Courtyard", "destination_key": "Courtyard"}], + objects=[{"id": "Workbench", "key": "Workbench", "short_desc": "A broad workbench."}], + ) + ) + assert perception is not None + assert "Workshop" in perception.description + assert "Workbench" in perception.description + + +def test_evennia_command_result_becomes_perception(): + perception = ws_to_perception(command_result("sess-2", "Timmy", "look Book of the Soul", "Book of the Soul. A doctrinal anchor.", True)) + assert perception is not None + assert "succeeded" in perception.description.lower() + assert "Book of the Soul" in perception.description + + +def test_evennia_actor_located_becomes_perception(): + perception = ws_to_perception(actor_located("Timmy", "Gate")) + assert perception is not None + assert "Gate" in perception.description + + +def test_evennia_command_issued_schema(): + event = command_issued("sess-3", "Timmy", "chapel") + assert event["type"] == "evennia.command_issued" + assert event["command_text"] == "chapel"