Compare commits

..

1 Commits

Author SHA1 Message Date
Alexander Whitestone
eb28e1f8e4 WIP: issue #701 (mimo swarm)
Some checks failed
CI / test (pull_request) Failing after 8s
CI / validate (pull_request) Failing after 13s
Review Approval Gate / verify-review (pull_request) Failing after 3s
2026-04-10 19:48:31 -04:00
8 changed files with 171 additions and 835 deletions

79
app.js
View File

@@ -1,3 +1,5 @@
shell-init: error retrieving current directory: getcwd: cannot access parent directories: No such file or directory
chdir: error retrieving current directory: getcwd: cannot access parent directories: No such file or directory
import * as THREE from 'three';
import { EffectComposer } from 'three/addons/postprocessing/EffectComposer.js';
import { RenderPass } from 'three/addons/postprocessing/RenderPass.js';
@@ -1982,24 +1984,67 @@ function setupControls() {
document.getElementById('chat-quick-actions').addEventListener('click', (e) => {
const btn = e.target.closest('.quick-action-btn');
if (!btn) return;
const action = btn.dataset.action;
switch(action) {
case 'status':
sendChatMessage("Timmy, what is the current system status?");
break;
case 'agents':
sendChatMessage("Timmy, check on all active agents.");
break;
case 'portals':
openPortalAtlas();
break;
case 'help':
sendChatMessage("Timmy, I need assistance with Nexus navigation.");
break;
}
handleQuickAction(btn.dataset.action);
});
// ═══ QUICK ACTION HANDLER ═══
function handleQuickAction(action) {
switch(action) {
case 'status': {
const portalCount = portals.length;
const onlinePortals = portals.filter(p => p.userData && p.userData.status === 'online').length;
const agentCount = agents.length;
const wsState = wsConnected ? 'ONLINE' : 'OFFLINE';
const wsColor = wsConnected ? '#4af0c0' : '#ff4466';
addChatMessage('system', `[SYSTEM STATUS]`);
addChatMessage('timmy', `Nexus operational. ${portalCount} portals registered (${onlinePortals} online). ${agentCount} agent presences active. Hermes WebSocket: ${wsState}. Navigation mode: ${NAV_MODES[navModeIdx].toUpperCase()}. Performance tier: ${performanceTier.toUpperCase()}.`);
break;
}
case 'agents': {
addChatMessage('system', `[AGENT ROSTER]`);
if (agents.length === 0) {
addChatMessage('timmy', 'No active agent presences detected in the Nexus. The thought stream and harness pulse are the primary indicators of system activity.');
} else {
const roster = agents.map(a => `- ${(a.userData && a.userData.name) || a.name || 'Unknown'}: ${(a.userData && a.userData.status) || 'active'}`).join('\n');
addChatMessage('timmy', `Active agents:\n${roster}`);
}
break;
}
case 'portals':
openPortalAtlas();
break;
case 'heartbeat': {
const agentLog = document.getElementById('agent-log-content');
const recentEntries = agentLog ? agentLog.querySelectorAll('.agent-log-entry') : [];
const entryCount = recentEntries.length;
addChatMessage('system', `[HEARTBEAT INSPECTION]`);
addChatMessage('timmy', `Hermes heartbeat ${wsConnected ? 'active' : 'inactive'}. ${entryCount} recent entries in thought stream. WebSocket reconnect timer: ${wsReconnectTimer ? 'active' : 'idle'}. Harness pulse mesh: ${harnessPulseMesh ? 'rendering' : 'standby'}.`);
break;
}
case 'thoughts': {
const agentLog = document.getElementById('agent-log-content');
const entries = agentLog ? Array.from(agentLog.querySelectorAll('.agent-log-entry')).slice(0, 5) : [];
addChatMessage('system', `[THOUGHT STREAM]`);
if (entries.length === 0) {
addChatMessage('timmy', 'The thought stream is quiet. No recent agent entries detected.');
} else {
const summary = entries.map(e => '> ' + e.textContent.trim()).join('\n');
addChatMessage('timmy', `Recent thoughts:\n${summary}`);
}
break;
}
case 'help': {
addChatMessage('system', `[NEXUS HELP]`);
addChatMessage('timmy', `Navigation: WASD to move, mouse to look around.\n` +
`Press V to cycle: Walk / Orbit / Fly mode.\n` +
`Enter to chat. Escape to close overlays.\n` +
`Press F near a portal to enter. Press E near a vision point to read.\n` +
`Press Tab for Portal Atlas.\n` +
`The Batcave Terminal shows system logs. The Workshop Terminal shows tool output.`);
break;
}
}
}
document.getElementById('portal-close-btn').addEventListener('click', closePortalOverlay);
document.getElementById('vision-close-btn').addEventListener('click', closeVisionOverlay);

View File

@@ -1,151 +0,0 @@
# Bannerlord Local Mac Setup
> **Status:** READY FOR TESTING
> **Platform:** macOS (Apple Silicon / Intel)
> **Source:** GOG (not Steam)
> **Last Updated:** 2026-04-10
## Problem
Bannerlord is a Windows game. Alexander has it from GOG on macOS.
We need it running locally through emulation before the harness can observe it.
## Architecture
```
┌─────────────────────────────────────────────────────────────────┐
│ LOCAL BANNERLORD ON MAC │
│ │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────────┐ │
│ │ Bannerlord │ │ Emulator │ │ macOS Desktop │ │
│ │ (GOG) │───►│ Wine/Whisky/ │───►│ (the screen) │ │
│ │ │ │ CrossOver │ │ │ │
│ └──────────────┘ └──────────────┘ └────────┬─────────┘ │
│ │ │
│ ┌─────────────────────────────────────────────────┤ │
│ │ Bannerlord Harness │ │
│ │ ┌────────────┐ ┌───────────┐ ┌───────────┐ │ │
│ │ │ capture_ │ │ execute_ │ │ bannerlord│ │ │
│ │ │ state() │ │ action() │ │ _local.py │ │ │
│ │ └────────────┘ └───────────┘ └───────────┘ │ │
│ │ │ ▲ │ │ │
│ │ ▼ │ ▼ │ │
│ │ ┌─────────────────────────────────────────┐ │ │
│ │ │ MCP Servers (desktop-control) │ │ │
│ │ │ Screenshots + keyboard/mouse │ │ │
│ │ └─────────────────────────────────────────┘ │ │
│ └─────────────────────────────────────────────────┘ │
│ │
│ ┌─────────────────────────────────────────────────┐ │
│ │ Hermes WebSocket │ │
│ │ Telemetry + ODA loop │ │
│ └─────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────┘
```
## Components
| File | Purpose |
|------|---------|
| `scripts/bannerlord_launcher.sh` | Shell launcher — detects emulator + game, launches |
| `nexus/bannerlord_local.py` | Python module — programmatic readiness + launch control |
| `nexus/bannerlord_harness.py` | Existing harness — extended with `--local` and `--launch-local` |
| `portals.json` | Portal metadata — updated with `local_launch` block |
## Emulator Priority
1. **Whisky**`/Applications/Whisky.app` (preferred, best macOS integration)
2. **CrossOver**`/Applications/CrossOver.app` (good, paid)
3. **Homebrew Wine**`wine64` / `wine` on PATH (free, may need Rosetta on ARM)
## Quick Start
### Check Readiness
```bash
# Shell
./scripts/bannerlord_launcher.sh --check --verbose
# Python
python3 -m nexus.bannerlord_local --check --json
# Through harness
python3 -m nexus.bannerlord_harness --local --mock
```
### Launch Game
```bash
# Shell
./scripts/bannerlord_launcher.sh --launch
# Python
python3 -m nexus.bannerlord_local --launch --json
# Through harness (launches game, then runs ODA)
python3 -m nexus.bannerlord_harness --launch-local --mock
```
### Stop Game
```bash
python3 -m nexus.bannerlord_local --stop
```
## GOG Install Paths Searched
The launcher checks these paths in order:
1. `/Applications/Games/Mount & Blade II Bannerlord`
2. `~/GOG Games/Mount and Blade II Bannerlord`
3. `~/Games/Mount & Blade II Bannerlord`
4. `/Applications/Mount & Blade II Bannerlord`
5. `~/Library/Application Support/GOG.com/Galaxy/Applications/*/`
6. Recursive `find` as last resort
The game must have `bin/Generic/Bannerlord.exe` relative to the install root.
## Portal Metadata
The `portals.json` bannerlord entry now includes:
```json
"environment": "local",
"local_launch": {
"platform": "macos",
"source": "gog",
"emulator_required": true,
"emulator_options": ["whisky", "crossover", "wine"],
"launcher": "scripts/bannerlord_launcher.sh",
"harness_bridge": "nexus/bannerlord_local.py",
"check_command": "python3 -m nexus.bannerlord_local --check --json"
}
```
## Honest Status
| Component | Status |
|-----------|--------|
| Launcher script | Written, needs Mac testing |
| Python local module | Written, needs Mac testing |
| Harness integration | Added `--local`/`--launch-local` flags |
| Portal metadata | Updated |
| MCP observation of emulated window | Untested — depends on emulator window visibility |
| ODA loop with emulated game | Untested — needs game actually running |
## What Could Go Wrong
- **Emulator not installed:** User must install Whisky, CrossOver, or wine
- **Game not found:** User must install GOG Bannerlord to a known path
- **Performance:** Wine on Apple Silicon requires Rosetta + possible DXVK setup
- **Window title:** The emulated window may not match "Mount & Blade II: Bannerlord" — the harness may need to detect the actual window title
- **MCP desktop-control on macOS:** pyautogui on macOS needs Accessibility permissions
## Next Steps
1. Alexander runs `./scripts/bannerlord_launcher.sh --check --verbose` on his Mac
2. If missing emulator, install Whisky (`brew install --cask whisky`)
3. If missing game, install GOG Bannerlord
4. Run `--launch` to verify the game opens
5. Run `--launch-local --mock` to verify harness integration
6. Test MCP screenshots of the emulated window

View File

@@ -1,3 +1,5 @@
shell-init: error retrieving current directory: getcwd: cannot access parent directories: No such file or directory
chdir: error retrieving current directory: getcwd: cannot access parent directories: No such file or directory
<!DOCTYPE html>
<html lang="en" data-theme="dark">
<head>
@@ -143,10 +145,39 @@
</div>
</div>
<div id="chat-quick-actions" class="chat-quick-actions">
<button class="quick-action-btn" data-action="status">System Status</button>
<button class="quick-action-btn" data-action="agents">Agent Check</button>
<button class="quick-action-btn" data-action="portals">Portal Atlas</button>
<button class="quick-action-btn" data-action="help">Help</button>
<div class="starter-label">STARTER PROMPTS</div>
<div class="starter-grid">
<button class="starter-btn" data-action="heartbeat" title="Check Timmy heartbeat and system health">
<span class="starter-icon"></span>
<span class="starter-text">Inspect Heartbeat</span>
<span class="starter-desc">System health &amp; connectivity</span>
</button>
<button class="starter-btn" data-action="portals" title="Browse the portal atlas">
<span class="starter-icon">🌐</span>
<span class="starter-text">Portal Atlas</span>
<span class="starter-desc">Browse connected worlds</span>
</button>
<button class="starter-btn" data-action="agents" title="Check active agent status">
<span class="starter-icon"></span>
<span class="starter-text">Agent Status</span>
<span class="starter-desc">Who is in the fleet</span>
</button>
<button class="starter-btn" data-action="memory" title="View memory crystals">
<span class="starter-icon"></span>
<span class="starter-text">Memory Crystals</span>
<span class="starter-desc">Inspect stored knowledge</span>
</button>
<button class="starter-btn" data-action="ask" title="Ask Timmy anything">
<span class="starter-icon"></span>
<span class="starter-text">Ask Timmy</span>
<span class="starter-desc">Start a conversation</span>
</button>
<button class="starter-btn" data-action="sovereignty" title="Learn about sovereignty">
<span class="starter-icon"></span>
<span class="starter-text">Sovereignty</span>
<span class="starter-desc">What this space is</span>
</button>
</div>
</div>
<div class="chat-input-row">
<input type="text" id="chat-input" class="chat-input" placeholder="Speak to Timmy..." autocomplete="off">

View File

@@ -836,43 +836,8 @@ async def main():
default=1.0,
help="Delay between iterations in seconds (default: 1.0)",
)
parser.add_argument(
"--local",
action="store_true",
help="Check local macOS Bannerlord readiness before starting",
)
parser.add_argument(
"--launch-local",
action="store_true",
help="Launch local Bannerlord on macOS via emulator before ODA loop",
)
args = parser.parse_args()
# Handle local macOS Bannerlord
if args.local or args.launch_local:
try:
from nexus.bannerlord_local import (
check_local_readiness, launch_bannerlord, LocalStatus,
)
state = check_local_readiness()
log.info(f"Local check: {state.status.value}")
log.info(f" Emulator: {state.emulator.name or 'none'}")
log.info(f" Game: {state.game.game_dir or 'not found'}")
log.info(f" Message: {state.message}")
if args.launch_local:
if state.status == LocalStatus.READY:
state = launch_bannerlord(state)
log.info(f"Launch result: {state.status.value}{state.message}")
elif state.status == LocalStatus.RUNNING:
log.info(f"Already running (PID: {state.process_id})")
else:
log.error(f"Cannot launch: {state.message}")
return
except ImportError:
log.warning("bannerlord_local module not available — skipping local check")
# Create harness
harness = BannerlordHarness(
hermes_ws_url=args.hermes_ws,

View File

@@ -1,394 +0,0 @@
#!/usr/bin/env python3
"""
Bannerlord Local Manager — macOS Emulator Bridge
Detects and manages a local Bannerlord installation on macOS.
Provides status queries, launch control, and process monitoring
for the Bannerlord harness.
This module bridges the gap between:
- The GamePortal Protocol (MCP-based observation/action)
- A local GOG Bannerlord running through Wine/Whisky/CrossOver on macOS
The harness does NOT change — this module just manages the game process.
"""
from __future__ import annotations
import json
import logging
import os
import platform
import subprocess
import time
from dataclasses import dataclass, field
from enum import Enum
from pathlib import Path
from typing import Optional
log = logging.getLogger("bannerlord.local")
class EmulatorType(Enum):
WHISKY = "whisky"
CROSSOVER = "crossover"
WINE = "wine"
UNKNOWN = "unknown"
class LocalStatus(Enum):
READY = "ready"
MISSING_EMULATOR = "missing_emulator"
MISSING_GAME = "missing_game"
RUNNING = "running"
CRASHED = "crashed"
ERROR = "error"
# Standard GOG install paths on macOS
GOG_SEARCH_PATHS = [
Path("/Applications/Games/Mount & Blade II Bannerlord"),
Path.home() / "GOG Games" / "Mount and Blade II Bannerlord",
Path.home() / "Games" / "Mount & Blade II Bannerlord",
Path("/Applications/Mount & Blade II Bannerlord"),
]
BANNERLORD_EXE_RELATIVE = "bin/Generic/Bannerlord.exe"
LAUNCHER_SCRIPT = Path(__file__).parent.parent / "scripts" / "bannerlord_launcher.sh"
@dataclass
class EmulatorInfo:
"""Detected Windows emulator on macOS."""
name: str = ""
path: str = ""
emulator_type: EmulatorType = EmulatorType.UNKNOWN
found: bool = False
@dataclass
class GameInstall:
"""Detected Bannerlord GOG installation."""
game_dir: str = ""
game_exe: str = ""
found: bool = False
source: str = "" # "gog", "gog-galaxy", "manual"
@dataclass
class LocalState:
"""Full local Bannerlord state."""
status: LocalStatus = LocalStatus.ERROR
emulator: EmulatorInfo = field(default_factory=EmulatorInfo)
game: GameInstall = field(default_factory=GameInstall)
process_id: Optional[int] = None
message: str = ""
is_macos: bool = False
def to_dict(self) -> dict:
return {
"status": self.status.value,
"emulator": {
"name": self.emulator.name,
"path": self.emulator.path,
"type": self.emulator.emulator_type.value,
"found": self.emulator.found,
},
"game": {
"game_dir": self.game.game_dir,
"game_exe": self.game.game_exe,
"found": self.game.found,
"source": self.game.source,
},
"process_id": self.process_id,
"message": self.message,
"is_macos": self.is_macos,
}
def detect_macos() -> bool:
"""Check if running on macOS."""
return platform.system() == "Darwin"
def detect_emulator() -> EmulatorInfo:
"""Find a Windows emulator on macOS."""
info = EmulatorInfo()
# Whisky
whisky_path = "/Applications/Whisky.app/Contents/Resources/Libraries/wine/bin/wine64"
if os.path.isfile(whisky_path) and os.access(whisky_path, os.X_OK):
info.name = "Whisky"
info.path = whisky_path
info.emulator_type = EmulatorType.WHISKY
info.found = True
return info
# CrossOver
cx_path = "/Applications/CrossOver.app/Contents/SharedSupport/CrossOver/bin/wine"
if os.path.isfile(cx_path) and os.access(cx_path, os.X_OK):
info.name = "CrossOver"
info.path = cx_path
info.emulator_type = EmulatorType.CROSSOVER
info.found = True
return info
# Homebrew wine
for candidate in ["wine64", "wine"]:
try:
result = subprocess.run(
["which", candidate],
capture_output=True, text=True, timeout=5,
)
if result.returncode == 0 and result.stdout.strip():
info.name = candidate
info.path = result.stdout.strip()
info.emulator_type = EmulatorType.WINE
info.found = True
return info
except (subprocess.TimeoutExpired, FileNotFoundError):
continue
return info
def detect_game() -> GameInstall:
"""Find the Bannerlord GOG installation."""
install = GameInstall()
# Check standard paths
for path in GOG_SEARCH_PATHS:
exe_path = path / BANNERLORD_EXE_RELATIVE
if exe_path.is_file():
install.game_dir = str(path)
install.game_exe = str(exe_path)
install.found = True
install.source = "gog"
return install
# Check GOG Galaxy paths
galaxy_base = Path.home() / "Library/Application Support/GOG.com/Galaxy/Applications"
if galaxy_base.is_dir():
for child in galaxy_base.iterdir():
candidate = child / "Mount & Blade II Bannerlord" / BANNERLORD_EXE_RELATIVE
if candidate.is_file():
install.game_dir = str(candidate.parent.parent)
install.game_exe = str(candidate)
install.found = True
install.source = "gog-galaxy"
return install
# Last resort: find
try:
result = subprocess.run(
["find", "/Applications", str(Path.home() / "GOG Games"),
str(Path.home() / "Games"), "-name", "Bannerlord.exe",
"-type", "f"],
capture_output=True, text=True, timeout=15,
)
if result.returncode == 0 and result.stdout.strip():
first_line = result.stdout.strip().split("\n")[0]
install.game_exe = first_line
install.game_dir = str(Path(first_line).parent.parent)
install.found = True
install.source = "search"
return install
except (subprocess.TimeoutExpired, FileNotFoundError):
pass
return install
def check_local_readiness() -> LocalState:
"""Full local readiness check. Returns complete state."""
state = LocalState()
state.is_macos = detect_macos()
if not state.is_macos:
state.status = LocalStatus.ERROR
state.message = "Not macOS — local manager is Mac-only"
return state
state.emulator = detect_emulator()
if not state.emulator.found:
state.status = LocalStatus.MISSING_EMULATOR
state.message = "No Windows emulator found (install Whisky, CrossOver, or wine)"
return state
state.game = detect_game()
if not state.game.found:
state.status = LocalStatus.MISSING_GAME
state.message = "Bannerlord GOG installation not found in known paths"
return state
# Check if already running
pid = _read_pid()
if pid and _is_process_running(pid):
state.status = LocalStatus.RUNNING
state.process_id = pid
state.message = f"Bannerlord already running (PID: {pid})"
else:
state.status = LocalStatus.READY
state.message = "Ready to launch"
return state
def launch_bannerlord(state: Optional[LocalState] = None) -> LocalState:
"""Launch Bannerlord via the emulator. Returns updated state."""
if state is None:
state = check_local_readiness()
if state.status not in (LocalStatus.READY, LocalStatus.RUNNING):
return state
if state.status == LocalStatus.RUNNING:
state.message = f"Already running (PID: {state.process_id})"
return state
# Check if launcher script exists
if LAUNCHER_SCRIPT.is_file():
log.info(f"Using launcher script: {LAUNCHER_SCRIPT}")
try:
result = subprocess.run(
["bash", str(LAUNCHER_SCRIPT), "--launch"],
capture_output=True, text=True, timeout=30,
cwd=str(LAUNCHER_SCRIPT.parent.parent),
)
if result.returncode == 0:
# Parse PID from output
for line in result.stdout.strip().split("\n"):
if "PID:" in line:
try:
pid = int(line.split("PID:")[1].strip().rstrip(")"))
state.process_id = pid
except (ValueError, IndexError):
pass
state.status = LocalStatus.RUNNING
state.message = "Launched via launcher script"
return state
except (subprocess.TimeoutExpired, FileNotFoundError) as e:
log.warning(f"Launcher script failed: {e}, falling back to direct launch")
# Direct launch fallback
try:
log.info(f"Launching Bannerlord directly via {state.emulator.name}")
proc = subprocess.Popen(
[state.emulator.path, state.game.game_exe],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
cwd=state.game.game_dir,
)
state.process_id = proc.pid
state.status = LocalStatus.RUNNING
state.message = f"Launched (PID: {proc.pid})"
_write_pid(proc.pid)
except Exception as e:
state.status = LocalStatus.CRASHED
state.message = f"Launch failed: {e}"
return state
def stop_bannerlord() -> bool:
"""Stop a running Bannerlord process."""
pid = _read_pid()
if not pid or not _is_process_running(pid):
_clear_pid()
return False
try:
os.kill(pid, 15) # SIGTERM
time.sleep(1)
if _is_process_running(pid):
os.kill(pid, 9) # SIGKILL
_clear_pid()
log.info(f"Stopped Bannerlord (PID: {pid})")
return True
except ProcessLookupError:
_clear_pid()
return False
# ═══════════════════════════════════════════════════════════════════════════
# PID FILE MANAGEMENT
# ═══════════════════════════════════════════════════════════════════════════
PID_FILE = Path("/tmp/bannerlord.pid")
def _read_pid() -> Optional[int]:
try:
if PID_FILE.is_file():
return int(PID_FILE.read_text().strip())
except (ValueError, OSError):
pass
return None
def _write_pid(pid: int):
try:
PID_FILE.write_text(str(pid))
except OSError as e:
log.warning(f"Failed to write PID file: {e}")
def _clear_pid():
try:
if PID_FILE.is_file():
PID_FILE.unlink()
except OSError:
pass
def _is_process_running(pid: int) -> bool:
try:
os.kill(pid, 0)
return True
except (ProcessLookupError, PermissionError):
return False
# ═══════════════════════════════════════════════════════════════════════════
# CLI
# ═══════════════════════════════════════════════════════════════════════════
def main():
import argparse
logging.basicConfig(level=logging.INFO, format="%(message)s")
parser = argparse.ArgumentParser(description="Bannerlord Local Manager — macOS")
parser.add_argument("--check", action="store_true", help="Check readiness")
parser.add_argument("--launch", action="store_true", help="Launch the game")
parser.add_argument("--stop", action="store_true", help="Stop running game")
parser.add_argument("--json", action="store_true", help="Output as JSON")
args = parser.parse_args()
if args.stop:
stopped = stop_bannerlord()
if args.json:
print(json.dumps({"stopped": stopped}))
else:
print("Stopped." if stopped else "Not running.")
return
if args.launch:
state = launch_bannerlord()
else:
state = check_local_readiness()
if args.json:
print(json.dumps(state.to_dict(), indent=2))
else:
print(f"Status: {state.status.value}")
print(f"Emulator: {state.emulator.name or 'none'} ({state.emulator.emulator_type.value})")
print(f"Game: {state.game.game_dir or 'not found'}")
if state.process_id:
print(f"PID: {state.process_id}")
print(f"Message: {state.message}")
if __name__ == "__main__":
main()

View File

@@ -23,22 +23,13 @@
"rotation": { "y": 0.5 },
"portal_type": "game-world",
"world_category": "strategy-rpg",
"environment": "local",
"environment": "production",
"access_mode": "operator",
"readiness_state": "active",
"telemetry_source": "hermes-harness:bannerlord",
"owner": "Timmy",
"app_id": 261550,
"window_title": "Mount & Blade II: Bannerlord",
"local_launch": {
"platform": "macos",
"source": "gog",
"emulator_required": true,
"emulator_options": ["whisky", "crossover", "wine"],
"launcher": "scripts/bannerlord_launcher.sh",
"harness_bridge": "nexus/bannerlord_local.py",
"check_command": "python3 -m nexus.bannerlord_local --check --json"
},
"destination": {
"url": "https://bannerlord.timmy.foundation",
"type": "harness",

View File

@@ -1,223 +0,0 @@
#!/usr/bin/env bash
# Bannerlord Local Launcher for macOS
# Detects Wine/Whisky/CrossOver, finds GOG Bannerlord install, launches it.
#
# Usage:
# ./scripts/bannerlord_launcher.sh [--check] [--launch] [--verbose]
#
# Modes:
# --check Check environment only (no launch). Exits 0 if ready.
# --launch Launch the game (default if no flags)
# --verbose Print detailed diagnostic info
set -euo pipefail
# ═══════════════════════════════════════════════════════════════════════════
# CONFIGURATION
# ═══════════════════════════════════════════════════════════════════════════
BANNERLORD_EXE="bin/Generic/Bannerlord.exe"
GOG_PATHS=(
"/Applications/Games/Mount & Blade II Bannerlord"
"$HOME/GOG Games/Mount and Blade II Bannerlord"
"$HOME/Games/Mount & Blade II Bannerlord"
"/Applications/Mount & Blade II Bannerlord"
)
# Also check common GOG Galaxy paths
GOG_GALAXY_PATHS=(
"$HOME/Library/Application Support/GOG.com/Galaxy/Applications/*/Mount & Blade II Bannerlord"
)
# Emulator priority: Whisky > CrossOver > Homebrew Wine > system wine
EMULATOR_NAMES=("Whisky" "CrossOver" "Wine" "wine64" "wine")
VERBOSE=0
CHECK_ONLY=0
LAUNCH=0
# ═══════════════════════════════════════════════════════════════════════════
# ARGUMENT PARSING
# ═══════════════════════════════════════════════════════════════════════════
for arg in "$@"; do
case "$arg" in
--check) CHECK_ONLY=1 ;;
--launch) LAUNCH=1 ;;
--verbose) VERBOSE=1 ;;
*) echo "Unknown arg: $arg"; exit 1 ;;
esac
done
if [ "$CHECK_ONLY" -eq 0 ] && [ "$LAUNCH" -eq 0 ]; then
LAUNCH=1 # Default to launch mode
fi
log() { echo "[bannerlord] $*"; }
vlog() { [ "$VERBOSE" -eq 1 ] && echo "[bannerlord:debug] $*" || true; }
# ═══════════════════════════════════════════════════════════════════════════
# EMULATOR DETECTION
# ═══════════════════════════════════════════════════════════════════════════
find_emulator() {
local emulator_path=""
local emulator_name=""
local emulator_type=""
# Check for Whisky (macOS Wine wrapper)
if [ -d "/Applications/Whisky.app" ]; then
emulator_path="/Applications/Whisky.app/Contents/Resources/Libraries/wine/bin/wine64"
if [ -x "$emulator_path" ]; then
emulator_name="Whisky"
emulator_type="whisky"
fi
fi
# Check for CrossOver
if [ -z "$emulator_path" ] && [ -d "/Applications/CrossOver.app" ]; then
emulator_path="/Applications/CrossOver.app/Contents/SharedSupport/CrossOver/bin/wine"
if [ -x "$emulator_path" ]; then
emulator_name="CrossOver"
emulator_type="crossover"
fi
fi
# Check for Homebrew wine
if [ -z "$emulator_path" ]; then
for candidate in wine64 wine; do
if command -v "$candidate" >/dev/null 2>&1; then
emulator_path="$(command -v "$candidate")"
emulator_name="$candidate"
emulator_type="wine"
break
fi
done
fi
if [ -n "$emulator_path" ]; then
EMULATOR_PATH="$emulator_path"
EMULATOR_NAME="$emulator_name"
EMULATOR_TYPE="$emulator_type"
return 0
fi
return 1
}
# ═══════════════════════════════════════════════════════════════════════════
# GAME DETECTION
# ═══════════════════════════════════════════════════════════════════════════
find_bannerlord() {
# Check standard GOG paths
for path in "${GOG_PATHS[@]}"; do
if [ -f "$path/$BANNERLORD_EXE" ]; then
GAME_DIR="$path"
GAME_EXE="$path/$BANNERLORD_EXE"
return 0
fi
done
# Check GOG Galaxy paths (glob expansion)
for pattern in "${GOG_GALAXY_PATHS[@]}"; do
# shellcheck disable=SC2086
for path in $pattern; do
if [ -d "$path" ] && [ -f "$path/$BANNERLORD_EXE" ]; then
GAME_DIR="$path"
GAME_EXE="$path/$BANNERLORD_EXE"
return 0
fi
done
done
# Search with find as last resort
local found
found=$(find /Applications "$HOME/GOG Games" "$HOME/Games" -name "Bannerlord.exe" -type f 2>/dev/null | head -1)
if [ -n "$found" ]; then
GAME_EXE="$found"
GAME_DIR="$(dirname "$(dirname "$found")")"
return 0
fi
return 1
}
# ═══════════════════════════════════════════════════════════════════════════
# STATUS REPORTING
# ═══════════════════════════════════════════════════════════════════════════
emit_status() {
local status="$1"
local message="$2"
# JSON output for harness consumption
echo "{\"status\":\"$status\",\"emulator\":\"${EMULATOR_NAME:-none}\",\"emulator_type\":\"${EMULATOR_TYPE:-none}\",\"game_dir\":\"${GAME_DIR:-}\",\"game_exe\":\"${GAME_EXE:-}\",\"message\":\"$message\"}"
}
# ═══════════════════════════════════════════════════════════════════════════
# MAIN
# ═══════════════════════════════════════════════════════════════════════════
main() {
# Verify macOS
if [ "$(uname)" != "Darwin" ]; then
emit_status "error" "Not macOS — this launcher is Mac-only"
exit 1
fi
log "Bannerlord Local Launcher — macOS"
# Find emulator
if find_emulator; then
log "Emulator found: $EMULATOR_NAME ($EMULATOR_PATH)"
vlog " Type: $EMULATOR_TYPE"
else
log "ERROR: No Windows emulator found."
log "Install one of: Whisky, CrossOver, or wine (brew install --cask wine-stable)"
emit_status "missing_emulator" "No Windows emulator installed"
exit 1
fi
# Find game
if find_bannerlord; then
log "Bannerlord found: $GAME_DIR"
vlog " Exe: $GAME_EXE"
else
log "ERROR: Bannerlord not found in known GOG paths."
log "Checked: ${GOG_PATHS[*]}"
emit_status "missing_game" "Bannerlord GOG installation not found"
exit 1
fi
# Check mode
if [ "$CHECK_ONLY" -eq 1 ]; then
log "Check passed. Ready to launch."
emit_status "ready" "Emulator and game both found"
exit 0
fi
# Launch
if [ "$LAUNCH" -eq 1 ]; then
log "Launching Bannerlord via $EMULATOR_NAME..."
emit_status "launching" "Starting Bannerlord through $EMULATOR_NAME"
cd "$GAME_DIR"
# Launch in background, redirect output
"$EMULATOR_PATH" "$GAME_EXE" "$@" >/dev/null 2>&1 &
local pid=$!
log "Bannerlord started (PID: $pid)"
echo "$pid" > /tmp/bannerlord.pid
# Wait a moment and check it's still running
sleep 2
if kill -0 "$pid" 2>/dev/null; then
log "Bannerlord is running."
emit_status "running" "Bannerlord PID $pid"
exit 0
else
log "WARNING: Bannerlord process exited quickly. Check Wine logs."
emit_status "crashed" "Process exited within 2 seconds"
exit 1
fi
fi
}
main "$@"

