From 1fe5176ebc048d8e3b454c930750689044d9adef Mon Sep 17 00:00:00 2001 From: Alexander Whitestone Date: Sat, 28 Mar 2026 16:25:18 -0400 Subject: [PATCH] feat: feed Evennia world events into Nexus websocket bridge --- FIRST_LIGHT_REPORT_EVENNIA_BRIDGE.md | 49 ++++++++++++++ nexus/evennia_ws_bridge.py | 99 ++++++++++++++++++++++++++++ tests/test_evennia_ws_bridge.py | 36 ++++++++++ 3 files changed, 184 insertions(+) create mode 100644 FIRST_LIGHT_REPORT_EVENNIA_BRIDGE.md create mode 100644 nexus/evennia_ws_bridge.py create mode 100644 tests/test_evennia_ws_bridge.py diff --git a/FIRST_LIGHT_REPORT_EVENNIA_BRIDGE.md b/FIRST_LIGHT_REPORT_EVENNIA_BRIDGE.md new file mode 100644 index 0000000..0cf4ff1 --- /dev/null +++ b/FIRST_LIGHT_REPORT_EVENNIA_BRIDGE.md @@ -0,0 +1,49 @@ +# First Light Report — Evennia to Nexus Bridge + +Issue: +- #727 Feed Evennia room/command events into the Nexus websocket bridge + +What was implemented: +- `nexus/evennia_ws_bridge.py` — reads Evennia telemetry JSONL and publishes normalized Evennia→Nexus events into the local websocket bridge +- `EVENNIA_NEXUS_EVENT_PROTOCOL.md` — canonical event family contract +- `nexus/evennia_event_adapter.py` — normalization helpers (already merged in #725) +- `nexus/perception_adapter.py` support for `evennia.actor_located`, `evennia.room_snapshot`, and `evennia.command_result` +- tests locking the bridge parsing and event contract + +Proof method: +1. Start local Nexus websocket bridge on `ws://127.0.0.1:8765` +2. Open a websocket listener +3. Replay a real committed Evennia example trace from `timmy-home` +4. Confirm normalized events are received over the websocket + +Observed received messages (excerpt): +```json +[ + { + "type": "evennia.session_bound", + "hermes_session_id": "world-basics-trace.example", + "evennia_account": "Timmy", + "evennia_character": "Timmy" + }, + { + "type": "evennia.command_issued", + "actor_id": "timmy", + "command_text": "look" + }, + { + "type": "evennia.command_result", + "actor_id": "timmy", + "command_text": "look", + "output_text": "Chapel A quiet room set apart for prayer, conscience, grief, and right alignment...", + "success": true + } +] +``` + +Interpretation: +- Evennia world telemetry can now be published into the Nexus websocket bridge without inventing a second world model. +- The bridge is thin: it translates and forwards. +- Nexus-side perception code can now consume these events as part of Timmy's sensorium. + +Why this matters: +This is the first live seam where Timmy's persistent Evennia place can begin to appear inside the Nexus-facing world model. diff --git a/nexus/evennia_ws_bridge.py b/nexus/evennia_ws_bridge.py new file mode 100644 index 0000000..a86140b --- /dev/null +++ b/nexus/evennia_ws_bridge.py @@ -0,0 +1,99 @@ +#!/usr/bin/env python3 +"""Publish Evennia telemetry logs into the Nexus websocket bridge.""" + +from __future__ import annotations + +import argparse +import asyncio +import json +import re +from pathlib import Path +from typing import Iterable + +import websockets + +from nexus.evennia_event_adapter import actor_located, command_issued, command_result, room_snapshot, session_bound + +ANSI_RE = re.compile(r"\x1b\[[0-9;]*[A-Za-z]") + + +def strip_ansi(text: str) -> str: + return ANSI_RE.sub("", text or "") + + +def clean_lines(text: str) -> list[str]: + text = strip_ansi(text).replace("\r", "") + return [line.strip() for line in text.split("\n") if line.strip()] + + +def parse_room_output(text: str): + lines = clean_lines(text) + if len(lines) < 2: + return None + title = lines[0] + desc = lines[1] + exits = [] + objects = [] + for line in lines[2:]: + if line.startswith("Exits:"): + raw = line.split(":", 1)[1].strip() + raw = raw.replace(" and ", ", ") + exits = [{"key": token.strip(), "destination_id": token.strip().title(), "destination_key": token.strip().title()} for token in raw.split(",") if token.strip()] + elif line.startswith("You see:"): + raw = line.split(":", 1)[1].strip() + raw = raw.replace(" and ", ", ") + parts = [token.strip() for token in raw.split(",") if token.strip()] + objects = [{"id": p.removeprefix('a ').removeprefix('an '), "key": p.removeprefix('a ').removeprefix('an '), "short_desc": p} for p in parts] + return {"title": title, "desc": desc, "exits": exits, "objects": objects} + + +def normalize_event(raw: dict, hermes_session_id: str) -> list[dict]: + out: list[dict] = [] + event = raw.get("event") + actor = raw.get("actor", "Timmy") + timestamp = raw.get("timestamp") + + if event == "connect": + out.append(session_bound(hermes_session_id, evennia_account=actor, evennia_character=actor, timestamp=timestamp)) + parsed = parse_room_output(raw.get("output", "")) + if parsed: + out.append(actor_located(actor, parsed["title"], parsed["title"], timestamp=timestamp)) + out.append(room_snapshot(parsed["title"], parsed["title"], parsed["desc"], exits=parsed["exits"], objects=parsed["objects"], timestamp=timestamp)) + return out + + if event == "command": + cmd = raw.get("command", "") + output = raw.get("output", "") + out.append(command_issued(hermes_session_id, actor, cmd, timestamp=timestamp)) + success = not output.startswith("Command '") and not output.startswith("Could not find") + out.append(command_result(hermes_session_id, actor, cmd, strip_ansi(output), success=success, timestamp=timestamp)) + parsed = parse_room_output(output) + if parsed: + out.append(actor_located(actor, parsed["title"], parsed["title"], timestamp=timestamp)) + out.append(room_snapshot(parsed["title"], parsed["title"], parsed["desc"], exits=parsed["exits"], objects=parsed["objects"], timestamp=timestamp)) + return out + + return out + + +async def playback(log_path: Path, ws_url: str): + hermes_session_id = log_path.stem + async with websockets.connect(ws_url) as ws: + for line in log_path.read_text(encoding="utf-8").splitlines(): + if not line.strip(): + continue + raw = json.loads(line) + for event in normalize_event(raw, hermes_session_id): + await ws.send(json.dumps(event)) + + +def main(): + parser = argparse.ArgumentParser(description="Publish Evennia telemetry into the Nexus websocket bridge") + parser.add_argument("log_path", help="Path to Evennia telemetry JSONL") + parser.add_argument("--ws", default="ws://127.0.0.1:8765", help="Nexus websocket bridge URL") + args = parser.parse_args() + asyncio.run(playback(Path(args.log_path).expanduser(), args.ws)) + + +if __name__ == "__main__": + main() diff --git a/tests/test_evennia_ws_bridge.py b/tests/test_evennia_ws_bridge.py new file mode 100644 index 0000000..fc8e780 --- /dev/null +++ b/tests/test_evennia_ws_bridge.py @@ -0,0 +1,36 @@ +from nexus.evennia_ws_bridge import clean_lines, normalize_event, parse_room_output, strip_ansi + + +def test_strip_ansi_removes_escape_codes(): + assert strip_ansi('\x1b[1mGate\x1b[0m') == 'Gate' + + +def test_parse_room_output_extracts_room_exits_and_objects(): + parsed = parse_room_output('\x1b[1mChapel\x1b[0m\nQuiet room.\nExits: courtyard\nYou see: a Book of the Soul and a Prayer Wall') + assert parsed['title'] == 'Chapel' + assert parsed['exits'][0]['key'] == 'courtyard' + keys = [obj['key'] for obj in parsed['objects']] + assert 'Book of the Soul' in keys + assert 'Prayer Wall' in keys + + +def test_normalize_connect_emits_session_and_room_events(): + events = normalize_event({'event': 'connect', 'actor': 'Timmy', 'output': 'Gate\nA threshold.\nExits: enter'}, 'sess1') + types = [event['type'] for event in events] + assert 'evennia.session_bound' in types + assert 'evennia.actor_located' in types + assert 'evennia.room_snapshot' in types + + +def test_normalize_command_emits_command_and_snapshot(): + events = normalize_event({'event': 'command', 'actor': 'timmy', 'command': 'courtyard', 'output': 'Courtyard\nOpen court.\nExits: gate, workshop\nYou see: a Map Table'}, 'sess2') + types = [event['type'] for event in events] + assert types[0] == 'evennia.command_issued' + assert 'evennia.command_result' in types + assert 'evennia.room_snapshot' in types + + +def test_normalize_failed_command_marks_failure(): + events = normalize_event({'event': 'command', 'actor': 'timmy', 'command': 'workshop', 'output': "Command 'workshop' is not available."}, 'sess3') + result = [event for event in events if event['type'] == 'evennia.command_result'][0] + assert result['success'] is False