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

AutoMiningScript

【SpaceEngineer】全自动挖矿脚本

公开
关注 0 Fork 0 Star 0
UTF-8
"""Record a real AMS fleet cycle through AgentBridge; never invent game state."""
import argparse
from collections import defaultdict
from datetime import datetime, timezone
import json
from pathlib import Path
import subprocess
import sys
import tempfile
import time

from trace_ams import MAPPINGS, STATES


class Client:
    def __init__(self, cli, pid=None):
        self.cli, self.pid = str(cli), pid

    def call(self, method, parameters=None):
        payload = json.dumps(parameters or {})
        request_file = None
        try:
            # PB sources exceed the Windows command-line limit. The CLI accepts
            # UTF-8 request files and the plugin still enforces its normal limits.
            if len(payload) > 4096:
                with tempfile.NamedTemporaryFile(mode="w", suffix=".json", encoding="utf-8", delete=False) as stream:
                    request_file = Path(stream.name)
                    stream.write(payload)
                payload = "@" + str(request_file)
            args = [self.cli, "call", method, "--params", payload]
            if self.pid:
                args += ["--pid", str(self.pid)]
            run = subprocess.run(args, capture_output=True, encoding="utf-8", timeout=35)
        finally:
            if request_file is not None:
                request_file.unlink()
        value = json.loads(run.stdout)
        if run.returncode or "error" in value:
            raise RuntimeError(json.dumps(value, ensure_ascii=False))
        return value["result"]


def write(path, value):
    temporary = path.with_name(path.name + ".tmp")
    temporary.write_text(json.dumps(value, ensure_ascii=False, indent=2), encoding="utf-8")
    temporary.replace(path)


def setup(client, preparation, directory):
    status = client.call("world.status")
    if not status.get("canMutate") or Path(status["path"]) != Path(preparation["debugWorld"]):
        raise RuntimeError("Expected the authorized debug copy to be ready")
    if (directory / "fixture.json").exists():
        raise FileExistsError("Baseline already exists; use a new directory")
    directory.mkdir(parents=True, exist_ok=True)
    fixture = {"world": status, "programs": [], "observedIds": []}
    for old in preparation["programmableBlocks"]:
        program = client.call("pb.read", {"entityId": old["entityId"]})
        write(directory / ("program-" + old["entityId"] + ".json"), program)
        role = "fleet" if "舰队主控" in program["source"].splitlines()[0] else "miner"
        if program["sha256"] != old["sourceSha256"] or not program["hasInstance"] or program["hasCompileErrors"]:
            raise RuntimeError("Unexpected source or compilation failure: " + old["name"])
        if role == "miner" and program["sha256"] not in MAPPINGS:
            raise RuntimeError("Derive the exact packed field mapping before recording this version")
        fixture["programs"].append({"id": old["entityId"], "gridId": old["gridId"], "name": old["name"], "role": role, "sha256": program["sha256"]})
        blocks, offset = [], 0
        while True:
            page = client.call("blocks.list", {"gridId": old["gridId"], "limit": 256, "offset": offset})
            blocks += page["blocks"]
            if not page["truncated"]:
                break
            offset += len(page["blocks"])
        write(directory / ("blocks-" + old["gridId"] + ".json"), blocks)
        fixture["observedIds"] += [b["entityId"] for b in blocks if b.get("inventoryCount", 0) > 0 or b["entityId"] == old["entityId"]]
        full = client.call("telemetry.snapshot", {"gridId": old["gridId"], "limit": 1024, "includeScreens": False})
        write(directory / ("hardware-" + old["gridId"] + ".json"), full)
    fixture["observedIds"] = list(dict.fromkeys(fixture["observedIds"]))
    write(directory / "fixture.json", fixture)
    return fixture


def sample(client, fixture):
    record = {"utc": datetime.now(timezone.utc).isoformat(), "snapshots": [], "miners": []}
    cargo = defaultdict(lambda: defaultdict(float))
    connectors, grids = defaultdict(list), {}
    ids = fixture["observedIds"]
    for index in range(0, len(ids), 256):
        shot = client.call("telemetry.snapshot", {"entityIds": ids[index:index+256], "limit": 256, "includeScreens": False})
        record["snapshots"].append(shot)
        grids.update((g["entityId"], g) for g in shot["grids"])
        for block in shot["blocks"]:
            if "telemetryError" in block:
                raise RuntimeError("Incomplete telemetry: " + str(block))
            if "connector" in block:
                connectors[block["gridId"]].append({"id": block["entityId"], **block["connector"]})
            if block.get("cargoInventory"):
                for inventory in block.get("inventories", []):
                    if inventory.get("truncated"):
                        raise RuntimeError("Truncated cargo inventory")
                    for item in inventory.get("items", []):
                        if item["type"] == "MyObjectBuilder_Ore":
                            cargo[block["gridId"]][item["subtype"]] += item["amount"]
    for program in fixture["programs"]:
        if program["role"] != "miner":
            continue
        # Depth 3 expands cached rays/resources and can exhaust the plugin's
        # 600-node budget before reaching the state fields on a busy miner.
        fields = client.call("pb.inspect", {"entityId": program["id"], "fields": ["E", "H"], "depth": 2})["fields"]
        mapping = MAPPINGS[program["sha256"]]
        role = fields["H"]
        flight, job = role[mapping["flight"]], role[mapping["job"]]
        grid = grids[program["gridId"]]
        record["miners"].append({"id": program["id"], "name": program["name"], "gridId": program["gridId"],
            "sourceSha256": program["sha256"], "time": fields["E"], "state": STATES.get(role[mapping["state"]], role[mapping["state"]]),
            "reason": role["l"], "speed": grid["speed"], "position": grid["pose"]["position"], "integrity": grid.get("integrity"),
            "jobId": job.get("g") if isinstance(job, dict) else None, "job": job, "report": role[mapping["report"]],
            "cargoOres": dict(cargo[program["gridId"]]), "connectors": connectors[program["gridId"]],
            "flight": {key: flight[mapping[key]] for key in ("command", "attitude", "clearance", "scanIndex", "blocked", "problem", "cameraDetail", "obstacle")},
            "fields": fields})
    record["cargoByGrid"] = dict(cargo)
    return record