View File

@@ -983,7 +983,7 @@ canvas#nexus-canvas {
.chat-quick-actions {
display: flex;
flex-wrap: wrap;
flex-direction: column;
gap: 6px;
padding: 8px 12px;
border-top: 1px solid var(--color-border);
@@ -991,6 +991,75 @@ canvas#nexus-canvas {
pointer-events: auto;
}
.chat-quick-actions.hidden {
display: none;
}
.starter-label {
font-family: var(--font-display);
font-size: 9px;
letter-spacing: 0.15em;
color: var(--color-primary-dim);
text-transform: uppercase;
padding: 0 2px;
}
.starter-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 4px;
}
.starter-btn {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 1px;
background: rgba(74, 240, 192, 0.06);
border: 1px solid rgba(74, 240, 192, 0.15);
color: var(--color-primary);
font-family: var(--font-body);
padding: 6px 8px;
cursor: pointer;
transition: all var(--transition-ui);
text-align: left;
}
.starter-btn:hover {
background: rgba(74, 240, 192, 0.15);
border-color: var(--color-primary);
color: #fff;
}
.starter-btn:hover .starter-icon {
color: #fff;
}
.starter-btn:active {
transform: scale(0.97);
}
.starter-icon {
font-size: 12px;
color: var(--color-primary);
line-height: 1;
}
.starter-text {
font-size: 10px;
font-weight: 600;
white-space: nowrap;
}
.starter-desc {
font-size: 8px;
color: rgba(74, 240, 192, 0.5);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 100%;
}
/* Add hover effect for MemPalace mining button */
.quick-action-btn:hover {
background: var(--color-primary-dim);
@@ -1136,6 +1205,9 @@ canvas#nexus-canvas {
.hud-location {
font-size: var(--text-xs);
}
.starter-grid {
grid-template-columns: repeat(2, 1fr);
}
}
@media (max-width: 480px) {