XFE Git
XFE Studio Git
Git 首页 全局搜索
XFE 主站 文档 NuGet

XFE.SpaceEngineers.AgentBridge

【SpaceEngineer】AI调试插件

公开
关注 0 Fork 0 Star 0
UTF-8
using System.Diagnostics;
using System.Globalization;
using System.Text;
using System.Text.Json;

namespace XFE.SeAgent.Cli;

public sealed class CliApplication(EndpointDiscovery? discovery = null, AgentPipeClient? client = null, OfflineWorldStore? worlds = null, IWorldUseGuard? host = null)
{
    private readonly EndpointDiscovery discovery = discovery ?? new();
    private readonly AgentPipeClient client = client ?? new();
    private readonly OfflineWorldStore worlds = worlds ?? new();
    private readonly IWorldUseGuard host = host ?? new HostStatus(discovery);
    private static readonly JsonSerializerOptions Json = new(JsonSerializerDefaults.Web);

    public async Task<int> RunAsync(string[] args, TextWriter output, TextWriter error, CancellationToken token = default)
    {
        try
        {
            if (args.Length == 0 || args[0] is "help" or "--help" or "-h")
            {
                await WriteAsync(output, new { name = "xfe-se", protocolVersion = 1, commands = new[] {
                    "discover [--pid PID]", "call METHOD [--params JSON|@FILE] [--pipe NAME|--pid PID] [--timeout 30]",
                    "deploy --block ID --file SCRIPT.CS --expected-hash SHA256 [--pipe NAME|--pid PID]",
                    "watch --block ID,ID --seconds 30 --interval 1 --out telemetry.jsonl [--pipe NAME|--pid PID]",
                    "host status",
                    "world list [--root SAVE_ROOT]", "world inspect --path WORLD", "world read --path WORLD --block ID",
                    "world backup --path WORLD [--out NEW_DIRECTORY]",
                    "world deploy --manifest FILE [--apply true|false]",
                    "world restore --backup DEPLOYMENT_BACKUP" },
                    note = "stdout 为 JSON,诊断写入 stderr;多个游戏运行时必须选择 --pipe 或 --pid。" });
                return 0;
            }
            var arguments = Parse(args);
            if (arguments.Command == "host")
            { await WriteAsync(output, new { hosts = host.Inspect() }); return 0; }
            if (arguments.Command == "world")
            {
                object result = arguments.Method switch
                {
                    "list" => worlds.List(arguments.Value("--root")),
                    "inspect" => worlds.Inspect(arguments.Required("--path")),
                    "read" => worlds.ReadProgram(arguments.Required("--path"), arguments.Required("--block")),
                    "backup" => worlds.Backup(arguments.Required("--path"), arguments.Value("--out")),
                    "deploy" => worlds.Deploy(arguments.Required("--manifest"), Boolean(arguments.Value("--apply") ?? "false")),
                    "restore" => worlds.Restore(arguments.Required("--backup")),
                    _ => throw new CliUsageException("未知 world 操作。")
                };
                await WriteAsync(output, result); return 0;
            }
            if (arguments.Command == "discover")
            {
                var endpoints = discovery.Discover(arguments.Value("--endpoint-directory"));
                if (arguments.Value("--pid") is string pid) { int selectedPid = PositiveInt(pid, "PID"); endpoints = endpoints.Where(item => item.Pid == selectedPid).ToArray(); }
                await WriteAsync(output, new { endpoints }); return 0;
            }
            string pipe = ResolvePipe(arguments);
            int timeout = checked((int)Math.Round(Number(arguments.Value("--timeout") ?? "30", "请求超时", .001, 30) * 1000));
            string method;
            JsonElement parameters;
            if (arguments.Command == "call")
            {
                method = arguments.Method!;
                string json = arguments.Value("--params") ?? "{}";
                if (json.StartsWith('@')) json = await ReadBoundedTextAsync(json[1..], token);
                using var document = JsonDocument.Parse(json, new JsonDocumentOptions { MaxDepth = 64 });
                if (document.RootElement.ValueKind != JsonValueKind.Object) throw new CliUsageException("--params 必须是 JSON 对象。");
                parameters = document.RootElement.Clone();
            }
            else if (arguments.Command == "deploy")
            {
                method = "pb.deploy";
                string id = EntityId(arguments.Required("--block")), hash = arguments.Required("--expected-hash");
                if (hash.Length != 64 || !hash.All(Uri.IsHexDigit)) throw new CliUsageException("--expected-hash 必须是 64 位 SHA-256;先用 pb.read 获取当前脚本哈希。");
                string source = await ReadBoundedTextAsync(arguments.Required("--file"), token);
                parameters = JsonSerializer.SerializeToElement(new { entityId = id, source, expectedSha256 = hash.ToLowerInvariant() });
            }
            else return await WatchAsync(arguments, pipe, timeout, output, error, token);
            var response = await client.CallAsync(pipe, method, parameters, timeout, token);
            await WriteAsync(output, response.Envelope);
            if (response.IsError) { await error.WriteLineAsync(response.Envelope.GetProperty("error").GetRawText()); return 4; }
            return 0;
        }
        catch (OperationCanceledException)
        { await FailureAsync(output, error, "cancelled", "客户端等待已取消。"); return 130; }
        catch (CliUsageException ex)
        { await FailureAsync(output, error, "invalid_arguments", ex.Message); return 2; }
        catch (JsonException ex)
        { await FailureAsync(output, error, "invalid_json", "输入 JSON 无效:" + ex.Message); return 2; }
        catch (AgentClientException ex)
        { await FailureAsync(output, error, ex.Code, ex.Message); return 3; }
        catch (OfflineWorldException ex)
        { await FailureAsync(output, error, ex.Code, ex.Message); return 5; }
        catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or ArgumentException or OverflowException or System.Xml.XmlException)
        { await FailureAsync(output, error, "local_error", ex.Message); return 1; }
    }

    private async Task<int> WatchAsync(Arguments arguments, string pipe, int timeout, TextWriter output, TextWriter error, CancellationToken token)
    {
        string[] ids = arguments.Required("--block").Split(',').Select(EntityId).Distinct(StringComparer.Ordinal).ToArray();
        double seconds = Number(arguments.Value("--seconds") ?? "30", "采样时长", .05, 86400);
        double interval = Number(arguments.Value("--interval") ?? "1", "采样间隔", .05, 60);
        string path = Path.GetFullPath(arguments.Required("--out")); Directory.CreateDirectory(Path.GetDirectoryName(path)!);
        await using var file = new StreamWriter(new FileStream(path, FileMode.Create, FileAccess.Write, FileShare.Read), new UTF8Encoding(false)) { NewLine = "\n" };
        var parameters = JsonSerializer.SerializeToElement(new { entityIds = ids });
        var clock = Stopwatch.StartNew(); int samples = 0;
        do
        {
            token.ThrowIfCancellationRequested();
            var response = await client.CallAsync(pipe, "telemetry.snapshot", parameters, timeout, token);
            await file.WriteLineAsync(JsonSerializer.Serialize(new { capturedUtc = DateTimeOffset.UtcNow, response = response.Envelope }, Json)); await file.FlushAsync(token); samples++;
            if (response.IsError)
            {
                await WriteAsync(output, new { samples, outputPath = path, error = response.Envelope.GetProperty("error") });
                await error.WriteLineAsync(response.Envelope.GetProperty("error").GetRawText()); return 4;
            }
            double remaining = seconds - clock.Elapsed.TotalSeconds;
            if (remaining <= 0) break;
            await Task.Delay(TimeSpan.FromSeconds(Math.Min(interval, remaining)), token);
        } while (clock.Elapsed.TotalSeconds < seconds);
        await WriteAsync(output, new { samples, elapsedSeconds = clock.Elapsed.TotalSeconds, outputPath = path }); return 0;
    }

    private string ResolvePipe(Arguments arguments)
    {
        if (arguments.Value("--pipe") is string pipe)
        {
            if (arguments.Value("--pid") != null) throw new CliUsageException("--pipe 与 --pid 只能选一个。");
            return pipe;
        }
        var endpoints = discovery.Discover(arguments.Value("--endpoint-directory"));
        if (arguments.Value("--pid") is string pid) { int selectedPid = PositiveInt(pid, "PID"); endpoints = endpoints.Where(item => item.Pid == selectedPid).ToArray(); }
        if (endpoints.Count == 0) throw new CliUsageException("未发现匹配的活动 Agent;请确认游戏中已加载插件,或明确指定 --pipe。");
        if (endpoints.Count > 1) throw new CliUsageException("多个游戏 Agent 正在运行,请使用 --pid 或 --pipe 选择一个。");
        return endpoints[0].PipeName;
    }

    private static async Task<string> ReadBoundedTextAsync(string path, CancellationToken token)
    {
        if (new FileInfo(path).Length > AgentPipeClient.MaximumRequestBytes) throw new CliUsageException("输入文件超过 1 MiB 请求限制。");
        string content = await File.ReadAllTextAsync(path, token);
        if (Encoding.UTF8.GetByteCount(content) > AgentPipeClient.MaximumRequestBytes) throw new CliUsageException("输入文件超过 1 MiB 请求限制。");
        return content;
    }
    private static async Task WriteAsync(TextWriter writer, object value) => await writer.WriteLineAsync(JsonSerializer.Serialize(value, Json));
    private static async Task FailureAsync(TextWriter output, TextWriter error, string code, string message)
    { await WriteAsync(output, new { error = new { code, message } }); await error.WriteLineAsync(message); }
    private static int PositiveInt(string value, string name) => int.TryParse(value, NumberStyles.None, CultureInfo.InvariantCulture, out int number) && number > 0 ? number : throw new CliUsageException(name + " 必须是正整数。");
    private static string EntityId(string value) => long.TryParse(value.Trim(), NumberStyles.AllowLeadingSign, CultureInfo.InvariantCulture, out long id) && id != 0 ? id.ToString(CultureInfo.InvariantCulture) : throw new CliUsageException("方块 ID 必须是非零 Int64 十进制整数。");
    private static double Number(string text, string name, double min, double max) => double.TryParse(text, NumberStyles.Float, CultureInfo.InvariantCulture, out double value) && double.IsFinite(value) && value >= min && value <= max ? value : throw new CliUsageException($"{name}必须在 {min} 至 {max} 秒之间。");
    private static bool Boolean(string value) => bool.TryParse(value, out bool result) ? result : throw new CliUsageException("--apply 必须是 true 或 false。");

    private static Arguments Parse(string[] args)
    {
        string command = args[0];
        if (command is not ("discover" or "call" or "deploy" or "watch" or "host" or "world")) throw new CliUsageException("未知命令;使用 --help 查看用法。");
        int position = 1; string? method = null;
        if (command is "call" or "world" or "host") { if (args.Length < 2 || args[1].StartsWith("--")) throw new CliUsageException(command + " 需要方法名。"); method = args[position++]; }
        string[] specific = command switch { "call" => ["--params"], "deploy" => ["--block", "--file", "--expected-hash"], "watch" => ["--block", "--seconds", "--interval", "--out"], _ => [] };
        string[] local = command == "host" && method == "status" ? [] : command == "world" ? method switch
        {
            "list" => ["--root"], "inspect" => ["--path"], "read" => ["--path", "--block"],
            "backup" => ["--path", "--out"], "deploy" => ["--manifest", "--apply"], "restore" => ["--backup"],
            _ => throw new CliUsageException("未知 world 操作。")
        } : command == "host" ? throw new CliUsageException("未知 host 操作。") : [];
        var allowed = (command is "host" or "world" ? local : specific.Concat(command == "discover" ? ["--pid", "--endpoint-directory"] : new[] { "--pipe", "--pid", "--timeout", "--endpoint-directory" })).ToHashSet(StringComparer.Ordinal);
        var options = new Dictionary<string, string>(StringComparer.Ordinal);
        while (position < args.Length)
        {
            string option = args[position++];
            if (!allowed.Contains(option) || position == args.Length) throw new CliUsageException("未知选项或缺少参数值:" + option);
            if (!options.TryAdd(option, args[position++])) throw new CliUsageException("选项重复:" + option);
        }
        return new(command, method, options);
    }

    private sealed record Arguments(string Command, string? Method, Dictionary<string, string> Options)
    {
        public string? Value(string key) => Options.GetValueOrDefault(key);
        public string Required(string key) => Value(key) is string value && value.Length > 0 ? value : throw new CliUsageException("缺少必填选项:" + key);
    }
    private sealed class CliUsageException(string message) : Exception(message);
}