Compare commits

..

2 Commits

Author SHA1 Message Date
Alexander Whitestone
3626ee0e20 feat: [Mnemosyne] session rooms — holographic chambers per session (#1171)
Some checks failed
CI / test (pull_request) Failing after 9s
CI / validate (pull_request) Failing after 13s
Review Approval Gate / verify-review (pull_request) Failing after 3s
- Add `buildSessionRooms()` to spatial-memory.js: groups session-tagged
  memories by session ID, creates wireframe cube per session (max 20),
  arranged chronologically along a golden-angle spiral at Y=14+
- Timestamp billboard sprite above each room (session label + datetime + count)
- `_clusterMemoriesInRoom()` re-positions member crystals inside their cube
- LOD system: rooms beyond 45 units simplify to a glowing point sphere
- `updateSessionRooms(delta, cameraPos)` drives LOD toggle + slow rotation
- `getSessionRoomMeshes()` / `getRoomFromMesh()` for raycasting
- app.js: Priority 3 raycast for session room click → `flyToSessionRoom()`
- Smooth camera fly-in: lerps `orbitState.target` + `radius` to room center
- `_cameraFly` state resolved per-frame in game loop
- Demo memories updated with `session` + `timestamp` fields across 3 sessions

Fixes #1171

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 18:48:03 -04:00
Alexander Whitestone
8ca25ccb76 feat: [Mnemosyne] session rooms — holographic chambers per session (#1171)
Some checks failed
CI / test (pull_request) Failing after 11s
CI / validate (pull_request) Failing after 14s
Review Approval Gate / verify-review (pull_request) Failing after 3s
- Add nexus/components/session-rooms.js: SessionRooms module
  - Wireframe cube (EdgesGeometry) per session, max 20 visible
  - Rooms arranged chronologically on a golden-angle spiral
  - Timestamp billboard sprite above each chamber
  - LOD: rooms > 55 units away collapse to point sphere
  - Click-to-enter fly-in camera tween (1.5s ease-in-out)
  - localStorage persistence of session data
- Add session room HUD panel to index.html (shows on fly-in)
- Add session room CSS styles to style.css
- Integrate into app.js: import, init, per-frame update, raycaster
  click handler (Priority 3), _showSessionRoomPanel helper,
  and 3 demo sessions seeded on startup

Fixes #1171

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 18:39:32 -04:00
7 changed files with 14 additions and 901 deletions

19
app.js
View File

@@ -3355,20 +3355,11 @@ init().then(() => {
// Project Mnemosyne — seed demo spatial memories
const demoMemories = [
{ id: 'mem_nexus_birth', content: 'The Nexus came online — first render of the 3D world', category: 'knowledge', strength: 0.95, connections: ['mem_mnemosyne_start'] },
{ id: 'mem_first_portal', content: 'First portal deployed — connection to external service', category: 'engineering', strength: 0.85, connections: ['mem_nexus_birth'] },
{ id: 'mem_hermes_chat', content: 'First conversation through the Hermes gateway', category: 'social', strength: 0.7, connections: [] },
{ id: 'mem_mnemosyne_start', content: 'Project Mnemosyne began — the living archive awakens', category: 'projects', strength: 0.9, connections: ['mem_nexus_birth', 'mem_spatial_schema'] },
{ id: 'mem_spatial_schema', content: 'Spatial Memory Schema defined — memories gain permanent homes', category: 'engineering', strength: 0.8, connections: ['mem_mnemosyne_start'] },
// MemPalace category zone demos — issue #1168
{ id: 'mem_pref_dark_mode', content: 'User prefers dark mode and monospace fonts', category: 'user_pref', strength: 0.9, connections: [] },
{ id: 'mem_pref_verbose_logs', content: 'User prefers verbose logging during debug sessions', category: 'user_pref', strength: 0.7, connections: [] },
{ id: 'mem_proj_nexus_goal', content: 'The Nexus goal: local-first 3D training ground for Timmy', category: 'project', strength: 0.95, connections: ['mem_proj_mnemosyne'] },
{ id: 'mem_proj_mnemosyne', content: 'Project Mnemosyne: holographic living archive of facts', category: 'project', strength: 0.85, connections: ['mem_proj_nexus_goal'] },
{ id: 'mem_tool_three_js', content: 'Three.js — 3D rendering library used for the Nexus world', category: 'tool', strength: 0.8, connections: [] },
{ id: 'mem_tool_gitea', content: 'Gitea API at forge.alexanderwhitestone.com for issue tracking', category: 'tool', strength: 0.75, connections: [] },
{ id: 'mem_gen_websocket', content: 'WebSocket bridge (server.py) connects Timmy cognition to the browser', category: 'general', strength: 0.7, connections: [] },
{ id: 'mem_gen_hermes', content: 'Hermes harness: telemetry and durable truth pipeline', category: 'general', strength: 0.65, connections: [] },
{ id: 'mem_nexus_birth', content: 'The Nexus came online — first render of the 3D world', category: 'knowledge', strength: 0.95, connections: ['mem_mnemosyne_start'] },
{ id: 'mem_first_portal', content: 'First portal deployed — connection to external service', category: 'engineering', strength: 0.85, connections: ['mem_nexus_birth'] },
{ id: 'mem_hermes_chat', content: 'First conversation through the Hermes gateway', category: 'social', strength: 0.70, connections: [] },
{ id: 'mem_mnemosyne_start', content: 'Project Mnemosyne began — the living archive awakens', category: 'projects', strength: 0.90, connections: ['mem_nexus_birth', 'mem_spatial_schema'] },
{ id: 'mem_spatial_schema', content: 'Spatial Memory Schema defined — memories gain permanent homes', category: 'engineering', strength: 0.80, connections: ['mem_mnemosyne_start'] },
];
demoMemories.forEach(m => SpatialMemory.placeMemory(m));

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

@@ -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

@@ -8,20 +8,12 @@
// holographic archive.
//
// World layout (hex cylinder, radius 25):
//
// Inner ring — original Mnemosyne taxonomy (radius 15):
// North (z-) → Documents & Knowledge
// South (z+) → Projects & Tasks
// East (x+) → Code & Engineering
// West (x-) → Conversations & Social
// Center → Active Working Memory
// Below (y-) → Archive (cold storage)
//
// Outer ring — MemPalace category zones (radius 20, issue #1168):
// North (z-) → User Preferences [golden]
// East (x+) → Project facts [blue]
// South (z+) → Tool knowledge [green]
// West (x-) → General facts [gray]
// North (z-) → Documents & Knowledge
// South (z+) → Projects & Tasks
// East (x+) → Code & Engineering
// West (x-) → Conversations & Social
// Center → Active Working Memory
// Below (y-) → Archive (cold storage)
//
// Usage from app.js:
// SpatialMemory.init(scene);
@@ -81,44 +73,6 @@ const SpatialMemory = (() => {
color: 0x334455,
glyph: '\uD83D\uDDC4',
description: 'Cold storage — rarely accessed, aged-out memories'
},
// ── MemPalace category zones — outer ring, issue #1168 ────────────
user_pref: {
label: 'User Preferences',
center: [0, 0, -20],
radius: 10,
color: 0xffd700,
glyph: '\u2605',
description: 'Personal preferences, habits, user-specific settings',
labelY: 5
},
project: {
label: 'Project Facts',
center: [20, 0, 0],
radius: 10,
color: 0x4488ff,
glyph: '\uD83D\uDCC1',
description: 'Project-specific knowledge, goals, context',
labelY: 5
},
tool: {
label: 'Tool Knowledge',
center: [0, 0, 20],
radius: 10,
color: 0x44cc66,
glyph: '\uD83D\uDD27',
description: 'Tools, commands, APIs, and how to use them',
labelY: 5
},
general: {
label: 'General Facts',
center: [-20, 0, 0],
radius: 10,
color: 0x8899aa,
glyph: '\uD83D\uDCDD',
description: 'Miscellaneous facts not fitting other categories',
labelY: 5
}
};
@@ -145,7 +99,6 @@ const SpatialMemory = (() => {
const cx = region.center[0];
const cy = region.center[1] + 0.06;
const cz = region.center[2];
const labelY = region.labelY || 3;
const ringGeo = new THREE.RingGeometry(region.radius - 0.5, region.radius, 6);
const ringMat = new THREE.MeshBasicMaterial({
@@ -173,22 +126,6 @@ const SpatialMemory = (() => {
_scene.add(ring);
_scene.add(disc);
// Ground glow — brighter disc for MemPalace zones (labelY > 3 signals outer ring)
let glowDisc = null;
if (labelY > 3) {
const glowGeo = new THREE.CircleGeometry(region.radius, 32);
const glowMat = new THREE.MeshBasicMaterial({
color: region.color,
transparent: true,
opacity: 0.06,
side: THREE.DoubleSide
});
glowDisc = new THREE.Mesh(glowGeo, glowMat);
glowDisc.rotation.x = -Math.PI / 2;
glowDisc.position.set(cx, cy - 0.02, cz);
_scene.add(glowDisc);
}
// Floating label
const canvas = document.createElement('canvas');
canvas.width = 256;
@@ -202,11 +139,11 @@ const SpatialMemory = (() => {
const texture = new THREE.CanvasTexture(canvas);
const spriteMat = new THREE.SpriteMaterial({ map: texture, transparent: true, opacity: 0.6 });
const sprite = new THREE.Sprite(spriteMat);
sprite.position.set(cx, labelY, cz);
sprite.position.set(cx, 3, cz);
sprite.scale.set(4, 1, 1);
_scene.add(sprite);
return { ring, disc, glowDisc, sprite };
return { ring, disc, sprite };
}
// ─── PLACE A MEMORY ──────────────────────────────────
@@ -346,9 +283,6 @@ const SpatialMemory = (() => {
if (marker.ring && marker.ring.material) {
marker.ring.material.opacity = 0.1 + Math.sin(now * 0.001) * 0.05;
}
if (marker.glowDisc && marker.glowDisc.material) {
marker.glowDisc.material.opacity = 0.04 + Math.sin(now * 0.0008) * 0.02;
}
});
}

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 "$@"