forked from Rockachopa/Timmy-time-dashboard
This commit is contained in:
@@ -374,6 +374,21 @@ class Settings(BaseSettings):
|
||||
error_feedback_enabled: bool = True # Auto-create bug report tasks
|
||||
error_dedup_window_seconds: int = 300 # 5-min dedup window
|
||||
|
||||
# ── Bannerlord / GABS ────────────────────────────────────────────
|
||||
# GABS (Game Action Bridge Server) TCP JSON-RPC endpoint.
|
||||
# The GABS mod runs inside the Windows VM and exposes a JSON-RPC server
|
||||
# on port 4825 that Timmy uses to read and act on Bannerlord game state.
|
||||
# Set GABS_HOST to the VM's LAN IP (e.g. "10.0.0.50") to enable.
|
||||
gabs_enabled: bool = False
|
||||
gabs_host: str = "127.0.0.1"
|
||||
gabs_port: int = 4825
|
||||
gabs_timeout: float = 5.0 # socket timeout in seconds
|
||||
# How often (seconds) the observer polls GABS for fresh game state.
|
||||
gabs_poll_interval: int = 60
|
||||
# Path to the Bannerlord journal inside the memory vault.
|
||||
# Relative to repo root. Written by the GABS observer loop.
|
||||
gabs_journal_path: str = "memory/bannerlord/journal.md"
|
||||
|
||||
# ── Scripture / Biblical Integration ──────────────────────────────
|
||||
# Enable the biblical text module.
|
||||
scripture_enabled: bool = True
|
||||
|
||||
9
src/integrations/bannerlord/__init__.py
Normal file
9
src/integrations/bannerlord/__init__.py
Normal file
@@ -0,0 +1,9 @@
|
||||
"""Bannerlord — GABS TCP bridge for Mount & Blade II: Bannerlord.
|
||||
|
||||
Provides:
|
||||
- GabsClient: low-level JSON-RPC 2.0 TCP client (port 4825)
|
||||
- BannerlordObserver: observe() loop that polls game state and journals to SOUL.md
|
||||
|
||||
Epic: #1091 (Project Bannerlord)
|
||||
M1: #1093 (Passive Lord — Observer Mode via GABS)
|
||||
"""
|
||||
148
src/integrations/bannerlord/gabs_client.py
Normal file
148
src/integrations/bannerlord/gabs_client.py
Normal file
@@ -0,0 +1,148 @@
|
||||
"""GABS TCP JSON-RPC 2.0 client.
|
||||
|
||||
Low-level transport layer for communicating with the Bannerlord.GABS mod.
|
||||
GABS runs inside the Windows VM and listens on port 4825. Messages are
|
||||
newline-delimited JSON-RPC 2.0.
|
||||
|
||||
Wire format::
|
||||
|
||||
-> {"jsonrpc":"2.0","method":"core/get_game_state","id":1}\\n
|
||||
<- {"jsonrpc":"2.0","result":{...},"id":1}\\n
|
||||
|
||||
All public methods raise :class:`GabsError` on failure so callers can
|
||||
degrade gracefully without inspecting raw socket errors.
|
||||
|
||||
Refs: #1093 (M1 Observer), #1091 (Epic)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import socket
|
||||
from typing import Any
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_DEFAULT_HOST = "127.0.0.1"
|
||||
_DEFAULT_PORT = 4825
|
||||
_DEFAULT_TIMEOUT = 5.0
|
||||
_RECV_BUFSIZE = 4096
|
||||
|
||||
|
||||
class GabsError(Exception):
|
||||
"""Raised when a GABS call fails (connection, protocol, or RPC error)."""
|
||||
|
||||
|
||||
class GabsClient:
|
||||
"""Synchronous TCP JSON-RPC 2.0 client for Bannerlord.GABS.
|
||||
|
||||
Each public call opens a fresh TCP connection, sends the request, reads
|
||||
the response, and closes the socket. This avoids persistent-connection
|
||||
complexity and is fast enough for poll intervals of ≥1 s.
|
||||
|
||||
Args:
|
||||
host: VM IP or hostname (default ``127.0.0.1``).
|
||||
port: GABS TCP port (default ``4825``).
|
||||
timeout: Socket timeout in seconds (default ``5.0``).
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
host: str = _DEFAULT_HOST,
|
||||
port: int = _DEFAULT_PORT,
|
||||
timeout: float = _DEFAULT_TIMEOUT,
|
||||
) -> None:
|
||||
self.host = host
|
||||
self.port = port
|
||||
self.timeout = timeout
|
||||
self._req_id = 0
|
||||
|
||||
# ── Public API ──────────────────────────────────────────────────────────
|
||||
|
||||
def call(self, method: str, params: dict[str, Any] | None = None) -> Any:
|
||||
"""Send a JSON-RPC request and return the ``result`` value.
|
||||
|
||||
Args:
|
||||
method: RPC method name (e.g. ``"core/get_game_state"``).
|
||||
params: Optional parameters dict.
|
||||
|
||||
Returns:
|
||||
The ``result`` field from the JSON-RPC response.
|
||||
|
||||
Raises:
|
||||
GabsError: On any connection, protocol, or application-level error.
|
||||
"""
|
||||
self._req_id += 1
|
||||
payload: dict[str, Any] = {
|
||||
"jsonrpc": "2.0",
|
||||
"method": method,
|
||||
"id": self._req_id,
|
||||
}
|
||||
if params:
|
||||
payload["params"] = params
|
||||
|
||||
try:
|
||||
sock = socket.create_connection((self.host, self.port), timeout=self.timeout)
|
||||
except OSError as exc:
|
||||
raise GabsError(f"TCP connect to {self.host}:{self.port} failed: {exc}") from exc
|
||||
|
||||
try:
|
||||
sock.settimeout(self.timeout)
|
||||
raw = json.dumps(payload) + "\n"
|
||||
sock.sendall(raw.encode())
|
||||
|
||||
buf = b""
|
||||
while b"\n" not in buf:
|
||||
chunk = sock.recv(_RECV_BUFSIZE)
|
||||
if not chunk:
|
||||
raise GabsError("Connection closed before response received")
|
||||
buf += chunk
|
||||
|
||||
line = buf.split(b"\n", 1)[0]
|
||||
resp: dict[str, Any] = json.loads(line.decode())
|
||||
except GabsError:
|
||||
raise
|
||||
except json.JSONDecodeError as exc:
|
||||
raise GabsError(f"Malformed JSON from GABS: {exc}") from exc
|
||||
except OSError as exc:
|
||||
raise GabsError(f"Socket error reading from GABS: {exc}") from exc
|
||||
finally:
|
||||
sock.close()
|
||||
|
||||
if "error" in resp:
|
||||
err = resp["error"]
|
||||
code = err.get("code", "?")
|
||||
msg = err.get("message", "unknown error")
|
||||
raise GabsError(f"GABS RPC error [{code}]: {msg}")
|
||||
|
||||
return resp.get("result")
|
||||
|
||||
def ping(self) -> bool:
|
||||
"""Return True if GABS responds to a ping, False otherwise."""
|
||||
try:
|
||||
self.call("ping")
|
||||
return True
|
||||
except GabsError as exc:
|
||||
logger.debug("GABS ping failed: %s", exc)
|
||||
return False
|
||||
|
||||
def get_game_state(self) -> dict[str, Any]:
|
||||
"""Return the current Bannerlord campaign game state."""
|
||||
result = self.call("core/get_game_state")
|
||||
return result if isinstance(result, dict) else {}
|
||||
|
||||
def get_player(self) -> dict[str, Any]:
|
||||
"""Return the player hero's stats and status."""
|
||||
result = self.call("hero/get_player")
|
||||
return result if isinstance(result, dict) else {}
|
||||
|
||||
def get_player_party(self) -> dict[str, Any]:
|
||||
"""Return the player's party composition and stats."""
|
||||
result = self.call("party/get_player_party")
|
||||
return result if isinstance(result, dict) else {}
|
||||
|
||||
def list_kingdoms(self) -> list[dict[str, Any]]:
|
||||
"""Return the list of all active kingdoms in the campaign."""
|
||||
result = self.call("kingdom/list_kingdoms")
|
||||
return result if isinstance(result, list) else []
|
||||
239
src/integrations/bannerlord/observer.py
Normal file
239
src/integrations/bannerlord/observer.py
Normal file
@@ -0,0 +1,239 @@
|
||||
"""Bannerlord Observer — Passive Lord (M1).
|
||||
|
||||
Implements the observe() loop: poll GABS for game state and write a
|
||||
structured journal entry to the configured journal file (default
|
||||
``memory/bannerlord/journal.md``).
|
||||
|
||||
This is pure observation — no actions are taken. The observer records
|
||||
state every ``gabs_poll_interval`` seconds and tracks how many in-game
|
||||
days have been observed.
|
||||
|
||||
Usage::
|
||||
|
||||
from integrations.bannerlord.observer import BannerlordObserver
|
||||
observer = BannerlordObserver()
|
||||
await observer.observe() # runs indefinitely
|
||||
await observer.observe(days=7) # stop after 7 in-game days observed
|
||||
|
||||
Refs: #1093 (M1 Observer), #1091 (Epic)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from config import settings
|
||||
from integrations.bannerlord.gabs_client import GabsClient, GabsError
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _get_journal_path() -> Path:
|
||||
"""Resolve the journal file path from settings (relative to repo root)."""
|
||||
repo_root = getattr(settings, "repo_root", None) or os.getcwd()
|
||||
return Path(repo_root) / settings.gabs_journal_path
|
||||
|
||||
|
||||
def _format_journal_entry(
|
||||
snapshot: dict[str, Any],
|
||||
wall_ts: datetime,
|
||||
entry_num: int,
|
||||
) -> str:
|
||||
"""Format a game-state snapshot as a Markdown journal entry.
|
||||
|
||||
Args:
|
||||
snapshot: Merged dict of all GABS responses.
|
||||
wall_ts: Wall-clock timestamp of the observation.
|
||||
entry_num: Sequential entry counter.
|
||||
|
||||
Returns:
|
||||
A Markdown string ready to append to the journal file.
|
||||
"""
|
||||
ts = wall_ts.strftime("%Y-%m-%d %H:%M:%S UTC")
|
||||
|
||||
# ── Game state fields ─────────────────────────────────────────────
|
||||
game: dict[str, Any] = snapshot.get("game_state", {})
|
||||
hero: dict[str, Any] = snapshot.get("player", {})
|
||||
party: dict[str, Any] = snapshot.get("player_party", {})
|
||||
kingdoms: list[dict[str, Any]] = snapshot.get("kingdoms", [])
|
||||
|
||||
in_game_day = game.get("day", "?")
|
||||
in_game_season = game.get("season", "?")
|
||||
campaign_phase = game.get("campaign_phase", "?")
|
||||
|
||||
hero_name = hero.get("name", "unknown")
|
||||
hero_clan = hero.get("clan", "?")
|
||||
hero_renown = hero.get("renown", "?")
|
||||
hero_level = hero.get("level", "?")
|
||||
hero_gold = hero.get("gold", "?")
|
||||
hero_location = hero.get("current_settlement", hero.get("location", "?"))
|
||||
|
||||
party_size = party.get("size", "?")
|
||||
party_morale = party.get("morale", "?")
|
||||
party_food_days = party.get("food_days_left", "?")
|
||||
|
||||
# ── Kingdom summary ───────────────────────────────────────────────
|
||||
kingdom_lines = []
|
||||
for k in kingdoms[:6]: # cap at 6 to keep entries readable
|
||||
name = k.get("name", "?")
|
||||
ruler = k.get("ruler", "?")
|
||||
strength = k.get("military_strength", "?")
|
||||
kingdom_lines.append(f" - {name} (ruler: {ruler}, strength: {strength})")
|
||||
kingdoms_section = "\n".join(kingdom_lines) if kingdom_lines else " - (no data)"
|
||||
|
||||
return f"""
|
||||
---
|
||||
|
||||
## Entry #{entry_num:04d} — Day {in_game_day} / {in_game_season}
|
||||
|
||||
**Observed:** {ts}
|
||||
**Campaign phase:** {campaign_phase}
|
||||
|
||||
### Hero
|
||||
- **Name:** {hero_name} ({hero_clan})
|
||||
- **Level:** {hero_level} | **Renown:** {hero_renown} | **Gold:** {hero_gold} d
|
||||
- **Location:** {hero_location}
|
||||
|
||||
### Party
|
||||
- **Size:** {party_size} troops | **Morale:** {party_morale} | **Food:** {party_food_days} days
|
||||
|
||||
### Kingdoms
|
||||
{kingdoms_section}
|
||||
|
||||
"""
|
||||
|
||||
|
||||
# ── Observer ──────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class BannerlordObserver:
|
||||
"""Poll GABS and journal Bannerlord game state to Markdown.
|
||||
|
||||
Args:
|
||||
host: GABS VM host (defaults to ``settings.gabs_host``).
|
||||
port: GABS port (defaults to ``settings.gabs_port``).
|
||||
timeout: Socket timeout in seconds.
|
||||
poll_interval: Seconds between polls (defaults to ``settings.gabs_poll_interval``).
|
||||
journal_path: Override the output path (defaults to ``settings.gabs_journal_path``).
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
host: str | None = None,
|
||||
port: int | None = None,
|
||||
timeout: float | None = None,
|
||||
poll_interval: int | None = None,
|
||||
journal_path: str | None = None,
|
||||
) -> None:
|
||||
self._host = host or settings.gabs_host
|
||||
self._port = port or settings.gabs_port
|
||||
self._timeout = timeout if timeout is not None else settings.gabs_timeout
|
||||
self._poll_interval = poll_interval if poll_interval is not None else settings.gabs_poll_interval
|
||||
self._journal_path = Path(journal_path) if journal_path else _get_journal_path()
|
||||
self._entry_count = 0
|
||||
self._days_observed: set[str] = set()
|
||||
|
||||
# ── Public ────────────────────────────────────────────────────────
|
||||
|
||||
async def observe(self, days: int = 0) -> None:
|
||||
"""Run the observer loop.
|
||||
|
||||
Args:
|
||||
days: Stop after this many unique in-game days have been logged.
|
||||
Pass ``0`` (default) to run indefinitely.
|
||||
"""
|
||||
logger.info(
|
||||
"BannerlordObserver starting — target=%s:%d interval=%ds journal=%s",
|
||||
self._host,
|
||||
self._port,
|
||||
self._poll_interval,
|
||||
self._journal_path,
|
||||
)
|
||||
self._ensure_journal_header()
|
||||
|
||||
client = GabsClient(host=self._host, port=self._port, timeout=self._timeout)
|
||||
|
||||
while True:
|
||||
snapshot = await asyncio.to_thread(self._poll_snapshot, client)
|
||||
|
||||
if snapshot is not None:
|
||||
self._entry_count += 1
|
||||
wall_ts = datetime.now(UTC)
|
||||
entry = _format_journal_entry(snapshot, wall_ts, self._entry_count)
|
||||
await asyncio.to_thread(self._append_to_journal, entry)
|
||||
|
||||
in_game_day = str(snapshot.get("game_state", {}).get("day", ""))
|
||||
if in_game_day:
|
||||
self._days_observed.add(in_game_day)
|
||||
logger.info(
|
||||
"Observer entry #%d — in-game day %s (%d unique days seen)",
|
||||
self._entry_count,
|
||||
in_game_day,
|
||||
len(self._days_observed),
|
||||
)
|
||||
|
||||
if days and len(self._days_observed) >= days:
|
||||
logger.info(
|
||||
"Observer goal reached: %d in-game days observed. Stopping.",
|
||||
days,
|
||||
)
|
||||
return
|
||||
|
||||
await asyncio.sleep(self._poll_interval)
|
||||
|
||||
# ── Internal ──────────────────────────────────────────────────────
|
||||
|
||||
def _poll_snapshot(self, client: GabsClient) -> dict[str, Any] | None:
|
||||
"""Synchronous: call GABS and return a merged snapshot dict.
|
||||
|
||||
Returns None on failure (GABS unreachable — degrade gracefully).
|
||||
"""
|
||||
snapshot: dict[str, Any] = {}
|
||||
|
||||
try:
|
||||
snapshot["game_state"] = client.get_game_state()
|
||||
except GabsError as exc:
|
||||
logger.warning("GABS get_game_state failed: %s", exc)
|
||||
return None
|
||||
|
||||
for method, key, fetcher in [
|
||||
("hero/get_player", "player", client.get_player),
|
||||
("party/get_player_party", "player_party", client.get_player_party),
|
||||
("kingdom/list_kingdoms", "kingdoms", client.list_kingdoms),
|
||||
]:
|
||||
try:
|
||||
snapshot[key] = fetcher()
|
||||
except GabsError as exc:
|
||||
logger.warning("GABS %s failed (partial snapshot): %s", method, exc)
|
||||
snapshot[key] = {} if key != "kingdoms" else []
|
||||
|
||||
return snapshot
|
||||
|
||||
def _ensure_journal_header(self) -> None:
|
||||
"""Create the journal file with a Markdown header if it doesn't exist."""
|
||||
if self._journal_path.exists():
|
||||
return
|
||||
self._journal_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
header = (
|
||||
"# Bannerlord Journal — Timmy's Campaign Observations\n\n"
|
||||
"> Passive Lord (M1) — Observer mode. "
|
||||
"Timmy watches, learns, and waits.\n\n"
|
||||
"Epic: #1091 · M1: #1093\n"
|
||||
)
|
||||
self._journal_path.write_text(header, encoding="utf-8")
|
||||
logger.info("Created journal at %s", self._journal_path)
|
||||
|
||||
def _append_to_journal(self, entry: str) -> None:
|
||||
"""Append a formatted entry to the journal file."""
|
||||
try:
|
||||
with self._journal_path.open("a", encoding="utf-8") as fh:
|
||||
fh.write(entry)
|
||||
except OSError as exc:
|
||||
logger.error("Failed to write journal entry: %s", exc)
|
||||
Reference in New Issue
Block a user