diff --git a/src/API/BuildOps.lua b/src/API/BuildOps.lua new file mode 100644 index 0000000000..f8db84843f --- /dev/null +++ b/src/API/BuildOps.lua @@ -0,0 +1,786 @@ +-- Thin wrappers around PoB headless objects for programmatic operations + +local M = {} + +local MIN_PLAYER_LEVEL = 1 +local MAX_PLAYER_LEVEL = 100 +local NUM_FLASK_SLOTS = 5 +local MAX_ITEM_TEXT_LENGTH = 10240 -- 10KB + +function M.get_main_output() + if not build or not build.calcsTab then + return nil, "build not initialized" + end + if build.calcsTab.BuildOutput then + build.calcsTab:BuildOutput() + end + local output = build.calcsTab and build.calcsTab.mainOutput or nil + if not output then + return nil, "no output available" + end + return output +end + +function M.export_stats(fields) + local output, err = M.get_main_output() + if not output then + return nil, err + end + local wanted = fields or { + "Life", "EnergyShield", "Armour", "Evasion", + "FireResist", "ColdResist", "LightningResist", "ChaosResist", + "BlockChance", "SpellBlockChance", + "LifeRegen", "Mana", "ManaRegen", "ManaUnreserved", + "Ward", "DodgeChance", "SpellDodgeChance", + "TotalEHP", "PhysicalDamageReduction", + "AttackDodgeChance", "EffectiveMovementSpeedMod", + "SpellSuppressionChance", "LifeLeechGainRate", "ManaLeechGainRate", + "EnduranceChargesMax", "FrenzyChargesMax", "PowerChargesMax", + } + local result = {} + for _, k in ipairs(wanted) do + if type(output[k]) ~= 'nil' then + result[k] = output[k] + end + end + local minionOutput = output.Minion + if minionOutput and type(minionOutput) == 'table' then + local minionWanted = { + "Life", "EnergyShield", "Armour", "Evasion", + "TotalDPS", "CombinedDPS", "AverageDamage", "Speed", + "FireResist", "ColdResist", "LightningResist", "ChaosResist", + "BlockChance", "PhysicalDamageReduction", + } + for _, k in ipairs(minionWanted) do + if type(minionOutput[k]) ~= 'nil' then + result["Minion" .. k] = minionOutput[k] + end + end + end + result._meta = result._meta or {} + if build and build.targetVersion then + result._meta.treeVersion = tostring(build.targetVersion) + end + if build and build.characterLevel then + result._meta.level = tonumber(build.characterLevel) + end + if build and build.buildName then + result._meta.buildName = tostring(build.buildName) + end + return result +end + +function M.get_tree() + if not build or not build.spec then + return nil, "build/spec not initialized" + end + local spec = build.spec + local out = { + treeVersion = spec.treeVersion, + classId = tonumber(spec.curClassId) or 0, + ascendClassId = tonumber(spec.curAscendClassId) or 0, + secondaryAscendClassId = tonumber(spec.curSecondaryAscendClassId or 0) or 0, + nodes = {}, + masteryEffects = {}, + } + for id, _ in pairs(spec.allocNodes or {}) do + table.insert(out.nodes, id) + end + for mastery, effect in pairs(spec.masterySelections or {}) do + out.masteryEffects[mastery] = effect + end + table.sort(out.nodes) + return out +end + +-- params: { classId, ascendClassId, secondaryAscendClassId?, nodes:[int], masteryEffects?:{[id]=effect}, treeVersion? } +function M.set_tree(params) + if not build or not build.spec then + return nil, "build/spec not initialized" + end + if type(params) ~= 'table' then + return nil, "invalid params" + end + local classId = tonumber(params.classId or 0) or 0 + local ascendId = tonumber(params.ascendClassId or 0) or 0 + local secondaryId = tonumber(params.secondaryAscendClassId or 0) or 0 + local nodes = {} + if type(params.nodes) == 'table' then + for _, v in ipairs(params.nodes) do + table.insert(nodes, tonumber(v)) + end + end + local mastery = params.masteryEffects or {} + local treeVersion = params.treeVersion + build.spec:ImportFromNodeList(classId, ascendId, secondaryId, nodes, {}, mastery, treeVersion) + M.get_main_output() + return true +end + +function M.export_build_xml() + if not build or not build.SaveDB then + return nil, 'build not initialized' + end + local xml = build:SaveDB('api-export') + if not xml then return nil, 'failed to compose xml' end + return xml +end + +function M.set_level(level) + if not build or not build.configTab then + return nil, 'build/config not initialized' + end + local lvl = tonumber(level) + if not lvl or lvl < MIN_PLAYER_LEVEL or lvl > MAX_PLAYER_LEVEL then + return nil, string.format('invalid level (must be %d-%d)', MIN_PLAYER_LEVEL, MAX_PLAYER_LEVEL) + end + build.characterLevel = lvl + build.characterLevelAutoMode = false + if build.configTab and build.configTab.BuildModList then + build.configTab:BuildModList() + end + M.get_main_output() + return true +end + +function M.get_build_info() + if not build then return nil, 'build not initialized' end + local info = { + name = build.buildName, + level = build.characterLevel, + className = build.spec and build.spec.curClassName or nil, + ascendClassName = build.spec and build.spec.curAscendClassName or nil, + treeVersion = build.targetVersion or (build.spec and build.spec.treeVersion) or nil, + } + return info +end + +function M.update_tree_delta(params) + if not build or not build.spec then return nil, 'build/spec not initialized' end + local current, err = M.get_tree() + if not current then return nil, err end + local set = {} + for _, id in ipairs(current.nodes) do set[id] = true end + if params and type(params.removeNodes) == 'table' then + for _, id in ipairs(params.removeNodes) do set[tonumber(id)] = nil end + end + if params and type(params.addNodes) == 'table' then + for _, id in ipairs(params.addNodes) do set[tonumber(id)] = true end + end + local nodes = {} + for id,_ in pairs(set) do table.insert(nodes, id) end + table.sort(nodes) + local mastery = current.masteryEffects or {} + local classId = params.classId or current.classId or 0 + local ascendId = params.ascendClassId or current.ascendClassId or 0 + local secId = params.secondaryAscendClassId or current.secondaryAscendClassId or 0 + local tv = params.treeVersion or current.treeVersion + build.spec:ImportFromNodeList(tonumber(classId) or 0, tonumber(ascendId) or 0, tonumber(secId) or 0, nodes, {}, mastery, tv) + M.get_main_output() + return true +end + + +-- params: { addNodes?: number[], removeNodes?: number[], useFullDPS?: boolean } +function M.calc_with(params) + if not build or not build.calcsTab then return nil, 'build not initialized' end + local calcFunc, baseOut = build.calcsTab:GetMiscCalculator() + local override = {} + if params and type(params.addNodes) == 'table' then + override.addNodes = {} + for _, id in ipairs(params.addNodes) do + local n = build.spec and build.spec.nodes and build.spec.nodes[tonumber(id)] + if n then override.addNodes[n] = true end + end + end + if params and type(params.removeNodes) == 'table' then + override.removeNodes = {} + for _, id in ipairs(params.removeNodes) do + local n = build.spec and build.spec.nodes and build.spec.nodes[tonumber(id)] + if n then override.removeNodes[n] = true end + end + end + local out = calcFunc(override, params and params.useFullDPS) + return out, baseOut +end + + +function M.get_config() + if not build or not build.configTab then return nil, 'build/config not initialized' end + local cfg = { + bandit = build.configTab.input and build.configTab.input.bandit or build.bandit, + pantheonMajorGod = build.configTab.input and build.configTab.input.pantheonMajorGod or build.pantheonMajorGod, + pantheonMinorGod = build.configTab.input and build.configTab.input.pantheonMinorGod or build.pantheonMinorGod, + enemyLevel = build.configTab.enemyLevel, + } + return cfg +end + +function M.set_config(params) + if not build or not build.configTab then return nil, 'build/config not initialized' end + if type(params) ~= 'table' then return nil, 'invalid params' end + local input = build.configTab.input or {} + build.configTab.input = input + local changed = false + if params.bandit ~= nil then input.bandit = tostring(params.bandit); changed = true end + if params.pantheonMajorGod ~= nil then input.pantheonMajorGod = tostring(params.pantheonMajorGod); changed = true end + if params.pantheonMinorGod ~= nil then input.pantheonMinorGod = tostring(params.pantheonMinorGod); changed = true end + if params.enemyLevel ~= nil then build.configTab.enemyLevel = tonumber(params.enemyLevel) or build.configTab.enemyLevel; changed = true end + if params.enemyFireResist ~= nil then input.enemyFireResistance = tonumber(params.enemyFireResist); changed = true end + if params.enemyColdResist ~= nil then input.enemyColdResistance = tonumber(params.enemyColdResist); changed = true end + if params.enemyLightningResist ~= nil then input.enemyLightningResistance = tonumber(params.enemyLightningResist); changed = true end + if params.enemyChaosResist ~= nil then input.enemyChaosResistance = tonumber(params.enemyChaosResist); changed = true end + if params.enemyArmour ~= nil then input.enemyArmour = tonumber(params.enemyArmour); changed = true end + if params.enemyEvasion ~= nil then input.enemyEvasion = tonumber(params.enemyEvasion); changed = true end + if params.usePowerCharges ~= nil then input.usePowerCharges = params.usePowerCharges; changed = true end + if params.useFrenzyCharges ~= nil then input.useFrenzyCharges = params.useFrenzyCharges; changed = true end + if params.useEnduranceCharges ~= nil then input.useEnduranceCharges = params.useEnduranceCharges; changed = true end + if params.conditionShockedGround ~= nil then input.conditionShockedGround = params.conditionShockedGround; changed = true end + if params.conditionFortify ~= nil then input.conditionFortify = params.conditionFortify; changed = true end + if params.conditionLeeching ~= nil then input.conditionLeeching = params.conditionLeeching; changed = true end + if params.buffOnslaught ~= nil then input.buffOnslaught = params.buffOnslaught; changed = true end + if params.enemyIsBoss ~= nil then input.enemyIsBoss = tostring(params.enemyIsBoss); changed = true end + if changed and build.configTab.BuildModList then build.configTab:BuildModList() end + M.get_main_output() + return true +end + + +function M.get_skills() + if not build or not build.skillsTab or not build.calcsTab then return nil, 'skills not initialized' end + local groups = {} + for idx, g in ipairs(build.skillsTab.socketGroupList or {}) do + local names = {} + if g.displaySkillList then + for _, eff in ipairs(g.displaySkillList) do + if eff and eff.activeEffect and eff.activeEffect.grantedEffect then + table.insert(names, eff.activeEffect.grantedEffect.name) + end + end + end + local gems = {} + if g.gemList then + for gemIdx, gem in ipairs(g.gemList) do + table.insert(gems, { + index = gemIdx, + name = gem.nameSpec or gem.name or '', + level = gem.level or 1, + quality = gem.quality or 0, + qualityId = gem.qualityId or 'Default', + enabled = gem.enabled ~= false, + isSupport = gem.skillId and gem.skillId:find('Support') ~= nil or false, + }) + end + end + table.insert(groups, { + index = idx, + label = g.label, + slot = g.slot, + enabled = g.enabled, + includeInFullDPS = g.includeInFullDPS, + mainActiveSkill = g.mainActiveSkill, + skills = names, + gems = gems, + }) + end + local result = { + mainSocketGroup = build.mainSocketGroup, + calcsSkillNumber = build.calcsTab.input and build.calcsTab.input.skill_number or nil, + groups = groups, + } + return result +end + +function M.set_main_selection(params) + if not build or not build.skillsTab or not build.calcsTab then return nil, 'skills not initialized' end + if type(params) ~= 'table' then return nil, 'invalid params' end + if params.mainSocketGroup ~= nil then + build.mainSocketGroup = tonumber(params.mainSocketGroup) or build.mainSocketGroup + end + local g = build.skillsTab.socketGroupList[build.mainSocketGroup] + if not g then return nil, 'invalid mainSocketGroup' end + if params.mainActiveSkill ~= nil then + g.mainActiveSkill = tonumber(params.mainActiveSkill) or g.mainActiveSkill + end + if params.skillPart ~= nil then + local idx = g.mainActiveSkill or 1 + local src = g.displaySkillList and g.displaySkillList[idx] and g.displaySkillList[idx].activeEffect and g.displaySkillList[idx].activeEffect.srcInstance + if src then src.skillPart = tonumber(params.skillPart) end + end + -- Keep calcsTab in sync: use active group index + build.calcsTab.input.skill_number = build.mainSocketGroup + M.get_main_output() + return true +end + +function M.add_item_text(params) + if not build or not build.itemsTab then return nil, 'items not initialized' end + if type(params) ~= 'table' or type(params.text) ~= 'string' then return nil, 'missing text' end + + if #params.text == 0 then return nil, 'item text cannot be empty' end + if #params.text > MAX_ITEM_TEXT_LENGTH then + return nil, string.format('item text too long (max %d bytes)', MAX_ITEM_TEXT_LENGTH) + end + + local ok, item = pcall(new, 'Item', params.text) + if not ok then return nil, 'invalid item text: ' .. tostring(item) end + if not item or not item.baseName then return nil, 'failed to parse item' end + + item:NormaliseQuality() + build.itemsTab:AddItem(item, params.noAutoEquip == true) + if params.slotName then + local slot = tostring(params.slotName) + if build.itemsTab.slots[slot] then + build.itemsTab.slots[slot]:SetSelItemId(item.id) + build.itemsTab:PopulateSlots() + end + end + build.itemsTab:AddUndoState() + build.buildFlag = true + M.get_main_output() + return { id = item.id, name = item.name, slot = params.slotName or item:GetPrimarySlot() } +end + +function M.set_flask_active(params) + if not build or not build.itemsTab then return nil, 'items not initialized' end + if type(params) ~= 'table' then return nil, 'invalid params' end + local idx = tonumber(params.index) + local active = params.active == true + if not idx or idx < 1 or idx > NUM_FLASK_SLOTS then + return nil, string.format('invalid flask index (must be 1-%d)', NUM_FLASK_SLOTS) + end + local slotName = 'Flask ' .. tostring(idx) + if not build.itemsTab.activeItemSet or not build.itemsTab.activeItemSet[slotName] then return nil, 'slot not found' end + build.itemsTab.activeItemSet[slotName].active = active + -- Re-populate slots so flask effects are applied before recalculating + if build.itemsTab.PopulateSlots then + build.itemsTab:PopulateSlots() + end + if build.configTab and build.configTab.BuildModList then + build.configTab:BuildModList() + end + build.itemsTab:AddUndoState() + build.buildFlag = true + M.get_main_output() + return true +end + + +function M.get_items() + if not build or not build.itemsTab then return nil, 'items not initialized' end + local itemsTab = build.itemsTab + local result = { } + -- Prefer orderedSlots for deterministic order + local ordered = itemsTab.orderedSlots or {} + local seen = {} + local function add_slot(slotName) + if seen[slotName] then return end + seen[slotName] = true + local slotCtrl = itemsTab.slots[slotName] + if not slotCtrl then return end + local selId = slotCtrl.selItemId or 0 + local entry = { slot = slotName, id = selId } + if selId > 0 then + local it = itemsTab.items[selId] + if it then + entry.name = it.name + entry.baseName = it.baseName + entry.type = it.type + entry.rarity = it.rarity + entry.raw = it.raw + end + end + -- Flask/Tincture activation flag stored in activeItemSet + local set = itemsTab.activeItemSet + if set and set[slotName] and set[slotName].active ~= nil then + entry.active = set[slotName].active and true or false + end + table.insert(result, entry) + end + for _, slot in ipairs(ordered) do + if slot and slot.slotName then add_slot(slot.slotName) end + end + -- Add any remaining slots not in ordered list + for slotName, _ in pairs(itemsTab.slots or {}) do add_slot(slotName) end + return result +end + + +-- params: { label?: string, slot?: string, enabled?: boolean, includeInFullDPS?: boolean } +function M.create_socket_group(params) + if not build or not build.skillsTab then return nil, 'skills not initialized' end + if type(params) ~= 'table' then params = {} end + + local socketGroup = { + label = params.label or '', + slot = params.slot, + enabled = params.enabled ~= false, + includeInFullDPS = params.includeInFullDPS == true, + gemList = {}, + mainActiveSkill = 1, + mainActiveSkillCalcs = 1, + } + + local skillSetId = build.skillsTab.activeSkillSetId or 1 + local skillSet = build.skillsTab.skillSets[skillSetId] + if not skillSet then return nil, 'active skill set not found' end + + table.insert(skillSet.socketGroupList, socketGroup) + local index = #skillSet.socketGroupList + + if build.skillsTab.ProcessSocketGroup then + build.skillsTab:ProcessSocketGroup(socketGroup) + end + + build.buildFlag = true + M.get_main_output() + + return { index = index, label = socketGroup.label } +end + +-- params: { groupIndex: number, gemName: string, level?: number, quality?: number, qualityId?: string, enabled?: boolean } +function M.add_gem(params) + if not build or not build.skillsTab then return nil, 'skills not initialized' end + if type(params) ~= 'table' then return nil, 'invalid params' end + if not params.groupIndex or not params.gemName then return nil, 'missing groupIndex or gemName' end + + local skillSetId = build.skillsTab.activeSkillSetId or 1 + local skillSet = build.skillsTab.skillSets[skillSetId] + if not skillSet then return nil, 'active skill set not found' end + + local groupIndex = tonumber(params.groupIndex) + local socketGroup = skillSet.socketGroupList[groupIndex] + if not socketGroup then return nil, 'socket group not found at index ' .. tostring(groupIndex) end + + local gemInstance = { + nameSpec = tostring(params.gemName), + level = tonumber(params.level) or 20, + quality = tonumber(params.quality) or 0, + qualityId = params.qualityId or 'Default', + enabled = params.enabled ~= false, + enableGlobal1 = true, + enableGlobal2 = false, + count = tonumber(params.count) or 1, + } + + if build.data and build.data.gems then + for _, gemData in pairs(build.data.gems) do + if gemData.name == gemInstance.nameSpec or gemData.nameSpec == gemInstance.nameSpec then + gemInstance.gemId = gemData.id + if gemData.grantedEffect then + gemInstance.skillId = gemData.grantedEffect.id + elseif gemData.grantedEffectId then + gemInstance.skillId = gemData.grantedEffectId + end + gemInstance.gemData = gemData + break + end + end + end + + table.insert(socketGroup.gemList, gemInstance) + local gemIndex = #socketGroup.gemList + + if build.skillsTab.ProcessSocketGroup then + build.skillsTab:ProcessSocketGroup(socketGroup) + end + + build.buildFlag = true + M.get_main_output() + + return { gemIndex = gemIndex, name = gemInstance.nameSpec } +end + +-- params: { groupIndex: number, gemIndex: number, level: number } +function M.set_gem_level(params) + if not build or not build.skillsTab then return nil, 'skills not initialized' end + if type(params) ~= 'table' then return nil, 'invalid params' end + if not params.groupIndex or not params.gemIndex or not params.level then + return nil, 'missing groupIndex, gemIndex, or level' + end + + local skillSetId = build.skillsTab.activeSkillSetId or 1 + local skillSet = build.skillsTab.skillSets[skillSetId] + if not skillSet then return nil, 'active skill set not found' end + + local groupIndex = tonumber(params.groupIndex) + local gemIndex = tonumber(params.gemIndex) + local level = tonumber(params.level) + + local socketGroup = skillSet.socketGroupList[groupIndex] + if not socketGroup then return nil, 'socket group not found' end + + local gemInstance = socketGroup.gemList[gemIndex] + if not gemInstance then return nil, 'gem not found' end + + if level < 1 or level > 40 then return nil, 'invalid level (must be 1-40)' end + + gemInstance.level = level + + if build.skillsTab.ProcessSocketGroup then + build.skillsTab:ProcessSocketGroup(socketGroup) + end + + build.buildFlag = true + M.get_main_output() + + return true +end + +-- params: { groupIndex: number, gemIndex: number, quality: number, qualityId?: string } +function M.set_gem_quality(params) + if not build or not build.skillsTab then return nil, 'skills not initialized' end + if type(params) ~= 'table' then return nil, 'invalid params' end + if not params.groupIndex or not params.gemIndex or not params.quality then + return nil, 'missing groupIndex, gemIndex, or quality' + end + + local skillSetId = build.skillsTab.activeSkillSetId or 1 + local skillSet = build.skillsTab.skillSets[skillSetId] + if not skillSet then return nil, 'active skill set not found' end + + local groupIndex = tonumber(params.groupIndex) + local gemIndex = tonumber(params.gemIndex) + local quality = tonumber(params.quality) + + local socketGroup = skillSet.socketGroupList[groupIndex] + if not socketGroup then return nil, 'socket group not found' end + + local gemInstance = socketGroup.gemList[gemIndex] + if not gemInstance then return nil, 'gem not found' end + + if quality < 0 or quality > 23 then return nil, 'invalid quality (must be 0-23)' end + + gemInstance.quality = quality + if params.qualityId then + gemInstance.qualityId = tostring(params.qualityId) + end + + if build.skillsTab.ProcessSocketGroup then + build.skillsTab:ProcessSocketGroup(socketGroup) + end + + build.buildFlag = true + M.get_main_output() + + return true +end + +-- params: { groupIndex: number } +function M.remove_skill(params) + if not build or not build.skillsTab then return nil, 'skills not initialized' end + if type(params) ~= 'table' then return nil, 'invalid params' end + if not params.groupIndex then return nil, 'missing groupIndex' end + + local skillSetId = build.skillsTab.activeSkillSetId or 1 + local skillSet = build.skillsTab.skillSets[skillSetId] + if not skillSet then return nil, 'active skill set not found' end + + local groupIndex = tonumber(params.groupIndex) + local socketGroup = skillSet.socketGroupList[groupIndex] + if not socketGroup then return nil, 'socket group not found' end + + -- Don't allow removing special groups with sources + if socketGroup.source then + return nil, 'cannot remove special socket groups (item/node granted skills)' + end + + table.remove(skillSet.socketGroupList, groupIndex) + + build.buildFlag = true + M.get_main_output() + + return true +end + +-- params: { groupIndex: number, gemIndex: number } +function M.remove_gem(params) + if not build or not build.skillsTab then return nil, 'skills not initialized' end + if type(params) ~= 'table' then return nil, 'invalid params' end + if not params.groupIndex or not params.gemIndex then + return nil, 'missing groupIndex or gemIndex' + end + + local skillSetId = build.skillsTab.activeSkillSetId or 1 + local skillSet = build.skillsTab.skillSets[skillSetId] + if not skillSet then return nil, 'active skill set not found' end + + local groupIndex = tonumber(params.groupIndex) + local gemIndex = tonumber(params.gemIndex) + + local socketGroup = skillSet.socketGroupList[groupIndex] + if not socketGroup then return nil, 'socket group not found' end + + local gemInstance = socketGroup.gemList[gemIndex] + if not gemInstance then return nil, 'gem not found' end + + table.remove(socketGroup.gemList, gemIndex) + + if build.skillsTab.ProcessSocketGroup then + build.skillsTab:ProcessSocketGroup(socketGroup) + end + + build.buildFlag = true + M.get_main_output() + + return true +end + + +-- params: { path: string } +function M.save_build(params) + if not build or not build.SaveDB then + return nil, 'build not initialized' + end + if type(params) ~= 'table' or type(params.path) ~= 'string' or params.path == '' then + return nil, 'missing or invalid path' + end + + -- Sync curAscendClassName from the current ascendClassId so the Build XML + -- element always reflects the live state (guards against stale names after + -- set_tree or new_build with a different ascendancy). + if build.spec and build.spec.curClass and build.spec.curClass.classes then + local ascendId = build.spec.curAscendClassId or 0 + local ascendClass = build.spec.curClass.classes[ascendId] or build.spec.curClass.classes[0] + if ascendClass and ascendClass.name then + build.spec.curAscendClassName = ascendClass.name + end + end + + -- Re-process all socket groups so that gem modifications made via add_gem / + -- set_gem_level / set_gem_quality are fully resolved before SaveDB serialises + -- the skillsTab. ProcessSocketGroup populates gemData / grantedEffect from + -- the current gemId/nameSpec, ensuring accurate gemId and nameSpec values in + -- the output XML. + if build.skillsTab and build.skillsTab.socketGroupList then + for _, socketGroup in ipairs(build.skillsTab.socketGroupList) do + if build.skillsTab.ProcessSocketGroup then + build.skillsTab:ProcessSocketGroup(socketGroup) + end + end + end + + local xml = build:SaveDB('api-export') + if not xml then return nil, 'failed to compose xml' end + local f, ferr = io.open(params.path, 'w') + if not f then return nil, 'failed to open file: ' .. tostring(ferr) end + f:write(xml) + f:close() + return { path = params.path, size = #xml } +end + +-- params: { keyword: string, nodeType?: string ('normal'|'notable'|'keystone'), maxResults?: number, includeAllocated?: boolean } +function M.search_nodes(params) + if not build or not build.spec then return nil, 'build/spec not initialized' end + if type(params) ~= 'table' or type(params.keyword) ~= 'string' then + return nil, 'missing or invalid keyword' + end + + local keyword = params.keyword:lower() + local nodeType = params.nodeType and params.nodeType:lower() or nil + local maxResults = tonumber(params.maxResults) or 50 + local includeAllocated = params.includeAllocated ~= false + + local results = {} + local count = 0 + + local allocatedSet = {} + if build.spec.allocNodes then + for id, _ in pairs(build.spec.allocNodes) do + allocatedSet[id] = true + end + end + + for id, node in pairs(build.spec.nodes) do + if count >= maxResults then break end + + if not includeAllocated and allocatedSet[id] then + goto continue + end + + if nodeType then + local nType = 'normal' + if node.isKeystone then nType = 'keystone' + elseif node.isNotable then nType = 'notable' + elseif node.isJewelSocket then nType = 'jewel' + elseif node.isMultipleChoiceOption then nType = 'mastery' + elseif node.ascendancyName then nType = 'ascendancy' + end + if nType ~= nodeType then goto continue end + end + + local matches = false + if node.name and node.name:lower():find(keyword, 1, true) then + matches = true + end + + if not matches and node.sd then + for _, stat in ipairs(node.sd) do + if type(stat) == 'string' and stat:lower():find(keyword, 1, true) then + matches = true + break + end + end + end + + if not matches and node.modList then + for _, mod in ipairs(node.modList) do + local modStr = tostring(mod) + if modStr:lower():find(keyword, 1, true) then + matches = true + break + end + end + end + + if matches then + local nodeType = 'normal' + if node.isKeystone then nodeType = 'keystone' + elseif node.isNotable then nodeType = 'notable' + elseif node.isJewelSocket then nodeType = 'jewel' + elseif node.isMultipleChoiceOption then nodeType = 'mastery' + elseif node.ascendancyName then nodeType = 'ascendancy' + end + + local stats = {} + if node.sd then + for _, stat in ipairs(node.sd) do + if type(stat) == 'string' then + table.insert(stats, stat) + end + end + end + + table.insert(results, { + id = id, + name = node.name or 'Unnamed', + type = nodeType, + stats = stats, + allocated = allocatedSet[id] == true, + x = node.x, + y = node.y, + orbit = node.orbit, + orbitIndex = node.orbitIndex, + ascendancyName = node.ascendancyName, + }) + count = count + 1 + end + + ::continue:: + end + + -- Sort results: keystones first, then notables, then normal + table.sort(results, function(a, b) + local typeOrder = { keystone = 1, notable = 2, jewel = 3, mastery = 4, ascendancy = 5, normal = 6 } + local aOrder = typeOrder[a.type] or 99 + local bOrder = typeOrder[b.type] or 99 + if aOrder ~= bOrder then + return aOrder < bOrder + end + return (a.name or '') < (b.name or '') + end) + + return { nodes = results, count = #results } +end + +return M diff --git a/src/API/Handlers.lua b/src/API/Handlers.lua new file mode 100644 index 0000000000..19866b861b --- /dev/null +++ b/src/API/Handlers.lua @@ -0,0 +1,280 @@ +-- Shared JSON-RPC handlers for PoB API (transport-agnostic) + +-- Debug logging control +local DEBUG = os.getenv('POB_API_DEBUG') == '1' +local function debug_log(msg) + if DEBUG then io.stderr:write('[Handlers] ' .. msg .. '\n') end +end + +-- Resolve BuildOps reliably regardless of CWD +local BuildOps +do + debug_log('Attempting to require API.BuildOps') + local ok_ops, mod = pcall(require, 'API.BuildOps') + debug_log('pcall require result: ok=' .. tostring(ok_ops) .. ', mod=' .. tostring(mod)) + if ok_ops and mod then + debug_log('Successfully loaded BuildOps via require') + BuildOps = mod + else + debug_log('require failed, trying dofile fallbacks') + -- Try path relative to this file's directory + local dir = '' + local info = debug and debug.getinfo and debug.getinfo(1, 'S') + local src = info and info.source or '' + if type(src) == 'string' and src:sub(1,1) == '@' then + local p = src:sub(2) + dir = (p:gsub('[^/\\]+$', '')) + end + local tried = {} + local function try(p) + if p then table.insert(tried, p) end + if not p then return false end + debug_log('Trying to load: ' .. tostring(p)) + local ok2, m = pcall(dofile, p) + if ok2 and m then + debug_log('Successfully loaded BuildOps from: ' .. tostring(p)) + BuildOps = m + return true + end + debug_log('Failed to load from: ' .. tostring(p) .. ' - error: ' .. tostring(m)) + return false + end + if not BuildOps then + local _ = try(dir .. 'BuildOps.lua') + or try((rawget(_G,'POB_SCRIPT_DIR') or '.') .. '/API/BuildOps.lua') + or try('API/BuildOps.lua') + or try('src/API/BuildOps.lua') + end + if not BuildOps then + io.stderr:write('[Handlers] BuildOps.lua not found. Tried paths: ' .. table.concat(tried, ', ') .. '\n') + error('API/BuildOps.lua not found. Tried: ' .. table.concat(tried, ', ')) + end + end +end + +local API_VERSION = "1.0.0" + +local function version_meta() + return { + number = _G.launch and launch.versionNumber or '?', + branch = _G.launch and launch.versionBranch or '?', + platform = _G.launch and launch.versionPlatform or '?', + apiVersion = API_VERSION, + } +end + +local handlers = {} + +handlers.ping = function(params) + return { ok = true, pong = true } +end + +handlers.version = function(params) + return { ok = true, version = version_meta() } +end + +-- Class name → classId mapping (PoE1) +local CLASS_IDS = { + Scion=0, Marauder=1, Ranger=2, Witch=3, Duelist=4, Templar=5, Shadow=6, + scion=0, marauder=1, ranger=2, witch=3, duelist=4, templar=5, shadow=6, +} +-- Ascendancy index matches the order in TreeData/3_27/tree.lua ["ascendancies"] array (1-based) +local ASCENDANCY_IDS = { + [0] = { Ascendant=1 }, -- Scion + [1] = { Juggernaut=1, Berserker=2, Chieftain=3 }, -- Marauder + [2] = { Raider=1, Deadeye=2, Pathfinder=3 }, -- Ranger + [3] = { Occultist=1, Elementalist=2, Necromancer=3 }, -- Witch + [4] = { Slayer=1, Gladiator=2, Champion=3 }, -- Duelist + [5] = { Inquisitor=1, Hierophant=2, Guardian=3 }, -- Templar + [6] = { Assassin=1, Trickster=2, Saboteur=3 }, -- Shadow +} + +handlers.new_build = function(params) + if not _G.newBuild then + return { ok = false, error = 'headless wrapper not initialized' } + end + _G.newBuild() + if params and (params.className or params.ascendancy) then + local classId = 0 + local ascendId = 0 + if params.className then + classId = CLASS_IDS[params.className] or CLASS_IDS[params.className:lower()] or 0 + end + if params.ascendancy and ASCENDANCY_IDS[classId] then + ascendId = ASCENDANCY_IDS[classId][params.ascendancy] or 0 + end + if build and build.spec then + build.spec:ImportFromNodeList(classId, ascendId, 0, {}, {}, {}) + end + end + return { ok = true } +end + +handlers.load_build_xml = function(params) + if not params or type(params.xml) ~= 'string' then + return { ok = false, error = 'missing xml' } + end + local name = (params.name and tostring(params.name)) or 'API Build' + if not _G.loadBuildFromXML then + return { ok = false, error = 'headless wrapper not initialized' } + end + _G.loadBuildFromXML(params.xml, name) + return { ok = true, build_id = 1 } +end + +handlers.get_stats = function(params) + local fields = params and params.fields or nil + local stats, err = BuildOps.export_stats(fields) + if not stats then + return { ok = false, error = err } + end + return { ok = true, stats = stats } +end + +handlers.get_items = function(params) + local list, err = BuildOps.get_items() + if not list then return { ok = false, error = err } end + return { ok = true, items = list } +end + +handlers.get_skills = function(params) + local info, err = BuildOps.get_skills() + if not info then return { ok = false, error = err } end + return { ok = true, skills = info } +end + +handlers.get_tree = function(params) + local tree, err = BuildOps.get_tree() + if not tree then + return { ok = false, error = err } + end + return { ok = true, tree = tree } +end + +handlers.set_main_selection = function(params) + local ok2, err = BuildOps.set_main_selection(params or {}) + if not ok2 then return { ok = false, error = err } end + local skills = BuildOps.get_skills() + return { ok = true, skills = skills } +end + +handlers.set_tree = function(params) + local ok2, err = BuildOps.set_tree(params or {}) + if not ok2 then + return { ok = false, error = err } + end + local tree = BuildOps.get_tree() + return { ok = true, tree = tree } +end + +handlers.add_item_text = function(params) + local res, err = BuildOps.add_item_text(params or {}) + if not res then return { ok = false, error = err } end + return { ok = true, item = res } +end + +handlers.export_build_xml = function(params) + local xml, err = BuildOps.export_build_xml() + if not xml then return { ok = false, error = err } end + return { ok = true, xml = xml } +end + +handlers.set_level = function(params) + if not params or params.level == nil then + return { ok = false, error = 'missing level' } + end + local ok2, err = BuildOps.set_level(params.level) + if not ok2 then return { ok = false, error = err } end + return { ok = true } +end + +handlers.set_flask_active = function(params) + local ok2, err = BuildOps.set_flask_active(params or {}) + if not ok2 then return { ok = false, error = err } end + return { ok = true } +end + +handlers.get_build_info = function(params) + local info, err = BuildOps.get_build_info() + if not info then return { ok = false, error = err } end + return { ok = true, info = info } +end + +handlers.update_tree_delta = function(params) + local ok2, err = BuildOps.update_tree_delta(params or {}) + if not ok2 then return { ok = false, error = err } end + local tree = BuildOps.get_tree() + return { ok = true, tree = tree } +end + +handlers.calc_with = function(params) + local out, base = BuildOps.calc_with(params or {}) + if not out then return { ok = false, error = base } end + return { ok = true, output = out } +end + +handlers.get_config = function(params) + local cfg, err = BuildOps.get_config() + if not cfg then return { ok = false, error = err } end + return { ok = true, config = cfg } +end + +handlers.set_config = function(params) + local ok2, err = BuildOps.set_config(params or {}) + if not ok2 then return { ok = false, error = err } end + local cfg = BuildOps.get_config() + return { ok = true, config = cfg } +end + +handlers.create_socket_group = function(params) + local res, err = BuildOps.create_socket_group(params or {}) + if not res then return { ok = false, error = err or 'failed to create socket group' } end + return { ok = true, socketGroup = res } +end + +handlers.add_gem = function(params) + local res, err = BuildOps.add_gem(params or {}) + if not res then return { ok = false, error = err or 'failed to add gem' } end + return { ok = true, gem = res } +end + +handlers.set_gem_level = function(params) + local ok2, err = BuildOps.set_gem_level(params or {}) + if not ok2 then return { ok = false, error = err or 'failed to set gem level' } end + return { ok = true } +end + +handlers.set_gem_quality = function(params) + local ok2, err = BuildOps.set_gem_quality(params or {}) + if not ok2 then return { ok = false, error = err or 'failed to set gem quality' } end + return { ok = true } +end + +handlers.remove_skill = function(params) + local ok2, err = BuildOps.remove_skill(params or {}) + if not ok2 then return { ok = false, error = err or 'failed to remove skill' } end + return { ok = true } +end + +handlers.remove_gem = function(params) + local ok2, err = BuildOps.remove_gem(params or {}) + if not ok2 then return { ok = false, error = err or 'failed to remove gem' } end + return { ok = true } +end + +handlers.search_nodes = function(params) + local res, err = BuildOps.search_nodes(params or {}) + if not res then return { ok = false, error = err or 'failed to search nodes' } end + return { ok = true, results = res } +end + +handlers.save_build = function(params) + local res, err = BuildOps.save_build(params or {}) + if not res then return { ok = false, error = err or 'failed to save build' } end + return { ok = true, result = res } +end + +return { + handlers = handlers, + version_meta = version_meta, +} diff --git a/src/API/Server.lua b/src/API/Server.lua new file mode 100644 index 0000000000..0860ed058e --- /dev/null +++ b/src/API/Server.lua @@ -0,0 +1,82 @@ +-- Simple stdio JSON-RPC loop exposing a tiny PoB API + +-- Debug logging control +local DEBUG = os.getenv('POB_API_DEBUG') == '1' +local function debug_log(msg) + if DEBUG then io.stderr:write('[Server] ' .. msg .. '\n') end +end + +local ok, json = pcall(require, 'dkjson') +if not ok then + local base = rawget(_G, 'POB_SCRIPT_DIR') or '.' + local candidates = { + base .. '/runtime/lua/dkjson.lua', + base .. '/../runtime/lua/dkjson.lua', + 'runtime/lua/dkjson.lua', + '../runtime/lua/dkjson.lua', + } + for _, p in ipairs(candidates) do + local ok2, mod = pcall(dofile, p) + if ok2 and type(mod) == 'table' then json = mod; ok = true; break end + end + if not ok then error('dkjson not found; ensure PoB runtime or dkjson is available') end +end + +local function j_encode(tbl) + return json.encode(tbl, { indent = false }) +end +local function j_decode(txt) + return json.decode(txt) +end + +local function write_line(tbl) + io.write(j_encode(tbl), "\n") + io.flush() +end + +local function read_line() + return io.read("*l") +end + +debug_log('About to require API.Handlers') +local ok, API = pcall(require, 'API.Handlers') +if not ok then + debug_log('Failed to require API.Handlers: ' .. tostring(API)) + error('Failed to load API.Handlers: ' .. tostring(API)) +end +debug_log('Successfully loaded API.Handlers') +local handlers = API.handlers +local function get_version_meta() + return API.version_meta() +end + +handlers.quit = function(params) + return { ok = true, quit = true } +end + +write_line({ ok = true, ready = true, version = get_version_meta() }) +while true do + local line = read_line() + if not line then break end + if #line == 0 then goto continue end + local msg = j_decode(line) + if not msg or type(msg) ~= 'table' then + write_line({ ok = false, error = 'invalid json' }) + goto continue + end + local action = msg.action + local params = msg.params or {} + local handler = handlers[action] + if not handler then + write_line({ ok = false, error = 'unknown action: '..tostring(action) }) + goto continue + end + local ok2, res = pcall(handler, params) + if not ok2 then + write_line({ ok = false, error = 'exception: '..tostring(res) }) + else + write_line(res) + if action == 'quit' then break end + end + ::continue:: +end diff --git a/src/HeadlessWrapper.lua b/src/HeadlessWrapper.lua index 774f3ae214..5c5658ca83 100644 --- a/src/HeadlessWrapper.lua +++ b/src/HeadlessWrapper.lua @@ -169,6 +169,10 @@ function GetCloudProvider(fullPath) return nil, nil, nil end +function GetVirtualScreenSize() + return 1920, 1080 +end + local l_require = require function require(name) @@ -179,6 +183,121 @@ function require(name) return l_require(name) end +local function get_script_dir() + local info = debug and debug.getinfo and debug.getinfo(1, 'S') + local src = info and info.source or '' + if type(src) == 'string' and src:sub(1,1) == '@' then + local path = src:sub(2) + return (path:gsub('[^/\\]+$', '')):gsub('[ /\\]$', '') + end + return '' +end +local POB_SCRIPT_DIR = get_script_dir() +-- If script dir unknown (e.g., launched as 'luajit HeadlessWrapper.lua'), fall back: +if POB_SCRIPT_DIR == '' then + -- Case 1: running inside src + local f1 = io.open('HeadlessWrapper.lua', 'r') + if f1 then f1:close(); POB_SCRIPT_DIR = '.' end +end +if POB_SCRIPT_DIR == '' then + -- Case 2: running from repo root + local f2 = io.open('src/HeadlessWrapper.lua', 'r') + if f2 then f2:close(); POB_SCRIPT_DIR = 'src' end +end +if POB_SCRIPT_DIR ~= '' then + _G.POB_SCRIPT_DIR = POB_SCRIPT_DIR + local pathSegs = {} + table.insert(pathSegs, POB_SCRIPT_DIR .. '/?.lua') + table.insert(pathSegs, POB_SCRIPT_DIR .. '/?/init.lua') + table.insert(pathSegs, package.path) + package.path = table.concat(pathSegs, ';') + -- Add runtime lua path so modules like 'xml' resolve without external LUA_PATH + local runtimeCandidates = { + POB_SCRIPT_DIR .. '/runtime/lua', + POB_SCRIPT_DIR .. '/../runtime/lua', + 'runtime/lua', + '../runtime/lua', + } + for _, rp in ipairs(runtimeCandidates) do + local test = io.open(rp .. '/xml.lua', 'r') + if test then + test:close() + if not string.find(package.path, rp .. '/?.lua', 1, true) then + package.path = rp .. '/?.lua;' .. rp .. '/?/init.lua;' .. package.path + end + break + end + end +end + +local function has_flag(flag) + if type(arg) ~= 'table' then return false end + for i = 1, #arg do if arg[i] == flag then return true end end + return false +end + +if os.getenv('POB_API_STDIO') == '1' or has_flag('--stdio') then + -- Provide utf8 fallback if not present to avoid requiring external luautf8 + if type(_G.utf8) ~= 'table' then + local ok_u, mod = pcall(require, 'utf8') + if not ok_u or type(mod) ~= 'table' then + local stubCandidates = { + (POB_SCRIPT_DIR ~= '' and (POB_SCRIPT_DIR .. '/utf8.lua')) or nil, + (POB_SCRIPT_DIR ~= '' and (POB_SCRIPT_DIR .. '/lua-utf8.lua')) or nil, + 'src/utf8.lua', 'src/lua-utf8.lua' + } + for _, sp in ipairs(stubCandidates) do + if sp then + local ok2, stub = pcall(dofile, sp) + if ok2 and type(stub) == 'table' then _G.utf8 = stub; break end + end + end + else + _G.utf8 = mod + end + end + -- Load Launch.lua first to initialize mainObject and build system + dofile("Launch.lua") + + -- Initialize the build system (same as non-STDIO mode) + mainObject.continuousIntegrationMode = os.getenv("CI") + runCallback("OnInit") + runCallback("OnFrame") -- Need at least one frame for everything to initialise + + if mainObject.promptMsg then + -- Something went wrong during startup + io.stderr:write('[HeadlessWrapper] Error during init: ' .. tostring(mainObject.promptMsg) .. '\n') + error(mainObject.promptMsg) + end + + function newBuild() + mainObject.main:SetMode("BUILD", false, "Help, I'm stuck in Path of Building!") + runCallback("OnFrame") + end + function loadBuildFromXML(xmlText, name) + mainObject.main:SetMode("BUILD", false, name or "", xmlText) + runCallback("OnFrame") + end + _G.newBuild = newBuild + _G.loadBuildFromXML = loadBuildFromXML + _G.build = mainObject.main.modes["BUILD"] + + local srvPath = (POB_SCRIPT_DIR ~= '' and (POB_SCRIPT_DIR .. '/API/Server.lua')) or 'API/Server.lua' + dofile(srvPath) + return +end + +do + local candidates = { "../runtime/lua", "runtime/lua", "../../runtime/lua" } + for _, p in ipairs(candidates) do + local f = io.open(p .. "/xml.lua", "r") + if f then f:close() + if not string.find(package.path, p .. "/?.lua", 1, true) then + package.path = package.path .. ";" .. p .. "/?.lua" + end + end + end +end dofile("Launch.lua") diff --git a/src/lua-utf8.lua b/src/lua-utf8.lua new file mode 100644 index 0000000000..d4fe5cbfad --- /dev/null +++ b/src/lua-utf8.lua @@ -0,0 +1,27 @@ +-- Minimal utf8 stub for headless usage; not full Unicode support. +-- Provides functions used by PoB in non-UI paths. +local M = {} + +M.gsub = string.gsub +M.find = string.find +M.sub = string.sub +M.reverse = string.reverse +M.match = string.match + +-- Very naive next-codepoint boundary: moves one byte forward/backward. +function M.next(s, i, dir) + if type(s) ~= 'string' then return nil end + i = tonumber(i) or 1 + dir = tonumber(dir) or 1 + if dir >= 0 then + local j = i + 1 + if j > #s + 1 then return #s + 1 end + return j + else + local j = i - 1 + if j < 0 then return 0 end + return j + end +end + +return M diff --git a/src/utf8.lua b/src/utf8.lua new file mode 100644 index 0000000000..98965980fa --- /dev/null +++ b/src/utf8.lua @@ -0,0 +1,28 @@ +-- Minimal utf8 stub for headless usage; not full Unicode support. +-- Provides only functions used by non-UI PoB paths. Do NOT use for UI text. +local M = {} + +M.gsub = string.gsub +M.find = string.find +M.sub = string.sub +M.reverse = string.reverse +M.match = string.match + +-- Very naive next-codepoint boundary: moves one byte forward/backward. +function M.next(s, i, dir) + if type(s) ~= 'string' then return nil end + i = tonumber(i) or 1 + dir = tonumber(dir) or 1 + if dir >= 0 then + local j = i + 1 + if j > #s + 1 then return #s + 1 end + return j + else + local j = i - 1 + if j < 0 then return 0 end + return j + end +end + +return M +