Compare commits
1 Commits
mimo/code/
...
mimo/creat
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
43455e9c83 |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -7,3 +7,6 @@ mempalace/__pycache__/
|
|||||||
|
|
||||||
# Prevent agents from writing to wrong path (see issue #1145)
|
# Prevent agents from writing to wrong path (see issue #1145)
|
||||||
public/nexus/
|
public/nexus/
|
||||||
|
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
|||||||
124
app.js
124
app.js
@@ -713,6 +713,8 @@ async function init() {
|
|||||||
connectHermes();
|
connectHermes();
|
||||||
fetchGiteaData();
|
fetchGiteaData();
|
||||||
setInterval(fetchGiteaData, 30000); // Refresh every 30s
|
setInterval(fetchGiteaData, 30000); // Refresh every 30s
|
||||||
|
updateHeartbeatBriefing(); // Initial briefing load
|
||||||
|
setInterval(updateHeartbeatBriefing, 60000); // Refresh briefing every 60s
|
||||||
|
|
||||||
composer = new EffectComposer(renderer);
|
composer = new EffectComposer(renderer);
|
||||||
composer.addPass(new RenderPass(scene, camera));
|
composer.addPass(new RenderPass(scene, camera));
|
||||||
@@ -1140,6 +1142,7 @@ async function fetchGiteaData() {
|
|||||||
const worldState = JSON.parse(atob(content.content));
|
const worldState = JSON.parse(atob(content.content));
|
||||||
updateNexusCommand(worldState);
|
updateNexusCommand(worldState);
|
||||||
updateSovereignHealth();
|
updateSovereignHealth();
|
||||||
|
updateHeartbeatBriefing();
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Failed to fetch Gitea data:', e);
|
console.error('Failed to fetch Gitea data:', e);
|
||||||
@@ -1235,6 +1238,127 @@ function updateNexusCommand(state) {
|
|||||||
terminal.updatePanelText(lines);
|
terminal.updatePanelText(lines);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ═══ HEARTBEAT BRIEFING PANEL ═════════════════════════════════════════
|
||||||
|
async function updateHeartbeatBriefing() {
|
||||||
|
const container = document.getElementById('heartbeat-briefing-content');
|
||||||
|
const pulseDot = document.querySelector('.hb-pulse-dot');
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
let data = null;
|
||||||
|
try {
|
||||||
|
// Derive briefing endpoint from current location or fallback to localhost
|
||||||
|
const briefingUrl = window.location.protocol === 'file:'
|
||||||
|
? 'http://localhost:8766/api/briefing'
|
||||||
|
: `${window.location.protocol}//${window.location.hostname}:8766/api/briefing`;
|
||||||
|
const res = await fetch(briefingUrl);
|
||||||
|
if (res.ok) data = await res.json();
|
||||||
|
} catch (e) {
|
||||||
|
// Server not reachable — show honest offline state
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!data) {
|
||||||
|
if (pulseDot) pulseDot.classList.add('offline');
|
||||||
|
container.innerHTML = '<div class="hb-empty">Briefing offline<br>No connection to Nexus gateway</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pulseDot) pulseDot.classList.remove('offline');
|
||||||
|
|
||||||
|
let html = '';
|
||||||
|
|
||||||
|
// ── Core Heartbeat ──
|
||||||
|
html += '<div class="briefing-section">';
|
||||||
|
html += '<div class="briefing-section-label">Nexus Core</div>';
|
||||||
|
const hb = data.core_heartbeat;
|
||||||
|
if (hb) {
|
||||||
|
const age = hb.age_secs != null ? hb.age_secs : '?';
|
||||||
|
const ageLabel = typeof age === 'number'
|
||||||
|
? (age < 60 ? `${age.toFixed(0)}s ago` : `${(age / 60).toFixed(1)}m ago`)
|
||||||
|
: age;
|
||||||
|
const isAlive = typeof age === 'number' && age < 120;
|
||||||
|
html += `<div class="hb-core-row">
|
||||||
|
<span class="hb-core-status ${isAlive ? 'alive' : 'dead'}">${isAlive ? '● ALIVE' : '○ STALE'}</span>
|
||||||
|
<span class="hb-core-meta">cycle ${hb.cycle || '?'} · ${hb.model || 'unknown'} · ${ageLabel}</span>
|
||||||
|
</div>`;
|
||||||
|
html += `<div class="hb-core-meta">status: ${hb.status || '?'}</div>`;
|
||||||
|
} else {
|
||||||
|
html += '<div class="hb-core-row"><span class="hb-core-status dead">○ NO DATA</span></div>';
|
||||||
|
}
|
||||||
|
html += '</div>';
|
||||||
|
|
||||||
|
// ── Cron Heartbeats ──
|
||||||
|
const cron = data.cron_heartbeat;
|
||||||
|
if (cron && cron.jobs && cron.jobs.length > 0) {
|
||||||
|
html += '<div class="briefing-section">';
|
||||||
|
html += '<div class="briefing-section-label">Cron Heartbeats</div>';
|
||||||
|
html += `<div class="hb-cron-row">
|
||||||
|
<span class="hb-cron-healthy">● ${cron.healthy_count} healthy</span>
|
||||||
|
${cron.stale_count > 0 ? `<span class="hb-cron-stale">⚠ ${cron.stale_count} stale</span>` : ''}
|
||||||
|
</div>`;
|
||||||
|
for (const job of cron.jobs.slice(0, 5)) {
|
||||||
|
const cls = job.healthy ? 'healthy' : 'stale';
|
||||||
|
html += `<div class="hb-cron-job">
|
||||||
|
<span class="hb-cron-job-name">${esc(job.job)}</span>
|
||||||
|
<span class="hb-cron-job-status ${cls}">${esc(job.message)}</span>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
if (cron.jobs.length > 5) {
|
||||||
|
html += `<div class="hb-core-meta">+${cron.jobs.length - 5} more jobs</div>`;
|
||||||
|
}
|
||||||
|
html += '</div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Morning Report ──
|
||||||
|
const report = data.morning_report;
|
||||||
|
if (report) {
|
||||||
|
html += '<div class="briefing-section">';
|
||||||
|
html += '<div class="briefing-section-label">Latest Report</div>';
|
||||||
|
|
||||||
|
// Aggregate stats
|
||||||
|
let totalClosed = 0, totalMerged = 0;
|
||||||
|
if (report.repos) {
|
||||||
|
for (const r of Object.values(report.repos)) {
|
||||||
|
totalClosed += r.closed_issues || 0;
|
||||||
|
totalMerged += r.merged_prs || 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
html += `<div class="hb-stats-row">
|
||||||
|
<div class="hb-stat"><span class="hb-stat-value" style="color:#4af0c0">${totalClosed}</span><span class="hb-stat-label">Closed</span></div>
|
||||||
|
<div class="hb-stat"><span class="hb-stat-value" style="color:#7b5cff">${totalMerged}</span><span class="hb-stat-label">Merged</span></div>
|
||||||
|
<div class="hb-stat"><span class="hb-stat-value" style="color:#ffd700">${(report.blockers || []).length}</span><span class="hb-stat-label">Blockers</span></div>
|
||||||
|
</div>`;
|
||||||
|
|
||||||
|
// Highlights (up to 3)
|
||||||
|
if (report.highlights && report.highlights.length > 0) {
|
||||||
|
for (const h of report.highlights.slice(0, 3)) {
|
||||||
|
html += `<div class="hb-core-meta">+ ${esc(h)}</div>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Blockers
|
||||||
|
if (report.blockers && report.blockers.length > 0) {
|
||||||
|
for (const b of report.blockers) {
|
||||||
|
html += `<div class="hb-blocker">⚠ ${esc(b)}</div>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
html += `<div class="hb-timestamp">Report: ${esc(report.generated_at || '?')}</div>`;
|
||||||
|
html += '</div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Timestamp ──
|
||||||
|
html += `<div class="hb-timestamp">Briefing updated: ${new Date().toLocaleTimeString('en-US', { hour12: false })}</div>`;
|
||||||
|
|
||||||
|
container.innerHTML = html;
|
||||||
|
}
|
||||||
|
|
||||||
|
function esc(str) {
|
||||||
|
if (!str) return '';
|
||||||
|
const d = document.createElement('div');
|
||||||
|
d.textContent = String(str);
|
||||||
|
return d.innerHTML;
|
||||||
|
}
|
||||||
|
|
||||||
// ═══ AGENT PRESENCE SYSTEM ═══
|
// ═══ AGENT PRESENCE SYSTEM ═══
|
||||||
function createAgentPresences() {
|
function createAgentPresences() {
|
||||||
const agentData = [
|
const agentData = [
|
||||||
|
|||||||
@@ -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
|
|
||||||
@@ -96,6 +96,12 @@
|
|||||||
<div class="panel-header">SOVEREIGN HEALTH</div>
|
<div class="panel-header">SOVEREIGN HEALTH</div>
|
||||||
<div id="sovereign-health-content" class="panel-content"></div>
|
<div id="sovereign-health-content" class="panel-content"></div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="hud-panel hud-panel-briefing" id="heartbeat-briefing-log">
|
||||||
|
<div class="panel-header">
|
||||||
|
<span class="hb-pulse-dot"></span> HEARTBEAT BRIEFING
|
||||||
|
</div>
|
||||||
|
<div id="heartbeat-briefing-content" class="panel-content"></div>
|
||||||
|
</div>
|
||||||
<div class="hud-panel" id="calibrator-log">
|
<div class="hud-panel" id="calibrator-log">
|
||||||
<div class="panel-header">ADAPTIVE CALIBRATOR</div>
|
<div class="panel-header">ADAPTIVE CALIBRATOR</div>
|
||||||
<div id="calibrator-log-content" class="panel-content"></div>
|
<div id="calibrator-log-content" class="panel-content"></div>
|
||||||
|
|||||||
@@ -836,43 +836,8 @@ async def main():
|
|||||||
default=1.0,
|
default=1.0,
|
||||||
help="Delay between iterations in seconds (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()
|
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
|
# Create harness
|
||||||
harness = BannerlordHarness(
|
harness = BannerlordHarness(
|
||||||
hermes_ws_url=args.hermes_ws,
|
hermes_ws_url=args.hermes_ws,
|
||||||
|
|||||||
@@ -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()
|
|
||||||
11
portals.json
11
portals.json
@@ -23,22 +23,13 @@
|
|||||||
"rotation": { "y": 0.5 },
|
"rotation": { "y": 0.5 },
|
||||||
"portal_type": "game-world",
|
"portal_type": "game-world",
|
||||||
"world_category": "strategy-rpg",
|
"world_category": "strategy-rpg",
|
||||||
"environment": "local",
|
"environment": "production",
|
||||||
"access_mode": "operator",
|
"access_mode": "operator",
|
||||||
"readiness_state": "active",
|
"readiness_state": "active",
|
||||||
"telemetry_source": "hermes-harness:bannerlord",
|
"telemetry_source": "hermes-harness:bannerlord",
|
||||||
"owner": "Timmy",
|
"owner": "Timmy",
|
||||||
"app_id": 261550,
|
"app_id": 261550,
|
||||||
"window_title": "Mount & Blade II: Bannerlord",
|
"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": {
|
"destination": {
|
||||||
"url": "https://bannerlord.timmy.foundation",
|
"url": "https://bannerlord.timmy.foundation",
|
||||||
"type": "harness",
|
"type": "harness",
|
||||||
|
|||||||
@@ -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 "$@"
|
|
||||||
144
server.py
144
server.py
@@ -3,17 +3,156 @@
|
|||||||
The Nexus WebSocket Gateway — Robust broadcast bridge for Timmy's consciousness.
|
The Nexus WebSocket Gateway — Robust broadcast bridge for Timmy's consciousness.
|
||||||
This server acts as the central hub for the-nexus, connecting the mind (nexus_think.py),
|
This server acts as the central hub for the-nexus, connecting the mind (nexus_think.py),
|
||||||
the body (Evennia/Morrowind), and the visualization surface.
|
the body (Evennia/Morrowind), and the visualization surface.
|
||||||
|
|
||||||
|
Serves HTTP alongside WebSocket:
|
||||||
|
GET /api/briefing — heartbeat + morning report data for the HUD briefing panel
|
||||||
"""
|
"""
|
||||||
import asyncio
|
import asyncio
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
|
import os
|
||||||
import signal
|
import signal
|
||||||
import sys
|
import sys
|
||||||
from typing import Set
|
from datetime import datetime, timezone
|
||||||
|
from http.server import HTTPServer, SimpleHTTPRequestHandler
|
||||||
|
from pathlib import Path
|
||||||
|
from threading import Thread
|
||||||
|
from typing import Any, Dict, Set
|
||||||
|
|
||||||
# Branch protected file - see POLICY.md
|
# Branch protected file - see POLICY.md
|
||||||
import websockets
|
import websockets
|
||||||
|
|
||||||
|
|
||||||
|
# ── HTTP Briefing Endpoint ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
HEARTBEAT_PATH = Path.home() / ".nexus" / "heartbeat.json"
|
||||||
|
REPORTS_DIR = Path.home() / ".local" / "timmy" / "reports"
|
||||||
|
CRON_HEARTBEAT_DIR_PRIMARY = Path("/var/run/bezalel/heartbeats")
|
||||||
|
CRON_HEARTBEAT_DIR_FALLBACK = Path.home() / ".bezalel" / "heartbeats"
|
||||||
|
|
||||||
|
|
||||||
|
def _read_json_file(path: Path) -> Any:
|
||||||
|
"""Read and parse a JSON file. Returns None on failure."""
|
||||||
|
try:
|
||||||
|
return json.loads(path.read_text(encoding="utf-8"))
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_cron_dir() -> Path:
|
||||||
|
"""Return the first writable cron heartbeat directory."""
|
||||||
|
for d in [CRON_HEARTBEAT_DIR_PRIMARY, CRON_HEARTBEAT_DIR_FALLBACK]:
|
||||||
|
if d.exists() and os.access(str(d), os.R_OK):
|
||||||
|
return d
|
||||||
|
return CRON_HEARTBEAT_DIR_FALLBACK
|
||||||
|
|
||||||
|
|
||||||
|
def _read_cron_heartbeats() -> list:
|
||||||
|
"""Read all .last files from the cron heartbeat directory."""
|
||||||
|
hb_dir = _resolve_cron_dir()
|
||||||
|
if not hb_dir.exists():
|
||||||
|
return []
|
||||||
|
now = datetime.now(timezone.utc).timestamp()
|
||||||
|
jobs = []
|
||||||
|
for f in sorted(hb_dir.glob("*.last")):
|
||||||
|
data = _read_json_file(f)
|
||||||
|
if data is None:
|
||||||
|
jobs.append({"job": f.stem, "healthy": False, "message": "corrupt"})
|
||||||
|
continue
|
||||||
|
ts = float(data.get("timestamp", 0))
|
||||||
|
interval = int(data.get("interval", 3600))
|
||||||
|
age = now - ts
|
||||||
|
is_stale = age > (2 * interval)
|
||||||
|
jobs.append({
|
||||||
|
"job": f.stem,
|
||||||
|
"healthy": not is_stale,
|
||||||
|
"age_secs": round(age, 1),
|
||||||
|
"interval": interval,
|
||||||
|
"last_seen": datetime.fromtimestamp(ts, tz=timezone.utc).isoformat() if ts else None,
|
||||||
|
"message": f"{'STALE' if is_stale else 'OK'} ({age:.0f}s / {interval}s)" if ts else "no timestamp",
|
||||||
|
})
|
||||||
|
return jobs
|
||||||
|
|
||||||
|
|
||||||
|
def _latest_morning_report() -> Dict[str, Any] | None:
|
||||||
|
"""Find the most recent morning report file."""
|
||||||
|
if not REPORTS_DIR.exists():
|
||||||
|
return None
|
||||||
|
reports = sorted(REPORTS_DIR.glob("morning-*.json"), reverse=True)
|
||||||
|
if not reports:
|
||||||
|
return None
|
||||||
|
return _read_json_file(reports[0])
|
||||||
|
|
||||||
|
|
||||||
|
def _build_briefing() -> Dict[str, Any]:
|
||||||
|
"""Assemble the full briefing payload from real files."""
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
# Core heartbeat
|
||||||
|
core_hb = _read_json_file(HEARTBEAT_PATH)
|
||||||
|
if core_hb:
|
||||||
|
beat_ts = float(core_hb.get("timestamp", 0))
|
||||||
|
core_hb["age_secs"] = round(now.timestamp() - beat_ts, 1) if beat_ts else None
|
||||||
|
|
||||||
|
# Cron heartbeats
|
||||||
|
cron_jobs = _read_cron_heartbeats()
|
||||||
|
healthy_count = sum(1 for j in cron_jobs if j.get("healthy"))
|
||||||
|
stale_count = sum(1 for j in cron_jobs if not j.get("healthy"))
|
||||||
|
|
||||||
|
# Morning report
|
||||||
|
report = _latest_morning_report()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"generated_at": now.isoformat(),
|
||||||
|
"core_heartbeat": core_hb,
|
||||||
|
"cron_heartbeat": {
|
||||||
|
"jobs": cron_jobs,
|
||||||
|
"healthy_count": healthy_count,
|
||||||
|
"stale_count": stale_count,
|
||||||
|
},
|
||||||
|
"morning_report": report,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class BriefingHandler(SimpleHTTPRequestHandler):
|
||||||
|
"""Minimal HTTP handler that only serves /api/briefing."""
|
||||||
|
|
||||||
|
def do_GET(self):
|
||||||
|
if self.path == "/api/briefing":
|
||||||
|
try:
|
||||||
|
data = _build_briefing()
|
||||||
|
body = json.dumps(data).encode("utf-8")
|
||||||
|
self.send_response(200)
|
||||||
|
self.send_header("Content-Type", "application/json")
|
||||||
|
self.send_header("Content-Length", str(len(body)))
|
||||||
|
self.send_header("Access-Control-Allow-Origin", "*")
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(body)
|
||||||
|
except Exception as e:
|
||||||
|
self.send_error(500, str(e))
|
||||||
|
elif self.path == "/api/health":
|
||||||
|
body = json.dumps({"status": "ok"}).encode("utf-8")
|
||||||
|
self.send_response(200)
|
||||||
|
self.send_header("Content-Type", "application/json")
|
||||||
|
self.send_header("Access-Control-Allow-Origin", "*")
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(body)
|
||||||
|
else:
|
||||||
|
self.send_error(404)
|
||||||
|
|
||||||
|
def log_message(self, fmt, *args):
|
||||||
|
pass # Suppress HTTP access logs — WS gateway logs are enough
|
||||||
|
|
||||||
|
|
||||||
|
def start_http_server(port: int = 8766):
|
||||||
|
"""Run the HTTP server in a daemon thread."""
|
||||||
|
server = HTTPServer(("0.0.0.0", port), BriefingHandler)
|
||||||
|
thread = Thread(target=server.serve_forever, daemon=True)
|
||||||
|
thread.start()
|
||||||
|
logger = logging.getLogger("nexus-gateway")
|
||||||
|
logger.info(f"Briefing HTTP server started on http://0.0.0.0:{port}")
|
||||||
|
return server
|
||||||
|
|
||||||
# Configuration
|
# Configuration
|
||||||
PORT = 8765
|
PORT = 8765
|
||||||
HOST = "0.0.0.0" # Allow external connections if needed
|
HOST = "0.0.0.0" # Allow external connections if needed
|
||||||
@@ -80,6 +219,9 @@ async def broadcast_handler(websocket: websockets.WebSocketServerProtocol):
|
|||||||
|
|
||||||
async def main():
|
async def main():
|
||||||
"""Main server loop with graceful shutdown."""
|
"""Main server loop with graceful shutdown."""
|
||||||
|
# Start HTTP briefing endpoint alongside WS
|
||||||
|
http_server = start_http_server(port=8766)
|
||||||
|
|
||||||
logger.info(f"Starting Nexus WS gateway on ws://{HOST}:{PORT}")
|
logger.info(f"Starting Nexus WS gateway on ws://{HOST}:{PORT}")
|
||||||
|
|
||||||
# Set up signal handlers for graceful shutdown
|
# Set up signal handlers for graceful shutdown
|
||||||
|
|||||||
132
style.css
132
style.css
@@ -1224,6 +1224,138 @@ canvas#nexus-canvas {
|
|||||||
|
|
||||||
.pse-status { color: #4af0c0; font-weight: 600; }
|
.pse-status { color: #4af0c0; font-weight: 600; }
|
||||||
|
|
||||||
|
/* ═══ HEARTBEAT BRIEFING PANEL ═════════════════════════════════════ */
|
||||||
|
.hud-panel-briefing {
|
||||||
|
width: 320px;
|
||||||
|
max-height: 420px;
|
||||||
|
border-left-color: #7b5cff;
|
||||||
|
}
|
||||||
|
.hud-panel-briefing .panel-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
color: #7b5cff;
|
||||||
|
}
|
||||||
|
.hud-panel-briefing .panel-content {
|
||||||
|
max-height: 360px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
/* Pulse dot */
|
||||||
|
.hb-pulse-dot {
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #4af0c0;
|
||||||
|
box-shadow: 0 0 6px #4af0c0;
|
||||||
|
animation: hb-dot-pulse 2s ease-in-out infinite;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.hb-pulse-dot.offline {
|
||||||
|
background: #ff4466;
|
||||||
|
box-shadow: 0 0 6px #ff4466;
|
||||||
|
animation: none;
|
||||||
|
}
|
||||||
|
@keyframes hb-dot-pulse {
|
||||||
|
0%, 100% { opacity: 0.4; }
|
||||||
|
50% { opacity: 1; }
|
||||||
|
}
|
||||||
|
/* Briefing sections */
|
||||||
|
.briefing-section {
|
||||||
|
margin-bottom: 8px;
|
||||||
|
padding-bottom: 6px;
|
||||||
|
border-bottom: 1px solid rgba(123, 92, 255, 0.12);
|
||||||
|
}
|
||||||
|
.briefing-section:last-child { border-bottom: none; margin-bottom: 0; }
|
||||||
|
.briefing-section-label {
|
||||||
|
font-size: 9px;
|
||||||
|
letter-spacing: 0.12em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: #8a9ab8;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
/* Core heartbeat row */
|
||||||
|
.hb-core-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
.hb-core-status {
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
.hb-core-status.alive { color: #4af0c0; }
|
||||||
|
.hb-core-status.dead { color: #ff4466; }
|
||||||
|
.hb-core-meta {
|
||||||
|
font-size: 10px;
|
||||||
|
color: #8a9ab8;
|
||||||
|
}
|
||||||
|
/* Cron jobs */
|
||||||
|
.hb-cron-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 10px;
|
||||||
|
margin-bottom: 2px;
|
||||||
|
}
|
||||||
|
.hb-cron-healthy { color: #4af0c0; }
|
||||||
|
.hb-cron-stale { color: #ff4466; font-weight: 700; }
|
||||||
|
.hb-cron-job {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
font-size: 10px;
|
||||||
|
padding: 1px 0;
|
||||||
|
}
|
||||||
|
.hb-cron-job-name { color: #e0f0ff; }
|
||||||
|
.hb-cron-job-status.healthy { color: #4af0c0; }
|
||||||
|
.hb-cron-job-status.stale { color: #ff4466; font-weight: 700; }
|
||||||
|
/* Morning report stats */
|
||||||
|
.hb-stats-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
font-size: 10px;
|
||||||
|
}
|
||||||
|
.hb-stat {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1px;
|
||||||
|
}
|
||||||
|
.hb-stat-value {
|
||||||
|
font-family: 'Orbitron', sans-serif;
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
.hb-stat-label {
|
||||||
|
font-size: 9px;
|
||||||
|
color: #8a9ab8;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
/* Blockers */
|
||||||
|
.hb-blocker {
|
||||||
|
font-size: 10px;
|
||||||
|
color: #ff4466;
|
||||||
|
padding: 1px 0;
|
||||||
|
}
|
||||||
|
/* Narrative */
|
||||||
|
.hb-narrative {
|
||||||
|
font-size: 10px;
|
||||||
|
color: #8a9ab8;
|
||||||
|
line-height: 1.5;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
/* Empty / offline state */
|
||||||
|
.hb-empty {
|
||||||
|
font-size: 10px;
|
||||||
|
color: #8a9ab8;
|
||||||
|
text-align: center;
|
||||||
|
padding: 12px 0;
|
||||||
|
}
|
||||||
|
/* Timestamp */
|
||||||
|
.hb-timestamp {
|
||||||
|
font-size: 9px;
|
||||||
|
color: rgba(138, 154, 184, 0.6);
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
/* ═══════════════════════════════════════════
|
/* ═══════════════════════════════════════════
|
||||||
MNEMOSYNE — MEMORY CRYSTAL INSPECTION PANEL
|
MNEMOSYNE — MEMORY CRYSTAL INSPECTION PANEL
|
||||||
|
|||||||
Reference in New Issue
Block a user