diff --git a/.gitignore b/.gitignore index e707d9bac5..bb753184a1 100644 --- a/.gitignore +++ b/.gitignore @@ -45,3 +45,6 @@ runtime/imgui.ini src/poe_api_response.json +/.claude/ +.mcp.json +/pob-mcp/server/__pycache__/ diff --git a/pob-mcp-tick-debug.txt b/pob-mcp-tick-debug.txt new file mode 100644 index 0000000000..f121a7727c --- /dev/null +++ b/pob-mcp-tick-debug.txt @@ -0,0 +1,195 @@ +[2026-05-26 19:15:02] GUI bridge loaded successfully! Listening on port 12321 +[2026-05-26 19:15:03] First pobMcpTick called! +[2026-05-26 19:15:12] Accepted client connection! +[2026-05-26 19:15:12] Handshake sent: ok=true, err=nil +[2026-05-26 19:15:12] Disconnected client: ok=true, err=closed +[2026-05-26 19:15:12] Accepted client connection! +[2026-05-26 19:15:12] Handshake sent: ok=true, err=nil +[2026-05-26 19:15:12] Disconnected client: ok=true, err=closed +[2026-05-26 19:15:20] Accepted client connection! +[2026-05-26 19:15:20] Handshake sent: ok=true, err=nil +[2026-05-26 19:15:20] Disconnected client: ok=true, err=closed +[2026-05-26 19:15:26] Accepted client connection! +[2026-05-26 19:15:26] Handshake sent: ok=true, err=nil +[2026-05-26 19:15:26] Disconnected client: ok=true, err=closed +[2026-05-26 19:15:30] Accepted client connection! +[2026-05-26 19:15:30] Handshake sent: ok=true, err=nil +[2026-05-26 19:15:30] Disconnected client: ok=true, err=closed +[2026-05-26 19:16:25] Accepted client connection! +[2026-05-26 19:16:25] Handshake sent: ok=true, err=nil +[2026-05-26 19:16:30] Disconnected client: ok=true, err=closed +[2026-05-26 19:16:40] Accepted client connection! +[2026-05-26 19:16:40] Handshake sent: ok=true, err=nil +[2026-05-26 19:16:42] Disconnected client: ok=true, err=closed +[2026-05-26 19:16:54] Accepted client connection! +[2026-05-26 19:16:54] Handshake sent: ok=true, err=nil +[2026-05-26 19:16:58] Disconnected client: ok=true, err=closed +[2026-05-26 19:17:13] Accepted client connection! +[2026-05-26 19:17:13] Handshake sent: ok=true, err=nil +[2026-05-26 19:17:16] Disconnected client: ok=true, err=closed +[2026-05-26 19:17:27] Accepted client connection! +[2026-05-26 19:17:27] Handshake sent: ok=true, err=nil +[2026-05-26 19:17:27] Disconnected client: ok=true, err=closed +[2026-05-26 19:17:36] Accepted client connection! +[2026-05-26 19:17:36] Handshake sent: ok=true, err=nil +[2026-05-26 19:17:36] Disconnected client: ok=true, err=closed +[2026-05-26 19:17:42] Accepted client connection! +[2026-05-26 19:17:42] Handshake sent: ok=true, err=nil +[2026-05-26 19:17:42] Disconnected client: ok=true, err=closed +[2026-05-26 19:18:47] Accepted client connection! +[2026-05-26 19:18:47] Handshake sent: ok=true, err=nil +[2026-05-26 19:18:47] Disconnected client: ok=true, err=closed +[2026-05-26 19:18:47] Accepted client connection! +[2026-05-26 19:18:47] Handshake sent: ok=true, err=nil +[2026-05-26 19:18:47] Disconnected client: ok=true, err=closed +[2026-05-26 19:18:53] Accepted client connection! +[2026-05-26 19:18:53] Handshake sent: ok=true, err=nil +[2026-05-26 19:18:53] Disconnected client: ok=true, err=closed +[2026-05-26 19:18:53] Accepted client connection! +[2026-05-26 19:18:53] Handshake sent: ok=true, err=nil +[2026-05-26 19:18:53] Disconnected client: ok=true, err=closed +[2026-05-26 19:18:58] Accepted client connection! +[2026-05-26 19:18:58] Handshake sent: ok=true, err=nil +[2026-05-26 19:19:02] Disconnected client: ok=true, err=closed +[2026-05-26 19:19:13] Accepted client connection! +[2026-05-26 19:19:13] Handshake sent: ok=true, err=nil +[2026-05-26 19:19:14] Disconnected client: ok=true, err=closed +[2026-05-26 19:19:24] Accepted client connection! +[2026-05-26 19:19:24] Handshake sent: ok=true, err=nil +[2026-05-26 19:19:25] Disconnected client: ok=true, err=closed +[2026-05-26 19:19:34] Accepted client connection! +[2026-05-26 19:19:34] Handshake sent: ok=true, err=nil +[2026-05-26 19:20:57] Accepted client connection! +[2026-05-26 19:20:57] Handshake sent: ok=true, err=nil +[2026-05-26 19:20:57] Disconnected client: ok=true, err=closed +[2026-05-26 19:20:57] Disconnected client: ok=true, err=closed +[2026-05-26 19:20:57] Accepted client connection! +[2026-05-26 19:20:57] Handshake sent: ok=true, err=nil +[2026-05-26 19:20:57] Disconnected client: ok=true, err=closed +[2026-05-26 19:20:57] Accepted client connection! +[2026-05-26 19:20:57] Handshake sent: ok=true, err=nil +[2026-05-26 19:20:57] Disconnected client: ok=true, err=closed +[2026-05-26 19:20:57] Accepted client connection! +[2026-05-26 19:20:57] Handshake sent: ok=true, err=nil +[2026-05-26 19:20:57] Disconnected client: ok=true, err=closed +[2026-05-26 19:20:58] Accepted client connection! +[2026-05-26 19:20:58] Handshake sent: ok=true, err=nil +[2026-05-26 19:20:58] Disconnected client: ok=true, err=closed +[2026-05-26 19:20:58] Accepted client connection! +[2026-05-26 19:20:58] Handshake sent: ok=true, err=nil +[2026-05-26 19:20:58] Disconnected client: ok=true, err=closed +[2026-05-26 19:20:58] Accepted client connection! +[2026-05-26 19:20:58] Handshake sent: ok=true, err=nil +[2026-05-26 19:20:58] Disconnected client: ok=true, err=closed +[2026-05-26 19:20:58] Accepted client connection! +[2026-05-26 19:20:58] Handshake sent: ok=true, err=nil +[2026-05-26 19:20:58] Disconnected client: ok=true, err=closed +[2026-05-26 19:20:58] Accepted client connection! +[2026-05-26 19:20:58] Handshake sent: ok=true, err=nil +[2026-05-26 19:20:58] Disconnected client: ok=true, err=closed +[2026-05-26 19:20:58] Accepted client connection! +[2026-05-26 19:20:58] Handshake sent: ok=true, err=nil +[2026-05-26 19:21:03] Disconnected client: ok=true, err=closed +[2026-05-26 19:21:13] Accepted client connection! +[2026-05-26 19:21:13] Handshake sent: ok=true, err=nil +[2026-05-26 19:21:15] Disconnected client: ok=true, err=closed +[2026-05-26 19:28:08] Accepted client connection! +[2026-05-26 19:28:08] Handshake sent: ok=true, err=nil +[2026-05-26 19:28:08] Disconnected client: ok=true, err=closed +[2026-05-26 19:28:08] Accepted client connection! +[2026-05-26 19:28:08] Handshake sent: ok=true, err=nil +[2026-05-26 19:28:08] Disconnected client: ok=true, err=closed +[2026-05-26 19:28:08] Accepted client connection! +[2026-05-26 19:28:08] Handshake sent: ok=true, err=nil +[2026-05-26 19:28:08] Disconnected client: ok=true, err=closed +[2026-05-26 19:28:08] Accepted client connection! +[2026-05-26 19:28:08] Handshake sent: ok=true, err=nil +[2026-05-26 19:28:08] Disconnected client: ok=true, err=closed +[2026-05-26 19:28:08] Accepted client connection! +[2026-05-26 19:28:08] Handshake sent: ok=true, err=nil +[2026-05-26 19:28:08] Disconnected client: ok=true, err=closed +[2026-05-26 19:28:08] Accepted client connection! +[2026-05-26 19:28:08] Handshake sent: ok=true, err=nil +[2026-05-26 19:28:08] Disconnected client: ok=true, err=closed +[2026-05-26 19:28:08] Accepted client connection! +[2026-05-26 19:28:08] Handshake sent: ok=true, err=nil +[2026-05-26 19:28:08] Disconnected client: ok=true, err=closed +[2026-05-26 19:28:08] Accepted client connection! +[2026-05-26 19:28:08] Handshake sent: ok=true, err=nil +[2026-05-26 19:28:08] Disconnected client: ok=true, err=closed +[2026-05-26 19:28:08] Accepted client connection! +[2026-05-26 19:28:08] Handshake sent: ok=true, err=nil +[2026-05-26 19:28:08] Disconnected client: ok=true, err=closed +[2026-05-26 19:28:08] Accepted client connection! +[2026-05-26 19:28:08] Handshake sent: ok=true, err=nil +[2026-05-26 19:28:08] Disconnected client: ok=true, err=closed +[2026-05-26 19:28:08] Accepted client connection! +[2026-05-26 19:28:08] Handshake sent: ok=true, err=nil +[2026-05-26 19:28:08] Disconnected client: ok=true, err=closed +[2026-05-26 19:28:08] Accepted client connection! +[2026-05-26 19:28:08] Handshake sent: ok=true, err=nil +[2026-05-26 19:28:08] Disconnected client: ok=true, err=closed +[2026-05-26 19:28:08] Accepted client connection! +[2026-05-26 19:28:08] Handshake sent: ok=true, err=nil +[2026-05-26 19:28:08] Disconnected client: ok=true, err=closed +[2026-05-26 19:28:08] Accepted client connection! +[2026-05-26 19:28:08] Handshake sent: ok=true, err=nil +[2026-05-26 19:28:08] Disconnected client: ok=true, err=closed +[2026-05-26 19:28:08] Accepted client connection! +[2026-05-26 19:28:08] Handshake sent: ok=true, err=nil +[2026-05-26 19:28:08] Disconnected client: ok=true, err=closed +[2026-05-26 19:28:08] Accepted client connection! +[2026-05-26 19:28:08] Handshake sent: ok=true, err=nil +[2026-05-26 19:28:08] Disconnected client: ok=true, err=closed +[2026-05-26 19:28:08] Accepted client connection! +[2026-05-26 19:28:08] Handshake sent: ok=true, err=nil +[2026-05-26 19:28:08] Disconnected client: ok=true, err=closed +[2026-05-26 19:28:08] Accepted client connection! +[2026-05-26 19:28:08] Handshake sent: ok=true, err=nil +[2026-05-26 19:28:08] Disconnected client: ok=true, err=closed +[2026-05-26 19:28:08] Accepted client connection! +[2026-05-26 19:28:08] Handshake sent: ok=true, err=nil +[2026-05-26 19:28:08] Disconnected client: ok=true, err=closed +[2026-05-26 19:28:08] Accepted client connection! +[2026-05-26 19:28:08] Handshake sent: ok=true, err=nil +[2026-05-26 19:28:08] Disconnected client: ok=true, err=closed +[2026-05-26 19:28:08] Accepted client connection! +[2026-05-26 19:28:08] Handshake sent: ok=true, err=nil +[2026-05-26 19:28:08] Disconnected client: ok=true, err=closed +[2026-05-26 19:28:08] Accepted client connection! +[2026-05-26 19:28:08] Handshake sent: ok=true, err=nil +[2026-05-26 19:28:08] Disconnected client: ok=true, err=closed +[2026-05-26 19:28:08] Accepted client connection! +[2026-05-26 19:28:08] Handshake sent: ok=true, err=nil +[2026-05-26 19:28:08] Disconnected client: ok=true, err=closed +[2026-05-26 19:28:08] Accepted client connection! +[2026-05-26 19:28:08] Handshake sent: ok=true, err=nil +[2026-05-26 19:28:08] Disconnected client: ok=true, err=closed +[2026-05-26 19:28:08] Accepted client connection! +[2026-05-26 19:28:08] Handshake sent: ok=true, err=nil +[2026-05-26 19:28:08] Disconnected client: ok=true, err=closed +[2026-05-26 19:28:09] Accepted client connection! +[2026-05-26 19:28:09] Handshake sent: ok=true, err=nil +[2026-05-26 19:28:09] Disconnected client: ok=true, err=closed +[2026-05-26 19:28:09] Accepted client connection! +[2026-05-26 19:28:09] Handshake sent: ok=true, err=nil +[2026-05-26 19:28:09] Disconnected client: ok=true, err=closed +[2026-05-26 19:28:09] Accepted client connection! +[2026-05-26 19:28:09] Handshake sent: ok=true, err=nil +[2026-05-26 19:28:09] Disconnected client: ok=true, err=closed +[2026-05-26 19:28:09] Accepted client connection! +[2026-05-26 19:28:09] Handshake sent: ok=true, err=nil +[2026-05-26 19:28:09] Disconnected client: ok=true, err=closed +[2026-05-26 19:28:09] Accepted client connection! +[2026-05-26 19:28:09] Handshake sent: ok=true, err=nil +[2026-05-26 19:28:09] Disconnected client: ok=true, err=closed +[2026-05-26 19:28:09] Accepted client connection! +[2026-05-26 19:28:09] Handshake sent: ok=true, err=nil +[2026-05-26 19:28:09] Disconnected client: ok=true, err=closed +[2026-05-26 19:28:09] Accepted client connection! +[2026-05-26 19:28:09] Handshake sent: ok=true, err=nil +[2026-05-26 19:28:09] Disconnected client: ok=true, err=closed +[2026-05-26 21:08:34] GUI bridge loaded successfully! Listening on port 12321 +[2026-05-26 21:08:36] First pobMcpTick called! +[2026-06-01 18:43:43] GUI bridge loaded successfully! Listening on port 12321 +[2026-06-01 18:43:44] First pobMcpTick called! diff --git a/pob-mcp/README.md b/pob-mcp/README.md new file mode 100644 index 0000000000..66fd65f14f --- /dev/null +++ b/pob-mcp/README.md @@ -0,0 +1,136 @@ +# pob-mcp — MCP server for Path of Building (PoE2) + +Lets AI agents **analyze and tune builds** through the headless Path of Building +calculation engine: read computed stats, rank passive-tree nodes by marginal value, +and run what-if comparisons — without opening the PoB GUI. + +## Two operating modes + +### Live mode (PoB GUI running) — recommended + +``` +agent ──MCP(stdio)──▶ server/server.py ──TCP 127.0.0.1:12321──▶ gui_bridge.lua + └─ runs inside PoB GUI process + same build object as the GUI +``` + +When the PoB GUI is open, the MCP server connects to a tiny TCP bridge that runs +**inside the PoB process**. Every `allocate_nodes` or `set_config` call updates the +live `build` object immediately — you see the change in the PoB window before the +agent's response arrives. + +Requires a one-time ~5-line edit to `src/Launch.lua` (already applied in this repo). + +### Headless mode (fallback) + +``` +agent ──MCP(stdio)──▶ server/server.py ──JSON lines──▶ engine/bridge.lua (LuaJIT) + └─ loads src/HeadlessWrapper.lua +``` + +Used automatically when the PoB GUI is not running. Spawns a standalone LuaJIT +subprocess. The engine is crash-tolerant and replays the last build on restart. + +### Auto-detection + +`server.py` probes `127.0.0.1:12321` on startup. If the GUI bridge answers → live +mode. Otherwise → headless. No configuration needed. Force headless with +`POB_RUNTIME=headless`. + +## Prerequisites + +1. **LuaJIT** (native, recommended): + ```powershell + winget install DEVCOM.LuaJIT + ``` + Auto-detected on PATH or at `%LOCALAPPDATA%\Programs\LuaJIT\bin\luajit.exe`. + Override with `POB_LUAJIT=C:\path\to\luajit.exe`. + + *Alternative:* set `POB_RUNTIME=docker` to run the engine inside the PoB test image + (`ghcr.io/pathofbuildingcommunity/pathofbuilding-tests`) instead of native LuaJIT. + +2. **Python 3.10+** with the MCP SDK: + ```powershell + pip install -r pob-mcp/requirements.txt + ``` + +## Quick check + +```powershell +# from the repo root +python pob-mcp/server/smoke_test.py +``` + +This boots the engine, creates a build, and prints a couple of analysis results. + +## Connect to Claude Code + +Add to your MCP config (e.g. `.mcp.json` at the repo root or your user config): + +```json +{ + "mcpServers": { + "path-of-building": { + "command": "python", + "args": ["pob-mcp/server/server.py"] + } + } +} +``` + +Use an absolute path to `server.py` (and to a venv `python`) if your client does not +run from the repo root. + +## Tools + +### Analysis (read-only) + +| Tool | What it does | +|------|--------------| +| `load_build(source, name?)` | Load a build from raw XML, a PoB build code, or a `.xml` file path saved by the GUI. Call this first. | +| `get_stats(fields?)` | Full flat stat table (offence/defence/attributes/charges + `SkillDPS`), or just the requested keys. | +| `get_summary()` | Headline numbers plus sanity warnings (uncapped resists, low pool). | +| `rank_passive_nodes(metric, max_depth, limit)` | Rank unallocated nodes by gain to `metric` (FullDPS, CombinedDPS, Life, Evasion, …), with `delta` and `deltaPerPoint`. | +| `evaluate_change(add_nodes?, remove_nodes?, conditions?, metrics?, full_output?)` | What-if: deltas from adding/removing nodes, **without** mutating the build. | +| `set_config(key, value)` | Set a ConfigTab option (enemy level, charges, buffs) or `mainSocketGroup` and recalc. | +| `list_state(what)` | Inspect `summary` / `nodes` / `skills` / `items` / `config`. | + +### Mutation + export + +| Tool | What it does | +|------|--------------| +| `allocate_nodes(node_ids)` | Permanently allocate passive nodes and recalculate. Returns updated DPS/Life/ES. | +| `deallocate_nodes(node_ids)` | Permanently deallocate passive nodes and recalculate. | +| `save_build(path)` | Write the current (mutated) build to a `.xml` file. Open it in the PoB GUI via **File → Load Build**. | + +## Agent workflows + +### Analysis only + +`load_build` → `set_config("mainSocketGroup", N)` → `get_summary` → +`rank_passive_nodes` (find candidates) → `evaluate_change` (verify combinations) → iterate. + +### Tune the build and apply in GUI + +``` +1. In PoB GUI: File → Save build → build.xml +2. Agent: + load_build("C:/.../build.xml") + set_config("mainSocketGroup", 3) # pick the damage skill group + rank_passive_nodes("CombinedDPS", 3, 10) # find top candidates + evaluate_change(add_nodes=[32683, 4346]) # verify the combination + allocate_nodes([32683, 4346]) # apply permanently + save_build("C:/.../build.xml") # overwrite the same file +3. In PoB GUI: File → Load build → pick build.xml ← see the changes +``` + +## Notes + +- MCP and the PoB GUI run as **completely separate processes** — both can be open at the + same time without interfering. Changes in MCP are not live in the GUI; use `save_build` + then reload in the GUI. +- `set_config("mainSocketGroup", N)` switches the active skill group (1-indexed). Use + `list_state("skills")` to see which group index corresponds to which skill. +- This folder is intentionally separate from PoB. If you keep this in a fork, consider + adding `pob-mcp/` to `.gitignore` if you don't want it tracked alongside upstream. +- Loading a build replaces the previously loaded one; the engine holds a single build. diff --git a/pob-mcp/engine/bridge.lua b/pob-mcp/engine/bridge.lua new file mode 100644 index 0000000000..08d0fed75b --- /dev/null +++ b/pob-mcp/engine/bridge.lua @@ -0,0 +1,1221 @@ +-- pob-mcp engine bridge +-- +-- Loads Path of Building headless and exposes a line-delimited JSON protocol over +-- stdin/stdout so an external process (the MCP server) can drive build analysis. +-- +-- Must be launched with the working directory set to PoB's `src/` folder, and with +-- LUA_PATH / LUA_CPATH pointing at `../runtime/lua/?.lua` and `../runtime/?.dll` +-- (HeadlessWrapper requires `dofile("Launch.lua")` and the `lua-utf8` C module). +-- +-- Protocol: one JSON object per line in, one JSON object per line out. +-- request : {"id": , "cmd": "", ...args} +-- response: {"id": , "ok": true, "result": } +-- or {"id": , "ok": false, "error": ""} +-- +-- PoB writes a lot of diagnostic text via ConPrintf -> print. We redirect every +-- such write to stderr so stdout carries only protocol JSON. + +local realStdout = io.stdout + +local function logf(fmt, ...) + io.stderr:write(string.format(fmt, ...), "\n") +end + +print = function(...) + local n = select("#", ...) + local parts = {} + for i = 1, n do parts[i] = tostring((select(i, ...))) end + io.stderr:write(table.concat(parts, "\t"), "\n") +end + +-- Boot the engine (this defines: build, newBuild, loadBuildFromXML, runCallback, mainObject) +dofile("HeadlessWrapper.lua") + +if mainObject and mainObject.promptMsg then + io.stderr:write("FATAL during PoB startup: " .. tostring(mainObject.promptMsg) .. "\n") + os.exit(1) +end + +local dkjson = require("dkjson") + +-------------------------------------------------------------------------------- +-- helpers +-------------------------------------------------------------------------------- + +local function reply(obj) + realStdout:write(dkjson.encode(obj, { keyorder = nil }), "\n") + realStdout:flush() +end + +-- Convert non-finite numbers to nil so JSON encoding stays valid. +local function cleanNumber(v) + if v ~= v then return nil end -- NaN + if v == math.huge or v == -math.huge then return nil end + return v +end + +-- Pull scalar stats out of a PoB output table. Tables/functions are skipped, +-- except SkillDPS which is flattened to an array of {name, dps, ...}. +local function serializeOutput(output, fields) + local res = {} + if not output then return res end + if fields then + for _, k in ipairs(fields) do + local v = output[k] + local t = type(v) + if t == "number" then res[k] = cleanNumber(v) + elseif t == "string" or t == "boolean" then res[k] = v end + end + else + for k, v in pairs(output) do + local t = type(v) + if t == "number" then + local n = cleanNumber(v) + if n ~= nil then res[k] = n end + elseif t == "string" or t == "boolean" then + res[k] = v + end + end + end + if type(output.SkillDPS) == "table" then + local skills = {} + for _, s in ipairs(output.SkillDPS) do + skills[#skills + 1] = { + name = s.name, + dps = cleanNumber(s.dps), + count = s.count, + skillPart = s.skillPart, + trigger = s.trigger, + } + end + res.SkillDPS = skills + end + return res +end + +local function requireBuild() + if not build or build.dbFileName == nil and not build.spec then + error("no build loaded; call load_xml or new_build first") + end +end + +-- Resolve a list of node ids to the set form PoB's calculator expects: +-- { [nodeObject] = true, ... } +local function nodesById(ids) + local set = {} + local specNodes = build.spec and build.spec.nodes + if not specNodes then error("build has no passive tree spec") end + for _, id in ipairs(ids or {}) do + local node = specNodes[id] + if node then set[node] = true end + end + return set +end + +local function nextItemId() + local maxId = 0 + for id in pairs(build.itemsTab.items or {}) do + if type(id) == "number" and id > maxId then maxId = id end + end + return maxId + 1 +end + +local function serializeItemMods(item) + local mods = {} + local function add(list, mtype) + for _, ml in ipairs(list or {}) do + if ml.line then mods[#mods+1] = { type = mtype, line = ml.line } end + end + end + add(item.implicitModLines, "implicit") + add(item.explicitModLines, "explicit") + add(item.enchantModLines, "enchant") + add(item.runeModLines, "rune") + return mods +end + +local function recalc() + build.buildFlag = true + runCallback("OnFrame") +end + +-------------------------------------------------------------------------------- +-- command handlers +-------------------------------------------------------------------------------- + +local handlers = {} + +function handlers.ping() + return "pong" +end + +function handlers.new_build() + newBuild() + return { loaded = true } +end + +function handlers.load_xml(req) + if type(req.xml) ~= "string" or req.xml == "" then + error("load_xml requires a non-empty 'xml' string") + end + loadBuildFromXML(req.xml, req.name or "MCP build") + recalc() + local o = build.calcsTab.mainOutput + return { + loaded = true, + className = build.spec and build.spec.curClassName, + ascendancy = build.spec and build.spec.curAscendClassName, + level = build.characterLevel, + mainSocketGroup = build.mainSocketGroup, + FullDPS = o and cleanNumber(o.FullDPS), + Life = o and cleanNumber(o.Life), + } +end + +function handlers.get_output(req) + requireBuild() + return serializeOutput(build.calcsTab.mainOutput, req.fields) +end + +function handlers.set_config(req) + requireBuild() + if req.key == nil then error("set_config requires 'key'") end + if req.key == "mainSocketGroup" then + -- mainSocketGroup is a build-level property, not a configTab input. + build.mainSocketGroup = tonumber(req.value) or req.value + recalc() + return { key = req.key, value = build.mainSocketGroup } + end + build.configTab.input[req.key] = req.value + build.configTab:BuildModList() + recalc() + return { key = req.key, value = build.configTab.input[req.key] } +end + +-- Generic what-if. override = { addNodes=[ids], removeNodes=[ids], conditions=[..] } +-- Returns the resulting output plus deltas vs the current build for key stats. +function handlers.eval_override(req) + requireBuild() + local ov = req.override or {} + local override = {} + if ov.addNodes then override.addNodes = nodesById(ov.addNodes) end + if ov.removeNodes then override.removeNodes = nodesById(ov.removeNodes) end + if ov.conditions then override.conditions = ov.conditions end + + local calcFunc, baseOutput = build.calcsTab.calcs.getMiscCalculator(build) + local newOutput = calcFunc(override, req.useFullDPS ~= false) + + local metrics = req.metrics or { "FullDPS", "TotalDPS", "Life", "EnergyShield", "Mana" } + local deltas = {} + for _, m in ipairs(metrics) do + local a = tonumber(baseOutput[m]) or 0 + local b = tonumber(newOutput[m]) or 0 + deltas[m] = { base = cleanNumber(a), new = cleanNumber(b), delta = cleanNumber(b - a) } + end + local result = { deltas = deltas } + if req.fullOutput then + result.output = serializeOutput(newOutput, req.fields) + end + return result +end + +-- Permanently allocate passive nodes and recalculate. +-- req = { ids = [nodeId, ...] } +-- Returns { allocated=[ids actually newly allocated], alreadyAlloc=[ids already taken], +-- skipped=[ids with no path to tree] } +function handlers.allocate_nodes(req) + requireBuild() + build.spec:AddUndoState() + build.spec:BuildAllDependsAndPaths() + local ids = req.ids or {} + local newly = {} + local already = {} + local skipped = {} + for _, id in ipairs(ids) do + local node = build.spec.nodes[id] + if not node then error("node id not found: " .. tostring(id)) end + if node.alloc then + already[#already + 1] = id + elseif not node.path then + skipped[#skipped + 1] = id + else + build.spec:AllocNode(node) + newly[#newly + 1] = id + end + end + recalc() + local o = build.calcsTab.mainOutput + return { + allocated = newly, alreadyAlloc = already, skipped = skipped, + FullDPS = o and cleanNumber(o.FullDPS), + CombinedDPS = o and cleanNumber(o.CombinedDPS), + Life = o and cleanNumber(o.Life), + EnergyShield = o and cleanNumber(o.EnergyShield), + } +end + +-- Permanently deallocate passive nodes and recalculate. +-- req = { ids = [nodeId, ...] } +function handlers.deallocate_nodes(req) + requireBuild() + build.spec:AddUndoState() + local ids = req.ids or {} + local removed = {} + local notAlloc = {} + local protected = {} -- ClassStart / AscendClassStart — never removable + for _, id in ipairs(ids) do + local node = build.spec.nodes[id] + if not node then error("node id not found: " .. tostring(id)) end + if node.type == "ClassStart" or node.type == "AscendClassStart" then + protected[#protected + 1] = id + elseif not node.alloc then + notAlloc[#notAlloc + 1] = id + else + build.spec:DeallocNode(node) + removed[#removed + 1] = id + end + end + recalc() + local o = build.calcsTab.mainOutput + return { + removed = removed, notAlloc = notAlloc, protected = protected, + FullDPS = o and cleanNumber(o.FullDPS), + CombinedDPS = o and cleanNumber(o.CombinedDPS), + Life = o and cleanNumber(o.Life), + EnergyShield = o and cleanNumber(o.EnergyShield), + } +end + +-- BFS passive tree optimizer (mirrors gui_bridge.lua logic). +-- Evaluates entire allocation paths (not individual nodes), so intermediate +-- pathing nodes are factored into the cost. Supports weighted multi-metric +-- scoring and hard minimum-value constraints. +-- req = { metric, budget, maxDepth, weights?, minConstraints? } +function handlers.optimize_tree(req) + requireBuild() + local metric = req.metric or "CombinedDPS" + local budget = req.budget or 10 + local maxDepth = req.maxDepth or 3 + local weights = req.weights -- optional {metric->weight} + local minConstraints = req.minConstraints -- optional {metric->minValue} + + build.spec:AddUndoState() + build.spec:BuildAllDependsAndPaths() + + local calcFunc, baseOutput = build.calcsTab.calcs.getMiscCalculator(build) + local base = tonumber(baseOutput[metric]) or 0 + local deadline = os.clock() + 25 -- 25-second wall-clock limit + + local function computeScore(out) + if not weights then + return (tonumber(out[metric]) or 0) - base + end + local score = 0 + for m, w in pairs(weights) do + local a = tonumber(baseOutput[m]) or 0 + local b = tonumber(out[m]) or 0 + if a ~= 0 then score = score + w * (b - a) / math.abs(a) end + end + return score + end + + local function meetsConstraints(out) + if not minConstraints then return true end + for m, minVal in pairs(minConstraints) do + if (tonumber(out[m]) or 0) < minVal then return false end + end + return true + end + + local function pathCacheKey(pathList) + local keys = {} + for _, n in ipairs(pathList) do + if n.modKey and n.modKey ~= "" then keys[#keys + 1] = n.modKey end + end + table.sort(keys) + return table.concat(keys, "\0") + end + + local function gatherCandidates(depthLimit, remaining) + local candidates = {} + local seenKeys = {} + local grantedPassives = build.calcsTab.mainEnv and build.calcsTab.mainEnv.grantedPassives + local maxSearchDepth = math.min(8, remaining) + + local queue, qHead = {}, 1 + local seenStart = {} + for _, node in pairs(build.spec.nodes) do + if not node.alloc and not node.ascendancyName then + for _, nb in ipairs(node.linked or {}) do + if nb.alloc and not seenStart[node.id] then + seenStart[node.id] = true + queue[#queue + 1] = { + front = node, + pathList = { node }, + pathSet = { [node.id] = true }, + } + break + end + end + end + end + + while qHead <= #queue do + local item = queue[qHead]; qHead = qHead + 1 + local depth = #item.pathList + local node = item.front + + if node.modKey and node.modKey ~= "" + and not (grantedPassives and grantedPassives[node.id]) then + local shouldAdd = depth <= depthLimit + or (depth <= maxSearchDepth + and (node.type == "Notable" or node.type == "Keystone")) + if shouldAdd then + local ids = {} + for id in pairs(item.pathSet) do ids[#ids + 1] = id end + table.sort(ids) + local key = table.concat(ids, ",") + if not seenKeys[key] then + seenKeys[key] = true + local addSet = {} + for _, n in ipairs(item.pathList) do addSet[n] = true end + candidates[#candidates + 1] = { + node = node, + pathList = item.pathList, + cost = depth, + addSet = addSet, + } + end + end + end + + if depth < maxSearchDepth then + for _, nb in ipairs(node.linked or {}) do + if not nb.alloc and not nb.ascendancyName + and not item.pathSet[nb.id] then + local newList, newSet = {}, {} + for _, n in ipairs(item.pathList) do newList[#newList + 1] = n end + newList[#newList + 1] = nb + for k in pairs(item.pathSet) do newSet[k] = true end + newSet[nb.id] = true + queue[#queue + 1] = { + front = nb, + pathList = newList, + pathSet = newSet, + } + end + end + end + end + return candidates + end + + local remaining = budget + local steps = {} + while remaining > 0 do + if os.clock() > deadline then + steps[#steps + 1] = { timedOut = true, remainingPoints = remaining } + break + end + + local depthLimit = math.min(maxDepth, remaining) + local candidates = gatherCandidates(depthLimit, remaining) + local evalCache = {} + + local bestNode, bestPath, bestEfficiency, bestDelta = nil, nil, 0, 0 + for _, cand in ipairs(candidates) do + if os.clock() > deadline then break end + local ckey = pathCacheKey(cand.pathList) + local out = evalCache[ckey] + if not out then + out = calcFunc({ addNodes = cand.addSet }, true) + evalCache[ckey] = out + end + if meetsConstraints(out) then + local s = computeScore(out) + local efficiency = s / cand.cost + if efficiency > bestEfficiency then + bestEfficiency = efficiency + bestDelta = (tonumber(out[metric]) or 0) - base + bestNode = cand.node + bestPath = cand.pathList + end + end + end + if not bestNode then break end + + local before = 0 + for _ in pairs(build.spec.allocNodes) do before = before + 1 end + build.spec:AllocNode(bestNode, bestPath) + local after = 0 + for _ in pairs(build.spec.allocNodes) do after = after + 1 end + local actualCost = after - before + + remaining = remaining - actualCost + build.spec:BuildAllDependsAndPaths() + steps[#steps + 1] = { + step = #steps + 1, + id = bestNode.id, + name = bestNode.dn or bestNode.name, + type = bestNode.type, + delta = cleanNumber(bestDelta), + cost = actualCost, + remainingPoints = remaining, + } + + calcFunc, baseOutput = build.calcsTab.calcs.getMiscCalculator(build) + base = tonumber(baseOutput[metric]) or 0 + end + + recalc() + local o = build.calcsTab.mainOutput + return { + metric = metric, + steps = steps, + pointsUsed = budget - remaining, + stepsCount = #steps, + finalValue = cleanNumber(tonumber(o[metric]) or 0), + CombinedDPS = o and cleanNumber(o.CombinedDPS), + Life = o and cleanNumber(o.Life), + EnergyShield = o and cleanNumber(o.EnergyShield), + } +end + +-- Change the level and/or quality of a gem in a socket group. +-- req = { group=<1-based>, name=, level=?, quality=? } +function handlers.set_gem(req) + requireBuild() + local sg = build.skillsTab.socketGroupList[req.group] + if not sg then error("socket group " .. tostring(req.group) .. " not found") end + local found = false + for _, gem in ipairs(sg.gemList or {}) do + if gem.nameSpec == req.name then + if req.level ~= nil then gem.level = req.level end + if req.quality ~= nil then gem.quality = req.quality end + found = true + break + end + end + if not found then error("gem '" .. tostring(req.name) .. "' not found in group " .. tostring(req.group)) end + build.skillsTab:ProcessSocketGroup(sg) + recalc() + local o = build.calcsTab.mainOutput + return { + group = req.group, name = req.name, level = req.level, quality = req.quality, + CombinedDPS = o and cleanNumber(o.CombinedDPS), + FullDPS = o and cleanNumber(o.FullDPS), + EnergyShield = o and cleanNumber(o.EnergyShield), + } +end + +-- Undo the last passive tree change (allocate / deallocate / optimize_tree). +function handlers.undo_tree(req) + requireBuild() + build.spec:Undo() + recalc() + local o = build.calcsTab.mainOutput + return { CombinedDPS = o and cleanNumber(o.CombinedDPS), Life = o and cleanNumber(o.Life), EnergyShield = o and cleanNumber(o.EnergyShield) } +end + +-- Redo the last undone passive tree change. +function handlers.redo_tree(req) + requireBuild() + build.spec:Redo() + recalc() + local o = build.calcsTab.mainOutput + return { CombinedDPS = o and cleanNumber(o.CombinedDPS), Life = o and cleanNumber(o.Life), EnergyShield = o and cleanNumber(o.EnergyShield) } +end + +-- Export the current build state as XML (for saving or loading in PoB GUI). +function handlers.get_xml(req) + requireBuild() + local xml = build:SaveDB(req.name or "MCP build") + if type(xml) ~= "string" or xml == "" then + error("build:SaveDB() returned nothing; build may not be fully loaded") + end + return { xml = xml } +end + +-- Rank unallocated passive nodes by their marginal contribution. +-- req = { metric="FullDPS"|"Life"|..., maxDepth=, limit= } +function handlers.rank_nodes(req) + requireBuild() + local metric = req.metric or "FullDPS" + local maxDepth = req.maxDepth or 6 + local limit = req.limit or 25 + + -- Ensure node.path / node.pathDist are populated (the GUI builds these when the + -- tree is drawn; in headless we must trigger it explicitly). + build.spec:BuildAllDependsAndPaths() + + local calcFunc, baseOutput = build.calcsTab.calcs.getMiscCalculator(build) + local base = tonumber(baseOutput[metric]) or 0 + + local cache = {} + local results = {} + local evaluated = 0 + for nodeId, node in pairs(build.spec.nodes) do + if not node.alloc + and node.modKey and node.modKey ~= "" + and node.path + and (node.pathDist or 999) <= maxDepth + and not (build.calcsTab.mainEnv and build.calcsTab.mainEnv.grantedPassives[nodeId]) + and not node.ascendancyName then + evaluated = evaluated + 1 + local val = cache[node.modKey] + if val == nil then + local out = calcFunc({ addNodes = { [node] = true } }, req.useFullDPS ~= false) + val = tonumber(out[metric]) or 0 + cache[node.modKey] = val + end + local delta = val - base + if delta ~= 0 then + results[#results + 1] = { + id = nodeId, + name = node.dn or node.name, + type = node.type, + pathDist = node.pathDist, + delta = cleanNumber(delta), + deltaPerPoint = cleanNumber(delta / math.max(node.pathDist or 1, 1)), + } + end + end + end + table.sort(results, function(a, b) return (a.delta or 0) > (b.delta or 0) end) + local top = {} + for i = 1, math.min(limit, #results) do top[i] = results[i] end + return { metric = metric, base = cleanNumber(base), nodes = top, evaluated = evaluated, withEffect = #results } +end + +function handlers.list_state(req) + requireBuild() + local what = req.what or "summary" + if what == "config" then + local input = {} + for k, v in pairs(build.configTab.input) do + local t = type(v) + if t == "number" or t == "string" or t == "boolean" then input[k] = v end + end + return { input = input } + elseif what == "skills" then + local groups = {} + for _, sg in ipairs(build.skillsTab.socketGroupList or {}) do + local gems = {} + for _, gem in ipairs(sg.gemList or {}) do + gems[#gems + 1] = { name = gem.nameSpec, level = gem.level, quality = gem.quality, enabled = gem.enabled } + end + groups[#groups + 1] = { + label = sg.label, + slot = sg.slot, + enabled = sg.enabled, + includeInFullDPS = sg.includeInFullDPS, + mainActiveSkill = sg.mainActiveSkill, + gems = gems, + } + end + return { mainSocketGroup = build.mainSocketGroup, groups = groups } + elseif what == "items" then + local items = {} + for slotName, slot in pairs(build.itemsTab.slots or {}) do + if slot.selItemId and slot.selItemId ~= 0 then + local item = build.itemsTab.items[slot.selItemId] + if item then + local entry = { slot = slotName, name = item.name, rarity = item.rarity } + if slotName:match("^Flask") then entry.active = slot.active or false end + items[#items + 1] = entry + end + end + end + return { items = items } + elseif what == "nodes" then + local alloc = {} + local usedPoints = 0 + for nodeId, node in pairs(build.spec.allocNodes or {}) do + alloc[#alloc + 1] = { id = nodeId, name = node.dn or node.name, type = node.type } + -- Mirror PassiveSpec:CountAllocNodes — start nodes and free-allocate nodes + -- don't cost a passive point and must not be counted. + if node.type ~= "ClassStart" and node.type ~= "AscendClassStart" + and not node.isFreeAllocate then + usedPoints = usedPoints + 1 + end + end + return { allocated = alloc, usedPoints = usedPoints } + else -- summary + local o = build.calcsTab.mainOutput + return { + className = build.spec and build.spec.curClassName, + ascendancy = build.spec and build.spec.curAscendClassName, + level = build.characterLevel, + FullDPS = o and cleanNumber(o.FullDPS), + Life = o and cleanNumber(o.Life), + EnergyShield = o and cleanNumber(o.EnergyShield), + } + end +end + +function handlers.equip_item(req) + requireBuild() + if not req.slot then error("equip_item requires 'slot'") end + if not req.item_text then error("equip_item requires 'item_text'") end + local slot = build.itemsTab.slots[req.slot] + if not slot then + local avail = {} + for k in pairs(build.itemsTab.slots or {}) do avail[#avail+1] = k end + table.sort(avail) + error("slot '" .. req.slot .. "' not found. Available: " .. table.concat(avail, ", ")) + end + local item = new("Item") + item:ParseRaw(req.item_text) + local newId = nextItemId() + item.id = newId + build.itemsTab.items[newId] = item + slot.selItemId = newId + recalc() + local o = build.calcsTab.mainOutput + return { + slot = req.slot, name = item.name, rarity = item.rarity, itemId = newId, + FullDPS = o and cleanNumber(o.FullDPS), CombinedDPS = o and cleanNumber(o.CombinedDPS), + Life = o and cleanNumber(o.Life), EnergyShield = o and cleanNumber(o.EnergyShield), + } +end + +function handlers.remove_item(req) + requireBuild() + if not req.slot then error("remove_item requires 'slot'") end + local slot = build.itemsTab.slots[req.slot] + if not slot then error("slot '" .. req.slot .. "' not found") end + local oldId = slot.selItemId + slot.selItemId = 0 + recalc() + local o = build.calcsTab.mainOutput + return { + slot = req.slot, removedItemId = oldId, + FullDPS = o and cleanNumber(o.FullDPS), CombinedDPS = o and cleanNumber(o.CombinedDPS), + Life = o and cleanNumber(o.Life), EnergyShield = o and cleanNumber(o.EnergyShield), + } +end + +function handlers.get_item_details(req) + requireBuild() + if not req.slot then error("get_item_details requires 'slot'") end + local slot = build.itemsTab.slots[req.slot] + if not slot then error("slot '" .. req.slot .. "' not found") end + if not slot.selItemId or slot.selItemId == 0 then + return { slot = req.slot, empty = true } + end + local item = build.itemsTab.items[slot.selItemId] + if not item then return { slot = req.slot, empty = true } end + return { + slot = req.slot, id = item.id, name = item.name, + baseName = item.baseName, rarity = item.rarity, quality = item.quality, + raw = item.raw, mods = serializeItemMods(item), + } +end + +function handlers.eval_item_change(req) + requireBuild() + if not req.slot then error("eval_item_change requires 'slot'") end + if not req.item_text then error("eval_item_change requires 'item_text'") end + local slot = build.itemsTab.slots[req.slot] + if not slot then error("slot '" .. req.slot .. "' not found") end + local metrics = req.metrics or { "FullDPS", "TotalDPS", "CombinedDPS", "Life", "EnergyShield", "Mana" } + local baseVals = {} + local baseOut = build.calcsTab.mainOutput + for _, m in ipairs(metrics) do baseVals[m] = tonumber(baseOut and baseOut[m]) or 0 end + local item = new("Item") + item:ParseRaw(req.item_text) + local tempId = -(nextItemId()) + item.id = tempId + build.itemsTab.items[tempId] = item + local oldId = slot.selItemId + slot.selItemId = tempId + recalc() + local newOut = build.calcsTab.mainOutput + local deltas = {} + for _, m in ipairs(metrics) do + local a, b = baseVals[m], tonumber(newOut and newOut[m]) or 0 + deltas[m] = { base = cleanNumber(a), new = cleanNumber(b), delta = cleanNumber(b - a) } + end + slot.selItemId = oldId + build.itemsTab.items[tempId] = nil + recalc() + return { slot = req.slot, newItemName = item.name, newItemRarity = item.rarity, deltas = deltas } +end + +function handlers.add_gem(req) + requireBuild() + local sg = build.skillsTab.socketGroupList[req.group] + if not sg then error("socket group " .. tostring(req.group) .. " not found") end + local newGem = { + nameSpec = req.name, level = req.level or 1, quality = req.quality or 0, + enabled = true, enableGlobal1 = true, enableGlobal2 = true, count = 1, + } + table.insert(sg.gemList, newGem) + build.skillsTab:ProcessSocketGroup(sg) + recalc() + local o = build.calcsTab.mainOutput + return { + group = req.group, added = req.name, + FullDPS = o and cleanNumber(o.FullDPS), CombinedDPS = o and cleanNumber(o.CombinedDPS), + Life = o and cleanNumber(o.Life), EnergyShield = o and cleanNumber(o.EnergyShield), + } +end + +function handlers.remove_gem(req) + requireBuild() + local sg = build.skillsTab.socketGroupList[req.group] + if not sg then error("socket group " .. tostring(req.group) .. " not found") end + local found = false + for i, gem in ipairs(sg.gemList or {}) do + if gem.nameSpec == req.name then table.remove(sg.gemList, i); found = true; break end + end + if not found then error("gem '" .. req.name .. "' not found in group " .. tostring(req.group)) end + build.skillsTab:ProcessSocketGroup(sg) + recalc() + local o = build.calcsTab.mainOutput + return { + group = req.group, removed = req.name, + FullDPS = o and cleanNumber(o.FullDPS), CombinedDPS = o and cleanNumber(o.CombinedDPS), + Life = o and cleanNumber(o.Life), EnergyShield = o and cleanNumber(o.EnergyShield), + } +end + +function handlers.toggle_gem(req) + requireBuild() + local sg = build.skillsTab.socketGroupList[req.group] + if not sg then error("socket group " .. tostring(req.group) .. " not found") end + local found = false + for _, gem in ipairs(sg.gemList or {}) do + if gem.nameSpec == req.name then gem.enabled = req.enabled; found = true; break end + end + if not found then error("gem '" .. req.name .. "' not found in group " .. tostring(req.group)) end + build.skillsTab:ProcessSocketGroup(sg) + recalc() + local o = build.calcsTab.mainOutput + return { + group = req.group, gem = req.name, enabled = req.enabled, + FullDPS = o and cleanNumber(o.FullDPS), CombinedDPS = o and cleanNumber(o.CombinedDPS), + Life = o and cleanNumber(o.Life), EnergyShield = o and cleanNumber(o.EnergyShield), + } +end + +function handlers.toggle_skill_group(req) + requireBuild() + local sg = build.skillsTab.socketGroupList[req.group] + if not sg then error("socket group " .. tostring(req.group) .. " not found") end + sg.enabled = req.enabled + build.skillsTab:ProcessSocketGroup(sg) + recalc() + local o = build.calcsTab.mainOutput + return { + group = req.group, enabled = req.enabled, + FullDPS = o and cleanNumber(o.FullDPS), CombinedDPS = o and cleanNumber(o.CombinedDPS), + Life = o and cleanNumber(o.Life), EnergyShield = o and cleanNumber(o.EnergyShield), + } +end + +function handlers.set_main_skill(req) + requireBuild() + if not req.group then error("set_main_skill requires 'group'") end + local total = #(build.skillsTab.socketGroupList or {}) + if req.group < 1 or req.group > total then + error(string.format("group %d out of range (1-%d)", req.group, total)) + end + build.mainSocketGroup = req.group + recalc() + local o = build.calcsTab.mainOutput + return { + mainSocketGroup = req.group, + FullDPS = o and cleanNumber(o.FullDPS), CombinedDPS = o and cleanNumber(o.CombinedDPS), + Life = o and cleanNumber(o.Life), EnergyShield = o and cleanNumber(o.EnergyShield), + } +end + +function handlers.list_masteries(req) + requireBuild() + local allocatedOnly = req.allocatedOnly == true + local masteries = {} + for nodeId, node in pairs(build.spec.nodes) do + if node.type == "Mastery" then + if not allocatedOnly or node.alloc then + local effects = {} + for _, eff in ipairs(node.masteryEffects or {}) do + local effectId = eff.effect + local stats = {} + local effectData = build.spec.tree.masteryEffects and build.spec.tree.masteryEffects[effectId] + if effectData then + for _, s in ipairs(effectData.sd or {}) do stats[#stats+1] = s end + end + effects[#effects+1] = { id = effectId, stats = stats } + end + local selectedEffect = build.spec.masterySelections and build.spec.masterySelections[nodeId] + masteries[#masteries+1] = { + nodeId = nodeId, + name = node.dn or node.name, + allocated = node.alloc or false, + selectedEffect = selectedEffect, + effects = effects, + } + end + end + end + return { masteries = masteries } +end + +function handlers.set_mastery(req) + requireBuild() + if not req.nodeId then error("set_mastery requires 'nodeId'") end + if not req.effectId then error("set_mastery requires 'effectId'") end + local node = build.spec.nodes[req.nodeId] + if not node then error("node not found: " .. tostring(req.nodeId)) end + if node.type ~= "Mastery" then + error("node " .. req.nodeId .. " is type '" .. (node.type or "?") .. "', not 'Mastery'") + end + local validEffect = false + for _, eff in ipairs(node.masteryEffects or {}) do + if eff.effect == req.effectId then validEffect = true; break end + end + if not validEffect then + local valid = {} + for _, eff in ipairs(node.masteryEffects or {}) do valid[#valid+1] = tostring(eff.effect) end + error("effectId " .. req.effectId .. " not valid for this node. Valid ids: " .. table.concat(valid, ", ")) + end + if not build.spec.masterySelections then build.spec.masterySelections = {} end + build.spec.masterySelections[req.nodeId] = req.effectId + if build.spec.tree.ProcessStats then + build.spec.tree:ProcessStats(node) + end + recalc() + local o = build.calcsTab.mainOutput + return { + nodeId = req.nodeId, effectId = req.effectId, + FullDPS = o and cleanNumber(o.FullDPS), CombinedDPS = o and cleanNumber(o.CombinedDPS), + Life = o and cleanNumber(o.Life), EnergyShield = o and cleanNumber(o.EnergyShield), + } +end + +function handlers.get_node_info(req) + requireBuild() + build.spec:BuildAllDependsAndPaths() + local ids = req.ids or {} + local nodes = {} + for _, id in ipairs(ids) do + local node = build.spec.nodes[id] + if not node then + nodes[#nodes+1] = { id = id, error = "not found" } + else + local stats = {} + for _, s in ipairs(node.sd or {}) do stats[#stats+1] = s end + nodes[#nodes+1] = { + id = id, + name = node.dn or node.name, + type = node.type, + allocated = node.alloc or false, + stats = stats, + ascendancy = node.ascendancyName, + pathDist = node.pathDist, + } + end + end + return { nodes = nodes } +end + +function handlers.set_character(req) + requireBuild() + if req.level ~= nil then + local lvl = tonumber(req.level) + if not lvl or lvl < 1 or lvl > 100 then error("level must be 1-100") end + build.characterLevel = lvl + end + if req.className ~= nil then + local classId + local classes = build.spec.tree.classes or {} + for id, cls in pairs(classes) do + if type(cls) == "table" and cls.name and cls.name:lower() == req.className:lower() then + classId = id; break + end + end + if not classId then + local avail = {} + for _, cls in pairs(classes) do + if type(cls) == "table" and cls.name then avail[#avail+1] = cls.name end + end + table.sort(avail) + error("class '" .. req.className .. "' not found. Available: " .. table.concat(avail, ", ")) + end + build.spec:SelectClass(classId) + end + if req.ascendancy ~= nil then + local curClass = build.spec.tree.classes[build.spec.curClassId] + if not curClass then error("no current class; set className first") end + local ascId + for id, asc in pairs(curClass.ascendancies or {}) do + if type(asc) == "table" and asc.name and asc.name:lower() == req.ascendancy:lower() then + ascId = id; break + end + end + if not ascId then + local avail = {} + for _, asc in pairs(curClass.ascendancies or {}) do + if type(asc) == "table" and asc.name then avail[#avail+1] = asc.name end + end + table.sort(avail) + error("ascendancy '" .. req.ascendancy .. "' not found. Available: " .. table.concat(avail, ", ")) + end + build.spec:SelectAscendClass(ascId) + end + recalc() + local o = build.calcsTab.mainOutput + return { + level = build.characterLevel, + className = build.spec.curClassName, + ascendancy = build.spec.curAscendClassName, + FullDPS = o and cleanNumber(o.FullDPS), CombinedDPS = o and cleanNumber(o.CombinedDPS), + Life = o and cleanNumber(o.Life), EnergyShield = o and cleanNumber(o.EnergyShield), + } +end + +function handlers.set_bandit(req) + requireBuild() + if req.choice == nil then error("set_bandit requires 'choice'") end + build.bandit = req.choice + recalc() + local o = build.calcsTab.mainOutput + return { + bandit = build.bandit, + FullDPS = o and cleanNumber(o.FullDPS), CombinedDPS = o and cleanNumber(o.CombinedDPS), + Life = o and cleanNumber(o.Life), EnergyShield = o and cleanNumber(o.EnergyShield), + } +end + +function handlers.set_pantheon(req) + requireBuild() + if req.major ~= nil then build.pantheonMajorGod = req.major end + if req.minor ~= nil then build.pantheonMinorGod = req.minor end + recalc() + local o = build.calcsTab.mainOutput + return { + pantheonMajorGod = build.pantheonMajorGod, + pantheonMinorGod = build.pantheonMinorGod, + FullDPS = o and cleanNumber(o.FullDPS), CombinedDPS = o and cleanNumber(o.CombinedDPS), + Life = o and cleanNumber(o.Life), EnergyShield = o and cleanNumber(o.EnergyShield), + } +end + +function handlers.list_flasks(req) + requireBuild() + local flasks = {} + for slotName, slot in pairs(build.itemsTab.slots or {}) do + if slotName:match("^Flask") then + local entry = { slot = slotName, active = slot.active or false, empty = true } + if slot.selItemId and slot.selItemId ~= 0 then + local item = build.itemsTab.items[slot.selItemId] + if item then + entry.empty = false + entry.name = item.name + entry.rarity = item.rarity + if item.base and item.base.flask then + entry.flask = { + subType = item.base.subType, + life = item.base.flask.life, + mana = item.base.flask.mana, + duration = item.base.flask.duration, + chargesMax = item.base.flask.chargesMax, + chargesUsed = item.base.flask.chargesUsed, + } + end + if item.flaskData then + entry.flaskData = { + lifeTotal = cleanNumber(item.flaskData.lifeTotal), + manaTotal = cleanNumber(item.flaskData.manaTotal), + effectInc = cleanNumber(item.flaskData.effectInc), + } + end + end + end + flasks[#flasks+1] = entry + end + end + table.sort(flasks, function(a, b) return a.slot < b.slot end) + return { flasks = flasks } +end + +function handlers.toggle_flask(req) + requireBuild() + if not req.slot then error("toggle_flask requires 'slot'") end + if req.active == nil then error("toggle_flask requires 'active'") end + if not req.slot:match("^Flask") then + error("'" .. req.slot .. "' is not a flask slot (expected 'Flask 1' or 'Flask 2')") + end + local slot = build.itemsTab.slots[req.slot] + if not slot then error("slot '" .. req.slot .. "' not found") end + slot.active = req.active + if build.itemsTab.activeItemSet and build.itemsTab.activeItemSet[req.slot] then + build.itemsTab.activeItemSet[req.slot].active = req.active + end + recalc() + local o = build.calcsTab.mainOutput + return { + slot = req.slot, active = req.active, + FullDPS = o and cleanNumber(o.FullDPS), CombinedDPS = o and cleanNumber(o.CombinedDPS), + Life = o and cleanNumber(o.Life), EnergyShield = o and cleanNumber(o.EnergyShield), + } +end + +function handlers.list_keystones(req) + requireBuild() + build.spec:BuildAllDependsAndPaths() + local maxDepth = req.maxDepth + local keystones = {} + for nodeId, node in pairs(build.spec.nodes) do + if node.type == "Keystone" then + local dist = node.pathDist or (node.alloc and 0) or 999 + if not maxDepth or node.alloc or dist <= maxDepth then + local stats = {} + for _, s in ipairs(node.sd or {}) do stats[#stats+1] = s end + keystones[#keystones+1] = { + id = nodeId, + name = node.dn or node.name, + allocated = node.alloc or false, + pathDist = node.alloc and 0 or dist, + stats = stats, + } + end + end + end + table.sort(keystones, function(a, b) + if a.allocated ~= b.allocated then return a.allocated end + return (a.pathDist or 999) < (b.pathDist or 999) + end) + return { keystones = keystones, count = #keystones } +end + +function handlers.set_node_stat(req) + requireBuild() + if not req.nodeId then error("set_node_stat requires 'nodeId'") end + if req.index == nil then error("set_node_stat requires 'index'") end + if req.stat == nil then error("set_node_stat requires 'stat'") end + local node = build.spec.nodes[req.nodeId] + if not node then error("node not found: " .. tostring(req.nodeId)) end + if node.type == "ClassStart" or node.type == "AscendClassStart" then + error("cannot modify the start node (type: " .. node.type .. ")") + end + local sd = node.sd or {} + local idx = req.index + if idx < 1 or idx > #sd then + error("stat index " .. idx .. " out of range (node has " .. #sd .. " stats)") + end + local oldStat = sd[idx] + sd[idx] = req.stat + node.sd = sd + build.spec.tree:ProcessStats(node) + recalc() + local o = build.calcsTab.mainOutput + return { + nodeId = req.nodeId, + name = node.dn or node.name, + index = idx, + oldStat = oldStat, + newStat = req.stat, + stats = { + FullDPS = o and cleanNumber(o.FullDPS), + CombinedDPS = o and cleanNumber(o.CombinedDPS), + Life = o and cleanNumber(o.Life), + EnergyShield = o and cleanNumber(o.EnergyShield), + Armour = o and cleanNumber(o.Armour), + Evasion = o and cleanNumber(o.Evasion), + }, + } +end + +function handlers.evaluate_node_stat(req) + requireBuild() + if not req.nodeId then error("evaluate_node_stat requires 'nodeId'") end + if not req.overrides then error("evaluate_node_stat requires 'overrides'") end + local node = build.spec.nodes[req.nodeId] + if not node then error("node not found: " .. tostring(req.nodeId)) end + + -- Capture baseline + recalc() + local o0 = build.calcsTab.mainOutput + local keys = { + "FullDPS","CombinedDPS","Life","EnergyShield","Armour","Evasion", + "PhysicalDmgReductionPercent","SpellBlockChance","AttackBlockChance", + "MeleeEvadeChance","Mana","MaxManaReserved", + } + local before = {} + for _, k in ipairs(keys) do before[k] = o0 and cleanNumber(o0[k]) end + + -- Save original sd lines + local origSd = {} + for i, s in ipairs(node.sd or {}) do origSd[i] = s end + + -- Apply overrides: {index(string or number) -> newStatText} + for rawIdx, newStat in pairs(req.overrides) do + local i = tonumber(rawIdx) + if i and i >= 1 and i <= #origSd then + node.sd[i] = newStat + end + end + + -- Rebuild modList then recalc + build.spec.tree:ProcessStats(node) + recalc() + local o1 = build.calcsTab.mainOutput + local after = {} + for _, k in ipairs(keys) do after[k] = o1 and cleanNumber(o1[k]) end + + -- Restore original sd and modList + for i, s in ipairs(origSd) do node.sd[i] = s end + build.spec.tree:ProcessStats(node) + recalc() + + -- Compute delta for changed stats only + local delta = {} + for _, k in ipairs(keys) do + local b, a = before[k], after[k] + if type(b) == "number" and type(a) == "number" and a ~= b then + delta[k] = { before = b, after = a, diff = a - b } + end + end + + return { + nodeId = req.nodeId, + name = node.dn or node.name, + nodeType = node.type, + sdOriginal = origSd, + before = before, + after = after, + delta = delta, + } +end + +-------------------------------------------------------------------------------- +-- main loop +-------------------------------------------------------------------------------- + +logf("pob-mcp bridge ready") +reply({ event = "ready" }) + +while true do + local line = io.read("*l") + if line == nil then break end + if line ~= "" then + local req, _, perr = dkjson.decode(line) + if not req then + reply({ ok = false, error = "invalid JSON: " .. tostring(perr) }) + else + local handler = handlers[req.cmd] + if not handler then + reply({ id = req.id, ok = false, error = "unknown cmd: " .. tostring(req.cmd) }) + else + local ok, result = pcall(handler, req) + if ok then + reply({ id = req.id, ok = true, result = result }) + else + reply({ id = req.id, ok = false, error = tostring(result) }) + end + end + end + end +end diff --git a/pob-mcp/gui_bridge.lua b/pob-mcp/gui_bridge.lua new file mode 100644 index 0000000000..2c782e72a2 --- /dev/null +++ b/pob-mcp/gui_bridge.lua @@ -0,0 +1,1314 @@ +-- pob-mcp/gui_bridge.lua +-- +-- Loaded by src/Launch.lua at startup when the file is present. Creates a +-- non-blocking TCP server on 127.0.0.1:12321 and exports a global tick function +-- (_G.pobMcpTick) that Launch.lua calls each OnFrame. +-- +-- The MCP Python server auto-detects this port and switches to "GUI mode": +-- all commands operate on the live build object inside the running PoB process, +-- so changes (allocate nodes, config tweaks) appear in the GUI immediately +-- without saving/reloading XML. +-- +-- Protocol: same line-delimited JSON as the headless bridge (bridge.lua). +-- Both ends are therefore interchangeable from server.py's perspective. + +local ok, socket = pcall(require, "socket") +if not ok then + ConPrintf("pob-mcp: LuaSocket not available, GUI bridge disabled") + return +end + +local dkjson = require("dkjson") + +local PORT = 12321 + +-- Try to bind. If port is taken (another PoB instance already running with the +-- bridge) just silently skip — the Python client will use the existing one. +local srv, bindErr = socket.bind("*", PORT) +if not srv then + ConPrintf("pob-mcp: could not bind port %d: %s", PORT, tostring(bindErr)) + return +end +srv:settimeout(0) -- non-blocking accept + +local clients = {} -- open client sockets + +-------------------------------------------------------------------------------- +-- helpers +-------------------------------------------------------------------------------- + +local function cleanNumber(v) + if v ~= v or v == math.huge or v == -math.huge then return nil end + return v +end + +-- Returns the active build object when a build is open in the GUI, else nil. +local function getActiveBuild() + if not launch or not launch.main then return nil end + local modes = launch.main.modes + if not modes then return nil end + local bm = modes["BUILD"] + if not bm or not bm.spec or not bm.calcsTab then return nil end + return bm +end + +-- Synchronous recalc: update output tables and refresh the stat panel in the GUI. +local function guiRecalc(build) + wipeGlobalCache() + build.buildFlag = false + build.calcsTab:BuildOutput() + build:RefreshStatList() +end + +local function serializeOutput(output, fields) + local res = {} + if not output then return res end + if fields then + for _, k in ipairs(fields) do + local v = output[k] + local t = type(v) + if t == "number" then res[k] = cleanNumber(v) + elseif t == "string" or t == "boolean" then res[k] = v end + end + else + for k, v in pairs(output) do + local t = type(v) + if t == "number" then + local n = cleanNumber(v) + if n ~= nil then res[k] = n end + elseif t == "string" or t == "boolean" then + res[k] = v + end + end + end + if type(output.SkillDPS) == "table" then + local skills = {} + for _, s in ipairs(output.SkillDPS) do + skills[#skills + 1] = { + name = s.name, dps = cleanNumber(s.dps), + count = s.count, skillPart = s.skillPart, trigger = s.trigger, + } + end + res.SkillDPS = skills + end + return res +end + +local function nodesById(build, ids) + local set = {} + local specNodes = build.spec and build.spec.nodes + if not specNodes then error("build has no passive tree spec") end + for _, id in ipairs(ids or {}) do + local node = specNodes[id] + if node then set[node] = true end + end + return set +end + +local function nextItemId(build) + local maxId = 0 + for id in pairs(build.itemsTab.items or {}) do + if type(id) == "number" and id > maxId then maxId = id end + end + return maxId + 1 +end + +local function serializeItemMods(item) + local mods = {} + local function add(list, mtype) + for _, ml in ipairs(list or {}) do + if ml.line then mods[#mods+1] = { type = mtype, line = ml.line } end + end + end + add(item.implicitModLines, "implicit") + add(item.explicitModLines, "explicit") + add(item.enchantModLines, "enchant") + add(item.runeModLines, "rune") + return mods +end + +-------------------------------------------------------------------------------- +-- command handlers (same surface as bridge.lua) +-------------------------------------------------------------------------------- + +local handlers = {} + +function handlers.ping() return "pong" end + +-- Return a status flag so the client can tell GUI mode from headless mode. +function handlers.gui_status() + local build = getActiveBuild() + return { + gui = true, + buildOpen = build ~= nil, + className = build and build.spec and build.spec.curClassName, + ascendancy = build and build.spec and build.spec.curAscendClassName, + level = build and build.characterLevel, + } +end + +-- Load a build from a .xml file path or raw XML string. +-- In GUI mode this triggers PoB's own loader (SetMode), which processes the +-- build on the next OnFrame. The response arrives before the mode switch +-- completes, so the caller must wait a moment (Python side: 600 ms) and then +-- call get_output to confirm the build is ready. +function handlers.load_xml(req) + if not launch or not launch.main then error("launch.main not ready") end + if req.path then + launch.main:SetMode("BUILD", false, req.path) + elseif req.xml then + launch.main:SetMode("BUILD", false, req.name or "MCP build", req.xml) + else + error("load_xml requires 'xml' or 'path'") + end + return { loaded = true, pending = true } +end + +function handlers.get_output(req) + local build = getActiveBuild() + if not build then error("no build open in the PoB GUI") end + return serializeOutput(build.calcsTab.mainOutput, req.fields) +end + +function handlers.set_config(req) + local build = getActiveBuild() + if not build then error("no build open in the PoB GUI") end + if req.key == nil then error("set_config requires 'key'") end + if req.key == "mainSocketGroup" then + build.mainSocketGroup = tonumber(req.value) or req.value + else + build.configTab.input[req.key] = req.value + build.configTab:BuildModList() + end + guiRecalc(build) + return { key = req.key, value = req.value } +end + +function handlers.eval_override(req) + local build = getActiveBuild() + if not build then error("no build open in the PoB GUI") end + local ov = req.override or {} + local override = {} + if ov.addNodes then override.addNodes = nodesById(build, ov.addNodes) end + if ov.removeNodes then override.removeNodes = nodesById(build, ov.removeNodes) end + if ov.conditions then override.conditions = ov.conditions end + + local calcFunc, baseOutput = build.calcsTab.calcs.getMiscCalculator(build) + local newOutput = calcFunc(override, req.useFullDPS ~= false) + + local metrics = req.metrics or { "FullDPS", "TotalDPS", "CombinedDPS", "Life", "EnergyShield", "Mana" } + local deltas = {} + for _, m in ipairs(metrics) do + local a = tonumber(baseOutput[m]) or 0 + local b = tonumber(newOutput[m]) or 0 + deltas[m] = { base = cleanNumber(a), new = cleanNumber(b), delta = cleanNumber(b - a) } + end + local result = { deltas = deltas } + if req.fullOutput then result.output = serializeOutput(newOutput) end + return result +end + +function handlers.rank_nodes(req) + local build = getActiveBuild() + if not build then error("no build open in the PoB GUI") end + local metric = req.metric or "FullDPS" + local maxDepth = req.maxDepth or 6 + local limit = req.limit or 25 + + build.spec:BuildAllDependsAndPaths() + + local calcFunc, baseOutput = build.calcsTab.calcs.getMiscCalculator(build) + local base = tonumber(baseOutput[metric]) or 0 + + local cache = {} + local results = {} + local evaluated = 0 + for nodeId, node in pairs(build.spec.nodes) do + if not node.alloc + and node.modKey and node.modKey ~= "" + and node.path + and (node.pathDist or 999) <= maxDepth + and not (build.calcsTab.mainEnv and build.calcsTab.mainEnv.grantedPassives[nodeId]) + and not node.ascendancyName then + evaluated = evaluated + 1 + local val = cache[node.modKey] + if val == nil then + local out = calcFunc({ addNodes = { [node] = true } }, req.useFullDPS ~= false) + val = tonumber(out[metric]) or 0 + cache[node.modKey] = val + end + local delta = val - base + if delta ~= 0 then + results[#results + 1] = { + id = nodeId, + name = node.dn or node.name, + type = node.type, + pathDist = node.pathDist, + delta = cleanNumber(delta), + deltaPerPoint = cleanNumber(delta / math.max(node.pathDist or 1, 1)), + } + end + end + end + table.sort(results, function(a, b) return (a.delta or 0) > (b.delta or 0) end) + local top = {} + for i = 1, math.min(limit, #results) do top[i] = results[i] end + return { metric = metric, base = cleanNumber(base), nodes = top, + evaluated = evaluated, withEffect = #results } +end + +function handlers.allocate_nodes(req) + local build = getActiveBuild() + if not build then error("no build open in the PoB GUI") end + build.spec:AddUndoState() + build.spec:BuildAllDependsAndPaths() + local ids = req.ids or {} + local newly = {} + local already = {} + local skipped = {} + for _, id in ipairs(ids) do + local node = build.spec.nodes[id] + if not node then error("node id not found: " .. tostring(id)) end + if node.alloc then + already[#already + 1] = id + elseif not node.path then + skipped[#skipped + 1] = id + else + build.spec:AllocNode(node) + newly[#newly + 1] = id + end + end + guiRecalc(build) + local o = build.calcsTab.mainOutput + return { + allocated = newly, alreadyAlloc = already, skipped = skipped, + FullDPS = o and cleanNumber(o.FullDPS), + CombinedDPS = o and cleanNumber(o.CombinedDPS), + Life = o and cleanNumber(o.Life), + EnergyShield = o and cleanNumber(o.EnergyShield), + } +end + +function handlers.deallocate_nodes(req) + local build = getActiveBuild() + if not build then error("no build open in the PoB GUI") end + build.spec:AddUndoState() + local ids = req.ids or {} + local removed = {} + local notAlloc = {} + local protected = {} -- ClassStart / AscendClassStart — never removable + for _, id in ipairs(ids) do + local node = build.spec.nodes[id] + if not node then error("node id not found: " .. tostring(id)) end + if node.type == "ClassStart" or node.type == "AscendClassStart" then + protected[#protected + 1] = id + elseif not node.alloc then + notAlloc[#notAlloc + 1] = id + else + build.spec:DeallocNode(node) + removed[#removed + 1] = id + end + end + guiRecalc(build) + local o = build.calcsTab.mainOutput + return { + removed = removed, notAlloc = notAlloc, protected = protected, + FullDPS = o and cleanNumber(o.FullDPS), + CombinedDPS = o and cleanNumber(o.CombinedDPS), + Life = o and cleanNumber(o.Life), + EnergyShield = o and cleanNumber(o.EnergyShield), + } +end + +local function getSkillGroupDiag(build) + local groups = {} + for i, sg in ipairs(build.skillsTab.socketGroupList or {}) do + local gems = {} + for _, g in ipairs(sg.gemList or {}) do gems[#gems+1] = g.nameSpec end + groups[#groups+1] = { + index = i, label = sg.label, enabled = sg.enabled, + includeInFullDPS = sg.includeInFullDPS, + isMain = (i == build.mainSocketGroup), gems = gems, + } + end + return groups +end + +function handlers.optimize_tree(req) + local build = getActiveBuild() + if not build then error("no build open in the PoB GUI") end + local metric = req.metric or "CombinedDPS" + local budget = req.budget or 10 + local maxDepth = req.maxDepth or 3 + local weights = req.weights -- optional {metric->weight}, normalised scoring + local minConstraints = req.minConstraints -- optional {metric->minValue}, hard floor + + build.spec:BuildAllDependsAndPaths() + local calcFunc, baseOutput = build.calcsTab.calcs.getMiscCalculator(build) + local base = tonumber(baseOutput[metric]) or 0 + + if base == 0 and not weights then + local diag = getSkillGroupDiag(build) + error(string.format( + "Base %s = 0, cannot optimize. mainSocketGroup=%s. Skill groups: %s", + metric, tostring(build.mainSocketGroup), dkjson.encode(diag) + )) + end + + build.spec:AddUndoState() + local deadline = GetTime() + 25000 + + -- Weighted normalised score: sum of w_i * (new_i - base_i) / |base_i|. + -- Falls back to simple absolute delta when no weights are given. + local function computeScore(out) + if not weights then + return (tonumber(out[metric]) or 0) - base + end + local score = 0 + for m, w in pairs(weights) do + local a = tonumber(baseOutput[m]) or 0 + local b = tonumber(out[m]) or 0 + if a ~= 0 then score = score + w * (b - a) / math.abs(a) end + end + return score + end + + -- Returns false if any minConstraint would be violated by this output. + local function meetsConstraints(out) + if not minConstraints then return true end + for m, minVal in pairs(minConstraints) do + if (tonumber(out[m]) or 0) < minVal then return false end + end + return true + end + + -- Cache key based on the combined modKey set of the path. + -- Two paths whose nodes share identical modifier sets produce the same calc + -- result, so we can skip redundant calcFunc calls. + local function pathCacheKey(pathList) + local keys = {} + for _, n in ipairs(pathList) do + if n.modKey and n.modKey ~= "" then keys[#keys + 1] = n.modKey end + end + table.sort(keys) + return table.concat(keys, "\0") + end + + -- BFS from every node adjacent to the allocated tree. + -- Gathers all unique allocation-paths up to depthLimit for normal nodes, + -- and up to maxSearchDepth for Notables/Keystones (lookahead beyond budget step). + -- Deduplication: sorted path-id set → only one entry per unique allocation. + local function gatherCandidates(depthLimit, remaining) + local candidates = {} + local seenKeys = {} + local grantedPassives = build.calcsTab.mainEnv and build.calcsTab.mainEnv.grantedPassives + local maxSearchDepth = math.min(8, remaining) + + local queue, qHead = {}, 1 + local seenStart = {} + for _, node in pairs(build.spec.nodes) do + if not node.alloc and not node.ascendancyName then + for _, nb in ipairs(node.linked or {}) do + if nb.alloc and not seenStart[node.id] then + seenStart[node.id] = true + queue[#queue + 1] = { + front = node, + pathList = { node }, + pathSet = { [node.id] = true }, + } + break + end + end + end + end + + while qHead <= #queue do + local item = queue[qHead]; qHead = qHead + 1 + local depth = #item.pathList + local node = item.front + + if node.modKey and node.modKey ~= "" + and not (grantedPassives and grantedPassives[node.id]) then + local shouldAdd = depth <= depthLimit + or (depth <= maxSearchDepth + and (node.type == "Notable" or node.type == "Keystone")) + if shouldAdd then + local ids = {} + for id in pairs(item.pathSet) do ids[#ids + 1] = id end + table.sort(ids) + local key = table.concat(ids, ",") + if not seenKeys[key] then + seenKeys[key] = true + local addSet = {} + for _, n in ipairs(item.pathList) do addSet[n] = true end + candidates[#candidates + 1] = { + node = node, + pathList = item.pathList, + cost = depth, + addSet = addSet, + } + end + end + end + + if depth < maxSearchDepth then + for _, nb in ipairs(node.linked or {}) do + if not nb.alloc and not nb.ascendancyName + and not item.pathSet[nb.id] then + local newList, newSet = {}, {} + for _, n in ipairs(item.pathList) do newList[#newList + 1] = n end + newList[#newList + 1] = nb + for k in pairs(item.pathSet) do newSet[k] = true end + newSet[nb.id] = true + queue[#queue + 1] = { + front = nb, + pathList = newList, + pathSet = newSet, + } + end + end + end + end + return candidates + end + + local remaining = budget + local steps = {} + while remaining > 0 do + if GetTime() > deadline then + steps[#steps + 1] = { timedOut = true, remainingPoints = remaining } + break + end + + local depthLimit = math.min(maxDepth, remaining) + local candidates = gatherCandidates(depthLimit, remaining) + local evalCache = {} -- modKey-set cache, reset each step (base changed) + + local bestNode, bestPath, bestEfficiency, bestDelta = nil, nil, 0, 0 + for _, cand in ipairs(candidates) do + if GetTime() > deadline then break end + local ckey = pathCacheKey(cand.pathList) + local out = evalCache[ckey] + if not out then + out = calcFunc({ addNodes = cand.addSet }, true) + evalCache[ckey] = out + end + if meetsConstraints(out) then + local s = computeScore(out) + local efficiency = s / cand.cost + if efficiency > bestEfficiency then + bestEfficiency = efficiency + bestDelta = (tonumber(out[metric]) or 0) - base + bestNode = cand.node + bestPath = cand.pathList + end + end + end + if not bestNode then break end + + local before = 0 + for _ in pairs(build.spec.allocNodes) do before = before + 1 end + build.spec:AllocNode(bestNode, bestPath) + local after = 0 + for _ in pairs(build.spec.allocNodes) do after = after + 1 end + local actualCost = after - before + + remaining = remaining - actualCost + build.spec:BuildAllDependsAndPaths() + steps[#steps + 1] = { + step = #steps + 1, + id = bestNode.id, + name = bestNode.dn or bestNode.name, + type = bestNode.type, + delta = cleanNumber(bestDelta), + cost = actualCost, + remainingPoints = remaining, + } + + calcFunc, baseOutput = build.calcsTab.calcs.getMiscCalculator(build) + base = tonumber(baseOutput[metric]) or 0 + end + + guiRecalc(build) + local o = build.calcsTab.mainOutput + return { + metric = metric, + steps = steps, + pointsUsed = budget - remaining, + stepsCount = #steps, + finalValue = cleanNumber(tonumber(o[metric]) or 0), + CombinedDPS = o and cleanNumber(o.CombinedDPS), + Life = o and cleanNumber(o.Life), + EnergyShield = o and cleanNumber(o.EnergyShield), + } +end + +function handlers.set_gem(req) + local build = getActiveBuild() + if not build then error("no build open in the PoB GUI") end + local sg = build.skillsTab.socketGroupList[req.group] + if not sg then error("socket group " .. tostring(req.group) .. " not found") end + local found = false + for _, gem in ipairs(sg.gemList or {}) do + if gem.nameSpec == req.name then + if req.level ~= nil then gem.level = req.level end + if req.quality ~= nil then gem.quality = req.quality end + found = true + break + end + end + if not found then error("gem '" .. tostring(req.name) .. "' not found in group " .. tostring(req.group)) end + build.skillsTab:ProcessSocketGroup(sg) + guiRecalc(build) + local o = build.calcsTab.mainOutput + return { + group = req.group, name = req.name, level = req.level, quality = req.quality, + CombinedDPS = o and cleanNumber(o.CombinedDPS), + FullDPS = o and cleanNumber(o.FullDPS), + EnergyShield = o and cleanNumber(o.EnergyShield), + } +end + +function handlers.undo_tree(req) + local build = getActiveBuild() + if not build then error("no build open in the PoB GUI") end + build.spec:Undo() + guiRecalc(build) + local o = build.calcsTab.mainOutput + return { CombinedDPS = o and cleanNumber(o.CombinedDPS), Life = o and cleanNumber(o.Life), EnergyShield = o and cleanNumber(o.EnergyShield) } +end + +function handlers.redo_tree(req) + local build = getActiveBuild() + if not build then error("no build open in the PoB GUI") end + build.spec:Redo() + guiRecalc(build) + local o = build.calcsTab.mainOutput + return { CombinedDPS = o and cleanNumber(o.CombinedDPS), Life = o and cleanNumber(o.Life), EnergyShield = o and cleanNumber(o.EnergyShield) } +end + +function handlers.get_xml(req) + local build = getActiveBuild() + if not build then error("no build open in the PoB GUI") end + local xml = build:SaveDB(req.name or "MCP build") + if type(xml) ~= "string" or xml == "" then + error("build:SaveDB() returned nothing") + end + return { xml = xml } +end + +function handlers.list_state(req) + local build = getActiveBuild() + if not build then error("no build open in the PoB GUI") end + local what = req.what or "summary" + if what == "config" then + local input = {} + for k, v in pairs(build.configTab.input) do + local t = type(v) + if t == "number" or t == "string" or t == "boolean" then input[k] = v end + end + return { input = input } + elseif what == "skills" then + local groups = {} + for _, sg in ipairs(build.skillsTab.socketGroupList or {}) do + local gems = {} + for _, gem in ipairs(sg.gemList or {}) do + gems[#gems + 1] = { name = gem.nameSpec, level = gem.level, quality = gem.quality, enabled = gem.enabled } + end + groups[#groups + 1] = { + label = sg.label, slot = sg.slot, enabled = sg.enabled, + includeInFullDPS = sg.includeInFullDPS, + mainActiveSkill = sg.mainActiveSkill, gems = gems, + } + end + return { mainSocketGroup = build.mainSocketGroup, groups = groups } + elseif what == "items" then + local items = {} + for slotName, slot in pairs(build.itemsTab.slots or {}) do + if slot.selItemId and slot.selItemId ~= 0 then + local item = build.itemsTab.items[slot.selItemId] + if item then + local entry = { slot = slotName, name = item.name, rarity = item.rarity } + if slotName:match("^Flask") then entry.active = slot.active or false end + items[#items + 1] = entry + end + end + end + return { items = items } + elseif what == "nodes" then + local alloc = {} + local usedPoints = 0 + for nodeId, node in pairs(build.spec.allocNodes or {}) do + alloc[#alloc + 1] = { id = nodeId, name = node.dn or node.name, type = node.type } + -- Mirror PassiveSpec:CountAllocNodes — start nodes and free-allocate nodes + -- don't cost a passive point and must not be counted. + if node.type ~= "ClassStart" and node.type ~= "AscendClassStart" + and not node.isFreeAllocate then + usedPoints = usedPoints + 1 + end + end + return { allocated = alloc, usedPoints = usedPoints } + else + local o = build.calcsTab.mainOutput + return { + className = build.spec and build.spec.curClassName, + ascendancy = build.spec and build.spec.curAscendClassName, + level = build.characterLevel, + FullDPS = o and cleanNumber(o.FullDPS), + CombinedDPS = o and cleanNumber(o.CombinedDPS), + Life = o and cleanNumber(o.Life), + EnergyShield = o and cleanNumber(o.EnergyShield), + } + end +end + +function handlers.equip_item(req) + local build = getActiveBuild() + if not build then error("no build open in the PoB GUI") end + if not req.slot then error("equip_item requires 'slot'") end + if not req.item_text then error("equip_item requires 'item_text'") end + local slot = build.itemsTab.slots[req.slot] + if not slot then + local avail = {} + for k in pairs(build.itemsTab.slots or {}) do avail[#avail+1] = k end + table.sort(avail) + error("slot '" .. req.slot .. "' not found. Available: " .. table.concat(avail, ", ")) + end + local item = new("Item") + item:ParseRaw(req.item_text) + local newId = nextItemId(build) + item.id = newId + build.itemsTab.items[newId] = item + slot.selItemId = newId + guiRecalc(build) + local o = build.calcsTab.mainOutput + return { + slot = req.slot, name = item.name, rarity = item.rarity, itemId = newId, + FullDPS = o and cleanNumber(o.FullDPS), CombinedDPS = o and cleanNumber(o.CombinedDPS), + Life = o and cleanNumber(o.Life), EnergyShield = o and cleanNumber(o.EnergyShield), + } +end + +function handlers.remove_item(req) + local build = getActiveBuild() + if not build then error("no build open in the PoB GUI") end + if not req.slot then error("remove_item requires 'slot'") end + local slot = build.itemsTab.slots[req.slot] + if not slot then error("slot '" .. req.slot .. "' not found") end + local oldId = slot.selItemId + slot.selItemId = 0 + guiRecalc(build) + local o = build.calcsTab.mainOutput + return { + slot = req.slot, removedItemId = oldId, + FullDPS = o and cleanNumber(o.FullDPS), CombinedDPS = o and cleanNumber(o.CombinedDPS), + Life = o and cleanNumber(o.Life), EnergyShield = o and cleanNumber(o.EnergyShield), + } +end + +function handlers.get_item_details(req) + local build = getActiveBuild() + if not build then error("no build open in the PoB GUI") end + if not req.slot then error("get_item_details requires 'slot'") end + local slot = build.itemsTab.slots[req.slot] + if not slot then error("slot '" .. req.slot .. "' not found") end + if not slot.selItemId or slot.selItemId == 0 then + return { slot = req.slot, empty = true } + end + local item = build.itemsTab.items[slot.selItemId] + if not item then return { slot = req.slot, empty = true } end + return { + slot = req.slot, id = item.id, name = item.name, + baseName = item.baseName, rarity = item.rarity, quality = item.quality, + raw = item.raw, mods = serializeItemMods(item), + } +end + +function handlers.eval_item_change(req) + local build = getActiveBuild() + if not build then error("no build open in the PoB GUI") end + if not req.slot then error("eval_item_change requires 'slot'") end + if not req.item_text then error("eval_item_change requires 'item_text'") end + local slot = build.itemsTab.slots[req.slot] + if not slot then error("slot '" .. req.slot .. "' not found") end + local metrics = req.metrics or { "FullDPS", "TotalDPS", "CombinedDPS", "Life", "EnergyShield", "Mana" } + -- capture base values before any mutation + local baseVals = {} + local baseOut = build.calcsTab.mainOutput + for _, m in ipairs(metrics) do baseVals[m] = tonumber(baseOut and baseOut[m]) or 0 end + -- add temp item with negative ID so it doesn't collide with real items + local item = new("Item") + item:ParseRaw(req.item_text) + local tempId = -(nextItemId(build)) + item.id = tempId + build.itemsTab.items[tempId] = item + local oldId = slot.selItemId + slot.selItemId = tempId + guiRecalc(build) + local newOut = build.calcsTab.mainOutput + local deltas = {} + for _, m in ipairs(metrics) do + local a, b = baseVals[m], tonumber(newOut and newOut[m]) or 0 + deltas[m] = { base = cleanNumber(a), new = cleanNumber(b), delta = cleanNumber(b - a) } + end + -- restore original state + slot.selItemId = oldId + build.itemsTab.items[tempId] = nil + guiRecalc(build) + return { slot = req.slot, newItemName = item.name, newItemRarity = item.rarity, deltas = deltas } +end + +function handlers.add_gem(req) + local build = getActiveBuild() + if not build then error("no build open in the PoB GUI") end + local sg = build.skillsTab.socketGroupList[req.group] + if not sg then error("socket group " .. tostring(req.group) .. " not found") end + local newGem = { + nameSpec = req.name, level = req.level or 1, quality = req.quality or 0, + enabled = true, enableGlobal1 = true, enableGlobal2 = true, count = 1, + } + table.insert(sg.gemList, newGem) + build.skillsTab:ProcessSocketGroup(sg) + guiRecalc(build) + local o = build.calcsTab.mainOutput + return { + group = req.group, added = req.name, + FullDPS = o and cleanNumber(o.FullDPS), CombinedDPS = o and cleanNumber(o.CombinedDPS), + Life = o and cleanNumber(o.Life), EnergyShield = o and cleanNumber(o.EnergyShield), + } +end + +function handlers.remove_gem(req) + local build = getActiveBuild() + if not build then error("no build open in the PoB GUI") end + local sg = build.skillsTab.socketGroupList[req.group] + if not sg then error("socket group " .. tostring(req.group) .. " not found") end + local found = false + for i, gem in ipairs(sg.gemList or {}) do + if gem.nameSpec == req.name then table.remove(sg.gemList, i); found = true; break end + end + if not found then error("gem '" .. req.name .. "' not found in group " .. tostring(req.group)) end + build.skillsTab:ProcessSocketGroup(sg) + guiRecalc(build) + local o = build.calcsTab.mainOutput + return { + group = req.group, removed = req.name, + FullDPS = o and cleanNumber(o.FullDPS), CombinedDPS = o and cleanNumber(o.CombinedDPS), + Life = o and cleanNumber(o.Life), EnergyShield = o and cleanNumber(o.EnergyShield), + } +end + +function handlers.toggle_gem(req) + local build = getActiveBuild() + if not build then error("no build open in the PoB GUI") end + local sg = build.skillsTab.socketGroupList[req.group] + if not sg then error("socket group " .. tostring(req.group) .. " not found") end + local found = false + for _, gem in ipairs(sg.gemList or {}) do + if gem.nameSpec == req.name then gem.enabled = req.enabled; found = true; break end + end + if not found then error("gem '" .. req.name .. "' not found in group " .. tostring(req.group)) end + build.skillsTab:ProcessSocketGroup(sg) + guiRecalc(build) + local o = build.calcsTab.mainOutput + return { + group = req.group, gem = req.name, enabled = req.enabled, + FullDPS = o and cleanNumber(o.FullDPS), CombinedDPS = o and cleanNumber(o.CombinedDPS), + Life = o and cleanNumber(o.Life), EnergyShield = o and cleanNumber(o.EnergyShield), + } +end + +function handlers.toggle_skill_group(req) + local build = getActiveBuild() + if not build then error("no build open in the PoB GUI") end + local sg = build.skillsTab.socketGroupList[req.group] + if not sg then error("socket group " .. tostring(req.group) .. " not found") end + sg.enabled = req.enabled + build.skillsTab:ProcessSocketGroup(sg) + guiRecalc(build) + local o = build.calcsTab.mainOutput + return { + group = req.group, enabled = req.enabled, + FullDPS = o and cleanNumber(o.FullDPS), CombinedDPS = o and cleanNumber(o.CombinedDPS), + Life = o and cleanNumber(o.Life), EnergyShield = o and cleanNumber(o.EnergyShield), + } +end + +function handlers.set_main_skill(req) + local build = getActiveBuild() + if not build then error("no build open in the PoB GUI") end + if not req.group then error("set_main_skill requires 'group'") end + local total = #(build.skillsTab.socketGroupList or {}) + if req.group < 1 or req.group > total then + error(string.format("group %d out of range (1-%d)", req.group, total)) + end + build.mainSocketGroup = req.group + guiRecalc(build) + local o = build.calcsTab.mainOutput + return { + mainSocketGroup = req.group, + FullDPS = o and cleanNumber(o.FullDPS), CombinedDPS = o and cleanNumber(o.CombinedDPS), + Life = o and cleanNumber(o.Life), EnergyShield = o and cleanNumber(o.EnergyShield), + } +end + +function handlers.list_masteries(req) + local build = getActiveBuild() + if not build then error("no build open in the PoB GUI") end + local allocatedOnly = req.allocatedOnly == true + local masteries = {} + for nodeId, node in pairs(build.spec.nodes) do + if node.type == "Mastery" then + if not allocatedOnly or node.alloc then + local effects = {} + for _, eff in ipairs(node.masteryEffects or {}) do + local effectId = eff.effect + local stats = {} + local effectData = build.spec.tree.masteryEffects and build.spec.tree.masteryEffects[effectId] + if effectData then + for _, s in ipairs(effectData.sd or {}) do stats[#stats+1] = s end + end + effects[#effects+1] = { id = effectId, stats = stats } + end + local selectedEffect = build.spec.masterySelections and build.spec.masterySelections[nodeId] + masteries[#masteries+1] = { + nodeId = nodeId, + name = node.dn or node.name, + allocated = node.alloc or false, + selectedEffect = selectedEffect, + effects = effects, + } + end + end + end + return { masteries = masteries } +end + +function handlers.set_mastery(req) + local build = getActiveBuild() + if not build then error("no build open in the PoB GUI") end + if not req.nodeId then error("set_mastery requires 'nodeId'") end + if not req.effectId then error("set_mastery requires 'effectId'") end + local node = build.spec.nodes[req.nodeId] + if not node then error("node not found: " .. tostring(req.nodeId)) end + if node.type ~= "Mastery" then + error("node " .. req.nodeId .. " is type '" .. (node.type or "?") .. "', not 'Mastery'") + end + local validEffect = false + for _, eff in ipairs(node.masteryEffects or {}) do + if eff.effect == req.effectId then validEffect = true; break end + end + if not validEffect then + local valid = {} + for _, eff in ipairs(node.masteryEffects or {}) do valid[#valid+1] = tostring(eff.effect) end + error("effectId " .. req.effectId .. " not valid for this node. Valid ids: " .. table.concat(valid, ", ")) + end + if not build.spec.masterySelections then build.spec.masterySelections = {} end + build.spec.masterySelections[req.nodeId] = req.effectId + if build.spec.tree.ProcessStats then + build.spec.tree:ProcessStats(node) + end + guiRecalc(build) + local o = build.calcsTab.mainOutput + return { + nodeId = req.nodeId, effectId = req.effectId, + FullDPS = o and cleanNumber(o.FullDPS), CombinedDPS = o and cleanNumber(o.CombinedDPS), + Life = o and cleanNumber(o.Life), EnergyShield = o and cleanNumber(o.EnergyShield), + } +end + +function handlers.get_node_info(req) + local build = getActiveBuild() + if not build then error("no build open in the PoB GUI") end + build.spec:BuildAllDependsAndPaths() + local ids = req.ids or {} + local nodes = {} + for _, id in ipairs(ids) do + local node = build.spec.nodes[id] + if not node then + nodes[#nodes+1] = { id = id, error = "not found" } + else + local stats = {} + for _, s in ipairs(node.sd or {}) do stats[#stats+1] = s end + nodes[#nodes+1] = { + id = id, + name = node.dn or node.name, + type = node.type, + allocated = node.alloc or false, + stats = stats, + ascendancy = node.ascendancyName, + pathDist = node.pathDist, + } + end + end + return { nodes = nodes } +end + +function handlers.set_character(req) + local build = getActiveBuild() + if not build then error("no build open in the PoB GUI") end + if req.level ~= nil then + local lvl = tonumber(req.level) + if not lvl or lvl < 1 or lvl > 100 then error("level must be 1-100") end + build.characterLevel = lvl + end + if req.className ~= nil then + local classId + local classes = build.spec.tree.classes or {} + for id, cls in pairs(classes) do + if type(cls) == "table" and cls.name and cls.name:lower() == req.className:lower() then + classId = id; break + end + end + if not classId then + local avail = {} + for _, cls in pairs(classes) do + if type(cls) == "table" and cls.name then avail[#avail+1] = cls.name end + end + table.sort(avail) + error("class '" .. req.className .. "' not found. Available: " .. table.concat(avail, ", ")) + end + build.spec:SelectClass(classId) + end + if req.ascendancy ~= nil then + local curClass = build.spec.tree.classes[build.spec.curClassId] + if not curClass then error("no current class; set className first") end + local ascId + for id, asc in pairs(curClass.ascendancies or {}) do + if type(asc) == "table" and asc.name and asc.name:lower() == req.ascendancy:lower() then + ascId = id; break + end + end + if not ascId then + local avail = {} + for _, asc in pairs(curClass.ascendancies or {}) do + if type(asc) == "table" and asc.name then avail[#avail+1] = asc.name end + end + table.sort(avail) + error("ascendancy '" .. req.ascendancy .. "' not found. Available: " .. table.concat(avail, ", ")) + end + build.spec:SelectAscendClass(ascId) + end + guiRecalc(build) + local o = build.calcsTab.mainOutput + return { + level = build.characterLevel, + className = build.spec.curClassName, + ascendancy = build.spec.curAscendClassName, + FullDPS = o and cleanNumber(o.FullDPS), CombinedDPS = o and cleanNumber(o.CombinedDPS), + Life = o and cleanNumber(o.Life), EnergyShield = o and cleanNumber(o.EnergyShield), + } +end + +function handlers.set_bandit(req) + local build = getActiveBuild() + if not build then error("no build open in the PoB GUI") end + if req.choice == nil then error("set_bandit requires 'choice'") end + build.bandit = req.choice + guiRecalc(build) + local o = build.calcsTab.mainOutput + return { + bandit = build.bandit, + FullDPS = o and cleanNumber(o.FullDPS), CombinedDPS = o and cleanNumber(o.CombinedDPS), + Life = o and cleanNumber(o.Life), EnergyShield = o and cleanNumber(o.EnergyShield), + } +end + +function handlers.set_pantheon(req) + local build = getActiveBuild() + if not build then error("no build open in the PoB GUI") end + if req.major ~= nil then build.pantheonMajorGod = req.major end + if req.minor ~= nil then build.pantheonMinorGod = req.minor end + guiRecalc(build) + local o = build.calcsTab.mainOutput + return { + pantheonMajorGod = build.pantheonMajorGod, + pantheonMinorGod = build.pantheonMinorGod, + FullDPS = o and cleanNumber(o.FullDPS), CombinedDPS = o and cleanNumber(o.CombinedDPS), + Life = o and cleanNumber(o.Life), EnergyShield = o and cleanNumber(o.EnergyShield), + } +end + +function handlers.list_flasks(req) + local build = getActiveBuild() + if not build then error("no build open in the PoB GUI") end + local flasks = {} + for slotName, slot in pairs(build.itemsTab.slots or {}) do + if slotName:match("^Flask") then + local entry = { slot = slotName, active = slot.active or false, empty = true } + if slot.selItemId and slot.selItemId ~= 0 then + local item = build.itemsTab.items[slot.selItemId] + if item then + entry.empty = false + entry.name = item.name + entry.rarity = item.rarity + if item.base and item.base.flask then + entry.flask = { + subType = item.base.subType, + life = item.base.flask.life, + mana = item.base.flask.mana, + duration = item.base.flask.duration, + chargesMax = item.base.flask.chargesMax, + chargesUsed = item.base.flask.chargesUsed, + } + end + if item.flaskData then + entry.flaskData = { + lifeTotal = cleanNumber(item.flaskData.lifeTotal), + manaTotal = cleanNumber(item.flaskData.manaTotal), + effectInc = cleanNumber(item.flaskData.effectInc), + } + end + end + end + flasks[#flasks+1] = entry + end + end + table.sort(flasks, function(a, b) return a.slot < b.slot end) + return { flasks = flasks } +end + +function handlers.toggle_flask(req) + local build = getActiveBuild() + if not build then error("no build open in the PoB GUI") end + if not req.slot then error("toggle_flask requires 'slot'") end + if req.active == nil then error("toggle_flask requires 'active'") end + if not req.slot:match("^Flask") then + error("'" .. req.slot .. "' is not a flask slot (expected 'Flask 1' or 'Flask 2')") + end + local slot = build.itemsTab.slots[req.slot] + if not slot then error("slot '" .. req.slot .. "' not found") end + slot.active = req.active + if build.itemsTab.activeItemSet and build.itemsTab.activeItemSet[req.slot] then + build.itemsTab.activeItemSet[req.slot].active = req.active + end + guiRecalc(build) + local o = build.calcsTab.mainOutput + return { + slot = req.slot, active = req.active, + FullDPS = o and cleanNumber(o.FullDPS), CombinedDPS = o and cleanNumber(o.CombinedDPS), + Life = o and cleanNumber(o.Life), EnergyShield = o and cleanNumber(o.EnergyShield), + } +end + +function handlers.list_keystones(req) + local build = getActiveBuild() + if not build then error("no build open in the PoB GUI") end + build.spec:BuildAllDependsAndPaths() + local maxDepth = req.maxDepth -- nil = no limit + local keystones = {} + for nodeId, node in pairs(build.spec.nodes) do + if node.type == "Keystone" then + local dist = node.pathDist or (node.alloc and 0) or 999 + if not maxDepth or node.alloc or dist <= maxDepth then + local stats = {} + for _, s in ipairs(node.sd or {}) do stats[#stats+1] = s end + keystones[#keystones+1] = { + id = nodeId, + name = node.dn or node.name, + allocated = node.alloc or false, + pathDist = node.alloc and 0 or dist, + stats = stats, + } + end + end + end + -- Allocated first, then by distance + table.sort(keystones, function(a, b) + if a.allocated ~= b.allocated then return a.allocated end + return (a.pathDist or 999) < (b.pathDist or 999) + end) + return { keystones = keystones, count = #keystones } +end + +function handlers.set_node_stat(req) + local build = getActiveBuild() + if not build then error("no build open in the PoB GUI") end + if not req.nodeId then error("set_node_stat requires 'nodeId'") end + if req.index == nil then error("set_node_stat requires 'index'") end + if req.stat == nil then error("set_node_stat requires 'stat'") end + local node = build.spec.nodes[req.nodeId] + if not node then error("node not found: " .. tostring(req.nodeId)) end + if node.type == "ClassStart" or node.type == "AscendClassStart" then + error("cannot modify the start node (type: " .. node.type .. ")") + end + local sd = node.sd or {} + local idx = req.index + if idx < 1 or idx > #sd then + error("stat index " .. idx .. " out of range (node has " .. #sd .. " stats)") + end + local oldStat = sd[idx] + sd[idx] = req.stat + node.sd = sd + build.spec.tree:ProcessStats(node) + guiRecalc(build) + local o = build.calcsTab.mainOutput + return { + nodeId = req.nodeId, + name = node.dn or node.name, + index = idx, + oldStat = oldStat, + newStat = req.stat, + stats = { + FullDPS = o and cleanNumber(o.FullDPS), + CombinedDPS = o and cleanNumber(o.CombinedDPS), + Life = o and cleanNumber(o.Life), + EnergyShield = o and cleanNumber(o.EnergyShield), + Armour = o and cleanNumber(o.Armour), + Evasion = o and cleanNumber(o.Evasion), + }, + } +end + +function handlers.evaluate_node_stat(req) + local build = getActiveBuild() + if not build then error("no build open in the PoB GUI") end + if not req.nodeId then error("evaluate_node_stat requires 'nodeId'") end + if not req.overrides then error("evaluate_node_stat requires 'overrides'") end + local node = build.spec.nodes[req.nodeId] + if not node then error("node not found: " .. tostring(req.nodeId)) end + + -- Capture baseline + guiRecalc(build) + local o0 = build.calcsTab.mainOutput + local keys = { + "FullDPS","CombinedDPS","Life","EnergyShield","Armour","Evasion", + "PhysicalDmgReductionPercent","SpellBlockChance","AttackBlockChance", + "MeleeEvadeChance","Mana","MaxManaReserved", + } + local before = {} + for _, k in ipairs(keys) do before[k] = o0 and cleanNumber(o0[k]) end + + -- Save original sd lines + local origSd = {} + for i, s in ipairs(node.sd or {}) do origSd[i] = s end + + -- Apply overrides: {index(string or number) -> newStatText} + for rawIdx, newStat in pairs(req.overrides) do + local i = tonumber(rawIdx) + if i and i >= 1 and i <= #origSd then + node.sd[i] = newStat + end + end + + -- Rebuild modList then recalc + build.spec.tree:ProcessStats(node) + guiRecalc(build) + local o1 = build.calcsTab.mainOutput + local after = {} + for _, k in ipairs(keys) do after[k] = o1 and cleanNumber(o1[k]) end + + -- Restore original sd and modList + for i, s in ipairs(origSd) do node.sd[i] = s end + build.spec.tree:ProcessStats(node) + guiRecalc(build) + + -- Compute delta for changed stats only + local delta = {} + for _, k in ipairs(keys) do + local b, a = before[k], after[k] + if type(b) == "number" and type(a) == "number" and a ~= b then + delta[k] = { before = b, after = a, diff = a - b } + end + end + + return { + nodeId = req.nodeId, + name = node.dn or node.name, + nodeType = node.type, + sdOriginal = origSd, + before = before, + after = after, + delta = delta, + } +end + +-------------------------------------------------------------------------------- +-- per-client read buffer (handles partial lines) +-------------------------------------------------------------------------------- + +local buffers = {} -- socket -> accumulated string + +local function processClient(c) + buffers[c] = buffers[c] or "" + local chunk, err, partial = c:receive(4096) + local data = chunk or partial or "" + if data ~= "" then + buffers[c] = buffers[c] .. data + end + + -- Process all complete lines + while true do + local nl = buffers[c]:find("\n", 1, true) + if not nl then break end + local line = buffers[c]:sub(1, nl - 1) + buffers[c] = buffers[c]:sub(nl + 1) + if line ~= "" then + local req, _, perr = dkjson.decode(line) + if not req then + c:send(dkjson.encode({ ok = false, error = "invalid JSON: " .. tostring(perr) }) .. "\n") + else + local handler = handlers[req.cmd] + if not handler then + c:send(dkjson.encode({ id = req.id, ok = false, error = "unknown cmd: " .. tostring(req.cmd) }) .. "\n") + else + local ok2, result = pcall(handler, req) + if ok2 then + c:send(dkjson.encode({ id = req.id, ok = true, result = result }) .. "\n") + else + c:send(dkjson.encode({ id = req.id, ok = false, error = tostring(result) }) .. "\n") + end + end + end + end + end + + return err -- "closed" | "timeout" | nil +end + +-------------------------------------------------------------------------------- +local function logDebug(msg) + local f = io.open("c:/Users/imfal/Documents/GitHub/PathOfBuilding-PoE2/pob-mcp-tick-debug.txt", "a") + if f then + f:write(string.format("[%s] %s\n", os.date("%Y-%m-%d %H:%M:%S"), msg)) + f:close() + end +end + +logDebug("GUI bridge loaded successfully! Listening on port " .. PORT) + +function _G.pobMcpTick() + if not _G.pobMcpTickCount then + _G.pobMcpTickCount = 0 + logDebug("First pobMcpTick called!") + end + _G.pobMcpTickCount = _G.pobMcpTickCount + 1 + + -- Accept new connections (non-blocking) + local client = srv:accept() + if client then + logDebug("Accepted client connection!") + -- Send handshake with a safe short timeout to guarantee immediate delivery on Windows + client:settimeout(0.5) + local h_ok, h_err = pcall(function() client:send(dkjson.encode({ event = "ready", gui = true }) .. "\n") end) + logDebug("Handshake sent: ok=" .. tostring(h_ok) .. ", err=" .. tostring(h_err)) + client:settimeout(0) -- Set to non-blocking for all subsequent frame ticks + clients[#clients + 1] = client + end + + -- Serve existing clients + local i = 1 + while i <= #clients do + local ok, err = pcall(processClient, clients[i]) + if not ok or (err and err ~= "timeout") then + logDebug("Disconnected client: ok=" .. tostring(ok) .. ", err=" .. tostring(err)) + buffers[clients[i]] = nil + pcall(function() clients[i]:close() end) -- Safely close the client socket to prevent CLOSE_WAIT leaks! + table.remove(clients, i) + else + i = i + 1 + end + end +end + +ConPrintf("pob-mcp: GUI bridge listening on 127.0.0.1:%d", PORT) diff --git a/pob-mcp/requirements.txt b/pob-mcp/requirements.txt new file mode 100644 index 0000000000..a5aa6e2bbb --- /dev/null +++ b/pob-mcp/requirements.txt @@ -0,0 +1 @@ +mcp>=1.2.0 diff --git a/pob-mcp/server/__pycache__/buildcode.cpython-313.pyc b/pob-mcp/server/__pycache__/buildcode.cpython-313.pyc new file mode 100644 index 0000000000..fabc82e426 Binary files /dev/null and b/pob-mcp/server/__pycache__/buildcode.cpython-313.pyc differ diff --git a/pob-mcp/server/__pycache__/engine_client.cpython-313.pyc b/pob-mcp/server/__pycache__/engine_client.cpython-313.pyc new file mode 100644 index 0000000000..a27b9c47ea Binary files /dev/null and b/pob-mcp/server/__pycache__/engine_client.cpython-313.pyc differ diff --git a/pob-mcp/server/buildcode.py b/pob-mcp/server/buildcode.py new file mode 100644 index 0000000000..b11380ca9f --- /dev/null +++ b/pob-mcp/server/buildcode.py @@ -0,0 +1,42 @@ +"""Conversion between Path of Building share codes and raw build XML. + +A PoB build code is URL-safe base64 of zlib-deflated build XML. The headless engine +stubs out Deflate/Inflate, so we do the (de)compression here and hand the engine raw XML. + +Mirrors src/Classes/ImportTab.lua: + import: Inflate(base64.decode(code:gsub("-","+"):gsub("_","/"))) + export: base64.encode(Deflate(xml)):gsub("+","-"):gsub("/","_") +""" + +from __future__ import annotations + +import base64 +import zlib + + +def code_to_xml(code: str) -> str: + """Decode a PoB build code (URL-safe base64 + zlib) into build XML.""" + cleaned = "".join(code.split()) # strip whitespace/newlines + std = cleaned.replace("-", "+").replace("_", "/") + std += "=" * (-len(std) % 4) # restore base64 padding + raw = base64.b64decode(std) + return zlib.decompress(raw).decode("utf-8") + + +def xml_to_code(xml: str) -> str: + """Encode build XML into a PoB build code (URL-safe base64 + zlib).""" + deflated = zlib.compress(xml.encode("utf-8")) + b64 = base64.b64encode(deflated).decode("ascii") + return b64.replace("+", "-").replace("/", "_") + + +def looks_like_xml(text: str) -> bool: + return text.lstrip().startswith("<") + + +if __name__ == "__main__": + sample = "" + code = xml_to_code(sample) + roundtrip = code_to_xml(code) + assert roundtrip == sample, roundtrip + print("buildcode roundtrip OK:", code) diff --git a/pob-mcp/server/engine_client.py b/pob-mcp/server/engine_client.py new file mode 100644 index 0000000000..afe2b7f802 --- /dev/null +++ b/pob-mcp/server/engine_client.py @@ -0,0 +1,207 @@ +"""Client for the Path of Building GUI bridge. + +Connects to the TCP bridge (pob-mcp/gui_bridge.lua) that runs inside the +PoB GUI process on 127.0.0.1:12321. All commands operate on the live build +object — changes are visible in the GUI immediately. + +If PoB is not running, every command fails with a clear EngineError. +The connection is established lazily and re-established automatically +after PoB is restarted. +""" + +from __future__ import annotations + +import json +import socket as _socket +import sys +import threading +import time +from typing import Any, Optional + +GUI_HOST = "127.0.0.1" +GUI_PORT = 12321 +GUI_CONNECT_TIMEOUT = 1.5 # seconds to wait when probing for GUI +GUI_LOAD_WAIT = 0.7 # seconds to wait after load_xml in GUI mode + + +class EngineError(RuntimeError): + pass + + +# --------------------------------------------------------------------------- +# GUI mode — TCP socket transport +# --------------------------------------------------------------------------- + +class _GUITransport: + """Thin wrapper around a TCP socket to the PoB GUI bridge.""" + + def __init__(self, sock: _socket.socket) -> None: + self._sock = sock + self._buf = b"" + self._next_id = 0 + self._lock = threading.Lock() + + def _read_line(self, timeout: float = 30.0) -> str: + self._sock.settimeout(timeout) + deadline = time.monotonic() + timeout + while b"\n" not in self._buf: + remaining = deadline - time.monotonic() + if remaining <= 0: + raise EngineError("GUI bridge: timeout waiting for response") + self._sock.settimeout(remaining) + chunk = self._sock.recv(4096) + if not chunk: + raise EngineError("GUI bridge: connection closed") + self._buf += chunk + nl = self._buf.index(b"\n") + line = self._buf[:nl].decode("utf-8") + self._buf = self._buf[nl + 1:] + return line + + def request(self, cmd: str, **args: Any) -> Any: + with self._lock: + self._next_id += 1 + req_id = self._next_id + payload = json.dumps({"id": req_id, "cmd": cmd, **args}) + "\n" + self._sock.sendall(payload.encode("utf-8")) + while True: + line = self._read_line() + if not line: + continue + try: + msg = json.loads(line) + except json.JSONDecodeError: + continue + if msg.get("event"): + continue + if msg.get("id") != req_id: + continue + if not msg.get("ok"): + raise EngineError(msg.get("error", "unknown GUI bridge error")) + return msg.get("result") + + def close(self) -> None: + try: + self._sock.close() + except Exception: + pass + + +def _try_connect_gui() -> Optional[_GUITransport]: + """Try to connect to a running PoB GUI bridge. Returns None if not available.""" + sock = None + try: + sock = _socket.socket(_socket.AF_INET, _socket.SOCK_STREAM) + sock.settimeout(GUI_CONNECT_TIMEOUT) + sock.connect((GUI_HOST, GUI_PORT)) + sock.settimeout(5.0) + transport = _GUITransport(sock) + # Read the ready handshake the bridge sends on connect + line = transport._read_line(timeout=3.0) + msg = json.loads(line) + if msg.get("event") == "ready" and msg.get("gui"): + sys.stderr.write("[pob-mcp] Connected to PoB GUI bridge (live mode)\n") + sys.stderr.flush() + return transport + if sock: + sock.close() + return None + except Exception as _e: + sys.stderr.write(f"[pob-mcp] _try_connect_gui failed: {_e!r}\n") + sys.stderr.flush() + if sock: + try: + sock.close() + except Exception: + pass + return None + + +# --------------------------------------------------------------------------- +# Public client +# --------------------------------------------------------------------------- + +_NOT_RUNNING_MSG = ( + "Path of Building is not running or the GUI bridge is not ready. " + "Start PoB, wait for it to fully load, then retry." +) + + +class EngineClient: + """Engine client — GUI-only mode. + + Every request goes to the live PoB GUI bridge on 127.0.0.1:12321. + If PoB is not running, commands fail immediately with a clear error. + The connection is established lazily and re-established automatically + after PoB restarts (each request retries the connection if it was lost). + """ + + def __init__(self) -> None: + self._gui: Optional[_GUITransport] = None + self._lock = threading.Lock() + + def _ensure_gui(self) -> _GUITransport: + """Return a live GUI transport, or raise EngineError if PoB is not running.""" + # Liveness check on existing connection + if self._gui is not None: + try: + self._gui._sock.settimeout(0.05) + data = self._gui._sock.recv(1, _socket.MSG_PEEK) + if data == b"": + raise OSError("closed") + except (_socket.timeout, BlockingIOError): + pass # socket alive but no data pending + except OSError: + self._gui.close() + self._gui = None + + # Try (re)connecting + if self._gui is None: + self._gui = _try_connect_gui() + + if self._gui is None: + raise EngineError(_NOT_RUNNING_MSG) + + return self._gui + + def _drop_gui(self) -> None: + if self._gui: + self._gui.close() + self._gui = None + + # -- public API --------------------------------------------------------- + + def request(self, cmd: str, **args: Any) -> Any: + """Send a command to the PoB GUI bridge.""" + with self._lock: + transport = self._ensure_gui() + try: + return transport.request(cmd, **args) + except Exception as exc: + sys.stderr.write(f"[pob-mcp] GUI request failed: {exc}\n") + sys.stderr.flush() + self._drop_gui() + if isinstance(exc, EngineError): + raise + raise EngineError(f"GUI connection lost: {exc}") from exc + + def load_xml(self, xml: str, name: str = "MCP build") -> Any: + """Load build XML into the PoB GUI.""" + with self._lock: + transport = self._ensure_gui() + try: + result = transport.request("load_xml", xml=xml, name=name) + if result and result.get("pending"): + time.sleep(GUI_LOAD_WAIT) + return result + except Exception as exc: + sys.stderr.write(f"[pob-mcp] GUI load failed: {exc}\n") + sys.stderr.flush() + self._drop_gui() + if isinstance(exc, EngineError): + raise + raise EngineError(f"GUI load failed: {exc}") from exc + + @property + def is_gui_mode(self) -> bool: + return self._gui is not None diff --git a/pob-mcp/server/server.py b/pob-mcp/server/server.py new file mode 100644 index 0000000000..fc1d21752d --- /dev/null +++ b/pob-mcp/server/server.py @@ -0,0 +1,929 @@ +"""Path of Building (PoE2) MCP server. + +Exposes the headless PoB calculation engine to AI agents so they can analyze and +tune builds: read computed stats, rank passive-tree nodes by marginal value, and +run what-if comparisons without touching the PoB GUI. + +The engine runs as a separate LuaJIT subprocess (see engine_client.py); a crash +there never affects the PoB application itself. +""" + +from __future__ import annotations + +import asyncio +import os +import socket as _socket +import time +import traceback +from typing import Any, Optional + +from mcp.server.fastmcp import FastMCP + +import buildcode +from engine_client import EngineClient + +mcp = FastMCP("path-of-building") +engine = EngineClient() + +# In-memory snapshot registry: {name -> {xml, stats, created}} +# Persists for the lifetime of the MCP server process. +_snapshots: dict[str, dict] = {} + + +async def _call(cmd: str, **args: Any) -> Any: + return await asyncio.to_thread(engine.request, cmd, **args) + + +def _source_to_xml(source: str) -> str: + """Convert source (XML string / build code / file path) to raw XML.""" + text = source.strip() + if buildcode.looks_like_xml(text): + return text + if os.path.isfile(text): + with open(text, "r", encoding="utf-8") as fh: + return fh.read() + try: + return buildcode.code_to_xml(text) + except Exception as exc: + raise ValueError( + "source is not XML, an existing .xml path, or a valid build code" + ) from exc + + +@mcp.tool() +async def diagnose_connection() -> dict: + """Diagnose the connection to the PoB GUI bridge (port 12321). + + Returns detailed info about what's happening when connecting — use this + if get_summary / other tools fail with 'not running' errors. + """ + result: dict = {} + # Raw TCP test + try: + s = _socket.socket(_socket.AF_INET, _socket.SOCK_STREAM) + s.settimeout(1.0) + s.connect(("127.0.0.1", 12321)) + s.settimeout(3.0) + data = b"" + while b"\n" not in data: + chunk = s.recv(4096) + if not chunk: + break + data += chunk + s.close() + result["raw_tcp"] = "ok" + result["raw_response"] = data.decode("utf-8", errors="replace").strip() + except Exception as e: + result["raw_tcp"] = f"FAILED: {e!r}" + result["raw_traceback"] = traceback.format_exc() + # EngineClient test + from engine_client import _try_connect_gui + try: + t = _try_connect_gui() + if t is not None: + try: + summary = t.request("list_state", what="summary") + result["summary"] = summary + result["engine_client"] = "ok" + finally: + t.close() + else: + result["engine_client"] = "returned None (connect failed silently)" + except Exception as e: + result["engine_client"] = f"FAILED: {e!r}" + result["engine_traceback"] = traceback.format_exc() + return result + + +@mcp.tool() +async def load_build(source: str, name: str = "MCP build") -> dict: + """Load a build into the engine for analysis. + + `source` may be one of: + - a raw PoB build XML document (starts with `<`), + - a PoB share/build code (URL-safe base64 + zlib), + - a path to a `.xml` build file saved by the PoB GUI. + + Returns a short summary (class, ascendancy, level, FullDPS, Life). Call this + before any analysis tool. Loading replaces the previously loaded build. + """ + xml = _source_to_xml(source) + return await asyncio.to_thread(engine.load_xml, xml, name) + + +@mcp.tool() +async def get_stats(fields: Optional[list[str]] = None) -> dict: + """Return computed stats for the loaded build. + + With no `fields`, returns the full flat stat table (offence, defence, + attributes, charges, plus a `SkillDPS` breakdown array). Pass `fields` (e.g. + ["FullDPS", "Life", "FireResist"]) to fetch only those keys. + """ + return await _call("get_output", fields=fields) + + +@mcp.tool() +async def get_summary() -> dict: + """Return a condensed snapshot of the loaded build with sanity warnings. + + Includes headline offence/defence numbers and flags common problems such as + elemental resistances below the 75% cap or low life. + """ + o = await _call("get_output") + keys = [ + "FullDPS", "TotalDPS", "CombinedDPS", "Life", "EnergyShield", "Mana", + "Ward", "Armour", "Evasion", "TotalEHP", "FireResist", "ColdResist", + "LightningResist", "ChaosResist", "BlockChance", "SpellBlockChance", + "MeleeEvadeChance", "Str", "Dex", "Int", + ] + snapshot = {k: o[k] for k in keys if k in o} + warnings: list[str] = [] + for res in ("FireResist", "ColdResist", "LightningResist"): + val = o.get(res) + if isinstance(val, (int, float)) and val < 75: + warnings.append(f"{res} is {val:.0f}%, below the 75% cap") + life = o.get("Life") + es = o.get("EnergyShield", 0) or 0 + if isinstance(life, (int, float)) and (life + es) < 2000: + warnings.append(f"low effective pool: Life {life:.0f} + ES {es:.0f}") + snapshot["warnings"] = warnings + return snapshot + + +@mcp.tool() +async def rank_passive_nodes( + metric: str = "FullDPS", + max_depth: int = 6, + limit: int = 25, +) -> dict: + """Rank unallocated passive-tree nodes by how much they improve `metric`. + + For each reachable, unallocated node within `max_depth` skill points of the + current tree, computes the change to `metric` (e.g. "FullDPS", "Life", + "Evasion", "EnergyShield") if that node's modifiers were added. Returns the top + `limit` nodes sorted by absolute gain, each with `delta` and `deltaPerPoint` + (gain divided by points needed to reach it). Use this to decide what to take + next. Larger `max_depth` is more thorough but slower. + """ + return await _call("rank_nodes", metric=metric, maxDepth=max_depth, limit=limit) + + +@mcp.tool() +async def evaluate_change( + add_nodes: Optional[list[int]] = None, + remove_nodes: Optional[list[int]] = None, + conditions: Optional[list[str]] = None, + metrics: Optional[list[str]] = None, + full_output: bool = False, +) -> dict: + """Run a what-if calculation without modifying the loaded build. + + Provide passive node ids to add and/or remove (ids come from + `rank_passive_nodes` or `list_state(what="nodes")`), and optionally extra + `conditions` to enable. Returns base vs new values and the delta for each of + `metrics` (default: FullDPS, TotalDPS, Life, EnergyShield, Mana). Set + `full_output=True` to also get the full resulting stat table. + """ + override: dict[str, Any] = {} + if add_nodes: + override["addNodes"] = add_nodes + if remove_nodes: + override["removeNodes"] = remove_nodes + if conditions: + override["conditions"] = conditions + return await _call( + "eval_override", override=override, metrics=metrics, fullOutput=full_output + ) + + +@mcp.tool() +async def set_config(key: str, value: Any) -> dict: + """Set a build config option (ConfigTab input) and recalculate. + + This mutates the loaded build's configuration (e.g. enemy level, charge counts, + buff toggles) and triggers a recalc. Common keys mirror the PoB Configuration + tab, such as "enemyLevel", "conditionStationary", "usePowerCharges". Use + `list_state(what="config")` to inspect current values. + """ + return await _call("set_config", key=key, value=value) + + +@mcp.tool() +async def list_state(what: str = "summary") -> dict: + """Inspect the loaded build's current state. + + `what` selects the view: + - "summary": class, level, headline stats, + - "nodes": allocated passive nodes (id + name), + - "skills": socket groups and gems, with the main skill, + - "items": equipped items per slot, + - "config": current ConfigTab input values. + """ + return await _call("list_state", what=what) + + +@mcp.tool() +async def allocate_nodes(node_ids: list[int]) -> dict: + """Permanently allocate passive tree nodes in the loaded build and recalculate. + + `node_ids` is a list of node ids (integers) to allocate. Get candidate ids from + `rank_passive_nodes` or `list_state(what="nodes")`. Nodes that are already + allocated are reported in `alreadyAlloc` and silently skipped. Returns updated + headline stats (FullDPS, CombinedDPS, Life, EnergyShield) after recalc. + + Call `save_build` afterwards to persist changes to a file the PoB GUI can load. + """ + return await _call("allocate_nodes", ids=node_ids) + + +@mcp.tool() +async def deallocate_nodes(node_ids: list[int]) -> dict: + """Permanently deallocate passive tree nodes in the loaded build and recalculate. + + `node_ids` is a list of node ids to remove. Nodes that are not currently allocated + are reported in `notAlloc` and silently skipped. Returns updated headline stats + after recalc. + + Call `save_build` afterwards to persist changes to a file the PoB GUI can load. + """ + return await _call("deallocate_nodes", ids=node_ids) + + +@mcp.tool() +async def save_build(path: str) -> dict: + """Export the current (possibly mutated) build to an XML file. + + Writes the full PoB2 XML to `path` so it can be loaded in the PoB GUI via + File → Load Build. Overwrites the file if it already exists. Returns the path + and the byte size written. + + Typical workflow: + 1. load_build("C:/.../.xml") + 2. allocate_nodes([...]) / deallocate_nodes([...]) + 3. save_build("C:/.../.xml") ← same path to overwrite + 4. In PoB GUI: Load Build → pick the file. + """ + result = await _call("get_xml") + xml: str = result["xml"] + with open(path, "w", encoding="utf-8") as fh: + fh.write(xml) + return {"path": path, "bytes": len(xml.encode("utf-8"))} + + +@mcp.tool() +async def get_build_code() -> dict: + """Export the current build as a PoB share code (URL-safe base64+zlib). + + Returns the compact build code string you can paste into PoB (File → Import / + Export Build → Import from Code) or share with others. The code encodes the + full build XML. + """ + result = await _call("get_xml") + xml: str = result["xml"] + code = buildcode.xml_to_code(xml) + return {"code": code} + + +@mcp.tool() +async def optimize_tree( + metric: str = "CombinedDPS", + budget: int = 10, + max_depth: int = 3, + weights: Optional[dict[str, float]] = None, + min_constraints: Optional[dict[str, float]] = None, +) -> dict: + """Greedily allocate the best passive nodes one by one using BFS path evaluation. + + Each step evaluates reachable unallocated paths within `max_depth` distance + (plus Notables/Keystones up to depth 8 as lookahead). The gain is measured for + the ENTIRE PATH so the algorithm naturally avoids paths through useless nodes. + + **`weights`** — multi-metric normalised scoring. Instead of maximising a single + stat, optimise a weighted combination. Each metric is normalised by its base + value so different scales are comparable. Example: `{"CombinedDPS": 0.7, + "Life": 0.3}` balances damage and survivability. When omitted, falls back to + single-metric absolute delta on `metric`. + + **`min_constraints`** — hard floor on any stat. Steps that would push a metric + below the threshold are skipped entirely. Example: `{"Life": 3000, + "FireResist": 75}` ensures Life never drops below 3000 and fire resistance + stays capped. The optimiser stops early if no candidate satisfies all constraints. + + Each step result includes `type` (Normal / Notable / Keystone). The entire run + is saved as a single undo state so `undo_passive_changes` reverts all at once. + """ + return await _call( + "optimize_tree", + metric=metric, + budget=budget, + maxDepth=max_depth, + weights=weights, + minConstraints=min_constraints, + ) + + +@mcp.tool() +async def set_gem_level( + group: int, + name: str, + level: Optional[int] = None, + quality: Optional[int] = None, +) -> dict: + """Change the level and/or quality of a gem in a socket group and recalculate. + + `group` is the 1-based socket group index (use `list_state(what="skills")` to + find it). `name` must match the gem's `nameSpec` exactly (e.g. "Fireball", + "Increased Area of Effect"). At least one of `level` or `quality` must be + provided. + """ + if level is None and quality is None: + raise ValueError("at least one of level or quality must be provided") + return await _call("set_gem", group=group, name=name, level=level, quality=quality) + + +@mcp.tool() +async def undo_passive_changes() -> dict: + """Undo the last passive tree change (allocate, deallocate, or optimize_tree). + + Each call to `allocate_nodes`, `deallocate_nodes`, or `optimize_tree` saves an + undo state before making changes. This tool reverts to the previous state. + Returns updated headline stats after undo. + """ + return await _call("undo_tree") + + +@mcp.tool() +async def redo_passive_changes() -> dict: + """Redo the last undone passive tree change. + + Restores the state that was reverted by `undo_passive_changes`. Returns updated + headline stats after redo. + """ + return await _call("redo_tree") + + +@mcp.tool() +async def equip_item(slot: str, item_text: str) -> dict: + """Parse a raw item and equip it to a build slot, then recalculate. + + `slot` is the equipment slot name — use `list_state(what="items")` to see + currently equipped items and valid slot names (e.g. "Helmet", "Body Armour", + "Weapon 1", "Ring 1", "Amulet", "Belt", "Gloves", "Boots", "Flask 1"–"Flask 6"). + + `item_text` is the raw item text as copied from Path of Exile (the full block + starting with "Item Class:" or "Rarity:"). Returns updated headline stats after + the item is equipped. + """ + return await _call("equip_item", slot=slot, item_text=item_text) + + +@mcp.tool() +async def remove_item(slot: str) -> dict: + """Remove the item currently equipped in `slot` and recalculate. + + Use `list_state(what="items")` to see which slots have items. The item stays in + the build's item list but is no longer equipped. Returns updated headline stats. + """ + return await _call("remove_item", slot=slot) + + +@mcp.tool() +async def get_item_details(slot: str) -> dict: + """Return full details for the item equipped in `slot`. + + Returns the item's name, base, rarity, quality, raw text, and a `mods` list + with each modifier line grouped by type (implicit, explicit, enchant, rune). + Returns `{empty: true}` if the slot is unoccupied. + """ + return await _call("get_item_details", slot=slot) + + +@mcp.tool() +async def evaluate_item_change( + slot: str, + item_text: str, + metrics: Optional[list[str]] = None, +) -> dict: + """Compute the stat delta of replacing the item in `slot` without mutating the build. + + Temporarily swaps the item, recalculates, records the deltas for each of `metrics` + (default: FullDPS, TotalDPS, CombinedDPS, Life, EnergyShield, Mana), then + restores the original item. Returns `{slot, newItemName, newItemRarity, deltas}` + where each delta entry contains `{base, new, delta}`. + """ + return await _call("eval_item_change", slot=slot, item_text=item_text, metrics=metrics) + + +@mcp.tool() +async def add_gem( + group: int, + gem_name: str, + level: int = 1, + quality: int = 0, +) -> dict: + """Add a gem to a socket group and recalculate. + + `group` is the 1-based socket group index (use `list_state(what="skills")` to + find it). `gem_name` must match the gem's name as it appears in PoB (e.g. + "Fireball", "Magnified Effect"). Returns updated headline stats. + """ + return await _call("add_gem", group=group, name=gem_name, level=level, quality=quality) + + +@mcp.tool() +async def remove_gem(group: int, gem_name: str) -> dict: + """Remove a gem from a socket group and recalculate. + + `group` is the 1-based socket group index. `gem_name` must match the gem's + `nameSpec` exactly (use `list_state(what="skills")` to verify). Returns updated + headline stats. + """ + return await _call("remove_gem", group=group, name=gem_name) + + +@mcp.tool() +async def toggle_gem(group: int, gem_name: str, enabled: bool) -> dict: + """Enable or disable a gem in a socket group and recalculate. + + Useful for comparing builds with and without a support gem without removing it. + `enabled=False` disables the gem (it stays in the group but contributes nothing). + Returns updated headline stats. + """ + return await _call("toggle_gem", group=group, name=gem_name, enabled=enabled) + + +@mcp.tool() +async def toggle_skill_group(group: int, enabled: bool) -> dict: + """Enable or disable an entire socket group and recalculate. + + Disabling a group removes it from DPS and buff calculations without deleting it. + Use `list_state(what="skills")` to find the group index. Returns updated headline stats. + """ + return await _call("toggle_skill_group", group=group, enabled=enabled) + + +@mcp.tool() +async def set_main_skill(group: int) -> dict: + """Set the active (main) socket group used for DPS calculations and recalculate. + + `group` is the 1-based index of the socket group to make primary + (use `list_state(what="skills")` to find it). This is equivalent to clicking + a skill group in the PoB Skills tab. Returns updated headline stats. + """ + return await _call("set_main_skill", group=group) + + +@mcp.tool() +async def list_masteries(allocated_only: bool = False) -> dict: + """List all mastery nodes on the passive tree with their available effects. + + Each mastery node entry includes: `nodeId`, `name`, `allocated` (bool), + `selectedEffect` (currently chosen effect id, or null), and `effects` — an + array of `{id, stats[]}` describing every choosable effect. Set + `allocated_only=True` to skip unallocated mastery nodes and only show ones + where a selection can be made. + """ + return await _call("list_masteries", allocatedOnly=allocated_only) + + +@mcp.tool() +async def set_mastery(node_id: int, effect_id: int) -> dict: + """Select a mastery effect for an allocated mastery node and recalculate. + + `node_id` is the mastery node's id (from `list_masteries`). `effect_id` is the + id of the effect to assign (also from `list_masteries`). The node must already + be allocated on the passive tree. Returns updated headline stats after the new + effect is applied. + """ + return await _call("set_mastery", nodeId=node_id, effectId=effect_id) + + +@mcp.tool() +async def get_node_info(node_ids: list[int]) -> dict: + """Return detailed information about specific passive tree nodes. + + `node_ids` is a list of node ids (from `rank_passive_nodes`, `list_state`, or + `list_masteries`). For each node returns: `id`, `name`, `type` + (Normal/Notable/Keystone/Mastery/Socket), `allocated`, `stats` (array of human- + readable modifier lines), `ascendancy` (name if it's an ascendancy node), + `pathDist` (points needed to reach it). + """ + return await _call("get_node_info", ids=node_ids) + + +@mcp.tool() +async def set_character( + level: Optional[int] = None, + class_name: Optional[str] = None, + ascendancy: Optional[str] = None, +) -> dict: + """Change the character's level, class, and/or ascendancy and recalculate. + + All parameters are optional — supply only what you want to change. `class_name` + and `ascendancy` are matched case-insensitively (e.g. "witch", "Chronomancer"). + If you change `class_name`, the passive tree resets to the new class start. + Returns the applied level, className, ascendancy, and updated headline stats. + """ + return await _call( + "set_character", + level=level, + className=class_name, + ascendancy=ascendancy, + ) + + +@mcp.tool() +async def set_bandit(choice: str) -> dict: + """Set the bandit quest choice and recalculate. + + `choice` is the bandit name as it appears in PoB (e.g. "None", "Oak", + "Alira", "Kraityn"). Returns the applied bandit value and updated headline stats. + """ + return await _call("set_bandit", choice=choice) + + +@mcp.tool() +async def set_pantheon( + major: Optional[str] = None, + minor: Optional[str] = None, +) -> dict: + """Set the Pantheon god choices and recalculate. + + `major` and `minor` are the god names as they appear in PoB (e.g. "None", + "Soul of the Brine King"). Both are optional — omit to leave unchanged. + Returns the applied pantheon values and updated headline stats. + """ + return await _call("set_pantheon", major=major, minor=minor) + + +# --------------------------------------------------------------------------- +# Snapshots — pure Python, no new Lua handlers required +# --------------------------------------------------------------------------- + +_HEADLINE = ["FullDPS", "TotalDPS", "CombinedDPS", "Life", "EnergyShield", + "Mana", "Ward", "Armour", "Evasion", "TotalEHP"] + + +@mcp.tool() +async def create_snapshot(name: str) -> dict: + """Save the current build state as a named in-memory snapshot. + + Captures the full build XML and all computed stats. Snapshots persist for + the lifetime of the MCP server process. Overwrites any existing snapshot + with the same name. Returns a headline summary of the captured state. + """ + xml_result = await _call("get_xml") + stats = await _call("get_output") # full stat table — stored for later comparison + _snapshots[name] = { + "xml": xml_result["xml"], + "stats": stats, + "created": time.time(), + } + headline = {k: stats[k] for k in _HEADLINE if k in stats} + return {"name": name, "headline": headline, "totalStats": len(stats)} + + +@mcp.tool() +async def list_snapshots() -> dict: + """List all saved snapshots with their headline stats. + + Returns an array of `{name, created, headline}` entries sorted by creation + time (oldest first). + """ + entries = [] + for name, snap in sorted(_snapshots.items(), key=lambda x: x[1]["created"]): + headline = {k: snap["stats"][k] for k in _HEADLINE if k in snap["stats"]} + entries.append({ + "name": name, + "created": snap["created"], + "headline": headline, + }) + return {"snapshots": entries, "count": len(entries)} + + +@mcp.tool() +async def restore_snapshot(name: str) -> dict: + """Restore the build to a previously saved snapshot and recalculate. + + Loads the snapshot's XML into the engine exactly as `load_build` would. + All changes made after the snapshot was taken are discarded. Returns + headline stats after restoration. + """ + if name not in _snapshots: + available = list(_snapshots.keys()) + raise ValueError(f"snapshot '{name}' not found. Available: {available}") + return await asyncio.to_thread(engine.load_xml, _snapshots[name]["xml"], name) + + +@mcp.tool() +async def delete_snapshot(name: str) -> dict: + """Delete a named snapshot from memory. + + Returns the remaining snapshot names. + """ + if name not in _snapshots: + raise ValueError(f"snapshot '{name}' not found") + del _snapshots[name] + return {"deleted": name, "remaining": list(_snapshots.keys())} + + +@mcp.tool() +async def compare_snapshots( + name1: str, + name2: Optional[str] = None, + metrics: Optional[list[str]] = None, +) -> dict: + """Compare two snapshots (or a snapshot vs the current build) by stats. + + `name1` is a saved snapshot. `name2` defaults to `"current"` — the live + build without reloading. Provide a second snapshot name to compare two + saved states. `metrics` filters which stats to include in the delta table + (default: all headline stats). Each delta entry contains `{a, b, delta, + pct}` where `pct` is the percentage change relative to `a`. + """ + if name1 not in _snapshots: + raise ValueError(f"snapshot '{name1}' not found") + + if metrics is None: + metrics = _HEADLINE + + stats1 = _snapshots[name1]["stats"] + + if name2 is None or name2 == "current": + stats2 = await _call("get_output") + label2 = "current" + elif name2 not in _snapshots: + raise ValueError(f"snapshot '{name2}' not found") + else: + stats2 = _snapshots[name2]["stats"] + label2 = name2 + + deltas: dict = {} + for m in metrics: + a = float(stats1.get(m) or 0) + b = float(stats2.get(m) or 0) + if a == 0 and b == 0: + continue + pct = round((b - a) / a * 100, 2) if a != 0 else None + deltas[m] = {"a": a, "b": b, "delta": round(b - a, 4), "pct": pct} + + return {"snapshot1": name1, "snapshot2": label2, "deltas": deltas} + + +# --------------------------------------------------------------------------- +# Detailed analysis +# --------------------------------------------------------------------------- + +_DAMAGE_KEYS = [ + # Overall + "FullDPS", "TotalDPS", "CombinedDPS", "AverageDamage", "AverageBurstDamage", + # Per damage type (stored = pre-mitigation average hit) + "PhysicalStoredCombinedAvg", "FireStoredCombinedAvg", "ColdStoredCombinedAvg", + "LightningStoredCombinedAvg", "ChaosStoredCombinedAvg", "ElementalStoredCombinedAvg", + # DoT + "TotalDotDPS", "TotalDot", "PoisonDPS", "BleedDPS", "IgniteDPS", + "BurningGroundDPS", "CausticGroundDPS", "DecayDPS", + # Crit + "CritChance", "CritMultiplier", + # Hit / speed + "HitChance", "Speed", + # Penetration + "PhysicalPenetration", "FirePenetration", "ColdPenetration", + "LightningPenetration", "ChaosPenetration", +] + +_DEFENSE_KEYS = [ + # Pools + "Life", "LifeUnreserved", "EnergyShield", "Ward", "Mana", "ManaUnreserved", + "Spirit", "TotalEHP", + # Regen / recovery + "LifeRegen", "EnergyShieldRecharge", "EnergyShieldRechargeDelay", + "LifeLeechRate", "EnergyShieldLeechRate", "LifeOnHit", "EnergyShieldOnHit", + # Physical mitigation + "Armour", "Evasion", "PhysicalDamageReduction", + "MeleeEvadeChance", "ProjectileEvadeChance", "SpellEvadeChance", + # Block + "BlockChance", "ProjectileBlockChance", "SpellBlockChance", + "SpellProjectileBlockChance", + # Resistances + "FireResist", "ColdResist", "LightningResist", "ChaosResist", + "FireResistTotal", "ColdResistTotal", "LightningResistTotal", "ChaosResistTotal", + # Spell suppression + "SpellSuppressionChance", "SpellSuppressionEffect", + # Misc + "StunAvoidChance", "StunThreshold", +] + + +@mcp.tool() +async def get_damage_breakdown() -> dict: + """Return a structured damage breakdown for the active skill. + + Groups stats into: `overall` (FullDPS, TotalDPS, CombinedDPS, AverageDamage), + `byType` (per-element stored hit averages), `dot` (DoT DPS values), `crit` + (CritChance, CritMultiplier), `speed` (Speed, HitChance), and `penetration`. + Only non-zero stats are included. Also returns the `SkillDPS` skill-by-skill + breakdown array if available. + """ + raw = await _call("get_output", fields=_DAMAGE_KEYS) + + def pick(*keys: str) -> dict: + return {k: raw[k] for k in keys if raw.get(k)} + + result: dict[str, Any] = { + "overall": pick("FullDPS", "TotalDPS", "CombinedDPS", + "AverageDamage", "AverageBurstDamage"), + "byType": pick("PhysicalStoredCombinedAvg", "FireStoredCombinedAvg", + "ColdStoredCombinedAvg", "LightningStoredCombinedAvg", + "ChaosStoredCombinedAvg", "ElementalStoredCombinedAvg"), + "dot": pick("TotalDotDPS", "TotalDot", "PoisonDPS", "BleedDPS", + "IgniteDPS", "BurningGroundDPS", "CausticGroundDPS", "DecayDPS"), + "crit": pick("CritChance", "CritMultiplier"), + "speed": pick("Speed", "HitChance"), + "penetration": pick("PhysicalPenetration", "FirePenetration", "ColdPenetration", + "LightningPenetration", "ChaosPenetration"), + } + # Attach SkillDPS array from full output if present + full = await _call("get_output", fields=None) + if isinstance(full.get("SkillDPS"), list): + result["skillDPS"] = full["SkillDPS"] + return result + + +@mcp.tool() +async def get_defense_breakdown() -> dict: + """Return a structured defense breakdown for the loaded build. + + Groups stats into: `pools` (Life, ES, Ward, Mana, TotalEHP), `recovery` + (regen, leech, on-hit), `mitigation` (Armour, Evasion, PhysDmgReduction, + evade chances), `block`, `resistances`, `suppression`, and `misc`. + Only non-zero stats are included. + """ + raw = await _call("get_output", fields=_DEFENSE_KEYS) + + def pick(*keys: str) -> dict: + return {k: raw[k] for k in keys if raw.get(k)} + + return { + "pools": pick("Life", "LifeUnreserved", "EnergyShield", "Ward", + "Mana", "ManaUnreserved", "Spirit", "TotalEHP"), + "recovery": pick("LifeRegen", "EnergyShieldRecharge", "EnergyShieldRechargeDelay", + "LifeLeechRate", "EnergyShieldLeechRate", + "LifeOnHit", "EnergyShieldOnHit"), + "mitigation": pick("Armour", "Evasion", "PhysicalDamageReduction", + "MeleeEvadeChance", "ProjectileEvadeChance", "SpellEvadeChance"), + "block": pick("BlockChance", "ProjectileBlockChance", + "SpellBlockChance", "SpellProjectileBlockChance"), + "resistances": pick("FireResist", "ColdResist", "LightningResist", "ChaosResist", + "FireResistTotal", "ColdResistTotal", + "LightningResistTotal", "ChaosResistTotal"), + "suppression": pick("SpellSuppressionChance", "SpellSuppressionEffect"), + "misc": pick("StunAvoidChance", "StunThreshold"), + } + + +@mcp.tool() +async def compare_builds( + source: str, + metrics: Optional[list[str]] = None, +) -> dict: + """Compare the current build against another build without permanently replacing it. + + `source` accepts the same formats as `load_build` (XML, build code, or file path). + After the comparison the original build is restored. Returns `current` and `other` + class/level summaries plus a `deltas` table with `{current, other, delta, pct}` + for each metric (default: headline stats). + """ + if metrics is None: + metrics = _HEADLINE + + # Snapshot current state before doing anything + current_xml_str = (await _call("get_xml"))["xml"] + current_stats = await _call("get_output", fields=metrics) + current_summary = await _call("list_state", what="summary") + + try: + other_xml = _source_to_xml(source) + await asyncio.to_thread(engine.load_xml, other_xml, "compare_builds_tmp") + other_stats = await _call("get_output", fields=metrics) + other_summary = await _call("list_state", what="summary") + finally: + # Always restore the original build + await asyncio.to_thread(engine.load_xml, current_xml_str, "restored") + + deltas: dict = {} + for m in metrics: + a = float(current_stats.get(m) or 0) + b = float(other_stats.get(m) or 0) + if a == 0 and b == 0: + continue + pct = round((b - a) / a * 100, 2) if a != 0 else None + deltas[m] = {"current": a, "other": b, "delta": round(b - a, 4), "pct": pct} + + return { + "current": { + "class": current_summary.get("className"), + "ascendancy": current_summary.get("ascendancy"), + "level": current_summary.get("level"), + }, + "other": { + "class": other_summary.get("className"), + "ascendancy": other_summary.get("ascendancy"), + "level": other_summary.get("level"), + }, + "deltas": deltas, + } + + +@mcp.tool() +async def list_flasks() -> dict: + """Return the state of all flask slots with full flask details. + + For each slot (`"Flask 1"`, `"Flask 2"`) returns: `slot`, `active` (bool — + whether the flask is enabled for calculations), `empty`, and — when a flask + is equipped — `name`, `rarity`, `flask` (base data: subType, life/mana + recovery, duration, charges), and `flaskData` (computed effect values). + """ + return await _call("list_flasks") + + +@mcp.tool() +async def toggle_flask(slot: str, active: bool) -> dict: + """Enable or disable a flask for build calculations and recalculate. + + `slot` must be `"Flask 1"` or `"Flask 2"`. Setting `active=True` includes + the flask's bonuses and modifiers in the DPS/defence calculation — equivalent + to having the flask active in-game. Returns updated headline stats. + """ + return await _call("toggle_flask", slot=slot, active=active) + + +@mcp.tool() +async def list_keystones(max_depth: Optional[int] = None) -> dict: + """List keystone passive nodes — allocated and reachable ones on the tree. + + Returns every keystone with: `id`, `name`, `allocated`, `pathDist`, and + `stats` (array of readable modifier lines). Allocated keystones are listed + first, then sorted by proximity. Set `max_depth` to limit unallocated + keystones to those within that many points of the current tree (useful for + showing only realistic targets). + """ + return await _call("list_keystones", maxDepth=max_depth) + + +@mcp.tool() +async def set_node_stat(node_id: int, index: int, stat: str) -> dict: + """Permanently replace one stat line on a passive tree node and recalculate. + + This is a **mutating** operation — it modifies the node in the live build. + Use `evaluate_node_stat` if you only want a what-if preview without changing + the build. + + Args: + node_id: Numeric node ID (from `rank_passive_nodes`, `get_node_info`, etc.). + index: 1-based position in the node's stat list (`sdOriginal` from + `get_node_info`). For example, index=1 is the first stat line. + stat: New stat text exactly as PoB understands it, e.g. + `"20% increased maximum Life"` or `"+30 to Strength"`. + + Returns: + `nodeId`, `name`, `index`, `oldStat`, `newStat`, and `stats` dict with + headline metrics after the change (FullDPS, CombinedDPS, Life, + EnergyShield, Armour, Evasion). + """ + return await _call("set_node_stat", nodeId=node_id, index=index, stat=stat) + + +@mcp.tool() +async def evaluate_node_stat( + node_id: int, + overrides: dict[str, str], +) -> dict: + """What-if: preview the effect of different stat lines on a passive node. + + Non-mutating — the build is left exactly as it was. Internally the engine + modifies `node.sd`, calls `PassiveTree:ProcessStats` to rebuild the node's + modifier list, recalculates, captures the output, then restores everything. + + Args: + node_id: Numeric node ID. + overrides: Mapping of stat-line index (as a string, e.g. `"1"`) to the + replacement stat text. Example: + `{"1": "30% increased maximum Life"}` to preview doubling a + "+15% maximum Life" node. Unspecified lines are unchanged. + + Returns: + `nodeId`, `name`, `nodeType`, `sdOriginal` (full original stat list), + `before` and `after` dicts with the same set of headline metrics, and + `delta` — only the stats that actually changed, each as + `{before, after, diff}`. + """ + return await _call("evaluate_node_stat", nodeId=node_id, overrides=overrides) + + +def main() -> None: + mcp.run() + + +if __name__ == "__main__": + main() diff --git a/pob-mcp/server/smoke_test.py b/pob-mcp/server/smoke_test.py new file mode 100644 index 0000000000..c8ffa009ca --- /dev/null +++ b/pob-mcp/server/smoke_test.py @@ -0,0 +1,27 @@ +"""Boot the engine and exercise the core analysis path. No real build required.""" + +from __future__ import annotations + +import sys + +from engine_client import EngineClient + + +def main() -> int: + engine = EngineClient() + try: + assert engine.request("ping") == "pong" + print("ping OK") + print("new_build:", engine.request("new_build")) + print("summary:", engine.request("list_state", what="summary")) + ranked = engine.request("rank_nodes", metric="Evasion", maxDepth=2, limit=3) + print("rank Evasion:", ranked) + assert ranked["withEffect"] >= 1, "expected at least one evasion node" + print("\nSMOKE TEST PASSED") + return 0 + finally: + engine.stop() + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/src/HeadlessWrapper.lua b/src/HeadlessWrapper.lua index 2b391a68b9..1f3e2de42d 100644 --- a/src/HeadlessWrapper.lua +++ b/src/HeadlessWrapper.lua @@ -2,6 +2,7 @@ -- This wrapper allows the program to run headless on any OS (in theory) -- It can be run using a standard lua interpreter, although LuaJIT is preferable +_G.HEADLESS = true -- pob-mcp: signal to Launch.lua to skip GUI-only code -- Callbacks local callbackTable = { } diff --git a/src/Launch.lua b/src/Launch.lua index 2d5305cd5c..2d38ab03e5 100644 --- a/src/Launch.lua +++ b/src/Launch.lua @@ -84,6 +84,20 @@ function launch:OnInit() -- Run a background update check if developer mode is off self:CheckForUpdate(true) end + + -- pob-mcp: load GUI bridge if present (optional, no-op if missing or headless) + if not _G.HEADLESS then + local candidates = { "pob-mcp/gui_bridge.lua", "../pob-mcp/gui_bridge.lua" } + for _, path in ipairs(candidates) do + local f = io.open(path, "r") + if f then + f:close() + local ok, err = pcall(dofile, path) + if not ok then ConPrintf("pob-mcp LOAD ERROR: %s", tostring(err)) end + break + end + end + end end function launch:CanExit() @@ -114,6 +128,11 @@ function launch:OnFrame() end end end + -- pob-mcp: tick the GUI bridge (non-blocking, no-op if bridge not loaded) + if _G.pobMcpTick then + local ok, err = pcall(_G.pobMcpTick) + if not ok then ConPrintf("pob-mcp TICK ERROR: %s", tostring(err)) end + end self.devModeAlt = self.devMode and IsKeyDown("ALT") SetDrawLayer(1000) SetViewport() diff --git a/src/pob-mcp-debug.txt b/src/pob-mcp-debug.txt new file mode 100644 index 0000000000..5e0d0c273a --- /dev/null +++ b/src/pob-mcp-debug.txt @@ -0,0 +1,5 @@ +cwd probe: false / true +loading: ../pob-mcp/gui_bridge.lua +result: true nil +pobMcpTick defined: true +bind ok on port 12321