From 8edd38ab880211dbc579f9d09ebcda25bbd40e58 Mon Sep 17 00:00:00 2001 From: imfal Date: Mon, 25 May 2026 17:32:49 +1000 Subject: [PATCH 1/3] Add Path of Building MCP server and engine client - Introduced `requirements.txt` to specify dependencies. - Implemented `buildcode.py` for conversion between PoB share codes and XML. - Created `engine_client.py` to manage communication with the PoB engine. - Developed `server.py` to expose the PoB calculation engine to AI agents. - Added tools for loading builds, retrieving stats, and modifying passive nodes. - Implemented a smoke test in `smoke_test.py` to validate core functionality. - Updated `Launch.lua` to optionally load the GUI bridge for enhanced integration. - Added debug logging to `pob-mcp-debug.txt` for troubleshooting. --- .gitignore | 3 + pob-mcp/README.md | 136 +++++ pob-mcp/engine/bridge.lua | 507 +++++++++++++++++ pob-mcp/gui_bridge.lua | 532 ++++++++++++++++++ pob-mcp/requirements.txt | 1 + .../__pycache__/buildcode.cpython-313.pyc | Bin 0 -> 2552 bytes .../__pycache__/engine_client.cpython-313.pyc | Bin 0 -> 19224 bytes .../server/__pycache__/server.cpython-313.pyc | Bin 0 -> 8612 bytes pob-mcp/server/buildcode.py | 42 ++ pob-mcp/server/engine_client.py | 364 ++++++++++++ pob-mcp/server/server.py | 293 ++++++++++ pob-mcp/server/smoke_test.py | 27 + src/Launch.lua | 27 + 13 files changed, 1932 insertions(+) create mode 100644 pob-mcp/README.md create mode 100644 pob-mcp/engine/bridge.lua create mode 100644 pob-mcp/gui_bridge.lua create mode 100644 pob-mcp/requirements.txt create mode 100644 pob-mcp/server/__pycache__/buildcode.cpython-313.pyc create mode 100644 pob-mcp/server/__pycache__/engine_client.cpython-313.pyc create mode 100644 pob-mcp/server/__pycache__/server.cpython-313.pyc create mode 100644 pob-mcp/server/buildcode.py create mode 100644 pob-mcp/server/engine_client.py create mode 100644 pob-mcp/server/server.py create mode 100644 pob-mcp/server/smoke_test.py diff --git a/.gitignore b/.gitignore index e707d9bac5..203def0ee7 100644 --- a/.gitignore +++ b/.gitignore @@ -45,3 +45,6 @@ runtime/imgui.ini src/poe_api_response.json +/.claude/ +.gitignore +.mcp.json 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..a18afe8fa9 --- /dev/null +++ b/pob-mcp/engine/bridge.lua @@ -0,0 +1,507 @@ +-- 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 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 = {} + 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 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, + 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 + +-- Greedy passive tree optimizer: each step picks the single best node and allocates it. +-- One undo state is saved before all steps so the whole run can be undone at once. +-- req = { metric, budget, maxDepth } +function handlers.optimize_tree(req) + requireBuild() + local metric = req.metric or "CombinedDPS" + local budget = req.budget or 10 + local maxDepth = req.maxDepth or 3 + + build.spec:AddUndoState() + build.spec:BuildAllDependsAndPaths() + + local steps = {} + for step = 1, budget do + local calcFunc, baseOutput = build.calcsTab.calcs.getMiscCalculator(build) + local base = tonumber(baseOutput[metric]) or 0 + local bestNode, bestDelta, bestName = nil, 0, nil + local cache = {} + 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 node.ascendancyName + and not (build.calcsTab.mainEnv and build.calcsTab.mainEnv.grantedPassives[nodeId]) then + local val = cache[node.modKey] + if val == nil then + local out = calcFunc({ addNodes = { [node] = true } }, true) + val = tonumber(out[metric]) or 0 + cache[node.modKey] = val + end + local delta = val - base + if delta > bestDelta then + bestDelta, bestNode, bestName = delta, node, node.dn or node.name + end + end + end + if not bestNode then break end + build.spec:AllocNode(bestNode) + build.spec:BuildAllDependsAndPaths() + steps[#steps + 1] = { step = step, id = bestNode.id, name = bestName, delta = cleanNumber(bestDelta) } + end + + recalc() + local o = build.calcsTab.mainOutput + return { + metric = metric, steps = steps, pointsUsed = #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 + items[#items + 1] = { slot = slotName, name = item.name, rarity = item.rarity } + end + end + end + return { items = items } + elseif what == "nodes" then + local alloc = {} + for nodeId, node in pairs(build.spec.allocNodes or {}) do + alloc[#alloc + 1] = { id = nodeId, name = node.dn or node.name, type = node.type } + end + return { allocated = alloc, usedPoints = build.spec.allocNodes and #alloc } + 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 + +-------------------------------------------------------------------------------- +-- 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..6db93530ff --- /dev/null +++ b/pob-mcp/gui_bridge.lua @@ -0,0 +1,532 @@ +-- 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)) + local f = io.open("pob-mcp-debug.txt", "a") + if f then f:write("bind error: "..tostring(bindErr).."\n"); f:close() end + return +end +do + local f = io.open("pob-mcp-debug.txt", "a") + if f then f:write("bind ok on port "..PORT.."\n"); f:close() end +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 + +-------------------------------------------------------------------------------- +-- 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 = {} + 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 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, + 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.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 + + build.spec:AddUndoState() + build.spec:BuildAllDependsAndPaths() + + local steps = {} + for step = 1, budget do + local calcFunc, baseOutput = build.calcsTab.calcs.getMiscCalculator(build) + local base = tonumber(baseOutput[metric]) or 0 + local bestNode, bestDelta, bestName = nil, 0, nil + local cache = {} + 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 node.ascendancyName + and not (build.calcsTab.mainEnv and build.calcsTab.mainEnv.grantedPassives[nodeId]) then + local val = cache[node.modKey] + if val == nil then + local out = calcFunc({ addNodes = { [node] = true } }, true) + val = tonumber(out[metric]) or 0 + cache[node.modKey] = val + end + local delta = val - base + if delta > bestDelta then + bestDelta, bestNode, bestName = delta, node, node.dn or node.name + end + end + end + if not bestNode then break end + build.spec:AllocNode(bestNode) + build.spec:BuildAllDependsAndPaths() + steps[#steps + 1] = { step = step, id = bestNode.id, name = bestName, delta = cleanNumber(bestDelta) } + end + + guiRecalc(build) + local o = build.calcsTab.mainOutput + return { + metric = metric, steps = steps, pointsUsed = #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 items[#items + 1] = { slot = slotName, name = item.name, rarity = item.rarity } end + end + end + return { items = items } + elseif what == "nodes" then + local alloc = {} + for nodeId, node in pairs(build.spec.allocNodes or {}) do + alloc[#alloc + 1] = { id = nodeId, name = node.dn or node.name, type = node.type } + end + return { allocated = alloc, usedPoints = #alloc } + 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 + +-------------------------------------------------------------------------------- +-- 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 + +-------------------------------------------------------------------------------- +-- global tick function — called from Launch.lua:OnFrame every frame +-------------------------------------------------------------------------------- + +function _G.pobMcpTick() + -- Accept new connections (non-blocking) + local client = srv:accept() + if client then + client:settimeout(0) + clients[#clients + 1] = client + -- Send handshake so engine_client knows we're ready + client:send(dkjson.encode({ event = "ready", gui = true }) .. "\n") + end + + -- Serve existing clients + local i = 1 + while i <= #clients do + local err = processClient(clients[i]) + if err == "closed" then + buffers[clients[i]] = nil + 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 0000000000000000000000000000000000000000..407668de9b1d432d7a959a5937b8bfaf9cecb9b3 GIT binary patch literal 2552 zcma)7%Wo4$7@zg7U%TW%LqUWV28SXq!7+qKQ3#Kc08M$yn@EUQTCMF#vSGbz&8!m| z3CTyGhXhg7^hA!S)I-nxE0S7O!nB80^umoQ^wLwm+4Uw6<9 zL_{j?${-^RzOhh+(WE>n>uuIcNH*lTGlVkRb8(6IvxK<#uH{c*ZvtPb+fK!HCo!9{ zD8Xf~LKwE(3Z~X9E(s&Pck5=Ks(yj*dRJN+;e~C6#|k%d%$gv$WHEB~w8%H-*ri;B zOgNTLD(@x0qf-P=5v$@5#xQXwZI`Ibua_A1>OLM0Bl06|5P2Lk56==@@vskZ*$UCi zUbRLcRunNs;Hu)1Q(_SwVZz|j;dO7>lzNn5M$7pj$6}1I{7ALtQGe7b^*MEGK*d0I zpc=rfbF*O~eH9`~%!*}TlGRJuOfGXg)0@#-k_l2iqw9K*ggg{^2U=gDkGMf_tEBA1 zs-s77y%3k%%#sJm%;@SH@npKhHOqB9-}0eEcDvjHLGYXaLL?(3Z5#nDfKDpBZw*`! z8bbx}*-jBeA`rRKm{bt?wviDt+2yw*IrL(6)EOAA223Lg?0+}a5i>`1*0EQ>a4zXNP;WG1Kfd*vaKmk*J zZ)l)6#-N9bmO~yeLT37^UUAqf*Q>y)6b@#JI(f<#`Se| z_k8|Iez9kfHPxOqwRctRT^{0I+W-LpGGCWp=d3jYMPz z$R_75Ukygb76wB|{%-{LQPDs=Zn3g$Q_-GOlt|NJ5hdzL+65a=ZGkYHR#d!t0N0lM zu?Xo9Au^W%8ax#ZbuI&_nV2*HOxi8tG;k0Q5>TR_fktRq0hR{(tD|fF(bGqZ#>@NP z-(OBPKRwaxINA7e3lQg;>X8i@92`b92DA}M0 zo-)m?O9BKZbi|T(oIEQ(GI>+(kp7u(&(?sv9}shx`Vs? E2XG@)i~s-t literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..06d0f9dcc9b4eca60ab4970f61b7f6ea23a0adc8 GIT binary patch literal 19224 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

^{Cda*6oZcMyoPE>f- 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 j@~+?wTA#0% 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..4833cfda0c --- /dev/null +++ b/pob-mcp/server/engine_client.py @@ -0,0 +1,364 @@ +"""Spawn and talk to the Path of Building engine bridge. + +Two modes, selected automatically at startup: + +GUI mode — PoB GUI is running with pob-mcp/gui_bridge.lua loaded. + The client connects to a TCP socket on 127.0.0.1:12321 and + operates on the live build object inside the GUI process. + Changes (allocate nodes, config tweaks) are visible in the GUI + immediately without saving/reloading XML. + +Headless mode — No GUI. Spawns a separate LuaJIT subprocess running + pob-mcp/engine/bridge.lua. Identical JSON-line protocol over + stdin/stdout. Falls back here when the GUI is not running. + +The selection is transparent to server.py — both modes implement the same +request() / load_xml() API. +""" + +from __future__ import annotations + +import json +import os +import shutil +import socket as _socket +import subprocess +import sys +import threading +import time +from pathlib import Path +from typing import Any, Optional + +REPO_ROOT = Path(__file__).resolve().parents[2] +SRC_DIR = REPO_ROOT / "src" +RUNTIME_DIR = REPO_ROOT / "runtime" +BRIDGE_LUA = REPO_ROOT / "pob-mcp" / "engine" / "bridge.lua" + +GUI_HOST = "127.0.0.1" +GUI_PORT = 12321 +GUI_CONNECT_TIMEOUT = 0.3 # 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 + + +# --------------------------------------------------------------------------- +# Runtime detection helpers (headless mode only) +# --------------------------------------------------------------------------- + +def _find_luajit() -> Optional[str]: + env = os.environ.get("POB_LUAJIT") + if env and Path(env).exists(): + return env + found = shutil.which("luajit") + if found: + return found + local = os.environ.get("LOCALAPPDATA") + if local: + cand = Path(local) / "Programs" / "LuaJIT" / "bin" / "luajit.exe" + if cand.exists(): + return str(cand) + return None + + +def _build_launch() -> tuple[list[str], dict[str, str], str]: + runtime = os.environ.get("POB_RUNTIME", "").lower() + lua_path = f"{RUNTIME_DIR}/lua/?.lua;{RUNTIME_DIR}/lua/?/init.lua;;" + lua_cpath = f"{RUNTIME_DIR}/?.dll;{RUNTIME_DIR}/?.so;;" + + if runtime == "docker": + image = os.environ.get( + "POB_DOCKER_IMAGE", + "ghcr.io/pathofbuildingcommunity/pathofbuilding-tests:latest", + ) + argv = [ + "docker", "run", "--rm", "-i", + "-v", f"{REPO_ROOT}:/workdir:ro", + "-w", "/workdir/src", + "-e", "LUA_PATH=../runtime/lua/?.lua;../runtime/lua/?/init.lua;;", + "-e", "LUA_CPATH=../runtime/?.so;;", + image, + "luajit", "/workdir/pob-mcp/engine/bridge.lua", + ] + return argv, dict(os.environ), str(REPO_ROOT) + + luajit = _find_luajit() + if not luajit: + raise EngineError( + "LuaJIT not found. Install it (winget install DEVCOM.LuaJIT), set " + "POB_LUAJIT to luajit.exe, or set POB_RUNTIME=docker." + ) + env = dict(os.environ) + env["LUA_PATH"] = lua_path + env["LUA_CPATH"] = lua_cpath + argv = [luajit, str(BRIDGE_LUA)] + return argv, env, str(SRC_DIR) + + +# --------------------------------------------------------------------------- +# 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.""" + if os.environ.get("POB_RUNTIME", "").lower() in ("headless", "docker"): + return None # user explicitly wants headless + 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 + sock.close() + return None + except Exception: + return None + + +# --------------------------------------------------------------------------- +# Public client — auto-selects GUI vs headless +# --------------------------------------------------------------------------- + +class EngineClient: + """Engine client with automatic GUI / headless mode selection. + + On every request the client first checks if the PoB GUI bridge is + reachable (127.0.0.1:12321). If yes it uses that connection (live mode). + If not it falls back to the headless LuaJIT subprocess. + """ + + def __init__(self) -> None: + self._gui: Optional[_GUITransport] = None + self._proc: Optional[subprocess.Popen] = None + self._lock = threading.Lock() + self._next_id = 0 + self._last_xml: Optional[str] = None + self._stderr_thread: Optional[threading.Thread] = None + + # -- GUI mode ----------------------------------------------------------- + + def _ensure_gui(self) -> bool: + """Try (re)connecting to the GUI bridge. Returns True if connected.""" + if self._gui is not None: + # Quick liveness check + 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 # no data but still alive + except OSError: + self._gui.close() + self._gui = None + if self._gui is None: + self._gui = _try_connect_gui() + return self._gui is not None + + # -- Headless mode ------------------------------------------------------ + + def _alive(self) -> bool: + return self._proc is not None and self._proc.poll() is None + + def _drain_stderr(self, proc: subprocess.Popen) -> None: + assert proc.stderr is not None + for line in proc.stderr: + sys.stderr.write(f"[pob-engine] {line.rstrip()}\n") + sys.stderr.flush() + + def start(self) -> None: + if self._alive(): + return + argv, env, cwd = _build_launch() + self._proc = subprocess.Popen( + argv, cwd=cwd, env=env, + stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE, + text=True, encoding="utf-8", bufsize=1, + ) + self._stderr_thread = threading.Thread( + target=self._drain_stderr, args=(self._proc,), daemon=True + ) + self._stderr_thread.start() + self._read_until_ready() + + def _read_until_ready(self) -> None: + assert self._proc and self._proc.stdout + for _ in range(200): + line = self._proc.stdout.readline() + if not line: + raise EngineError("engine exited before becoming ready") + line = line.strip() + if not line: + continue + try: + msg = json.loads(line) + except json.JSONDecodeError: + continue + if msg.get("event") == "ready": + return + raise EngineError("engine did not emit ready event") + + def stop(self) -> None: + if self._proc: + try: + self._proc.terminate() + except Exception: + pass + self._proc = None + if self._gui: + self._gui.close() + self._gui = None + + def _raw_request(self, cmd: str, **args: Any) -> Any: + assert self._proc and self._proc.stdin and self._proc.stdout + self._next_id += 1 + req_id = self._next_id + payload = {"id": req_id, "cmd": cmd, **args} + self._proc.stdin.write(json.dumps(payload) + "\n") + self._proc.stdin.flush() + while True: + line = self._proc.stdout.readline() + if not line: + raise EngineError(f"engine closed stdout while waiting for '{cmd}'") + line = line.strip() + 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 engine error")) + return msg.get("result") + + def _replay(self) -> None: + if self._last_xml: + self._raw_request("load_xml", xml=self._last_xml, name="MCP build") + + # -- unified API -------------------------------------------------------- + + def request(self, cmd: str, **args: Any) -> Any: + """Send a command, auto-selecting GUI or headless transport.""" + with self._lock: + # Prefer GUI mode when available + if self._ensure_gui(): + try: + return self._gui.request(cmd, **args) # type: ignore[union-attr] + except EngineError: + raise + except Exception as exc: + # Connection dropped; clear and fall through to headless + sys.stderr.write(f"[pob-mcp] GUI connection lost: {exc}; falling back to headless\n") + sys.stderr.flush() + if self._gui: + self._gui.close() + self._gui = None + + # Headless fallback + if not self._alive(): + self.start() + self._replay() + try: + return self._raw_request(cmd, **args) + except EngineError: + self.stop() + self.start() + self._replay() + return self._raw_request(cmd, **args) + + def load_xml(self, xml: str, name: str = "MCP build") -> Any: + """Load build XML. In GUI mode, triggers PoB's own loader and waits.""" + with self._lock: + if self._ensure_gui(): + try: + result = self._gui.request("load_xml", xml=xml, name=name) # type: ignore[union-attr] + if result and result.get("pending"): + # PoB's SetMode is async; wait for the next OnFrame to apply it + time.sleep(GUI_LOAD_WAIT) + return result + except Exception as exc: + sys.stderr.write(f"[pob-mcp] GUI load failed: {exc}; falling back to headless\n") + sys.stderr.flush() + if self._gui: + self._gui.close() + self._gui = None + + # Headless + if not self._alive(): + self.start() + result = self._raw_request("load_xml", xml=xml, name=name) + self._last_xml = xml + return result + + @property + def is_gui_mode(self) -> bool: + """True when connected to the PoB GUI bridge.""" + 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..ef1b4edfb8 --- /dev/null +++ b/pob-mcp/server/server.py @@ -0,0 +1,293 @@ +"""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 +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() + + +async def _call(cmd: str, **args: Any) -> Any: + return await asyncio.to_thread(engine.request, cmd, **args) + + +@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. + """ + text = source.strip() + if buildcode.looks_like_xml(text): + xml = text + elif os.path.isfile(text): + with open(text, "r", encoding="utf-8") as fh: + xml = fh.read() + else: + try: + xml = buildcode.code_to_xml(text) + except Exception as exc: # noqa: BLE001 + raise ValueError( + "source is not XML, an existing .xml path, or a valid build code" + ) from exc + 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, +) -> dict: + """Greedily allocate the best passive nodes one by one to maximise `metric`. + + Each step evaluates all reachable unallocated nodes within `max_depth` distance + and picks the one with the highest gain to `metric` (e.g. "CombinedDPS", + "FullDPS", "Life", "EnergyShield"). Repeats up to `budget` times. The entire + run is saved as a single undo state so `undo_passive_changes` reverts all steps + at once. Returns the sequence of allocations and final stats. + """ + return await _call("optimize_tree", metric=metric, budget=budget, maxDepth=max_depth) + + +@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") + + +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/Launch.lua b/src/Launch.lua index 2d5305cd5c..023e32b982 100644 --- a/src/Launch.lua +++ b/src/Launch.lua @@ -84,6 +84,31 @@ 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) + do + local log = io.open("pob-mcp-debug.txt", "w") + local function logw(s) if log then log:write(s.."\n"); log:flush() end end + logw("cwd probe: "..tostring(io.open("pob-mcp/gui_bridge.lua","r") ~= nil) + .." / "..tostring(io.open("../pob-mcp/gui_bridge.lua","r") ~= nil)) + local candidates = { "pob-mcp/gui_bridge.lua", "../pob-mcp/gui_bridge.lua" } + local loaded, loadErr = false, nil + for _, path in ipairs(candidates) do + local f = io.open(path, "r") + if f then + f:close() + logw("loading: "..path) + local ok, err = pcall(dofile, path) + logw("result: "..tostring(ok).." "..tostring(err)) + loaded = ok + loadErr = err + break + end + end + if not loaded then logw("bridge not loaded: "..tostring(loadErr)) end + logw("pobMcpTick defined: "..tostring(_G.pobMcpTick ~= nil)) + if log then log:close() end + end end function launch:CanExit() @@ -114,6 +139,8 @@ function launch:OnFrame() end end end + -- pob-mcp: tick the GUI bridge (non-blocking, no-op if bridge not loaded) + if _G.pobMcpTick then pcall(_G.pobMcpTick) end self.devModeAlt = self.devMode and IsKeyDown("ALT") SetDrawLayer(1000) SetViewport() From 0a3c85ab3f019000b2f6e3a2a9b72040a054a978 Mon Sep 17 00:00:00 2001 From: imfal Date: Tue, 26 May 2026 16:08:44 +1000 Subject: [PATCH 2/3] =?UTF-8?q?=D0=9E=D0=B1=D0=BD=D0=BE=D0=B2=D0=B8=D1=82?= =?UTF-8?q?=D1=8C=20=D0=BF=D0=BE=D0=B4=D0=B4=D0=B5=D1=80=D0=B6=D0=BA=D1=83?= =?UTF-8?q?=20=D0=BA=D0=BB=D0=B8=D0=B5=D0=BD=D1=82=D0=B0=20=D0=B8=20=D1=81?= =?UTF-8?q?=D0=B5=D1=80=D0=B2=D0=B5=D1=80=D0=B0=20Path=20of=20Building:=20?= =?UTF-8?q?=D1=83=D0=BB=D1=83=D1=87=D1=88=D0=B8=D1=82=D1=8C=20=D0=B4=D0=B8?= =?UTF-8?q?=D0=B0=D0=B3=D0=BD=D0=BE=D1=81=D1=82=D0=B8=D0=BA=D1=83=20=D1=81?= =?UTF-8?q?=D0=BE=D0=B5=D0=B4=D0=B8=D0=BD=D0=B5=D0=BD=D0=B8=D1=8F,=20?= =?UTF-8?q?=D0=B4=D0=BE=D0=B1=D0=B0=D0=B2=D0=B8=D1=82=D1=8C=20=D0=BE=D0=B1?= =?UTF-8?q?=D1=80=D0=B0=D0=B1=D0=BE=D1=82=D0=BA=D1=83=20=D0=BE=D1=88=D0=B8?= =?UTF-8?q?=D0=B1=D0=BE=D0=BA=20=D0=B8=20=D0=BE=D0=BF=D1=82=D0=B8=D0=BC?= =?UTF-8?q?=D0=B8=D0=B7=D0=B8=D1=80=D0=BE=D0=B2=D0=B0=D1=82=D1=8C=20=D0=BA?= =?UTF-8?q?=D0=BE=D0=B4=20=D0=B4=D0=BB=D1=8F=20=D1=80=D0=B0=D0=B1=D0=BE?= =?UTF-8?q?=D1=82=D1=8B=20=D0=B2=20=D0=B1=D0=B5=D0=B7=D0=B3=D0=BE=D0=BB?= =?UTF-8?q?=D0=BE=D0=B2=D0=BE=D0=BC=20=D1=80=D0=B5=D0=B6=D0=B8=D0=BC=D0=B5?= =?UTF-8?q?.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 1 - pob-mcp/gui_bridge.lua | 180 ++++++++++-- .../__pycache__/buildcode.cpython-313.pyc | Bin 2552 -> 2552 bytes .../__pycache__/engine_client.cpython-313.pyc | Bin 19224 -> 10718 bytes .../server/__pycache__/server.cpython-313.pyc | Bin 8612 -> 0 bytes pob-mcp/server/engine_client.py | 276 ++++-------------- pob-mcp/server/server.py | 55 +++- src/HeadlessWrapper.lua | 1 + src/Launch.lua | 30 +- src/pob-mcp-debug.txt | 5 + 10 files changed, 275 insertions(+), 273 deletions(-) delete mode 100644 pob-mcp/server/__pycache__/server.cpython-313.pyc create mode 100644 src/pob-mcp-debug.txt diff --git a/.gitignore b/.gitignore index 203def0ee7..7b670152c7 100644 --- a/.gitignore +++ b/.gitignore @@ -46,5 +46,4 @@ runtime/imgui.ini src/poe_api_response.json /.claude/ -.gitignore .mcp.json diff --git a/pob-mcp/gui_bridge.lua b/pob-mcp/gui_bridge.lua index 6db93530ff..1376d32e37 100644 --- a/pob-mcp/gui_bridge.lua +++ b/pob-mcp/gui_bridge.lua @@ -27,14 +27,8 @@ local PORT = 12321 local srv, bindErr = socket.bind("*", PORT) if not srv then ConPrintf("pob-mcp: could not bind port %d: %s", PORT, tostring(bindErr)) - local f = io.open("pob-mcp-debug.txt", "a") - if f then f:write("bind error: "..tostring(bindErr).."\n"); f:close() end return end -do - local f = io.open("pob-mcp-debug.txt", "a") - if f then f:write("bind ok on port "..PORT.."\n"); f:close() end -end srv:settimeout(0) -- non-blocking accept local clients = {} -- open client sockets @@ -301,6 +295,20 @@ function handlers.deallocate_nodes(req) } 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 @@ -308,42 +316,156 @@ function handlers.optimize_tree(req) local budget = req.budget or 10 local maxDepth = req.maxDepth or 3 - build.spec:AddUndoState() build.spec:BuildAllDependsAndPaths() - local steps = {} - for step = 1, budget do - local calcFunc, baseOutput = build.calcsTab.calcs.getMiscCalculator(build) - local base = tonumber(baseOutput[metric]) or 0 - local bestNode, bestDelta, bestName = nil, 0, nil - local cache = {} - 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 node.ascendancyName - and not (build.calcsTab.mainEnv and build.calcsTab.mainEnv.grantedPassives[nodeId]) then - local val = cache[node.modKey] - if val == nil then - local out = calcFunc({ addNodes = { [node] = true } }, true) - val = tonumber(out[metric]) or 0 - cache[node.modKey] = val + local calcFunc, baseOutput = build.calcsTab.calcs.getMiscCalculator(build) + local base = tonumber(baseOutput[metric]) or 0 + if base == 0 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 + + -- BFS from every node adjacent to the allocated tree. + -- Finds ALL paths (not just PoB's single shortest path) up to depthLimit steps. + -- Returns a flat list of candidates: {node, pathList, cost, addSet}. + -- Each unique path to each reachable notable/normal node is a separate candidate. + -- Deduplication is by sorted node-id set so the same allocation isn't evaluated twice. + local function gatherCandidates(depthLimit) + local candidates = {} + local seenKeys = {} + local grantedPassives = build.calcsTab.mainEnv and build.calcsTab.mainEnv.grantedPassives + + -- BFS queue entries: {front=node, pathList={...}, pathSet={id=true}} + local queue = {} + local 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 - local delta = val - base - if delta > bestDelta then - bestDelta, bestNode, bestName = delta, node, node.dn or node.name + end + end + + while qHead <= #queue do + local item = queue[qHead]; qHead = qHead + 1 + local depth = #item.pathList + local node = item.front + + -- Add as candidate if this node contributes mods and fits in budget + if node.modKey and node.modKey ~= "" + and not (grantedPassives and grantedPassives[node.id]) then + + -- Key = endNodeId + sorted path ids (same allocation = same key) + 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 + + -- Expand deeper + if depth < depthLimit 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 = {} + for _, n in ipairs(item.pathList) do newList[#newList + 1] = n end + newList[#newList + 1] = nb + local newSet = {} + 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) + + local bestNode, bestPath, bestEfficiency, bestDelta = nil, nil, 0, 0 + for _, cand in ipairs(candidates) do + if GetTime() > deadline then break end + local out = calcFunc({ addNodes = cand.addSet }, true) + local val = tonumber(out[metric]) or 0 + local delta = val - base + local efficiency = delta / cand.cost + if efficiency > bestEfficiency then + bestEfficiency = efficiency + bestDelta = delta + bestNode = cand.node + bestPath = cand.pathList + end + end if not bestNode then break end - build.spec:AllocNode(bestNode) + + -- Allocate via our BFS-chosen path so PoB doesn't pick an arbitrary one + 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 = step, id = bestNode.id, name = bestName, delta = cleanNumber(bestDelta) } + steps[#steps + 1] = { + step = #steps + 1, id = bestNode.id, name = bestNode.dn or bestNode.name, + 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 = #steps, + 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), diff --git a/pob-mcp/server/__pycache__/buildcode.cpython-313.pyc b/pob-mcp/server/__pycache__/buildcode.cpython-313.pyc index 407668de9b1d432d7a959a5937b8bfaf9cecb9b3..fabc82e4264d180142eb0a5e5ceee64a0628a086 100644 GIT binary patch delta 14 Vcmew%{6l!d6c)zh%~M&DSphIT1$O`d delta 14 Vcmew%{6l!d6c$G3%~M&DSphG71y=w7 diff --git a/pob-mcp/server/__pycache__/engine_client.cpython-313.pyc b/pob-mcp/server/__pycache__/engine_client.cpython-313.pyc index 06d0f9dcc9b4eca60ab4970f61b7f6ea23a0adc8..a27b9c47eac6aa7b236abaab79dc431b20108d44 100644 GIT binary patch delta 5014 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 literal 19224 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

^{Cda*6oZcMyoPE>f- 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 j@~+?wTA#0% Optional[str]: - env = os.environ.get("POB_LUAJIT") - if env and Path(env).exists(): - return env - found = shutil.which("luajit") - if found: - return found - local = os.environ.get("LOCALAPPDATA") - if local: - cand = Path(local) / "Programs" / "LuaJIT" / "bin" / "luajit.exe" - if cand.exists(): - return str(cand) - return None - - -def _build_launch() -> tuple[list[str], dict[str, str], str]: - runtime = os.environ.get("POB_RUNTIME", "").lower() - lua_path = f"{RUNTIME_DIR}/lua/?.lua;{RUNTIME_DIR}/lua/?/init.lua;;" - lua_cpath = f"{RUNTIME_DIR}/?.dll;{RUNTIME_DIR}/?.so;;" - - if runtime == "docker": - image = os.environ.get( - "POB_DOCKER_IMAGE", - "ghcr.io/pathofbuildingcommunity/pathofbuilding-tests:latest", - ) - argv = [ - "docker", "run", "--rm", "-i", - "-v", f"{REPO_ROOT}:/workdir:ro", - "-w", "/workdir/src", - "-e", "LUA_PATH=../runtime/lua/?.lua;../runtime/lua/?/init.lua;;", - "-e", "LUA_CPATH=../runtime/?.so;;", - image, - "luajit", "/workdir/pob-mcp/engine/bridge.lua", - ] - return argv, dict(os.environ), str(REPO_ROOT) - - luajit = _find_luajit() - if not luajit: - raise EngineError( - "LuaJIT not found. Install it (winget install DEVCOM.LuaJIT), set " - "POB_LUAJIT to luajit.exe, or set POB_RUNTIME=docker." - ) - env = dict(os.environ) - env["LUA_PATH"] = lua_path - env["LUA_CPATH"] = lua_cpath - argv = [luajit, str(BRIDGE_LUA)] - return argv, env, str(SRC_DIR) - - # --------------------------------------------------------------------------- # GUI mode — TCP socket transport # --------------------------------------------------------------------------- @@ -158,8 +89,6 @@ def close(self) -> None: def _try_connect_gui() -> Optional[_GUITransport]: """Try to connect to a running PoB GUI bridge. Returns None if not available.""" - if os.environ.get("POB_RUNTIME", "").lower() in ("headless", "docker"): - return None # user explicitly wants headless try: sock = _socket.socket(_socket.AF_INET, _socket.SOCK_STREAM) sock.settimeout(GUI_CONNECT_TIMEOUT) @@ -175,190 +104,97 @@ def _try_connect_gui() -> Optional[_GUITransport]: return transport sock.close() return None - except Exception: + except Exception as _e: + sys.stderr.write(f"[pob-mcp] _try_connect_gui failed: {_e!r}\n") + sys.stderr.flush() return None # --------------------------------------------------------------------------- -# Public client — auto-selects GUI vs headless +# 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 with automatic GUI / headless mode selection. + """Engine client — GUI-only mode. - On every request the client first checks if the PoB GUI bridge is - reachable (127.0.0.1:12321). If yes it uses that connection (live mode). - If not it falls back to the headless LuaJIT subprocess. + 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._proc: Optional[subprocess.Popen] = None self._lock = threading.Lock() - self._next_id = 0 - self._last_xml: Optional[str] = None - self._stderr_thread: Optional[threading.Thread] = None - - # -- GUI mode ----------------------------------------------------------- - def _ensure_gui(self) -> bool: - """Try (re)connecting to the GUI bridge. Returns True if connected.""" + 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: - # Quick liveness check 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 # no data but still alive + 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() - return self._gui is not None - - # -- Headless mode ------------------------------------------------------ - def _alive(self) -> bool: - return self._proc is not None and self._proc.poll() is None + if self._gui is None: + raise EngineError(_NOT_RUNNING_MSG) - def _drain_stderr(self, proc: subprocess.Popen) -> None: - assert proc.stderr is not None - for line in proc.stderr: - sys.stderr.write(f"[pob-engine] {line.rstrip()}\n") - sys.stderr.flush() + return self._gui - def start(self) -> None: - if self._alive(): - return - argv, env, cwd = _build_launch() - self._proc = subprocess.Popen( - argv, cwd=cwd, env=env, - stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE, - text=True, encoding="utf-8", bufsize=1, - ) - self._stderr_thread = threading.Thread( - target=self._drain_stderr, args=(self._proc,), daemon=True - ) - self._stderr_thread.start() - self._read_until_ready() - - def _read_until_ready(self) -> None: - assert self._proc and self._proc.stdout - for _ in range(200): - line = self._proc.stdout.readline() - if not line: - raise EngineError("engine exited before becoming ready") - line = line.strip() - if not line: - continue - try: - msg = json.loads(line) - except json.JSONDecodeError: - continue - if msg.get("event") == "ready": - return - raise EngineError("engine did not emit ready event") - - def stop(self) -> None: - if self._proc: - try: - self._proc.terminate() - except Exception: - pass - self._proc = None + def _drop_gui(self) -> None: if self._gui: self._gui.close() self._gui = None - def _raw_request(self, cmd: str, **args: Any) -> Any: - assert self._proc and self._proc.stdin and self._proc.stdout - self._next_id += 1 - req_id = self._next_id - payload = {"id": req_id, "cmd": cmd, **args} - self._proc.stdin.write(json.dumps(payload) + "\n") - self._proc.stdin.flush() - while True: - line = self._proc.stdout.readline() - if not line: - raise EngineError(f"engine closed stdout while waiting for '{cmd}'") - line = line.strip() - 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 engine error")) - return msg.get("result") - - def _replay(self) -> None: - if self._last_xml: - self._raw_request("load_xml", xml=self._last_xml, name="MCP build") - - # -- unified API -------------------------------------------------------- + # -- public API --------------------------------------------------------- def request(self, cmd: str, **args: Any) -> Any: - """Send a command, auto-selecting GUI or headless transport.""" + """Send a command to the PoB GUI bridge.""" with self._lock: - # Prefer GUI mode when available - if self._ensure_gui(): - try: - return self._gui.request(cmd, **args) # type: ignore[union-attr] - except EngineError: - raise - except Exception as exc: - # Connection dropped; clear and fall through to headless - sys.stderr.write(f"[pob-mcp] GUI connection lost: {exc}; falling back to headless\n") - sys.stderr.flush() - if self._gui: - self._gui.close() - self._gui = None - - # Headless fallback - if not self._alive(): - self.start() - self._replay() + transport = self._ensure_gui() try: - return self._raw_request(cmd, **args) + return transport.request(cmd, **args) except EngineError: - self.stop() - self.start() - self._replay() - return self._raw_request(cmd, **args) + raise + except Exception as exc: + sys.stderr.write(f"[pob-mcp] GUI connection lost: {exc}\n") + sys.stderr.flush() + self._drop_gui() + raise EngineError(f"GUI connection lost: {exc}") from exc def load_xml(self, xml: str, name: str = "MCP build") -> Any: - """Load build XML. In GUI mode, triggers PoB's own loader and waits.""" + """Load build XML into the PoB GUI.""" with self._lock: - if self._ensure_gui(): - try: - result = self._gui.request("load_xml", xml=xml, name=name) # type: ignore[union-attr] - if result and result.get("pending"): - # PoB's SetMode is async; wait for the next OnFrame to apply it - time.sleep(GUI_LOAD_WAIT) - return result - except Exception as exc: - sys.stderr.write(f"[pob-mcp] GUI load failed: {exc}; falling back to headless\n") - sys.stderr.flush() - if self._gui: - self._gui.close() - self._gui = None - - # Headless - if not self._alive(): - self.start() - result = self._raw_request("load_xml", xml=xml, name=name) - self._last_xml = xml - return result + 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 EngineError: + raise + except Exception as exc: + sys.stderr.write(f"[pob-mcp] GUI load failed: {exc}\n") + sys.stderr.flush() + self._drop_gui() + raise EngineError(f"GUI load failed: {exc}") from exc @property def is_gui_mode(self) -> bool: - """True when connected to the PoB GUI bridge.""" return self._gui is not None diff --git a/pob-mcp/server/server.py b/pob-mcp/server/server.py index ef1b4edfb8..85d53a36b6 100644 --- a/pob-mcp/server/server.py +++ b/pob-mcp/server/server.py @@ -12,6 +12,8 @@ import asyncio import os +import socket as _socket +import traceback from typing import Any, Optional from mcp.server.fastmcp import FastMCP @@ -27,6 +29,49 @@ async def _call(cmd: str, **args: Any) -> Any: return await asyncio.to_thread(engine.request, cmd, **args) +@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: + summary = t.request("list_state", what="summary") + t.close() + result["engine_client"] = "ok" + result["summary"] = summary + 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. @@ -237,10 +282,12 @@ async def optimize_tree( """Greedily allocate the best passive nodes one by one to maximise `metric`. Each step evaluates all reachable unallocated nodes within `max_depth` distance - and picks the one with the highest gain to `metric` (e.g. "CombinedDPS", - "FullDPS", "Life", "EnergyShield"). Repeats up to `budget` times. The entire - run is saved as a single undo state so `undo_passive_changes` reverts all steps - at once. Returns the sequence of allocations and final stats. + and picks the one with the highest combined gain to `metric` (e.g. "CombinedDPS", + "FullDPS", "Life", "EnergyShield"). Crucially, the gain is measured for the + ENTIRE PATH to the candidate node (all intermediate nodes included), so the + algorithm naturally avoids paths through useless nodes. Repeats up to `budget` + times. The entire run is saved as a single undo state so `undo_passive_changes` + reverts all steps at once. Returns the sequence of allocations and final stats. """ return await _call("optimize_tree", metric=metric, budget=budget, maxDepth=max_depth) 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 023e32b982..2d38ab03e5 100644 --- a/src/Launch.lua +++ b/src/Launch.lua @@ -85,29 +85,18 @@ function launch:OnInit() self:CheckForUpdate(true) end - -- pob-mcp: load GUI bridge if present (optional, no-op if missing) - do - local log = io.open("pob-mcp-debug.txt", "w") - local function logw(s) if log then log:write(s.."\n"); log:flush() end end - logw("cwd probe: "..tostring(io.open("pob-mcp/gui_bridge.lua","r") ~= nil) - .." / "..tostring(io.open("../pob-mcp/gui_bridge.lua","r") ~= nil)) + -- 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" } - local loaded, loadErr = false, nil for _, path in ipairs(candidates) do local f = io.open(path, "r") if f then - f:close() - logw("loading: "..path) - local ok, err = pcall(dofile, path) - logw("result: "..tostring(ok).." "..tostring(err)) - loaded = ok - loadErr = err - break - end + f:close() + local ok, err = pcall(dofile, path) + if not ok then ConPrintf("pob-mcp LOAD ERROR: %s", tostring(err)) end + break + end end - if not loaded then logw("bridge not loaded: "..tostring(loadErr)) end - logw("pobMcpTick defined: "..tostring(_G.pobMcpTick ~= nil)) - if log then log:close() end end end @@ -140,7 +129,10 @@ function launch:OnFrame() end end -- pob-mcp: tick the GUI bridge (non-blocking, no-op if bridge not loaded) - if _G.pobMcpTick then pcall(_G.pobMcpTick) end + 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 From 2db9e7a1a03c3a3472fc95487ab503c74b682b9e Mon Sep 17 00:00:00 2001 From: imfal Date: Mon, 1 Jun 2026 18:49:26 +1000 Subject: [PATCH 3/3] . --- .gitignore | 1 + pob-mcp-tick-debug.txt | 195 ++++++++ pob-mcp/engine/bridge.lua | 786 +++++++++++++++++++++++++++++-- pob-mcp/gui_bridge.lua | 796 +++++++++++++++++++++++++++++--- pob-mcp/server/engine_client.py | 21 +- pob-mcp/server/server.py | 641 +++++++++++++++++++++++-- 6 files changed, 2303 insertions(+), 137 deletions(-) create mode 100644 pob-mcp-tick-debug.txt diff --git a/.gitignore b/.gitignore index 7b670152c7..bb753184a1 100644 --- a/.gitignore +++ b/.gitignore @@ -47,3 +47,4 @@ 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/engine/bridge.lua b/pob-mcp/engine/bridge.lua index a18afe8fa9..08d0fed75b 100644 --- a/pob-mcp/engine/bridge.lua +++ b/pob-mcp/engine/bridge.lua @@ -112,6 +112,28 @@ local function nodesById(ids) 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") @@ -237,13 +259,16 @@ end function handlers.deallocate_nodes(req) requireBuild() build.spec:AddUndoState() - local ids = req.ids or {} - local removed = {} - local notAlloc = {} + 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 not node.alloc then + 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) @@ -253,7 +278,7 @@ function handlers.deallocate_nodes(req) recalc() local o = build.calcsTab.mainOutput return { - removed = removed, notAlloc = notAlloc, + removed = removed, notAlloc = notAlloc, protected = protected, FullDPS = o and cleanNumber(o.FullDPS), CombinedDPS = o and cleanNumber(o.CombinedDPS), Life = o and cleanNumber(o.Life), @@ -261,51 +286,194 @@ function handlers.deallocate_nodes(req) } end --- Greedy passive tree optimizer: each step picks the single best node and allocates it. --- One undo state is saved before all steps so the whole run can be undone at once. --- req = { metric, budget, maxDepth } +-- 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 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 steps = {} - for step = 1, budget do - local calcFunc, baseOutput = build.calcsTab.calcs.getMiscCalculator(build) - local base = tonumber(baseOutput[metric]) or 0 - local bestNode, bestDelta, bestName = nil, 0, nil - local cache = {} - 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 node.ascendancyName - and not (build.calcsTab.mainEnv and build.calcsTab.mainEnv.grantedPassives[nodeId]) then - local val = cache[node.modKey] - if val == nil then - local out = calcFunc({ addNodes = { [node] = true } }, true) - val = tonumber(out[metric]) or 0 - cache[node.modKey] = val + 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 - local delta = val - base - if delta > bestDelta then - bestDelta, bestNode, bestName = delta, node, node.dn or node.name + 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 - build.spec:AllocNode(bestNode) + + 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 = step, id = bestNode.id, name = bestName, delta = cleanNumber(bestDelta) } + 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 = #steps, + 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), @@ -452,17 +620,26 @@ function handlers.list_state(req) if slot.selItemId and slot.selItemId ~= 0 then local item = build.itemsTab.items[slot.selItemId] if item then - items[#items + 1] = { slot = slotName, name = item.name, rarity = item.rarity } + 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 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 = build.spec.allocNodes and #alloc } + return { allocated = alloc, usedPoints = usedPoints } else -- summary local o = build.calcsTab.mainOutput return { @@ -476,6 +653,543 @@ function handlers.list_state(req) 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 -------------------------------------------------------------------------------- diff --git a/pob-mcp/gui_bridge.lua b/pob-mcp/gui_bridge.lua index 1376d32e37..2c782e72a2 100644 --- a/pob-mcp/gui_bridge.lua +++ b/pob-mcp/gui_bridge.lua @@ -105,6 +105,28 @@ local function nodesById(build, ids) 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) -------------------------------------------------------------------------------- @@ -271,13 +293,16 @@ 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 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 not node.alloc then + 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) @@ -287,7 +312,7 @@ function handlers.deallocate_nodes(req) guiRecalc(build) local o = build.calcsTab.mainOutput return { - removed = removed, notAlloc = notAlloc, + removed = removed, notAlloc = notAlloc, protected = protected, FullDPS = o and cleanNumber(o.FullDPS), CombinedDPS = o and cleanNumber(o.CombinedDPS), Life = o and cleanNumber(o.Life), @@ -312,15 +337,17 @@ 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 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 then + + if base == 0 and not weights then local diag = getSkillGroupDiag(build) error(string.format( "Base %s = 0, cannot optimize. mainSocketGroup=%s. Skill groups: %s", @@ -329,22 +356,55 @@ function handlers.optimize_tree(req) 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. - -- Finds ALL paths (not just PoB's single shortest path) up to depthLimit steps. - -- Returns a flat list of candidates: {node, pathList, cost, addSet}. - -- Each unique path to each reachable notable/normal node is a separate candidate. - -- Deduplication is by sorted node-id set so the same allocation isn't evaluated twice. - local function gatherCandidates(depthLimit) - local candidates = {} - local seenKeys = {} + -- 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) - -- BFS queue entries: {front=node, pathList={...}, pathSet={id=true}} - local queue = {} - local qHead = 1 + local queue, qHead = {}, 1 local seenStart = {} for _, node in pairs(build.spec.nodes) do if not node.alloc and not node.ascendancyName then @@ -367,38 +427,37 @@ function handlers.optimize_tree(req) local depth = #item.pathList local node = item.front - -- Add as candidate if this node contributes mods and fits in budget if node.modKey and node.modKey ~= "" and not (grantedPassives and grantedPassives[node.id]) then - - -- Key = endNodeId + sorted path ids (same allocation = same key) - 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, - } + 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 - -- Expand deeper - if depth < depthLimit then + 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 = {} + local newList, newSet = {}, {} for _, n in ipairs(item.pathList) do newList[#newList + 1] = n end newList[#newList + 1] = nb - local newSet = {} for k in pairs(item.pathSet) do newSet[k] = true end newSet[nb.id] = true queue[#queue + 1] = { @@ -414,7 +473,7 @@ function handlers.optimize_tree(req) end local remaining = budget - local steps = {} + local steps = {} while remaining > 0 do if GetTime() > deadline then steps[#steps + 1] = { timedOut = true, remainingPoints = remaining } @@ -422,25 +481,31 @@ function handlers.optimize_tree(req) end local depthLimit = math.min(maxDepth, remaining) - local candidates = gatherCandidates(depthLimit) + 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 out = calcFunc({ addNodes = cand.addSet }, true) - local val = tonumber(out[metric]) or 0 - local delta = val - base - local efficiency = delta / cand.cost - if efficiency > bestEfficiency then - bestEfficiency = efficiency - bestDelta = delta - bestNode = cand.node - bestPath = cand.pathList + 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 - -- Allocate via our BFS-chosen path so PoB doesn't pick an arbitrary one local before = 0 for _ in pairs(build.spec.allocNodes) do before = before + 1 end build.spec:AllocNode(bestNode, bestPath) @@ -451,8 +516,12 @@ function handlers.optimize_tree(req) remaining = remaining - actualCost build.spec:BuildAllDependsAndPaths() steps[#steps + 1] = { - step = #steps + 1, id = bestNode.id, name = bestNode.dn or bestNode.name, - delta = cleanNumber(bestDelta), cost = actualCost, + step = #steps + 1, + id = bestNode.id, + name = bestNode.dn or bestNode.name, + type = bestNode.type, + delta = cleanNumber(bestDelta), + cost = actualCost, remainingPoints = remaining, } @@ -463,7 +532,8 @@ function handlers.optimize_tree(req) guiRecalc(build) local o = build.calcsTab.mainOutput return { - metric = metric, steps = steps, + metric = metric, + steps = steps, pointsUsed = budget - remaining, stepsCount = #steps, finalValue = cleanNumber(tonumber(o[metric]) or 0), @@ -557,16 +627,27 @@ function handlers.list_state(req) 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 items[#items + 1] = { slot = slotName, name = item.name, rarity = item.rarity } end + 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 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 = #alloc } + return { allocated = alloc, usedPoints = usedPoints } else local o = build.calcsTab.mainOutput return { @@ -581,6 +662,567 @@ function handlers.list_state(req) 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) -------------------------------------------------------------------------------- @@ -625,25 +1267,43 @@ local function processClient(c) end -------------------------------------------------------------------------------- --- global tick function — called from Launch.lua:OnFrame every frame --------------------------------------------------------------------------------- +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 - client:settimeout(0) + 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 - -- Send handshake so engine_client knows we're ready - client:send(dkjson.encode({ event = "ready", gui = true }) .. "\n") end -- Serve existing clients local i = 1 while i <= #clients do - local err = processClient(clients[i]) - if err == "closed" then + 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 diff --git a/pob-mcp/server/engine_client.py b/pob-mcp/server/engine_client.py index cfc7a92d4d..afe2b7f802 100644 --- a/pob-mcp/server/engine_client.py +++ b/pob-mcp/server/engine_client.py @@ -20,7 +20,7 @@ GUI_HOST = "127.0.0.1" GUI_PORT = 12321 -GUI_CONNECT_TIMEOUT = 0.3 # seconds to wait when probing for GUI +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 @@ -89,6 +89,7 @@ def close(self) -> None: 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) @@ -102,11 +103,17 @@ def _try_connect_gui() -> Optional[_GUITransport]: sys.stderr.write("[pob-mcp] Connected to PoB GUI bridge (live mode)\n") sys.stderr.flush() return transport - sock.close() + 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 @@ -170,12 +177,12 @@ def request(self, cmd: str, **args: Any) -> Any: transport = self._ensure_gui() try: return transport.request(cmd, **args) - except EngineError: - raise except Exception as exc: - sys.stderr.write(f"[pob-mcp] GUI connection lost: {exc}\n") + 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: @@ -187,12 +194,12 @@ def load_xml(self, xml: str, name: str = "MCP build") -> Any: if result and result.get("pending"): time.sleep(GUI_LOAD_WAIT) return result - except EngineError: - raise 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 diff --git a/pob-mcp/server/server.py b/pob-mcp/server/server.py index 85d53a36b6..fc1d21752d 100644 --- a/pob-mcp/server/server.py +++ b/pob-mcp/server/server.py @@ -13,6 +13,7 @@ import asyncio import os import socket as _socket +import time import traceback from typing import Any, Optional @@ -24,11 +25,31 @@ 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). @@ -60,10 +81,12 @@ async def diagnose_connection() -> dict: try: t = _try_connect_gui() if t is not None: - summary = t.request("list_state", what="summary") - t.close() - result["engine_client"] = "ok" - result["summary"] = summary + 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: @@ -84,19 +107,7 @@ async def load_build(source: str, name: str = "MCP build") -> dict: Returns a short summary (class, ascendancy, level, FullDPS, Life). Call this before any analysis tool. Loading replaces the previously loaded build. """ - text = source.strip() - if buildcode.looks_like_xml(text): - xml = text - elif os.path.isfile(text): - with open(text, "r", encoding="utf-8") as fh: - xml = fh.read() - else: - try: - xml = buildcode.code_to_xml(text) - except Exception as exc: # noqa: BLE001 - raise ValueError( - "source is not XML, an existing .xml path, or a valid build code" - ) from exc + xml = _source_to_xml(source) return await asyncio.to_thread(engine.load_xml, xml, name) @@ -278,18 +289,37 @@ 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 to maximise `metric`. + """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. - Each step evaluates all reachable unallocated nodes within `max_depth` distance - and picks the one with the highest combined gain to `metric` (e.g. "CombinedDPS", - "FullDPS", "Life", "EnergyShield"). Crucially, the gain is measured for the - ENTIRE PATH to the candidate node (all intermediate nodes included), so the - algorithm naturally avoids paths through useless nodes. Repeats up to `budget` - times. The entire run is saved as a single undo state so `undo_passive_changes` - reverts all steps at once. Returns the sequence of allocations and final stats. + **`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) + return await _call( + "optimize_tree", + metric=metric, + budget=budget, + maxDepth=max_depth, + weights=weights, + minConstraints=min_constraints, + ) @mcp.tool() @@ -332,6 +362,565 @@ async def redo_passive_changes() -> dict: 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()