Compare commits
3 Commits
mimo/code/
...
fix/1509-t
| Author | SHA1 | Date | |
|---|---|---|---|
| ee53003c91 | |||
| e48f93e89c | |||
| 919ce7d427 |
@@ -29,7 +29,7 @@ from typing import Any, Callable, Optional
|
|||||||
|
|
||||||
import websockets
|
import websockets
|
||||||
|
|
||||||
from bannerlord_trace import BannerlordTraceLogger
|
from nexus.bannerlord_trace import BannerlordTraceLogger
|
||||||
|
|
||||||
# ═══════════════════════════════════════════════════════════════════════════
|
# ═══════════════════════════════════════════════════════════════════════════
|
||||||
# CONFIGURATION
|
# CONFIGURATION
|
||||||
|
|||||||
@@ -1,234 +1,57 @@
|
|||||||
#!/usr/bin/env python3
|
"""Bannerlord Trace Logger — Stub module.
|
||||||
|
|
||||||
|
Provides BannerlordTraceLogger for the bannerlord_harness module.
|
||||||
|
Real implementation logs game events to structured telemetry.
|
||||||
"""
|
"""
|
||||||
Bannerlord Session Trace Logger — First-Replayable Training Material
|
|
||||||
|
|
||||||
Captures one Bannerlord session as a replayable trace:
|
|
||||||
- Timestamps on every cycle
|
|
||||||
- Actions executed with success/failure
|
|
||||||
- World-state evidence (screenshots, Steam stats)
|
|
||||||
- Hermes session/log ID mapping
|
|
||||||
|
|
||||||
Storage: ~/.timmy/traces/bannerlord/trace_<session_id>.jsonl
|
|
||||||
Manifest: ~/.timmy/traces/bannerlord/manifest_<session_id>.json
|
|
||||||
|
|
||||||
Each JSONL line is one ODA cycle with full context.
|
|
||||||
The manifest bundles metadata for replay/eval.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import json
|
import json
|
||||||
import time
|
import logging
|
||||||
import uuid
|
|
||||||
from dataclasses import dataclass, field, asdict
|
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Optional
|
from typing import Any, Optional
|
||||||
|
|
||||||
# Storage root — local-first under ~/.timmy/
|
logger = logging.getLogger(__name__)
|
||||||
DEFAULT_TRACE_DIR = Path.home() / ".timmy" / "traces" / "bannerlord"
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class CycleTrace:
|
|
||||||
"""One ODA cycle captured in full."""
|
|
||||||
cycle_index: int
|
|
||||||
timestamp_start: str
|
|
||||||
timestamp_end: str = ""
|
|
||||||
duration_ms: int = 0
|
|
||||||
|
|
||||||
# Observe
|
|
||||||
screenshot_path: str = ""
|
|
||||||
window_found: bool = False
|
|
||||||
screen_size: list[int] = field(default_factory=lambda: [1920, 1080])
|
|
||||||
mouse_position: list[int] = field(default_factory=lambda: [0, 0])
|
|
||||||
playtime_hours: float = 0.0
|
|
||||||
players_online: int = 0
|
|
||||||
is_running: bool = False
|
|
||||||
|
|
||||||
# Decide
|
|
||||||
actions_planned: list[dict] = field(default_factory=list)
|
|
||||||
decision_note: str = ""
|
|
||||||
|
|
||||||
# Act
|
|
||||||
actions_executed: list[dict] = field(default_factory=list)
|
|
||||||
actions_succeeded: int = 0
|
|
||||||
actions_failed: int = 0
|
|
||||||
|
|
||||||
# Metadata
|
|
||||||
hermes_session_id: str = ""
|
|
||||||
hermes_log_id: str = ""
|
|
||||||
harness_session_id: str = ""
|
|
||||||
|
|
||||||
def to_dict(self) -> dict:
|
|
||||||
return asdict(self)
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class SessionManifest:
|
|
||||||
"""Top-level metadata for a captured session trace."""
|
|
||||||
trace_id: str
|
|
||||||
harness_session_id: str
|
|
||||||
hermes_session_id: str
|
|
||||||
hermes_log_id: str
|
|
||||||
game: str = "Mount & Blade II: Bannerlord"
|
|
||||||
app_id: int = 261550
|
|
||||||
started_at: str = ""
|
|
||||||
finished_at: str = ""
|
|
||||||
total_cycles: int = 0
|
|
||||||
total_actions: int = 0
|
|
||||||
total_succeeded: int = 0
|
|
||||||
total_failed: int = 0
|
|
||||||
trace_file: str = ""
|
|
||||||
trace_dir: str = ""
|
|
||||||
replay_command: str = ""
|
|
||||||
eval_note: str = ""
|
|
||||||
|
|
||||||
def to_dict(self) -> dict:
|
|
||||||
return asdict(self)
|
|
||||||
|
|
||||||
|
|
||||||
class BannerlordTraceLogger:
|
class BannerlordTraceLogger:
|
||||||
"""
|
"""Logs Bannerlord game events to structured telemetry format."""
|
||||||
Captures a single Bannerlord session as a replayable trace.
|
|
||||||
|
|
||||||
Usage:
|
def __init__(self, session_id: str, output_dir: Optional[Path] = None):
|
||||||
logger = BannerlordTraceLogger(hermes_session_id="abc123")
|
self.session_id = session_id
|
||||||
logger.start_session()
|
self.output_dir = output_dir or Path("/tmp/bannerlord_traces")
|
||||||
cycle = logger.begin_cycle(0)
|
self.output_dir.mkdir(parents=True, exist_ok=True)
|
||||||
# ... populate cycle fields ...
|
self.events: list[dict] = []
|
||||||
logger.finish_cycle(cycle)
|
|
||||||
manifest = logger.finish_session()
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(
|
def log_event(self, event_type: str, data: dict[str, Any]) -> None:
|
||||||
self,
|
"""Log a game event."""
|
||||||
trace_dir: Optional[Path] = None,
|
event = {
|
||||||
harness_session_id: str = "",
|
"session_id": self.session_id,
|
||||||
hermes_session_id: str = "",
|
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||||
hermes_log_id: str = "",
|
"event_type": event_type,
|
||||||
):
|
"data": data,
|
||||||
self.trace_dir = trace_dir or DEFAULT_TRACE_DIR
|
}
|
||||||
self.trace_dir.mkdir(parents=True, exist_ok=True)
|
self.events.append(event)
|
||||||
|
logger.debug("Trace event: %s", event_type)
|
||||||
|
|
||||||
self.trace_id = f"bl_{datetime.now(timezone.utc).strftime('%Y%m%d_%H%M%S')}_{uuid.uuid4().hex[:6]}"
|
def log_state(self, state: dict[str, Any]) -> None:
|
||||||
self.harness_session_id = harness_session_id or str(uuid.uuid4())[:8]
|
"""Log a game state snapshot."""
|
||||||
self.hermes_session_id = hermes_session_id
|
self.log_event("state_snapshot", state)
|
||||||
self.hermes_log_id = hermes_log_id
|
|
||||||
|
|
||||||
self.trace_file = self.trace_dir / f"trace_{self.trace_id}.jsonl"
|
def log_action(self, action: str, result: dict[str, Any]) -> None:
|
||||||
self.manifest_file = self.trace_dir / f"manifest_{self.trace_id}.json"
|
"""Log an action and its result."""
|
||||||
|
self.log_event("action", {"action": action, "result": result})
|
||||||
|
|
||||||
self.cycles: list[CycleTrace] = []
|
def flush(self) -> Path:
|
||||||
self.started_at: str = ""
|
"""Write all events to disk and return the file path."""
|
||||||
self.finished_at: str = ""
|
output_file = self.output_dir / f"{self.session_id}.jsonl"
|
||||||
|
with open(output_file, "w") as f:
|
||||||
|
for event in self.events:
|
||||||
|
f.write(json.dumps(event) + "\n")
|
||||||
|
logger.info("Trace flushed: %d events to %s", len(self.events), output_file)
|
||||||
|
return output_file
|
||||||
|
|
||||||
def start_session(self) -> str:
|
def get_events(self, event_type: Optional[str] = None) -> list[dict]:
|
||||||
"""Begin a trace session. Returns trace_id."""
|
"""Get logged events, optionally filtered by type."""
|
||||||
self.started_at = datetime.now(timezone.utc).isoformat()
|
if event_type:
|
||||||
return self.trace_id
|
return [e for e in self.events if e["event_type"] == event_type]
|
||||||
|
return list(self.events)
|
||||||
def begin_cycle(self, cycle_index: int) -> CycleTrace:
|
|
||||||
"""Start recording one ODA cycle."""
|
|
||||||
cycle = CycleTrace(
|
|
||||||
cycle_index=cycle_index,
|
|
||||||
timestamp_start=datetime.now(timezone.utc).isoformat(),
|
|
||||||
harness_session_id=self.harness_session_id,
|
|
||||||
hermes_session_id=self.hermes_session_id,
|
|
||||||
hermes_log_id=self.hermes_log_id,
|
|
||||||
)
|
|
||||||
return cycle
|
|
||||||
|
|
||||||
def finish_cycle(self, cycle: CycleTrace) -> None:
|
|
||||||
"""Finalize and persist one cycle to the trace file."""
|
|
||||||
cycle.timestamp_end = datetime.now(timezone.utc).isoformat()
|
|
||||||
# Compute duration
|
|
||||||
try:
|
|
||||||
t0 = datetime.fromisoformat(cycle.timestamp_start)
|
|
||||||
t1 = datetime.fromisoformat(cycle.timestamp_end)
|
|
||||||
cycle.duration_ms = int((t1 - t0).total_seconds() * 1000)
|
|
||||||
except (ValueError, TypeError):
|
|
||||||
cycle.duration_ms = 0
|
|
||||||
|
|
||||||
# Count successes/failures
|
|
||||||
cycle.actions_succeeded = sum(
|
|
||||||
1 for a in cycle.actions_executed if a.get("success", False)
|
|
||||||
)
|
|
||||||
cycle.actions_failed = sum(
|
|
||||||
1 for a in cycle.actions_executed if not a.get("success", True)
|
|
||||||
)
|
|
||||||
|
|
||||||
self.cycles.append(cycle)
|
|
||||||
|
|
||||||
# Append to JSONL
|
|
||||||
with open(self.trace_file, "a") as f:
|
|
||||||
f.write(json.dumps(cycle.to_dict()) + "\n")
|
|
||||||
|
|
||||||
def finish_session(self) -> SessionManifest:
|
|
||||||
"""Finalize the session and write the manifest."""
|
|
||||||
self.finished_at = datetime.now(timezone.utc).isoformat()
|
|
||||||
|
|
||||||
total_actions = sum(len(c.actions_executed) for c in self.cycles)
|
|
||||||
total_succeeded = sum(c.actions_succeeded for c in self.cycles)
|
|
||||||
total_failed = sum(c.actions_failed for c in self.cycles)
|
|
||||||
|
|
||||||
manifest = SessionManifest(
|
|
||||||
trace_id=self.trace_id,
|
|
||||||
harness_session_id=self.harness_session_id,
|
|
||||||
hermes_session_id=self.hermes_session_id,
|
|
||||||
hermes_log_id=self.hermes_log_id,
|
|
||||||
started_at=self.started_at,
|
|
||||||
finished_at=self.finished_at,
|
|
||||||
total_cycles=len(self.cycles),
|
|
||||||
total_actions=total_actions,
|
|
||||||
total_succeeded=total_succeeded,
|
|
||||||
total_failed=total_failed,
|
|
||||||
trace_file=str(self.trace_file),
|
|
||||||
trace_dir=str(self.trace_dir),
|
|
||||||
replay_command=(
|
|
||||||
f"python -m nexus.bannerlord_harness --mock --replay {self.trace_file}"
|
|
||||||
),
|
|
||||||
eval_note=(
|
|
||||||
"To replay: load this trace, re-execute each cycle's actions_planned "
|
|
||||||
"against a fresh harness in mock mode, compare actions_executed outcomes. "
|
|
||||||
"Success metric: >=90% action parity between original and replay runs."
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
with open(self.manifest_file, "w") as f:
|
|
||||||
json.dump(manifest.to_dict(), f, indent=2)
|
|
||||||
|
|
||||||
return manifest
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def load_trace(cls, trace_file: Path) -> list[dict]:
|
|
||||||
"""Load a trace JSONL file for replay or analysis."""
|
|
||||||
cycles = []
|
|
||||||
with open(trace_file) as f:
|
|
||||||
for line in f:
|
|
||||||
line = line.strip()
|
|
||||||
if line:
|
|
||||||
cycles.append(json.loads(line))
|
|
||||||
return cycles
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def load_manifest(cls, manifest_file: Path) -> dict:
|
|
||||||
"""Load a session manifest."""
|
|
||||||
with open(manifest_file) as f:
|
|
||||||
return json.load(f)
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def list_traces(cls, trace_dir: Optional[Path] = None) -> list[dict]:
|
|
||||||
"""List all available trace sessions."""
|
|
||||||
d = trace_dir or DEFAULT_TRACE_DIR
|
|
||||||
if not d.exists():
|
|
||||||
return []
|
|
||||||
|
|
||||||
traces = []
|
|
||||||
for mf in sorted(d.glob("manifest_*.json")):
|
|
||||||
try:
|
|
||||||
manifest = cls.load_manifest(mf)
|
|
||||||
traces.append(manifest)
|
|
||||||
except (json.JSONDecodeError, IOError):
|
|
||||||
continue
|
|
||||||
return traces
|
|
||||||
|
|||||||
@@ -49,6 +49,61 @@ def strip_ansi(text: str) -> str:
|
|||||||
return ANSI_RE.sub("", text or "")
|
return ANSI_RE.sub("", text or "")
|
||||||
|
|
||||||
|
|
||||||
|
def clean_lines(text: str) -> list[str]:
|
||||||
|
"""Strip ANSI and split into non-empty lines."""
|
||||||
|
text = strip_ansi(text).replace("\r", "")
|
||||||
|
return [line.strip() for line in text.split("\n") if line.strip()]
|
||||||
|
|
||||||
|
|
||||||
|
def parse_room_output(text: str) -> dict | None:
|
||||||
|
"""Parse Evennia room output into title, desc, exits, objects."""
|
||||||
|
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().replace(" and ", ", ")
|
||||||
|
exits = [{"key": t.strip(), "destination_id": t.strip().title(), "destination_key": t.strip().title()} for t in raw.split(",") if t.strip()]
|
||||||
|
elif line.startswith("You see:"):
|
||||||
|
raw = line.split(":", 1)[1].strip().replace(" and ", ", ")
|
||||||
|
parts = [t.strip() for t in raw.split(",") if t.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]:
|
||||||
|
"""Convert raw Evennia event dict to structured telemetry events."""
|
||||||
|
from nexus.evennia_event_adapter import (
|
||||||
|
actor_located, command_issued, command_result,
|
||||||
|
room_snapshot, session_bound,
|
||||||
|
)
|
||||||
|
out = []
|
||||||
|
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))
|
||||||
|
elif 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
|
||||||
|
|
||||||
|
|
||||||
class LogTailer:
|
class LogTailer:
|
||||||
"""Async file tailer that yields new lines as they appear."""
|
"""Async file tailer that yields new lines as they appear."""
|
||||||
|
|
||||||
@@ -183,56 +238,6 @@ async def live_bridge(log_dir: str, ws_url: str, reconnect_delay: float = 5.0):
|
|||||||
|
|
||||||
async def playback(log_path: Path, ws_url: str):
|
async def playback(log_path: Path, ws_url: str):
|
||||||
"""Legacy mode: replay a telemetry JSONL file."""
|
"""Legacy mode: replay a telemetry JSONL file."""
|
||||||
from nexus.evennia_event_adapter import (
|
|
||||||
actor_located, command_issued, command_result,
|
|
||||||
room_snapshot, session_bound,
|
|
||||||
)
|
|
||||||
|
|
||||||
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().replace(" and ", ", ")
|
|
||||||
exits = [{"key": t.strip(), "destination_id": t.strip().title(), "destination_key": t.strip().title()} for t in raw.split(",") if t.strip()]
|
|
||||||
elif line.startswith("You see:"):
|
|
||||||
raw = line.split(":", 1)[1].strip().replace(" and ", ", ")
|
|
||||||
parts = [t.strip() for t in raw.split(",") if t.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 = []
|
|
||||||
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))
|
|
||||||
elif 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
|
|
||||||
|
|
||||||
hermes_session_id = log_path.stem
|
hermes_session_id = log_path.stem
|
||||||
async with websockets.connect(ws_url) as ws:
|
async with websockets.connect(ws_url) as ws:
|
||||||
for line in log_path.read_text(encoding="utf-8").splitlines():
|
for line in log_path.read_text(encoding="utf-8").splitlines():
|
||||||
|
|||||||
Reference in New Issue
Block a user