mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-07 02:56:17 -10:00
Launcher MCP: reach a game started from its window with Agent Control on
The launcher's game_* tools only looked at the endpoint in its data folder, so a game the player started, which listens on the game's default endpoint, answered "not running under agent control". Each call now goes to the first endpoint where a game answers, the launcher's own first, restarting the horizon-ctl child when that changes: an installed horizon-ctl keeps the endpoint it started with. Whether a game answers is now tested on Windows too, with the endpoint's port and token, rather than by the file existing: a crashed game leaves the file. Fixes #9465 Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
7298c80f8f
commit
7975ff7942
4 files changed
+307
-29
No files matched your search
+19
-8
@@ -42,13 +42,20 @@ An agent adds one server, `horizon-launcher mcp`. It holds:
|
||||
- `account_sign_in`, `account_list`.
|
||||
- **The game's tools.** It runs the selected version's `horizon-ctl mcp` (shipped beside the
|
||||
game) as a child. It lists that server's tools as `game_<name>`, all but `launch`, and passes
|
||||
calls and results through, images included. `play` starts the game with a control socket in
|
||||
the launcher's data folder. While no game answers, `game_*` calls say to call `play`.
|
||||
calls and results through, images included. `play` starts the game with a control endpoint in
|
||||
the launcher's data folder. A game the player started and turned Agent Control on in answers on
|
||||
the game's default endpoint ([Connecting](#connecting)). Each call goes to the first of the two
|
||||
where a game answers, the launcher's first, so a game from `play` wins over one from the window.
|
||||
While no game answers on either, `game_*` calls say to call `play` or to turn Agent Control on.
|
||||
|
||||
The default endpoint is worked out in the launcher's own process, so `$HORIZON_CONTROL`, and on
|
||||
Linux `$XDG_RUNTIME_DIR`, must be the same for `horizon-launcher mcp` as for the game.
|
||||
|
||||
Why one server rather than two: the game's tools stay defined once, in horizon-ctl's table. The
|
||||
agent's client needs one entry and one approval. The version that plays is always the one the
|
||||
launcher selected, so the game's tools always match the game. `horizon-ctl mcp` stays available
|
||||
on its own, for a game started by hand or by a script.
|
||||
agent's client needs one entry and one approval, whichever way the game was started. The tools
|
||||
are always those of the version the launcher selected, even for a game of another version: a
|
||||
command that game does not know answers with an error, and `game_hello` tells which game it is.
|
||||
`horizon-ctl mcp` stays available on its own, for a game started by hand or by a script.
|
||||
|
||||
## Protocol revisions
|
||||
|
||||
@@ -185,12 +192,16 @@ turn the switch off.
|
||||
- `horizon-launcher agent-config [--client ID] [--json] [--test]` prints the same thing.
|
||||
- **Game, Horizon Settings > AI Agent:**
|
||||
- **Agent Control** (`agentControl` in horizon-extras.txt, off by default) opens the default
|
||||
endpoint (`$HORIZON_CONTROL`, else `horizon-control.sock` in the runtime directory) in a game
|
||||
the player started. Turning it off closes the endpoint and stops its listener.
|
||||
endpoint (`$HORIZON_CONTROL`, else `horizon-control.sock` in the runtime directory, or
|
||||
`horizon-control.json` in the temporary directory on Windows) in a game the player started.
|
||||
`horizon-launcher mcp` reaches it as it is, with no second server. Turning it off closes the
|
||||
endpoint and stops its listener.
|
||||
- The page shows whether a client is connected (`Server::clients`), when the last command came,
|
||||
and the socket's name.
|
||||
- **Copy Connection Details** copies the socket path, a `horizon-ctl --socket ... state` line
|
||||
and an `mcpServers` config for this game's `horizon-ctl mcp`.
|
||||
and an `mcpServers` config for this game's `horizon-ctl mcp`. That config points into the
|
||||
version's folder, so it stops working once the launcher removes that version; an agent that
|
||||
already has `horizon-launcher mcp` does not need it.
|
||||
- **The default:** a game an agent starts (`play` in MCP, `horizon-ctl launch`, `--control`) is
|
||||
under control. A game the player starts from the window is not, until they turn Agent Control
|
||||
on. An open socket lets any program of that user drive the game, so it opens only when an agent
|
||||
|
||||
+219
-12
@@ -1,12 +1,16 @@
|
||||
//! The running game's tools in the launcher's MCP, so an agent needs one server
|
||||
//! (docs/agent-control.md): the selected version's `horizon-ctl mcp` runs as a child, its tools
|
||||
//! are offered as `game_<name>`, and calls go to it unchanged. `play` starts the game
|
||||
//! with the control socket that child talks to.
|
||||
//! are offered as `game_<name>`, and calls go to it unchanged. `play` starts the game with
|
||||
//! the launcher's own control endpoint; a game the player started with Agent Control on answers
|
||||
//! on the game's default endpoint instead. The child talks to whichever of them answers.
|
||||
use horizon_launcher::backend;
|
||||
use serde_json::{json, Value};
|
||||
use std::ffi::OsString;
|
||||
use std::io::{BufRead, BufReader, Write};
|
||||
use std::path::PathBuf;
|
||||
use std::net::{Ipv4Addr, SocketAddr, TcpStream};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::{Child, ChildStdin, ChildStdout, Stdio};
|
||||
use std::time::Duration;
|
||||
|
||||
pub const PREFIX: &str = "game_";
|
||||
|
||||
@@ -37,16 +41,80 @@ pub fn control_args() -> Vec<String> {
|
||||
]
|
||||
}
|
||||
|
||||
/// Whether a game answers on the control endpoint (a crashed game's socket refuses).
|
||||
pub fn listening() -> bool {
|
||||
let path = socket_path();
|
||||
/// The endpoint a game listens on when nothing names one, which is where Agent Control opens it.
|
||||
/// The launcher runs whichever version is installed, so this is a contract with every game
|
||||
/// rather than shared code: `control::default_path` in horizon-client, and `horizon-ctl help`.
|
||||
fn default_endpoint(
|
||||
control: Option<OsString>,
|
||||
runtime: Option<OsString>,
|
||||
temp: PathBuf,
|
||||
) -> PathBuf {
|
||||
if let Some(path) = control {
|
||||
return path.into();
|
||||
}
|
||||
if !cfg!(unix) {
|
||||
return temp.join("horizon-control.json");
|
||||
}
|
||||
runtime
|
||||
.map_or(temp, PathBuf::from)
|
||||
.join("horizon-control.sock")
|
||||
}
|
||||
|
||||
/// The endpoint to talk to: the game `play` started, else the game answering on the default
|
||||
/// endpoint. With neither it stays the launcher's, the one `play` will open.
|
||||
fn choose_endpoint(own: PathBuf, default: PathBuf) -> PathBuf {
|
||||
if !answers(&own) && answers(&default) {
|
||||
default
|
||||
} else {
|
||||
own
|
||||
}
|
||||
}
|
||||
|
||||
fn current_endpoint() -> PathBuf {
|
||||
choose_endpoint(
|
||||
socket_path(),
|
||||
default_endpoint(
|
||||
std::env::var_os("HORIZON_CONTROL"),
|
||||
std::env::var_os("XDG_RUNTIME_DIR"),
|
||||
std::env::temp_dir(),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
/// Whether a game answers on an endpoint. The file alone says nothing: a crashed game leaves it.
|
||||
fn answers(path: &Path) -> bool {
|
||||
#[cfg(unix)]
|
||||
return std::os::unix::net::UnixStream::connect(path).is_ok();
|
||||
#[cfg(not(unix))]
|
||||
return path.exists();
|
||||
if path.extension().is_none_or(|e| e != "json") {
|
||||
return std::os::unix::net::UnixStream::connect(path).is_ok();
|
||||
}
|
||||
tcp_answers(path).unwrap_or(false)
|
||||
}
|
||||
|
||||
/// The file names a localhost port and its token (`control::connect` in horizon-client): a game
|
||||
/// accepts that token, and whatever took over the port of a game long gone does not.
|
||||
fn tcp_answers(path: &Path) -> Option<bool> {
|
||||
let endpoint: Value = serde_json::from_str(&std::fs::read_to_string(path).ok()?).ok()?;
|
||||
let port = u16::try_from(endpoint["port"].as_u64()?).ok()?;
|
||||
let token = endpoint["token"].as_str()?;
|
||||
let address = SocketAddr::from((Ipv4Addr::LOCALHOST, port));
|
||||
let stream = TcpStream::connect_timeout(&address, Duration::from_millis(500)).ok()?;
|
||||
stream.set_read_timeout(Some(Duration::from_secs(2))).ok()?;
|
||||
writeln!(&stream, "auth {token}").ok()?;
|
||||
let mut reply = String::new();
|
||||
BufReader::new(&stream).read_line(&mut reply).ok()?;
|
||||
Some(reply.contains("\"ok\""))
|
||||
}
|
||||
|
||||
/// Whether the game `play` started answers yet.
|
||||
pub fn listening() -> bool {
|
||||
answers(&socket_path())
|
||||
}
|
||||
|
||||
pub struct GameTools {
|
||||
/// The installed version this `horizon-ctl` belongs to.
|
||||
tag: String,
|
||||
/// The endpoint it talks to.
|
||||
endpoint: PathBuf,
|
||||
child: Child,
|
||||
stdin: ChildStdin,
|
||||
stdout: BufReader<ChildStdout>,
|
||||
@@ -57,13 +125,17 @@ pub struct GameTools {
|
||||
impl GameTools {
|
||||
/// Start `horizon-ctl mcp` of an installed version and read its tools.
|
||||
pub fn start(tag: &str) -> Result<Self, String> {
|
||||
Self::start_on(tag, current_endpoint())
|
||||
}
|
||||
|
||||
fn start_on(tag: &str, endpoint: PathBuf) -> Result<Self, String> {
|
||||
let ctl = backend::version_dir(tag).join(CTL);
|
||||
if !ctl.exists() {
|
||||
return Err(format!("{tag} has no {CTL}"));
|
||||
}
|
||||
let mut child = horizon_launcher::process::command(&ctl)
|
||||
.arg("--socket")
|
||||
.arg(socket_path())
|
||||
.arg(&endpoint)
|
||||
.arg("mcp")
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
@@ -73,6 +145,8 @@ impl GameTools {
|
||||
let stdin = child.stdin.take().ok_or("no stdin")?;
|
||||
let stdout = BufReader::new(child.stdout.take().ok_or("no stdout")?);
|
||||
let mut tools = Self {
|
||||
tag: tag.to_owned(),
|
||||
endpoint,
|
||||
child,
|
||||
stdin,
|
||||
stdout,
|
||||
@@ -97,9 +171,15 @@ impl GameTools {
|
||||
&self.tools
|
||||
}
|
||||
|
||||
/// Call a `game_` tool; the result is horizon-ctl's (text and image content).
|
||||
/// Call a `game_` tool; the result is horizon-ctl's (text and image content). The game can
|
||||
/// start, stop or turn Agent Control on between two calls, and an installed horizon-ctl keeps
|
||||
/// the endpoint it started with, so another endpoint means another child.
|
||||
pub fn call(&mut self, name: &str, args: &Value) -> Result<Value, String> {
|
||||
let inner = name.strip_prefix(PREFIX).ok_or("not a game tool")?;
|
||||
let endpoint = current_endpoint();
|
||||
if endpoint != self.endpoint {
|
||||
*self = Self::start_on(&self.tag, endpoint)?;
|
||||
}
|
||||
let result = self.rpc("tools/call", json!({ "name": inner, "arguments": args }))?;
|
||||
Ok(explain_unreachable(result))
|
||||
}
|
||||
@@ -175,7 +255,7 @@ fn explain_unreachable(mut result: Value) -> Value {
|
||||
});
|
||||
if unreachable {
|
||||
result["content"] = json!([{ "type": "text", "text": json!({
|
||||
"error": "The game is not running under agent control. Start it with play, then game_wait_ready.",
|
||||
"error": "The game is not running under agent control. Start it with play, then game_wait_ready, or ask the player to turn on Agent Control in the game they started (Options > Horizon Settings > AI Agent).",
|
||||
"kind": "state",
|
||||
}).to_string() }]);
|
||||
}
|
||||
@@ -185,6 +265,129 @@ fn explain_unreachable(mut result: Value) -> Value {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::net::TcpListener;
|
||||
|
||||
/// A folder for one test's endpoint files.
|
||||
fn scratch(name: &str) -> PathBuf {
|
||||
let dir = std::env::temp_dir().join(format!(
|
||||
"horizon-game-endpoint-{name}-{}",
|
||||
std::process::id()
|
||||
));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
dir
|
||||
}
|
||||
|
||||
fn write_endpoint(path: &Path, port: u16, token: &str) {
|
||||
let endpoint = json!({ "transport": "tcp", "port": port, "token": token });
|
||||
std::fs::write(path, endpoint.to_string()).unwrap();
|
||||
}
|
||||
|
||||
/// An endpoint a game listens on: the game's half of the token exchange.
|
||||
fn live_endpoint(path: &Path) {
|
||||
let listener = TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).unwrap();
|
||||
write_endpoint(path, listener.local_addr().unwrap().port(), "secret");
|
||||
std::thread::spawn(move || {
|
||||
for stream in listener.incoming().flatten() {
|
||||
let mut line = String::new();
|
||||
let _ = BufReader::new(&stream).read_line(&mut line);
|
||||
let reply = if line.trim() == "auth secret" {
|
||||
r#"{"ok":true}"#
|
||||
} else {
|
||||
r#"{"error":"bad or missing token: the first line must be auth TOKEN"}"#
|
||||
};
|
||||
let _ = writeln!(&stream, "{reply}");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// What a crashed game leaves: the file, and nothing on its port.
|
||||
fn stale_endpoint(path: &Path) {
|
||||
let port = TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
|
||||
.unwrap()
|
||||
.local_addr()
|
||||
.unwrap()
|
||||
.port();
|
||||
write_endpoint(path, port, "secret");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_game_the_launcher_started_comes_before_the_default_endpoint() {
|
||||
let dir = scratch("both");
|
||||
let (own, default) = (dir.join("game-control.json"), dir.join("default.json"));
|
||||
live_endpoint(&own);
|
||||
live_endpoint(&default);
|
||||
assert_eq!(choose_endpoint(own.clone(), default), own);
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
/// #9465: a game started from its window, with Agent Control turned on.
|
||||
#[test]
|
||||
fn with_no_game_from_the_launcher_the_default_endpoint_is_used() {
|
||||
let dir = scratch("fallback");
|
||||
let (own, default) = (dir.join("game-control.json"), dir.join("default.json"));
|
||||
live_endpoint(&default);
|
||||
assert_eq!(choose_endpoint(own.clone(), default.clone()), default);
|
||||
// A crashed game's file on the launcher's endpoint hides nothing.
|
||||
stale_endpoint(&own);
|
||||
assert_eq!(choose_endpoint(own, default.clone()), default);
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn with_no_game_answering_the_launchers_endpoint_stays() {
|
||||
let dir = scratch("none");
|
||||
let (own, default) = (dir.join("game-control.json"), dir.join("default.json"));
|
||||
assert_eq!(choose_endpoint(own.clone(), default.clone()), own);
|
||||
stale_endpoint(&default);
|
||||
assert_eq!(choose_endpoint(own.clone(), default), own);
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
/// A program that took over the port of a game long gone is not a game, and neither is a
|
||||
/// file that names no port.
|
||||
#[test]
|
||||
fn an_endpoint_that_refuses_its_token_does_not_answer() {
|
||||
let dir = scratch("refused");
|
||||
let refused = dir.join("refused.json");
|
||||
live_endpoint(&refused);
|
||||
let port = serde_json::from_str::<Value>(&std::fs::read_to_string(&refused).unwrap())
|
||||
.unwrap()["port"]
|
||||
.as_u64()
|
||||
.unwrap();
|
||||
assert!(answers(&refused));
|
||||
write_endpoint(&refused, port as u16, "another");
|
||||
assert!(!answers(&refused));
|
||||
let broken = dir.join("broken.json");
|
||||
std::fs::write(&broken, "{}").unwrap();
|
||||
assert!(!answers(&broken));
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_default_endpoint_is_the_games() {
|
||||
let temp = PathBuf::from("temp");
|
||||
let runtime = || Some(OsString::from("run"));
|
||||
assert_eq!(
|
||||
default_endpoint(Some("mine.sock".into()), runtime(), temp.clone()),
|
||||
PathBuf::from("mine.sock")
|
||||
);
|
||||
if cfg!(unix) {
|
||||
assert_eq!(
|
||||
default_endpoint(None, runtime(), temp.clone()),
|
||||
Path::new("run").join("horizon-control.sock")
|
||||
);
|
||||
assert_eq!(
|
||||
default_endpoint(None, None, temp.clone()),
|
||||
temp.join("horizon-control.sock")
|
||||
);
|
||||
} else {
|
||||
assert_eq!(
|
||||
default_endpoint(None, runtime(), temp.clone()),
|
||||
temp.join("horizon-control.json")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn offers_the_game_tools_prefixed_without_launch() {
|
||||
@@ -212,6 +415,10 @@ mod tests {
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.contains("with play"));
|
||||
assert!(result["content"][0]["text"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.contains("Agent Control"));
|
||||
let other = json!({ "content": [{ "type": "text", "text": "{\"error\":\"no world\"}" }], "isError": true });
|
||||
assert_eq!(explain_unreachable(other.clone()), other);
|
||||
}
|
||||
|
||||
+1
-1
@@ -12,7 +12,7 @@ use std::path::PathBuf;
|
||||
use std::time::Duration;
|
||||
|
||||
/// What an agent reads before its first call.
|
||||
const INSTRUCTIONS: &str = "Manage Horizon, a Rust reimplementation of Minecraft Java Edition: install versions, prepare them from the player's own Minecraft, start the client or a dedicated server. Every tool is a horizon-launcher CLI command of the same name (mods_search is horizon-launcher mods search). Once play started the game, the game_* tools play it: game_state, game_ui (every widget of the open screen), game_click, game_goto, game_screenshot...";
|
||||
const INSTRUCTIONS: &str = "Manage Horizon, a Rust reimplementation of Minecraft Java Edition: install versions, prepare them from the player's own Minecraft, start the client or a dedicated server. Every tool is a horizon-launcher CLI command of the same name (mods_search is horizon-launcher mods search). Once play started the game, or the player turned on Agent Control in a game they started, the game_* tools play it: game_state, game_ui (every widget of the open screen), game_click, game_goto, game_screenshot...";
|
||||
|
||||
/// The revisions this server advertises: `horizon_mcp` serves them (`2026-07-28` stateless,
|
||||
/// `2025-06-18` for the clients that still open with `initialize`).
|
||||
|
||||
@@ -3,9 +3,10 @@
|
||||
|
||||
A scratch launcher home gets one "installed" version made of this checkout's builds
|
||||
(horizon-client and horizon-ctl) and the prepared resources in .local/horizon-client. An MCP
|
||||
client then talks to `horizon-launcher mcp` alone: every CLI command is listed as a tool of the same
|
||||
name (versions, settings, mods_search...), a destructive one needs confirm, the game tools are
|
||||
listed, a call before the game runs says to start it, play starts it under control, and
|
||||
client then talks to `horizon-launcher mcp` alone. It first plays a game started from its window with
|
||||
Agent Control on, and a game on the launcher's own endpoint wins over it. Then every CLI command is
|
||||
listed as a tool of the same name (versions, settings, mods_search...), a destructive one needs confirm,
|
||||
the game tools are listed, a call before the game runs says to start it, play starts it under control, and
|
||||
game_wait_ready, game_state, game_ui and game_screenshot play it until game_quit. Then No AI is turned on with
|
||||
`horizon-launcher no-ai on`: `horizon-launcher mcp` refuses to start, and a session already running
|
||||
refuses its next call.
|
||||
@@ -23,6 +24,7 @@ import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
try:
|
||||
@@ -40,8 +42,9 @@ class Failed(Exception):
|
||||
|
||||
class Mcp:
|
||||
def __init__(self, launcher, env):
|
||||
self.env = env
|
||||
self.proc = subprocess.Popen([str(launcher), 'mcp'], env=env, stdin=subprocess.PIPE,
|
||||
stdout=subprocess.PIPE, text=True)
|
||||
stdout=subprocess.PIPE, text=True, encoding='utf-8')
|
||||
self.next_id = 0
|
||||
|
||||
def rpc(self, method, params):
|
||||
@@ -112,6 +115,56 @@ def scenario(m, out):
|
||||
return {'tools': len(tools)}
|
||||
|
||||
|
||||
def state_until(m, screen):
|
||||
"""game_state, until the game that answers is on this screen (a game that quit takes a moment to go)."""
|
||||
deadline = time.time() + 300
|
||||
while True:
|
||||
result, texts, _ = m.call('game_state')
|
||||
if not result['isError'] and json.loads(texts[-1])['screen'] == screen:
|
||||
return
|
||||
assert time.time() < deadline, f'no game on {screen}: {texts}'
|
||||
time.sleep(1)
|
||||
|
||||
|
||||
def window_game_scenario(m, client, resources, work):
|
||||
"""A game the player started (no --control) with Agent Control on is reached on the default endpoint,
|
||||
and a game on the launcher's own endpoint comes first while both run (#9465). That second game has the
|
||||
arguments play gives it (game_tools::control_args), started here because play needs a signed-in owner."""
|
||||
m.rpc('initialize', {'protocolVersion': '2025-06-18', 'capabilities': {},
|
||||
'clientInfo': {'name': 'launcher_mcp_e2e', 'version': '1'}})
|
||||
|
||||
def start(home, *extra):
|
||||
home.mkdir(exist_ok=True)
|
||||
# A first launch shows the accessibility onboarding before the title.
|
||||
(home / 'options.txt').write_text('onboardAccessibility:false\n')
|
||||
args = [str(client), '--resources', str(resources), *extra]
|
||||
if sys.platform == 'darwin':
|
||||
args.append('--offscreen')
|
||||
return subprocess.Popen(args, cwd=home, env=dict(m.env, HORIZON_HOME=str(home)),
|
||||
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||||
|
||||
window_home = work / 'window-home'
|
||||
window_home.mkdir()
|
||||
(window_home / 'horizon-extras.txt').write_text('agentControl:true\n')
|
||||
own = work / 'launcher' / ('game-control.sock' if os.name == 'posix' else 'game-control.json')
|
||||
games = [start(window_home)]
|
||||
try:
|
||||
state_until(m, 'title')
|
||||
games.append(start(work / 'launcher-game-home', '--control-socket', str(own), '--world', 'Agent', '--creative'))
|
||||
state_until(m, 'playing')
|
||||
m.call('game_quit')
|
||||
# Its game gone, the session is back to the window's.
|
||||
state_until(m, 'title')
|
||||
m.call('game_quit')
|
||||
for game in games:
|
||||
game.wait(timeout=60)
|
||||
finally:
|
||||
for game in games:
|
||||
if game.poll() is None:
|
||||
game.kill()
|
||||
return {'window_game': 'reached'}
|
||||
|
||||
|
||||
REFUSAL = "AI features are off: turn off No AI in the launcher's settings"
|
||||
|
||||
|
||||
@@ -163,14 +216,21 @@ def main():
|
||||
work = Path(tempfile.mkdtemp(prefix='hzlm-'))
|
||||
version = work / 'launcher/versions' / TAG
|
||||
(version / '.local/horizon-client').mkdir(parents=True)
|
||||
suffix = '.exe' if sys.platform == 'win32' else ''
|
||||
for name in ['horizon-client', 'horizon-ctl']:
|
||||
os.symlink(bins / name, version / name)
|
||||
os.symlink(resources, version / '.local/horizon-client' / lab.VERSION)
|
||||
# Linking a file on Windows needs Developer Mode or an administrator; a hard link needs neither.
|
||||
link = os.link if sys.platform == 'win32' else os.symlink
|
||||
link(bins / (name + suffix), version / (name + suffix))
|
||||
lab.link_dir(resources, version / '.local/horizon-client' / lab.VERSION)
|
||||
(version / '.installed').write_text('')
|
||||
env = lab.game_env(HORIZON_LAUNCHER_HOME=work / 'launcher', HORIZON_HOME=work / 'home', HORIZON_TELEMETRY_URL='off')
|
||||
(work / 'home').mkdir()
|
||||
# The game's default endpoint, this run's: a player's game with Agent Control on stays out of it.
|
||||
env = lab.game_env(HORIZON_LAUNCHER_HOME=work / 'launcher', HORIZON_HOME=work / 'home', HORIZON_TELEMETRY_URL='off',
|
||||
HORIZON_CONTROL=work / 'default.sock')
|
||||
m = Mcp(Path(args.launcher).resolve(), env)
|
||||
try:
|
||||
summary = scenario(m, out)
|
||||
summary = window_game_scenario(m, bins / ('horizon-client' + suffix), resources, work)
|
||||
summary.update(scenario(m, out))
|
||||
summary.update(no_ai_scenario(Path(args.launcher).resolve(), env, work / 'home'))
|
||||
print(f'[launcher-mcp] PASS: {summary}')
|
||||
ok = True
|
||||
|
||||
Reference in new issue
Block a user