def summary(record):
    return {"utc": record["utc"], "miners": [{key: miner[key] for key in ("name", "state", "reason", "jobId", "speed", "cargoOres")} for miner in record["miners"]]}


def report(directory, after):
    """Summarize recorded observations, never treat an assignment as mining."""
    miners, samples = {}, 0
    for path in sorted(directory.glob("trace-*.jsonl")):
        with path.open(encoding="utf-8") as stream:
            for line in stream:
                # A currently running recorder may not have finished its last line.
                if not line.endswith("\n"):
                    continue
                record = json.loads(line)
                if record["utc"] < after:
                    continue
                samples += 1
                for m in record["miners"]:
                    entry = miners.setdefault(m["id"], {"name": m["name"], "states": {}, "peakCargoKg": 0,
                        "firstOreUtc": None, "dockedEmptyAfterOreUtc": None, "maximumDamage": 0,
                        "minimumBlockCount": None, "minimumIntegrity": None, "sourceHashes": []})
                    entry["states"].setdefault(m["state"], record["utc"])
                    if m["sourceSha256"] not in entry["sourceHashes"]:
                        entry["sourceHashes"].append(m["sourceSha256"])
                    ore = sum(m["cargoOres"].values())
                    entry["peakCargoKg"] = max(entry["peakCargoKg"], ore)
                    if ore > .01 and entry["firstOreUtc"] is None:
                        entry["firstOreUtc"] = record["utc"]
                    if entry["firstOreUtc"] and ore <= .01 and any(c["status"] == "Connected" for c in m["connectors"]):
                        entry["dockedEmptyAfterOreUtc"] = entry["dockedEmptyAfterOreUtc"] or record["utc"]
                    if m.get("integrity"):
                        entry["maximumDamage"] = max(entry["maximumDamage"], m["integrity"]["currentDamage"])
                        for target, field in [("minimumBlockCount", "inspectedBlockCount"), ("minimumIntegrity", "current")]:
                            value = m["integrity"][field]
                            entry[target] = value if entry[target] is None else min(entry[target], value)
                    entry["lastState"] = m["state"]
    result = {"afterUtc": after, "samples": samples, "miners": miners,
        "observedMiners": len(miners), "observedOrePickup": sum(bool(m["firstOreUtc"]) for m in miners.values()),
        "observedDockedEmptyAfterPickup": sum(bool(m["dockedEmptyAfterOreUtc"]) for m in miners.values())}
    write(directory / "cycle-report.json", result)
    return result


def main():
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("mode", choices=("setup", "watch", "sample", "report"))
    parser.add_argument("--cli", type=Path)
    parser.add_argument("--pid", type=int)
    parser.add_argument("--directory", type=Path, required=True)
    parser.add_argument("--preparation", type=Path)
    parser.add_argument("--seconds", type=float, default=60)
    parser.add_argument("--interval", type=float, default=4)
    parser.add_argument("--after", help="Report only observations after this ISO UTC timestamp")
    args = parser.parse_args()
    if args.mode == "report":
        if not args.after:
            parser.error("report requires --after to exclude earlier cargo")
        print(json.dumps(report(args.directory, args.after), ensure_ascii=False, indent=2))
        return
    if args.cli is None:
        parser.error("--cli is required for game requests")
    client = Client(args.cli, args.pid)
    if args.mode == "setup":
        fixture = setup(client, json.loads(args.preparation.read_text(encoding="utf-8")), args.directory)
        print(json.dumps({"programs": len(fixture["programs"]), "observedBlocks": len(fixture["observedIds"])}))
        return
    fixture = json.loads((args.directory / "fixture.json").read_text(encoding="utf-8"))
    if args.mode == "sample":
        result = sample(client, fixture)
        write(args.directory / "latest.json", result)
        print(json.dumps(summary(result), ensure_ascii=False))
        return
    stamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%S")
    started = time.monotonic()
    with (args.directory / ("trace-" + stamp + ".jsonl")).open("x", encoding="utf-8") as output:
        while time.monotonic() - started < args.seconds:
            result = sample(client, fixture)
            output.write(json.dumps(result, ensure_ascii=False, separators=(",", ":")) + "\n")
            output.flush()
            write(args.directory / "latest.json", result)
            print(json.dumps(summary(result), ensure_ascii=False), flush=True)
            time.sleep(args.interval)


if __name__ == "__main__":
    sys.stdout.reconfigure(encoding="utf-8")
    main()