"""Refined PrUn (github.com/refined-prun/refined-prun) XIT ACT action-package JSON - the semi-auto buy-list format: importing one pre-fills the in-game CXPO buy tile for each material, the player still clicks the real buy button themselves. Schema confirmed from the extension's own source (src/store/user-data.types.d.ts, src/features/XIT/ACT/actions/cx-buy/cx-buy.ts, 2026-10-17). Gotcha (verified against the real extension, not just reading the source): `global.name` (and group/ action names) get turned directly into an in-game command string by ActionPackageList.vue - spaces become underscores but other punctuation survives into the command literally, and APEX's command parser then rejects it with a generic "Illegal command" error on Edit/Execute (NOT on import, so it looks like the JSON is fine until you actually open it). sanitize_name() strips anything but letters/digits/spaces to avoid this. """ import re def sanitize_name(name: str) -> str: return re.sub(r"[^A-Za-z0-9 ]", "", name).strip() def build_package(name: str, materials: dict[str, float], exchange: str = "AI1") -> dict: """materials: {ticker: qty}. Quantities are rounded up to whole units (CXPO buy amounts aren't fractional).""" name = sanitize_name(name) group = sanitize_name(name.lower()) or "group" import math mats = {tk.upper(): math.ceil(qty) for tk, qty in materials.items() if qty > 0} return { "global": {"name": name}, "groups": [ {"type": "Manual", "name": group, "materials": mats}, ], "actions": [ { "type": "CX Buy", "name": f"Buy {name}", "group": group, "exchange": exchange, "priceLimits": {}, "buyPartial": False, "allowUnfilled": False, "useCXInv": True, } ], }