use mlua::{Lua, Result as LuaResult};
use std::collections::{HashMap, HashSet, VecDeque};
use std::path::Path;
use thiserror::Error;
#[derive(Error, Debug)]
pub enum PobError {
#[error("Lua error: {0}")]
Lua(#[from] mlua::Error),
#[error("PoB not initialized")]
NotInitialized,
#[error("Invalid build code: {0}")]
InvalidBuildCode(String),
#[error("Calculation failed: {0}")]
CalculationFailed(String),
#[error("IO error: {0}")]
Io(#[from] std::io::Error),
}
pub struct PobHeadless {
lua: Lua,
initialized: bool,
pob_src_path: Option<std::path::PathBuf>,
}
#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
pub struct BuildStats {
#[serde(rename = "dps")]
pub total_dps: f64,
pub effective_hp: f64,
pub life: f64,
pub energy_shield: f64,
pub armour: f64,
pub evasion: f64,
pub fire_res: i32,
pub cold_res: i32,
pub lightning_res: i32,
pub chaos_res: i32,
}
impl PobHeadless {
pub fn new() -> LuaResult<Self> {
let lua = Lua::new();
Ok(Self {
lua,
initialized: false,
pob_src_path: None,
})
}
pub fn init(&mut self, pob_path: &str) -> Result<(), PobError> {
let pob_path = Path::new(pob_path);
let pob_src_path = pob_path.join("src");
let pob_runtime_lua = pob_path.join("runtime/lua");
let original_cwd = std::env::current_dir()?;
std::env::set_current_dir(&pob_src_path)?;
tracing::info!("Initializing PoB from {:?}", pob_src_path);
let runtime_lua_path = pob_runtime_lua
.to_str()
.ok_or_else(|| PobError::CalculationFailed("Invalid path".to_owned()))?;
self.lua
.load(format!(
r#"package.path = package.path .. ";{0}/?.lua;{0}/?/init.lua""#,
runtime_lua_path
))
.exec()?;
self.lua
.load(
r#"
package.preload['lua-utf8'] = function()
local utf8 = {}
utf8.reverse = string.reverse
utf8.gsub = string.gsub
utf8.find = string.find
utf8.sub = string.sub
utf8.match = string.match
utf8.len = string.len
function utf8.next(s, i, offset)
if offset == -1 then
return i > 1 and i - 1 or nil
else
return i < #s and i + 1 or nil
end
end
return utf8
end
"#,
)
.exec()?;
self.lua.load("arg = {}").exec()?;
let result = self.lua.load("dofile('HeadlessWrapper.lua')").exec();
std::env::set_current_dir(original_cwd)?;
result?;
self.initialized = true;
self.pob_src_path = Some(pob_src_path.to_owned());
tracing::info!("PoB headless initialized successfully");
Ok(())
}
pub fn load_build_xml(&self, xml: &str) -> Result<(), PobError> {
if !self.initialized {
return Err(PobError::NotInitialized);
}
let result = self.with_pob_cwd(|lua| {
let load_fn: mlua::Function = lua.globals().get("loadBuildFromXML")?;
load_fn.call::<()>((xml, "imported_build"))
})?;
tracing::debug!("Build loaded from XML");
Ok(result)
}
pub fn import_build(&self, _code: &str) -> Result<(), PobError> {
if !self.initialized {
return Err(PobError::NotInitialized);
}
Err(PobError::InvalidBuildCode(
"Build code import not yet implemented (requires Inflate)".to_owned(),
))
}
pub fn calculate(&self) -> Result<BuildStats, PobError> {
if !self.initialized {
return Err(PobError::NotInitialized);
}
let build: mlua::Table = self.lua.globals().get("build")?;
let calcs_tab: mlua::Table = build.get("calcsTab")?;
let main_output: mlua::Table = calcs_tab.get("mainOutput")?;
let calcs_output: mlua::Table = calcs_tab.get("calcsOutput")?;
let total_dps = main_output
.get::<f64>("TotalDPS")
.or_else(|_| main_output.get::<f64>("CombinedDPS"))
.unwrap_or(0.0);
let life = main_output.get::<f64>("Life").unwrap_or(0.0);
let energy_shield = main_output.get::<f64>("EnergyShield").unwrap_or(0.0);
let armour = main_output.get::<f64>("Armour").unwrap_or(0.0);
let evasion = main_output.get::<f64>("Evasion").unwrap_or(0.0);
let fire_res = main_output.get::<i32>("FireResist").unwrap_or(0);
let cold_res = main_output.get::<i32>("ColdResist").unwrap_or(0);
let lightning_res = main_output.get::<i32>("LightningResist").unwrap_or(0);
let chaos_res = main_output.get::<i32>("ChaosResist").unwrap_or(0);
let effective_hp = calcs_output
.get::<f64>("PhysicalMaximumHitTaken")
.unwrap_or(0.0);
Ok(BuildStats {
total_dps,
effective_hp,
life,
energy_shield,
armour,
evasion,
fire_res,
cold_res,
lightning_res,
chaos_res,
})
}
pub fn query_build_stats(&self) -> Result<serde_json::Value, PobError> {
if !self.initialized {
return Err(PobError::NotInitialized);
}
let build: mlua::Table = self.lua.globals().get("build")?;
let calcs_tab: mlua::Table = build.get("calcsTab")?;
let main_output: mlua::Table = calcs_tab.get("mainOutput")?;
let calcs_output: mlua::Table = calcs_tab.get("calcsOutput")?;
let offense_fields = &[
"TotalDPS",
"CombinedDPS",
"AverageHit",
"Speed",
"CritChance",
"CritMultiplier",
"HitChance",
"TotalDot",
"BleedDPS",
"IgniteDPS",
"PoisonDPS",
"FullDPS",
"WithPoisonDPS",
"WithIgniteDPS",
"WithBleedDPS",
"TotalDotDPS",
"Damage",
"PhysicalDamage",
"ElementalDamage",
"FireDamage",
"ColdDamage",
"LightningDamage",
"ChaosDamage",
];
let defense_fields = &[
"TotalEHP",
"PhysicalMaximumHitTaken",
"FireMaximumHitTaken",
"ColdMaximumHitTaken",
"LightningMaximumHitTaken",
"ChaosMaximumHitTaken",
"Armour",
"PhysicalDamageReduction",
"Evasion",
"EvadeChance",
"BlockChance",
"SpellBlockChance",
"SpellSuppressionChance",
"FireResist",
"ColdResist",
"LightningResist",
"ChaosResist",
"FireResistOverCap",
"ColdResistOverCap",
"LightningResistOverCap",
"ChaosResistOverCap",
];
let resource_fields = &[
"Life",
"LifeUnreserved",
"LifeRegenRecovery",
"Mana",
"ManaUnreserved",
"ManaRegenRecovery",
"EnergyShield",
"EnergyShieldRegenRecovery",
"Spirit",
];
let speed_fields = &[
"EffectiveMovementSpeedMod",
"AreaOfEffectRadiusMetres",
"Duration",
"ManaCost",
];
let charge_fields = &[
"PowerChargesMax",
"FrenzyChargesMax",
"EnduranceChargesMax",
];
let offense = read_fields(&main_output, offense_fields);
let mut defense = read_fields(&main_output, defense_fields);
merge_fields(&mut defense, &read_fields(&calcs_output, defense_fields));
let resources = read_fields(&main_output, resource_fields);
let speed = read_fields(&main_output, speed_fields);
let charges = read_fields(&main_output, charge_fields);
Ok(serde_json::json!({
"offense": offense,
"defense": defense,
"resources": resources,
"speed": speed,
"charges": charges,
}))
}
pub fn query_skill_list(&self) -> Result<serde_json::Value, PobError> {
if !self.initialized {
return Err(PobError::NotInitialized);
}
let build: mlua::Table = self.lua.globals().get("build")?;
let calcs_tab: mlua::Table = build.get("calcsTab")?;
let main_output: mlua::Table = calcs_tab.get("mainOutput")?;
let mut skill_dps_list = Vec::new();
if let Ok(skill_dps_table) = main_output.get::<mlua::Table>("SkillDPS") {
let len = skill_dps_table.raw_len();
for i in 1..=len {
if let Ok(entry) = skill_dps_table.get::<mlua::Table>(i) {
let mut skill = serde_json::Map::new();
if let Ok(name) = entry.get::<String>("name") {
skill.insert("name".to_owned(), serde_json::Value::String(name));
}
if let Ok(dps) = entry.get::<f64>("dps") {
skill.insert(
"dps".to_owned(),
serde_json::Value::Number(
serde_json::Number::from_f64(dps)
.unwrap_or_else(|| serde_json::Number::from(0)),
),
);
}
if let Ok(count) = entry.get::<i64>("count") {
skill.insert(
"count".to_owned(),
serde_json::Value::Number(count.into()),
);
}
if let Ok(trigger) = entry.get::<String>("trigger") {
skill.insert("trigger".to_owned(), serde_json::Value::String(trigger));
}
if let Ok(skill_part) = entry.get::<String>("skillPart") {
skill.insert(
"skillPart".to_owned(),
serde_json::Value::String(skill_part),
);
}
if !skill.is_empty() {
skill_dps_list.push(serde_json::Value::Object(skill));
}
}
}
}
let mut socket_groups = Vec::new();
if let Ok(skills_tab) = build.get::<mlua::Table>("skillsTab") {
if let Ok(group_list) = skills_tab.get::<mlua::Table>("socketGroupList") {
let len = group_list.raw_len();
for i in 1..=len {
if let Ok(group) = group_list.get::<mlua::Table>(i) {
let mut group_obj = serde_json::Map::new();
if let Ok(label) = group.get::<String>("displayLabel") {
group_obj.insert(
"label".to_owned(),
serde_json::Value::String(label),
);
}
if let Ok(enabled) = group.get::<bool>("enabled") {
group_obj
.insert("enabled".to_owned(), serde_json::Value::Bool(enabled));
}
if let Ok(slot) = group.get::<String>("slot") {
group_obj.insert("slot".to_owned(), serde_json::Value::String(slot));
}
let mut gems = Vec::new();
if let Ok(gem_list) = group.get::<mlua::Table>("gemList") {
let gem_len = gem_list.raw_len();
for j in 1..=gem_len {
if let Ok(gem) = gem_list.get::<mlua::Table>(j) {
let mut gem_obj = serde_json::Map::new();
if let Ok(name) = gem.get::<String>("nameSpec") {
gem_obj.insert(
"name".to_owned(),
serde_json::Value::String(name),
);
}
if let Ok(level) = gem.get::<i64>("level") {
gem_obj.insert(
"level".to_owned(),
serde_json::Value::Number(level.into()),
);
}
if let Ok(quality) = gem.get::<i64>("quality") {
gem_obj.insert(
"quality".to_owned(),
serde_json::Value::Number(quality.into()),
);
}
if let Ok(enabled) = gem.get::<bool>("enabled") {
gem_obj.insert(
"enabled".to_owned(),
serde_json::Value::Bool(enabled),
);
}
if !gem_obj.is_empty() {
gems.push(serde_json::Value::Object(gem_obj));
}
}
}
}
if !gems.is_empty() {
group_obj
.insert("gems".to_owned(), serde_json::Value::Array(gems));
}
if !group_obj.is_empty() {
socket_groups.push(serde_json::Value::Object(group_obj));
}
}
}
}
}
Ok(serde_json::json!({
"skill_dps": skill_dps_list,
"socket_groups": socket_groups,
}))
}
pub fn query_config(&self) -> Result<serde_json::Value, PobError> {
if !self.initialized {
return Err(PobError::NotInitialized);
}
let build: mlua::Table = self.lua.globals().get("build")?;
let config_tab: mlua::Table = build.get("configTab")?;
let input: mlua::Table = config_tab.get("input")?;
let mut config = serde_json::Map::new();
for pair in input.pairs::<String, mlua::Value>() {
let (key, value) = pair?;
let json_val = match value {
mlua::Value::Boolean(b) => serde_json::Value::Bool(b),
mlua::Value::Integer(n) => serde_json::Value::Number(n.into()),
mlua::Value::Number(n) => {
if let Some(num) = serde_json::Number::from_f64(n) {
serde_json::Value::Number(num)
} else {
continue;
}
}
mlua::Value::String(s) => {
serde_json::Value::String(
s.to_str().map(|s| s.to_owned()).unwrap_or_default(),
)
}
_ => continue,
};
config.insert(key, json_val);
}
Ok(serde_json::Value::Object(config))
}
pub fn query_item(&self, slot: &str) -> Result<serde_json::Value, PobError> {
if !self.initialized {
return Err(PobError::NotInitialized);
}
let build: mlua::Table = self.lua.globals().get("build")?;
let items_tab: mlua::Table = build.get("itemsTab")?;
let active_item_set: mlua::Table = items_tab.get("activeItemSet")?;
let slot_entry: mlua::Table = match active_item_set.get::<mlua::Table>(slot) {
Ok(t) => t,
Err(_) => return Ok(serde_json::json!({ "slot": slot, "empty": true })),
};
let sel_item_id: i64 = slot_entry.get::<i64>("selItemId").unwrap_or(0);
if sel_item_id == 0 {
return Ok(serde_json::json!({ "slot": slot, "empty": true }));
}
let items: mlua::Table = items_tab.get("items")?;
let item: mlua::Table = match items.get::<mlua::Table>(sel_item_id) {
Ok(t) => t,
Err(_) => return Ok(serde_json::json!({ "slot": slot, "empty": true })),
};
let name = item.get::<String>("name").unwrap_or_default();
let base_name = item
.get::<mlua::Table>("base")
.and_then(|b| b.get::<String>("name"))
.unwrap_or_default();
let rarity = item.get::<String>("rarity").unwrap_or_default();
let quality = item.get::<i64>("quality").unwrap_or(0);
let spirit = item.get::<i64>("spiritValue").unwrap_or(0);
let sockets = item.get::<i64>("itemSocketCount").unwrap_or(0);
let implicits = read_mod_lines(&item, "implicitModLines");
let explicits = read_mod_lines(&item, "explicitModLines");
let enchants = read_mod_lines(&item, "enchantModLines");
let runes = read_mod_lines(&item, "runeModLines");
Ok(serde_json::json!({
"slot": slot,
"name": name,
"base": base_name,
"rarity": rarity,
"quality": quality,
"spirit": spirit,
"sockets": sockets,
"implicits": implicits,
"explicits": explicits,
"enchants": enchants,
"runes": runes,
}))
}
pub fn query_empty_slots(&self) -> Result<serde_json::Value, PobError> {
if !self.initialized {
return Err(PobError::NotInitialized);
}
const ALL_SLOTS: &[&str] = &[
"Weapon 1",
"Weapon 2",
"Helmet",
"Body Armour",
"Gloves",
"Boots",
"Amulet",
"Ring 1",
"Ring 2",
"Ring 3",
"Belt",
"Charm 1",
"Charm 2",
"Charm 3",
"Flask 1",
"Flask 2",
];
let build: mlua::Table = self.lua.globals().get("build")?;
let items_tab: mlua::Table = build.get("itemsTab")?;
let active_item_set: mlua::Table = items_tab.get("activeItemSet")?;
let items: mlua::Table = items_tab.get("items")?;
let mut empty_slots = Vec::new();
let mut filled_slots = Vec::new();
for &slot in ALL_SLOTS {
let sel_item_id: i64 = active_item_set
.get::<mlua::Table>(slot)
.and_then(|entry| entry.get::<i64>("selItemId"))
.unwrap_or(0);
if sel_item_id == 0 {
empty_slots.push(slot);
continue;
}
let (name, rarity) = match items.get::<mlua::Table>(sel_item_id) {
Ok(item) => (
item.get::<String>("name").unwrap_or_default(),
item.get::<String>("rarity").unwrap_or_default(),
),
Err(_) => {
empty_slots.push(slot);
continue;
}
};
filled_slots.push(serde_json::json!({
"slot": slot,
"item": name,
"rarity": rarity,
}));
}
Ok(serde_json::json!({
"empty_slots": empty_slots,
"filled_slots": filled_slots,
"total_empty": empty_slots.len(),
"total_filled": filled_slots.len(),
}))
}
pub fn query_jewel(&self, socket_id: i64) -> Result<serde_json::Value, PobError> {
if !self.initialized {
return Err(PobError::NotInitialized);
}
let build: mlua::Table = self.lua.globals().get("build")?;
let spec: mlua::Table = build.get("spec")?;
let jewels: mlua::Table = match spec.get::<mlua::Table>("jewels") {
Ok(t) => t,
Err(_) => return Ok(serde_json::json!({ "socket_id": socket_id, "empty": true })),
};
let item_id: i64 = match jewels.get::<i64>(socket_id) {
Ok(id) if id != 0 => id,
_ => return Ok(serde_json::json!({ "socket_id": socket_id, "empty": true })),
};
let items_tab: mlua::Table = build.get("itemsTab")?;
let items: mlua::Table = items_tab.get("items")?;
let item: mlua::Table = match items.get::<mlua::Table>(item_id) {
Ok(t) => t,
Err(_) => return Ok(serde_json::json!({ "socket_id": socket_id, "empty": true })),
};
let name = item.get::<String>("name").unwrap_or_default();
let base_name = item
.get::<mlua::Table>("base")
.and_then(|b| b.get::<String>("name"))
.unwrap_or_default();
let rarity = item.get::<String>("rarity").unwrap_or_default();
let quality = item.get::<i64>("quality").unwrap_or(0);
let implicits = read_mod_lines(&item, "implicitModLines");
let explicits = read_mod_lines(&item, "explicitModLines");
let enchants = read_mod_lines(&item, "enchantModLines");
let runes = read_mod_lines(&item, "runeModLines");
Ok(serde_json::json!({
"socket_id": socket_id,
"name": name,
"base": base_name,
"rarity": rarity,
"quality": quality,
"implicits": implicits,
"explicits": explicits,
"enchants": enchants,
"runes": runes,
}))
}
pub fn query_passive_tree(&self) -> Result<serde_json::Value, PobError> {
if !self.initialized {
return Err(PobError::NotInitialized);
}
let build: mlua::Table = self.lua.globals().get("build")?;
let spec: mlua::Table = build.get("spec")?;
let class_name = spec.get::<String>("curClassName").unwrap_or_default();
let ascendancy_name = spec.get::<String>("curAscendClassName").unwrap_or_default();
let alloc_nodes: mlua::Table = spec.get("allocNodes")?;
let mut keystones = Vec::new();
let mut notables = Vec::new();
let mut ascendancy_nodes = Vec::new();
let mut masteries = Vec::new();
let mut jewel_sockets = Vec::new();
let mut total_allocated: u32 = 0;
for pair in alloc_nodes.pairs::<mlua::Value, mlua::Table>() {
let (key, node) = pair?;
total_allocated += 1;
let node_type = node.get::<String>("type").unwrap_or_default();
let name = node.get::<String>("dn").unwrap_or_default();
let asc_name: Option<String> = node.get::<String>("ascendancyName").ok();
if asc_name.is_some() {
let stats = read_node_stats(&node);
let mut entry = serde_json::json!({
"name": name,
"type": node_type,
});
if !stats.is_empty() {
entry["stats"] = serde_json::Value::Array(
stats.into_iter().map(serde_json::Value::String).collect(),
);
}
ascendancy_nodes.push(entry);
continue;
}
match node_type.as_str() {
"Keystone" => {
let stats = read_node_stats(&node);
let mut entry = serde_json::json!({ "name": name });
if !stats.is_empty() {
entry["stats"] = serde_json::Value::Array(
stats.into_iter().map(serde_json::Value::String).collect(),
);
}
keystones.push(entry);
}
"Notable" => {
let stats = read_node_stats(&node);
let mut entry = serde_json::json!({ "name": name });
if !stats.is_empty() {
entry["stats"] = serde_json::Value::Array(
stats.into_iter().map(serde_json::Value::String).collect(),
);
}
notables.push(entry);
}
"Mastery" => {
let stats = read_node_stats(&node);
let mut entry = serde_json::json!({ "name": name });
if !stats.is_empty() {
entry["stats"] = serde_json::Value::Array(
stats.into_iter().map(serde_json::Value::String).collect(),
);
}
masteries.push(entry);
}
"Socket" => {
let node_id = lua_value_to_i64(&key).unwrap_or(0);
jewel_sockets.push(serde_json::json!({
"node_id": node_id,
"name": name,
}));
}
_ => {}
}
}
Ok(serde_json::json!({
"class": class_name,
"ascendancy": ascendancy_name,
"total_allocated": total_allocated,
"keystones": keystones,
"notables": notables,
"ascendancy_nodes": ascendancy_nodes,
"masteries": masteries,
"jewel_sockets": jewel_sockets,
}))
}
pub fn query_passive_stats(
&self,
stat: &str,
radius: u32,
) -> Result<serde_json::Value, PobError> {
if !self.initialized {
return Err(PobError::NotInitialized);
}
let pattern = stat.to_lowercase();
let build: mlua::Table = self.lua.globals().get("build")?;
let spec: mlua::Table = build.get("spec")?;
let alloc_nodes: mlua::Table = spec.get("allocNodes")?;
let all_nodes: mlua::Table = spec.get("nodes")?;
let mut allocated_ids = HashSet::new();
let mut allocated_nodes = Vec::new();
let mut allocated_total: f64 = 0.0;
for pair in alloc_nodes.pairs::<mlua::Value, mlua::Table>() {
let (key, node) = pair?;
let node_id = lua_value_to_i64(&key).unwrap_or(0);
allocated_ids.insert(node_id);
let (matching_stats, value) = match_node_stats(&node, &pattern);
if !matching_stats.is_empty() {
let name = node.get::<String>("dn").unwrap_or_default();
allocated_total += value;
allocated_nodes.push(serde_json::json!({
"name": name,
"value": value,
"matching_stats": matching_stats,
}));
}
}
let mut adjacency: HashMap<i64, Vec<i64>> = HashMap::new();
for pair in all_nodes.pairs::<mlua::Value, mlua::Table>() {
let (key, node) = pair?;
let node_id = lua_value_to_i64(&key).unwrap_or(0);
let mut neighbors = Vec::new();
if let Ok(linked_ids) = node.get::<mlua::Table>("linkedId") {
for i in 1..=linked_ids.raw_len() {
if let Ok(linked_id) = linked_ids.get::<i64>(i) {
neighbors.push(linked_id);
}
}
}
adjacency.insert(node_id, neighbors);
}
let mut visited = allocated_ids.clone();
let mut queue = VecDeque::new();
for &id in &allocated_ids {
queue.push_back((id, 0u32));
}
let mut nearby_nodes = Vec::new();
let mut nearby_total: f64 = 0.0;
while let Some((current_id, dist)) = queue.pop_front() {
if let Some(neighbors) = adjacency.get(¤t_id) {
for &neighbor_id in neighbors {
if visited.contains(&neighbor_id) {
continue;
}
visited.insert(neighbor_id);
let next_dist = dist + 1;
if let Ok(node) = all_nodes.get::<mlua::Table>(neighbor_id) {
let (matching_stats, value) = match_node_stats(&node, &pattern);
if !matching_stats.is_empty() {
let name = node.get::<String>("dn").unwrap_or_default();
nearby_total += value;
nearby_nodes.push(serde_json::json!({
"name": name,
"value": value,
"distance": next_dist,
"matching_stats": matching_stats,
}));
}
}
if next_dist < radius {
queue.push_back((neighbor_id, next_dist));
}
}
}
}
nearby_nodes.sort_by(|a, b| {
let da = a["distance"].as_u64().unwrap_or(0);
let db = b["distance"].as_u64().unwrap_or(0);
da.cmp(&db).then_with(|| {
let va = b["value"].as_f64().unwrap_or(0.0);
let vb = a["value"].as_f64().unwrap_or(0.0);
va.partial_cmp(&vb).unwrap_or(std::cmp::Ordering::Equal)
})
});
Ok(serde_json::json!({
"stat": stat,
"allocated": {
"total_value": allocated_total,
"nodes": allocated_nodes,
},
"nearby_available": {
"total_value": nearby_total,
"nodes": nearby_nodes,
},
}))
}
pub fn query_unallocated_ascendancy(&self) -> Result<serde_json::Value, PobError> {
if !self.initialized {
return Err(PobError::NotInitialized);
}
let build: mlua::Table = self.lua.globals().get("build")?;
let spec: mlua::Table = build.get("spec")?;
let tree: mlua::Table = spec.get("tree")?;
let primary_name = spec
.get::<String>("curAscendClassName")
.unwrap_or_else(|_| "None".to_owned());
let secondary_name = spec
.get::<String>("curSecondaryAscendClassName")
.unwrap_or_else(|_| "None".to_owned());
let secondary_asc_names: HashSet<String> =
if let Ok(map) = tree.get::<mlua::Table>("secondaryAscendNameMap") {
map.pairs::<String, mlua::Value>()
.filter_map(|pair| pair.ok().map(|(k, _)| k))
.collect()
} else {
HashSet::new()
};
let alloc_nodes: mlua::Table = spec.get("allocNodes")?;
let mut allocated_ids = HashSet::new();
for pair in alloc_nodes.pairs::<mlua::Value, mlua::Table>() {
let (key, _) = pair?;
if let Some(id) = lua_value_to_i64(&key) {
allocated_ids.insert(id);
}
}
let has_primary = primary_name != "None" && !primary_name.is_empty();
let has_secondary = secondary_name != "None" && !secondary_name.is_empty();
let mut primary_allocated = Vec::new();
let mut primary_available = Vec::new();
let mut secondary_allocated = Vec::new();
let mut secondary_available = Vec::new();
let mut primary_points_spent: u32 = 0;
let mut secondary_points_spent: u32 = 0;
let all_nodes: mlua::Table = spec.get("nodes")?;
for pair in all_nodes.pairs::<mlua::Value, mlua::Table>() {
let (key, node) = pair?;
let node_id = lua_value_to_i64(&key).unwrap_or(0);
let asc_name = match node.get::<String>("ascendancyName") {
Ok(name) => name,
Err(_) => continue, };
let is_secondary = secondary_asc_names.contains(&asc_name);
let belongs = if is_secondary {
has_secondary && asc_name == secondary_name
} else {
has_primary && asc_name == primary_name
};
if !belongs {
continue;
}
let node_type = node.get::<String>("type").unwrap_or_default();
if node_type == "AscendClassStart" {
continue;
}
let name = node.get::<String>("dn").unwrap_or_default();
let stats = read_node_stats(&node);
let is_multiple_choice_option = node
.get::<bool>("isMultipleChoiceOption")
.unwrap_or(false);
let entry = {
let mut e = serde_json::json!({
"name": name,
"type": node_type,
});
if !stats.is_empty() {
e["stats"] = serde_json::Value::Array(
stats.into_iter().map(serde_json::Value::String).collect(),
);
}
e
};
let is_allocated = allocated_ids.contains(&node_id);
if is_secondary {
if is_allocated {
secondary_allocated.push(entry);
if !is_multiple_choice_option {
secondary_points_spent += 1;
}
} else {
secondary_available.push(entry);
}
} else {
if is_allocated {
primary_allocated.push(entry);
if !is_multiple_choice_option {
primary_points_spent += 1;
}
} else {
primary_available.push(entry);
}
}
}
let mut result = serde_json::json!({
"primary_ascendancy": primary_name,
"primary_allocated": primary_allocated,
"primary_available": primary_available,
"primary_points_spent": primary_points_spent,
});
if has_secondary {
result["secondary_ascendancy"] = serde_json::Value::String(secondary_name);
result["secondary_allocated"] = serde_json::Value::Array(secondary_allocated);
result["secondary_available"] = serde_json::Value::Array(secondary_available);
result["secondary_points_spent"] = serde_json::json!(secondary_points_spent);
}
Ok(result)
}
fn with_pob_cwd<F, R>(&self, f: F) -> Result<R, PobError>
where
F: FnOnce(&Lua) -> LuaResult<R>,
{
let pob_src = self
.pob_src_path
.as_ref()
.ok_or(PobError::NotInitialized)?;
let original_cwd = std::env::current_dir()?;
std::env::set_current_dir(pob_src)?;
let result = f(&self.lua);
std::env::set_current_dir(original_cwd)?;
Ok(result?)
}
pub fn export_build(&self) -> Result<String, PobError> {
if !self.initialized {
return Err(PobError::NotInitialized);
}
Ok(String::new())
}
pub fn set_passive_tree(&self, _tree_data: &str) -> Result<(), PobError> {
if !self.initialized {
return Err(PobError::NotInitialized);
}
Ok(())
}
pub fn set_main_skill(&self, _skill_name: &str) -> Result<(), PobError> {
if !self.initialized {
return Err(PobError::NotInitialized);
}
Ok(())
}
}
impl Default for PobHeadless {
fn default() -> Self {
Self::new().expect("Failed to create Lua runtime")
}
}
fn read_fields(table: &mlua::Table, fields: &[&str]) -> serde_json::Map<String, serde_json::Value> {
let mut map = serde_json::Map::new();
for &field in fields {
if let Ok(v) = table.get::<f64>(field) {
if v != 0.0 {
if let Some(num) = serde_json::Number::from_f64(v) {
map.insert(field.to_owned(), serde_json::Value::Number(num));
}
}
} else if let Ok(v) = table.get::<i64>(field) {
if v != 0 {
map.insert(
field.to_owned(),
serde_json::Value::Number(v.into()),
);
}
}
}
map
}
fn read_mod_lines(item: &mlua::Table, field: &str) -> Vec<String> {
let table = match item.get::<mlua::Table>(field) {
Ok(t) => t,
Err(_) => return Vec::new(),
};
(1..=table.raw_len())
.filter_map(|i| table.get::<mlua::Table>(i).ok())
.filter_map(|entry| entry.get::<String>("line").ok())
.collect()
}
fn merge_fields(
dst: &mut serde_json::Map<String, serde_json::Value>,
src: &serde_json::Map<String, serde_json::Value>,
) {
for (key, value) in src {
dst.entry(key.clone()).or_insert_with(|| value.clone());
}
}
fn read_node_stats(node: &mlua::Table) -> Vec<String> {
let table = match node.get::<mlua::Table>("sd") {
Ok(t) => t,
Err(_) => return Vec::new(),
};
(1..=table.raw_len())
.filter_map(|i| table.get::<String>(i).ok())
.collect()
}
fn lua_value_to_i64(val: &mlua::Value) -> Option<i64> {
match val {
mlua::Value::Integer(n) => Some(*n),
mlua::Value::Number(n) => Some(*n as i64),
_ => None,
}
}
fn match_node_stats(node: &mlua::Table, pattern: &str) -> (Vec<String>, f64) {
let sd = match node.get::<mlua::Table>("sd") {
Ok(t) => t,
Err(_) => return (Vec::new(), 0.0),
};
let mut matching = Vec::new();
let mut total = 0.0;
for i in 1..=sd.raw_len() {
if let Ok(line) = sd.get::<String>(i) {
if line.to_lowercase().contains(pattern) {
total += extract_stat_value(&line);
matching.push(line);
}
}
}
(matching, total)
}
fn extract_stat_value(line: &str) -> f64 {
let mut start = None;
let mut has_dot = false;
for (i, ch) in line.char_indices() {
match ch {
'0'..='9' => {
if start.is_none() {
start = Some(i);
}
}
'.' if start.is_some() && !has_dot => {
has_dot = true;
}
_ => {
if let Some(s) = start {
if let Ok(val) = line[s..i].parse::<f64>() {
return val;
}
start = None;
has_dot = false;
}
}
}
}
if let Some(s) = start {
line[s..].parse::<f64>().unwrap_or(0.0)
} else {
0.0
}
}