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)
{
    private readonly EndpointDiscovery discovery = discovery ?? new();
    private readonly AgentPipeClient client = client ?? new();
    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]" },
                    note = "stdout 为 JSON,诊断写入 stderr;多个游戏运行时必须选择 --pipe 或 --pid。" });
                return 0;
            }
            var arguments = Parse(args);
            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 (Exception ex) when (ex is IOException or UnauthorizedAccessException or ArgumentException or OverflowException)
        { 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 Arguments Parse(string[] args)
    {
        string command = args[0];
        if (command is not ("discover" or "call" or "deploy" or "watch")) throw new CliUsageException("未知命令;使用 --help 查看用法。");
        int position = 1; string? method = null;
        if (command == "call") { if (args.Length < 2 || args[1].StartsWith("--")) throw new CliUsageException("call 需要方法名。"); method = args[position++]; }
        string[] specific = command switch { "call" => ["--params"], "deploy" => ["--block", "--file", "--expected-hash"], "watch" => ["--block", "--seconds", "--interval", "--out"], _ => [] };
        var allowed = 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);
}