Skip to main content

poe2_agent/
pob.rs

1//! Path of Building 2 headless integration.
2//!
3//! Provides a Rust interface to Path of Building 2's
4//! calculation engine via embedded Lua.
5
6use mlua::{Lua, Result as LuaResult};
7use std::collections::{HashMap, HashSet, VecDeque};
8use std::path::Path;
9use thiserror::Error;
10
11// ---------------------------------------------------------------------------
12// Lua table field-extraction macros
13// ---------------------------------------------------------------------------
14
15/// Extract a typed field from a Lua table, returning the default on failure.
16///
17/// ```ignore
18/// lua_get!(table, "name" => String)          // unwrap_or_default()
19/// lua_get!(table, "quality" => i64, 0)       // unwrap_or(0)
20/// ```
21macro_rules! lua_get {
22    ($table:expr, $field:expr => $ty:ty) => {
23        $table.get::<$ty>($field).unwrap_or_default()
24    };
25    ($table:expr, $field:expr => $ty:ty, $default:expr) => {
26        $table.get::<$ty>($field).unwrap_or($default)
27    };
28}
29
30/// Insert a single Lua table field into a [`serde_json::Map`], skipping on failure.
31///
32/// Handles type-specific conversion. Supports key renaming with `=>` syntax.
33///
34/// ```ignore
35/// lua_json_insert!(map, table, "name", String);
36/// lua_json_insert!(map, table, "displayLabel" => "label", String);
37/// ```
38macro_rules! lua_json_insert {
39    ($map:expr, $table:expr, $field:expr, $ty:ident) => {
40        lua_json_insert!(@do $map, $table, $field, $field, $ty)
41    };
42    ($map:expr, $table:expr, $lua_key:expr => $json_key:expr, $ty:ident) => {
43        lua_json_insert!(@do $map, $table, $lua_key, $json_key, $ty)
44    };
45    (@do $map:expr, $table:expr, $lua_key:expr, $json_key:expr, String) => {
46        if let Ok(v) = $table.get::<String>($lua_key) {
47            $map.insert($json_key.to_owned(), serde_json::Value::String(v));
48        }
49    };
50    (@do $map:expr, $table:expr, $lua_key:expr, $json_key:expr, f64) => {
51        if let Ok(v) = $table.get::<f64>($lua_key) {
52            if let Some(n) = serde_json::Number::from_f64(v) {
53                $map.insert($json_key.to_owned(), serde_json::Value::Number(n));
54            }
55        }
56    };
57    (@do $map:expr, $table:expr, $lua_key:expr, $json_key:expr, i64) => {
58        if let Ok(v) = $table.get::<i64>($lua_key) {
59            $map.insert($json_key.to_owned(), serde_json::Value::Number(v.into()));
60        }
61    };
62    (@do $map:expr, $table:expr, $lua_key:expr, $json_key:expr, bool) => {
63        if let Ok(v) = $table.get::<bool>($lua_key) {
64            $map.insert($json_key.to_owned(), serde_json::Value::Bool(v));
65        }
66    };
67}
68
69/// Build a [`serde_json::Map`] from multiple optional Lua table fields.
70///
71/// Each field specifies its type. Absent fields are skipped.
72/// Use tuple syntax for key renaming.
73///
74/// ```ignore
75/// let map = lua_json_map!(table, {
76///     "name": String,
77///     "dps": f64,
78///     ("displayLabel", "label"): String,
79/// });
80/// ```
81macro_rules! lua_json_map {
82    ($table:expr, { $( $field:tt : $ty:ident ),* $(,)? }) => {{
83        #[allow(unused_mut)]
84        let mut map = serde_json::Map::new();
85        $(
86            lua_json_map!(@field map, $table, $field, $ty);
87        )*
88        map
89    }};
90    (@field $map:ident, $table:expr, ($lua_key:expr, $json_key:expr), $ty:ident) => {
91        lua_json_insert!($map, $table, $lua_key => $json_key, $ty);
92    };
93    (@field $map:ident, $table:expr, $field:expr, $ty:ident) => {
94        lua_json_insert!($map, $table, $field, $ty);
95    };
96}
97
98#[derive(Error, Debug)]
99pub enum PobError {
100    #[error("Lua error: {0}")]
101    Lua(#[from] mlua::Error),
102
103    #[error("PoB not initialized")]
104    NotInitialized,
105
106    #[error("Invalid build code: {0}")]
107    InvalidBuildCode(String),
108
109    #[error("Calculation failed: {0}")]
110    CalculationFailed(String),
111
112    #[error("IO error: {0}")]
113    Io(#[from] std::io::Error),
114}
115
116/// Path of Building headless instance.
117pub struct PobHeadless {
118    lua: Lua,
119    initialized: bool,
120    /// PoB `src/` directory — needed as CWD for Lua calls that trigger Build:Init.
121    pob_src_path: Option<std::path::PathBuf>,
122}
123
124/// Build statistics from PoB calculations.
125#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
126pub struct BuildStats {
127    #[serde(rename = "dps")]
128    pub total_dps: f64,
129    pub effective_hp: f64,
130    pub life: f64,
131    pub energy_shield: f64,
132    pub armour: f64,
133    pub evasion: f64,
134    pub fire_res: i32,
135    pub cold_res: i32,
136    pub lightning_res: i32,
137    pub chaos_res: i32,
138}
139
140impl PobHeadless {
141    /// Create a new PoB headless instance.
142    pub fn new() -> LuaResult<Self> {
143        let lua = Lua::new();
144        Ok(Self {
145            lua,
146            initialized: false,
147            pob_src_path: None,
148        })
149    }
150
151    /// Initialize PoB with the path to PoB2 installation.
152    ///
153    /// `pob_path` should point to the PoB2 root directory (containing `src/`).
154    pub fn init(&mut self, pob_path: &str) -> Result<(), PobError> {
155        let pob_path = Path::new(pob_path);
156        let pob_src_path = pob_path.join("src");
157        let pob_runtime_lua = pob_path.join("runtime/lua");
158
159        // PoB's Lua files use relative dofile() calls, so we must change to src/
160        let original_cwd = std::env::current_dir()?;
161        std::env::set_current_dir(&pob_src_path)?;
162
163        tracing::info!("Initializing PoB from {:?}", pob_src_path);
164
165        // Set up Lua package.path to include PoB's runtime/lua directory
166        // This is where xml.lua and other dependencies live
167        let runtime_lua_path = pob_runtime_lua
168            .to_str()
169            .ok_or_else(|| PobError::CalculationFailed("Invalid path".to_owned()))?;
170
171        self.lua
172            .load(format!(
173                r#"package.path = package.path .. ";{0}/?.lua;{0}/?/init.lua""#,
174                runtime_lua_path
175            ))
176            .exec()?;
177
178        // Provide a minimal lua-utf8 stub since it's a C module we can't load in safe mode
179        // This provides basic string operations that fall back to regular string functions
180        self.lua
181            .load(
182                r#"
183                package.preload['lua-utf8'] = function()
184                    local utf8 = {}
185                    utf8.reverse = string.reverse
186                    utf8.gsub = string.gsub
187                    utf8.find = string.find
188                    utf8.sub = string.sub
189                    utf8.match = string.match
190                    utf8.len = string.len
191                    function utf8.next(s, i, offset)
192                        if offset == -1 then
193                            return i > 1 and i - 1 or nil
194                        else
195                            return i < #s and i + 1 or nil
196                        end
197                    end
198                    return utf8
199                end
200                "#,
201            )
202            .exec()?;
203
204        // Provide empty arg table (command line arguments)
205        self.lua.load("arg = {}").exec()?;
206
207        // Load HeadlessWrapper.lua which bootstraps everything
208        let result = self.lua.load("dofile('HeadlessWrapper.lua')").exec();
209
210        // Always restore the original working directory
211        std::env::set_current_dir(original_cwd)?;
212
213        result?;
214
215        self.initialized = true;
216        self.pob_src_path = Some(pob_src_path.to_owned());
217        tracing::info!("PoB headless initialized successfully");
218        Ok(())
219    }
220
221    /// Load a build from XML export.
222    pub fn load_build_xml(&self, xml: &str) -> Result<(), PobError> {
223        if !self.initialized {
224            return Err(PobError::NotInitialized);
225        }
226
227        // loadBuildFromXML triggers Build:Init which re-creates all tabs via
228        // LoadModule (relative paths).  CWD must be the PoB src/ directory.
229        self.with_pob_cwd(|lua| {
230            let load_fn: mlua::Function = lua.globals().get("loadBuildFromXML")?;
231            load_fn.call::<()>((xml, "imported_build"))
232        })?;
233
234        tracing::debug!("Build loaded from XML");
235        Ok(())
236    }
237
238    /// Import a build from a PoB code (base64 encoded).
239    pub fn import_build(&self, _code: &str) -> Result<(), PobError> {
240        if !self.initialized {
241            return Err(PobError::NotInitialized);
242        }
243
244        // TODO: Decode and import build (requires Inflate support)
245        Err(PobError::InvalidBuildCode(
246            "Build code import not yet implemented (requires Inflate)".to_owned(),
247        ))
248    }
249
250    /// Calculate build statistics from the currently loaded build.
251    pub fn calculate(&self) -> Result<BuildStats, PobError> {
252        if !self.initialized {
253            return Err(PobError::NotInitialized);
254        }
255
256        // Navigate: build.calcsTab.mainOutput
257        let build: mlua::Table = self.lua.globals().get("build")?;
258        let calcs_tab: mlua::Table = build.get("calcsTab")?;
259        let main_output: mlua::Table = calcs_tab.get("mainOutput")?;
260
261        // For EHP, we need calcsOutput
262        let calcs_output: mlua::Table = calcs_tab.get("calcsOutput")?;
263
264        // Extract stats with safe defaults
265        let total_dps = main_output
266            .get::<f64>("TotalDPS")
267            .or_else(|_| main_output.get::<f64>("CombinedDPS"))
268            .unwrap_or(0.0);
269
270        let life = lua_get!(main_output, "Life" => f64);
271        let energy_shield = lua_get!(main_output, "EnergyShield" => f64);
272        let armour = lua_get!(main_output, "Armour" => f64);
273        let evasion = lua_get!(main_output, "Evasion" => f64);
274
275        let fire_res = lua_get!(main_output, "FireResist" => i32);
276        let cold_res = lua_get!(main_output, "ColdResist" => i32);
277        let lightning_res = lua_get!(main_output, "LightningResist" => i32);
278        let chaos_res = lua_get!(main_output, "ChaosResist" => i32);
279
280        let effective_hp = lua_get!(calcs_output, "PhysicalMaximumHitTaken" => f64);
281
282        Ok(BuildStats {
283            total_dps,
284            effective_hp,
285            life,
286            energy_shield,
287            armour,
288            evasion,
289            fire_res,
290            cold_res,
291            lightning_res,
292            chaos_res,
293        })
294    }
295
296    /// Query extended build stats (~40 fields) grouped by category.
297    ///
298    /// Reads from `mainOutput` and `calcsOutput`, returning a JSON object
299    /// with keys: offense, defense, resources, speed, charges.
300    pub fn query_build_stats(&self) -> Result<serde_json::Value, PobError> {
301        if !self.initialized {
302            return Err(PobError::NotInitialized);
303        }
304
305        let build: mlua::Table = self.lua.globals().get("build")?;
306        let calcs_tab: mlua::Table = build.get("calcsTab")?;
307        let main_output: mlua::Table = calcs_tab.get("mainOutput")?;
308        let calcs_output: mlua::Table = calcs_tab.get("calcsOutput")?;
309
310        let offense_fields = &[
311            "TotalDPS",
312            "CombinedDPS",
313            "AverageHit",
314            "Speed",
315            "CritChance",
316            "CritMultiplier",
317            "HitChance",
318            "TotalDot",
319            "BleedDPS",
320            "IgniteDPS",
321            "PoisonDPS",
322            "FullDPS",
323            "WithPoisonDPS",
324            "WithIgniteDPS",
325            "WithBleedDPS",
326            "TotalDotDPS",
327            "Damage",
328            "PhysicalDamage",
329            "ElementalDamage",
330            "FireDamage",
331            "ColdDamage",
332            "LightningDamage",
333            "ChaosDamage",
334        ];
335
336        let defense_fields = &[
337            "TotalEHP",
338            "PhysicalMaximumHitTaken",
339            "FireMaximumHitTaken",
340            "ColdMaximumHitTaken",
341            "LightningMaximumHitTaken",
342            "ChaosMaximumHitTaken",
343            "Armour",
344            "PhysicalDamageReduction",
345            "Evasion",
346            "EvadeChance",
347            "BlockChance",
348            "SpellBlockChance",
349            "SpellSuppressionChance",
350            "FireResist",
351            "ColdResist",
352            "LightningResist",
353            "ChaosResist",
354            "FireResistOverCap",
355            "ColdResistOverCap",
356            "LightningResistOverCap",
357            "ChaosResistOverCap",
358        ];
359
360        let resource_fields = &[
361            "Life",
362            "LifeUnreserved",
363            "LifeRegenRecovery",
364            "Mana",
365            "ManaUnreserved",
366            "ManaRegenRecovery",
367            "EnergyShield",
368            "EnergyShieldRegenRecovery",
369            "Spirit",
370        ];
371
372        let speed_fields = &[
373            "EffectiveMovementSpeedMod",
374            "AreaOfEffectRadiusMetres",
375            "Duration",
376            "ManaCost",
377        ];
378
379        let charge_fields = &["PowerChargesMax", "FrenzyChargesMax", "EnduranceChargesMax"];
380
381        let offense = read_fields(&main_output, offense_fields);
382        let mut defense = read_fields(&main_output, defense_fields);
383        // TotalEHP and MaxHitTaken fields come from calcsOutput
384        merge_fields(&mut defense, &read_fields(&calcs_output, defense_fields));
385        let resources = read_fields(&main_output, resource_fields);
386        let speed = read_fields(&main_output, speed_fields);
387        let charges = read_fields(&main_output, charge_fields);
388
389        Ok(serde_json::json!({
390            "offense": offense,
391            "defense": defense,
392            "resources": resources,
393            "speed": speed,
394            "charges": charges,
395        }))
396    }
397
398    /// Query the list of skills with their DPS and gem links.
399    ///
400    /// Reads from `SkillDPS` array in `mainOutput` and
401    /// `socketGroupList` in `skillsTab`.
402    pub fn query_skill_list(&self) -> Result<serde_json::Value, PobError> {
403        if !self.initialized {
404            return Err(PobError::NotInitialized);
405        }
406
407        let build: mlua::Table = self.lua.globals().get("build")?;
408        let calcs_tab: mlua::Table = build.get("calcsTab")?;
409        let main_output: mlua::Table = calcs_tab.get("mainOutput")?;
410
411        // Read SkillDPS array from mainOutput
412        let mut skill_dps_list = Vec::new();
413        if let Ok(skill_dps_table) = main_output.get::<mlua::Table>("SkillDPS") {
414            for i in 1..=skill_dps_table.raw_len() {
415                if let Ok(entry) = skill_dps_table.get::<mlua::Table>(i) {
416                    let skill = lua_json_map!(entry, {
417                        "name": String,
418                        "dps": f64,
419                        "count": i64,
420                        "trigger": String,
421                        "skillPart": String,
422                    });
423                    if !skill.is_empty() {
424                        skill_dps_list.push(serde_json::Value::Object(skill));
425                    }
426                }
427            }
428        }
429
430        // Read socket group list from skillsTab
431        let mut socket_groups = Vec::new();
432        if let Ok(skills_tab) = build.get::<mlua::Table>("skillsTab") {
433            if let Ok(group_list) = skills_tab.get::<mlua::Table>("socketGroupList") {
434                for i in 1..=group_list.raw_len() {
435                    if let Ok(group) = group_list.get::<mlua::Table>(i) {
436                        let mut group_obj = lua_json_map!(group, {
437                            ("displayLabel", "label"): String,
438                            "enabled": bool,
439                            "slot": String,
440                        });
441
442                        // Read gem list for this group
443                        let mut gems = Vec::new();
444                        if let Ok(gem_list) = group.get::<mlua::Table>("gemList") {
445                            for j in 1..=gem_list.raw_len() {
446                                if let Ok(gem) = gem_list.get::<mlua::Table>(j) {
447                                    let gem_obj = lua_json_map!(gem, {
448                                        ("nameSpec", "name"): String,
449                                        "level": i64,
450                                        "quality": i64,
451                                        "enabled": bool,
452                                    });
453                                    if !gem_obj.is_empty() {
454                                        gems.push(serde_json::Value::Object(gem_obj));
455                                    }
456                                }
457                            }
458                        }
459                        if !gems.is_empty() {
460                            group_obj.insert("gems".to_owned(), serde_json::Value::Array(gems));
461                        }
462
463                        if !group_obj.is_empty() {
464                            socket_groups.push(serde_json::Value::Object(group_obj));
465                        }
466                    }
467                }
468            }
469        }
470
471        Ok(serde_json::json!({
472            "skill_dps": skill_dps_list,
473            "socket_groups": socket_groups,
474        }))
475    }
476
477    /// Query the build configuration flags.
478    ///
479    /// Reads `build.configTab.input` as flat key-value pairs.
480    pub fn query_config(&self) -> Result<serde_json::Value, PobError> {
481        if !self.initialized {
482            return Err(PobError::NotInitialized);
483        }
484
485        let build: mlua::Table = self.lua.globals().get("build")?;
486        let config_tab: mlua::Table = build.get("configTab")?;
487        let input: mlua::Table = config_tab.get("input")?;
488
489        let mut config = serde_json::Map::new();
490        for pair in input.pairs::<String, mlua::Value>() {
491            let (key, value) = pair?;
492            let json_val = match value {
493                mlua::Value::Boolean(b) => serde_json::Value::Bool(b),
494                mlua::Value::Integer(n) => serde_json::Value::Number(n.into()),
495                mlua::Value::Number(n) => {
496                    if let Some(num) = serde_json::Number::from_f64(n) {
497                        serde_json::Value::Number(num)
498                    } else {
499                        continue;
500                    }
501                }
502                mlua::Value::String(s) => {
503                    serde_json::Value::String(s.to_str().map(|s| s.to_owned()).unwrap_or_default())
504                }
505                _ => continue,
506            };
507            config.insert(key, json_val);
508        }
509
510        Ok(serde_json::Value::Object(config))
511    }
512
513    /// Query the item equipped in the given slot.
514    ///
515    /// Returns a JSON object with the item's name, base type, rarity, quality,
516    /// spirit, sockets, and all mod lines (implicit, explicit, enchant, rune).
517    /// If the slot is empty, returns `{ "slot": "<name>", "empty": true }`.
518    pub fn query_item(&self, slot: &str) -> Result<serde_json::Value, PobError> {
519        if !self.initialized {
520            return Err(PobError::NotInitialized);
521        }
522
523        let build: mlua::Table = self.lua.globals().get("build")?;
524        let items_tab: mlua::Table = build.get("itemsTab")?;
525        let active_item_set: mlua::Table = items_tab.get("activeItemSet")?;
526
527        // Look up the slot in the active item set
528        let slot_entry: mlua::Table = match active_item_set.get::<mlua::Table>(slot) {
529            Ok(t) => t,
530            Err(_) => return Ok(serde_json::json!({ "slot": slot, "empty": true })),
531        };
532
533        // Read selItemId — if nil or 0, the slot is empty
534        let sel_item_id: i64 = lua_get!(slot_entry, "selItemId" => i64);
535        if sel_item_id == 0 {
536            return Ok(serde_json::json!({ "slot": slot, "empty": true }));
537        }
538
539        // Look up the item in items[selItemId]
540        let items: mlua::Table = items_tab.get("items")?;
541        let item: mlua::Table = match items.get::<mlua::Table>(sel_item_id) {
542            Ok(t) => t,
543            Err(_) => return Ok(serde_json::json!({ "slot": slot, "empty": true })),
544        };
545
546        let d = ItemFields::from_lua(&item);
547
548        Ok(serde_json::json!({
549            "slot": slot,
550            "name": d.name,
551            "base": d.base_name,
552            "rarity": d.rarity,
553            "quality": d.quality,
554            "spirit": d.spirit,
555            "sockets": d.sockets,
556            "implicits": d.implicits,
557            "explicits": d.explicits,
558            "enchants": d.enchants,
559            "runes": d.runes,
560        }))
561    }
562
563    /// Query all equipped items with compact mod summaries.
564    ///
565    /// Returns gear (all 16 slots with item details or empty marker), jewels
566    /// from the passive tree, and counts for empty/filled slots.
567    pub fn query_equipped_items(&self) -> Result<serde_json::Value, PobError> {
568        if !self.initialized {
569            return Err(PobError::NotInitialized);
570        }
571
572        const ALL_SLOTS: &[&str] = &[
573            "Weapon 1",
574            "Weapon 2",
575            "Helmet",
576            "Body Armour",
577            "Gloves",
578            "Boots",
579            "Amulet",
580            "Ring 1",
581            "Ring 2",
582            "Ring 3",
583            "Belt",
584            "Charm 1",
585            "Charm 2",
586            "Charm 3",
587            "Flask 1",
588            "Flask 2",
589        ];
590
591        let build: mlua::Table = self.lua.globals().get("build")?;
592        let items_tab: mlua::Table = build.get("itemsTab")?;
593        let active_item_set: mlua::Table = items_tab.get("activeItemSet")?;
594        let items: mlua::Table = items_tab.get("items")?;
595
596        let mut gear = Vec::new();
597        let mut empty_count: u32 = 0;
598        let mut filled_count: u32 = 0;
599
600        for &slot in ALL_SLOTS {
601            let sel_item_id: i64 = active_item_set
602                .get::<mlua::Table>(slot)
603                .and_then(|entry| entry.get::<i64>("selItemId"))
604                .unwrap_or(0);
605
606            if sel_item_id == 0 {
607                empty_count += 1;
608                gear.push(serde_json::json!({ "slot": slot, "empty": true }));
609                continue;
610            }
611
612            match items.get::<mlua::Table>(sel_item_id) {
613                Ok(item) => {
614                    filled_count += 1;
615                    let d = ItemFields::from_lua(&item);
616                    gear.push(serde_json::json!({
617                        "slot": slot,
618                        "name": d.name,
619                        "base": d.base_name,
620                        "rarity": d.rarity,
621                        "mods": d.all_mods(),
622                    }));
623                }
624                Err(_) => {
625                    empty_count += 1;
626                    gear.push(serde_json::json!({ "slot": slot, "empty": true }));
627                }
628            }
629        }
630
631        // Collect jewels from passive tree
632        let spec: mlua::Table = build.get("spec")?;
633        let mut jewels = Vec::new();
634        if let Ok(jewels_table) = spec.get::<mlua::Table>("jewels") {
635            for pair in jewels_table.pairs::<mlua::Value, mlua::Value>() {
636                let (key, val) = pair?;
637                let socket_id = lua_value_to_i64(&key).unwrap_or(0);
638                let item_id = lua_value_to_i64(&val).unwrap_or(0);
639                if socket_id == 0 || item_id == 0 {
640                    continue;
641                }
642
643                if let Ok(item) = items.get::<mlua::Table>(item_id) {
644                    let d = ItemFields::from_lua(&item);
645                    jewels.push(serde_json::json!({
646                        "socket_id": socket_id,
647                        "name": d.name,
648                        "base": d.base_name,
649                        "rarity": d.rarity,
650                        "mods": d.all_mods(),
651                    }));
652                }
653            }
654        }
655
656        Ok(serde_json::json!({
657            "gear": gear,
658            "jewels": jewels,
659            "empty_count": empty_count,
660            "filled_count": filled_count,
661        }))
662    }
663
664    /// Query a jewel socketed in a passive tree socket node.
665    ///
666    /// Returns a JSON object with the jewel's name, base type, rarity, quality,
667    /// and all mod lines. If the socket is empty or invalid, returns
668    /// `{ "socket_id": N, "empty": true }`.
669    pub fn query_jewel(&self, socket_id: i64) -> Result<serde_json::Value, PobError> {
670        if !self.initialized {
671            return Err(PobError::NotInitialized);
672        }
673
674        let build: mlua::Table = self.lua.globals().get("build")?;
675        let spec: mlua::Table = build.get("spec")?;
676
677        // build.spec.jewels[socketNodeId] → itemId
678        let jewels: mlua::Table = match spec.get::<mlua::Table>("jewels") {
679            Ok(t) => t,
680            Err(_) => return Ok(serde_json::json!({ "socket_id": socket_id, "empty": true })),
681        };
682
683        let item_id: i64 = match jewels.get::<i64>(socket_id) {
684            Ok(id) if id != 0 => id,
685            _ => return Ok(serde_json::json!({ "socket_id": socket_id, "empty": true })),
686        };
687
688        // build.itemsTab.items[itemId] → jewel item object
689        let items_tab: mlua::Table = build.get("itemsTab")?;
690        let items: mlua::Table = items_tab.get("items")?;
691        let item: mlua::Table = match items.get::<mlua::Table>(item_id) {
692            Ok(t) => t,
693            Err(_) => return Ok(serde_json::json!({ "socket_id": socket_id, "empty": true })),
694        };
695
696        let d = ItemFields::from_lua(&item);
697
698        Ok(serde_json::json!({
699            "socket_id": socket_id,
700            "name": d.name,
701            "base": d.base_name,
702            "rarity": d.rarity,
703            "quality": d.quality,
704            "implicits": d.implicits,
705            "explicits": d.explicits,
706            "enchants": d.enchants,
707            "runes": d.runes,
708        }))
709    }
710
711    /// Query the allocated passive tree nodes.
712    ///
713    /// Returns class, ascendancy, total node count, categorized node lists
714    /// (keystones, notables, ascendancy nodes, masteries, jewel sockets),
715    /// and aggregated stat totals from Normal nodes.
716    pub fn query_passive_tree(&self) -> Result<serde_json::Value, PobError> {
717        if !self.initialized {
718            return Err(PobError::NotInitialized);
719        }
720
721        let build: mlua::Table = self.lua.globals().get("build")?;
722        let spec: mlua::Table = build.get("spec")?;
723
724        let class_name = lua_get!(spec, "curClassName" => String);
725        let ascendancy_name = lua_get!(spec, "curAscendClassName" => String);
726
727        let alloc_nodes: mlua::Table = spec.get("allocNodes")?;
728
729        let mut keystones = Vec::new();
730        let mut notables = Vec::new();
731        let mut ascendancy_nodes = Vec::new();
732        let mut masteries = Vec::new();
733        let mut jewel_sockets = Vec::new();
734        let mut total_allocated: u32 = 0;
735        // Aggregate Normal node stats: stat line text → (count, sum of values)
736        let mut normal_stat_agg: HashMap<String, (u32, f64)> = HashMap::new();
737
738        for pair in alloc_nodes.pairs::<mlua::Value, mlua::Table>() {
739            let (key, node) = pair?;
740            total_allocated += 1;
741
742            let node_type = lua_get!(node, "type" => String);
743            let name = lua_get!(node, "dn" => String);
744            let asc_name: Option<String> = node.get::<String>("ascendancyName").ok();
745
746            // Ascendancy nodes go into their own bucket regardless of type
747            if asc_name.is_some() {
748                let stats = read_node_stats(&node);
749                let mut entry = serde_json::json!({
750                    "name": name,
751                    "type": node_type,
752                });
753                if !stats.is_empty() {
754                    entry["stats"] = serde_json::Value::Array(
755                        stats.into_iter().map(serde_json::Value::String).collect(),
756                    );
757                }
758                ascendancy_nodes.push(entry);
759                continue;
760            }
761
762            match node_type.as_str() {
763                "Keystone" => {
764                    let stats = read_node_stats(&node);
765                    let mut entry = serde_json::json!({ "name": name });
766                    if !stats.is_empty() {
767                        entry["stats"] = serde_json::Value::Array(
768                            stats.into_iter().map(serde_json::Value::String).collect(),
769                        );
770                    }
771                    keystones.push(entry);
772                }
773                "Notable" => {
774                    let stats = read_node_stats(&node);
775                    let mut entry = serde_json::json!({ "name": name });
776                    if !stats.is_empty() {
777                        entry["stats"] = serde_json::Value::Array(
778                            stats.into_iter().map(serde_json::Value::String).collect(),
779                        );
780                    }
781                    notables.push(entry);
782                }
783                "Mastery" => {
784                    let stats = read_node_stats(&node);
785                    let mut entry = serde_json::json!({ "name": name });
786                    if !stats.is_empty() {
787                        entry["stats"] = serde_json::Value::Array(
788                            stats.into_iter().map(serde_json::Value::String).collect(),
789                        );
790                    }
791                    masteries.push(entry);
792                }
793                "Socket" => {
794                    let node_id = lua_value_to_i64(&key).unwrap_or(0);
795                    jewel_sockets.push(serde_json::json!({
796                        "node_id": node_id,
797                        "name": name,
798                    }));
799                }
800                "Normal" => {
801                    // Aggregate stat lines from Normal nodes
802                    let stats = read_node_stats(&node);
803                    for stat_line in stats {
804                        let value = extract_stat_value(&stat_line);
805                        let entry = normal_stat_agg.entry(stat_line).or_insert((0, 0.0));
806                        entry.0 += 1;
807                        entry.1 += value;
808                    }
809                }
810                // ClassStart, AscendClassStart — counted but not listed
811                _ => {}
812            }
813        }
814
815        // Build stat_totals sorted by total value descending
816        let mut stat_totals: Vec<serde_json::Value> = normal_stat_agg
817            .into_iter()
818            .map(|(stat, (count, total))| {
819                serde_json::json!({
820                    "stat": stat,
821                    "count": count,
822                    "total": total,
823                })
824            })
825            .collect();
826        stat_totals.sort_by(|a, b| {
827            let va = b["total"].as_f64().unwrap_or(0.0);
828            let vb = a["total"].as_f64().unwrap_or(0.0);
829            va.partial_cmp(&vb).unwrap_or(std::cmp::Ordering::Equal)
830        });
831
832        Ok(serde_json::json!({
833            "class": class_name,
834            "ascendancy": ascendancy_name,
835            "total_allocated": total_allocated,
836            "keystones": keystones,
837            "notables": notables,
838            "ascendancy_nodes": ascendancy_nodes,
839            "masteries": masteries,
840            "jewel_sockets": jewel_sockets,
841            "stat_totals": stat_totals,
842        }))
843    }
844
845    /// Query how much of one or more stats comes from allocated passives and what's nearby.
846    ///
847    /// Performs case-insensitive substring matching on passive node stat descriptions.
848    /// Uses multi-source BFS from allocated nodes to find nearby unallocated nodes
849    /// with matching stats within `radius` hops. A single BFS pass serves all patterns.
850    pub fn query_passive_stats(
851        &self,
852        stats: &[String],
853        radius: u32,
854    ) -> Result<serde_json::Value, PobError> {
855        if !self.initialized {
856            return Err(PobError::NotInitialized);
857        }
858
859        let patterns: Vec<String> = stats.iter().map(|s| s.to_lowercase()).collect();
860        let build: mlua::Table = self.lua.globals().get("build")?;
861        let spec: mlua::Table = build.get("spec")?;
862        let alloc_nodes: mlua::Table = spec.get("allocNodes")?;
863        let all_nodes: mlua::Table = spec.get("nodes")?;
864
865        // Per-pattern accumulators
866        struct StatAccum {
867            allocated_total: f64,
868            allocated_nodes: Vec<serde_json::Value>,
869            nearby_total: f64,
870            nearby_nodes: Vec<serde_json::Value>,
871        }
872        let mut accums: Vec<StatAccum> = patterns
873            .iter()
874            .map(|_| StatAccum {
875                allocated_total: 0.0,
876                allocated_nodes: Vec::new(),
877                nearby_total: 0.0,
878                nearby_nodes: Vec::new(),
879            })
880            .collect();
881
882        // Phase A: Collect allocated node IDs and find matching stats
883        let mut allocated_ids = HashSet::new();
884
885        for pair in alloc_nodes.pairs::<mlua::Value, mlua::Table>() {
886            let (key, node) = pair?;
887            let node_id = lua_value_to_i64(&key).unwrap_or(0);
888            allocated_ids.insert(node_id);
889
890            for (pi, pattern) in patterns.iter().enumerate() {
891                let (matching_stats, value) = match_node_stats(&node, pattern);
892                if !matching_stats.is_empty() {
893                    accums[pi].allocated_total += value;
894                    accums[pi].allocated_nodes.push(serde_json::json!({
895                        "name": lua_get!(node, "dn" => String),
896                        "value": value,
897                        "matching_stats": matching_stats,
898                    }));
899                }
900            }
901        }
902
903        // Phase B: Build adjacency graph from all nodes
904        let mut adjacency: HashMap<i64, Vec<i64>> = HashMap::new();
905
906        for pair in all_nodes.pairs::<mlua::Value, mlua::Table>() {
907            let (key, node) = pair?;
908            let node_id = lua_value_to_i64(&key).unwrap_or(0);
909
910            let mut neighbors = Vec::new();
911            if let Ok(linked_ids) = node.get::<mlua::Table>("linkedId") {
912                for i in 1..=linked_ids.raw_len() {
913                    if let Ok(linked_id) = linked_ids.get::<i64>(i) {
914                        neighbors.push(linked_id);
915                    }
916                }
917            }
918            adjacency.insert(node_id, neighbors);
919        }
920
921        // Phase C: Multi-source BFS from allocated nodes (single pass, all patterns)
922        let mut visited = allocated_ids.clone();
923        let mut queue = VecDeque::new();
924
925        for &id in &allocated_ids {
926            queue.push_back((id, 0u32));
927        }
928
929        while let Some((current_id, dist)) = queue.pop_front() {
930            if let Some(neighbors) = adjacency.get(&current_id) {
931                for &neighbor_id in neighbors {
932                    if visited.contains(&neighbor_id) {
933                        continue;
934                    }
935                    visited.insert(neighbor_id);
936
937                    let next_dist = dist + 1;
938
939                    // Check this unallocated node against all patterns
940                    if let Ok(node) = all_nodes.get::<mlua::Table>(neighbor_id) {
941                        for (pi, pattern) in patterns.iter().enumerate() {
942                            let (matching_stats, value) = match_node_stats(&node, pattern);
943                            if !matching_stats.is_empty() {
944                                accums[pi].nearby_total += value;
945                                accums[pi].nearby_nodes.push(serde_json::json!({
946                                    "name": lua_get!(node, "dn" => String),
947                                    "value": value,
948                                    "distance": next_dist,
949                                    "matching_stats": matching_stats,
950                                }));
951                            }
952                        }
953                    }
954
955                    if next_dist < radius {
956                        queue.push_back((neighbor_id, next_dist));
957                    }
958                }
959            }
960        }
961
962        // Sort nearby nodes by distance, then by value descending
963        for accum in &mut accums {
964            accum.nearby_nodes.sort_by(|a, b| {
965                let da = a["distance"].as_u64().unwrap_or(0);
966                let db = b["distance"].as_u64().unwrap_or(0);
967                da.cmp(&db).then_with(|| {
968                    let va = b["value"].as_f64().unwrap_or(0.0);
969                    let vb = a["value"].as_f64().unwrap_or(0.0);
970                    va.partial_cmp(&vb).unwrap_or(std::cmp::Ordering::Equal)
971                })
972            });
973        }
974
975        // Build result grouped by stat pattern
976        let mut result_stats = serde_json::Map::new();
977        for (pi, stat_name) in stats.iter().enumerate() {
978            let accum = &accums[pi];
979            result_stats.insert(
980                stat_name.clone(),
981                serde_json::json!({
982                    "allocated": {
983                        "total_value": accum.allocated_total,
984                        "nodes": accum.allocated_nodes,
985                    },
986                    "nearby_available": {
987                        "total_value": accum.nearby_total,
988                        "nodes": accum.nearby_nodes,
989                    },
990                }),
991            );
992        }
993
994        Ok(serde_json::json!({ "stats": result_stats }))
995    }
996
997    /// Query ascendancy nodes: which are allocated and which are available.
998    ///
999    /// Returns primary and secondary ascendancy names, lists of allocated and
1000    /// available nodes with stats, and point counts for each ascendancy.
1001    pub fn query_unallocated_ascendancy(&self) -> Result<serde_json::Value, PobError> {
1002        if !self.initialized {
1003            return Err(PobError::NotInitialized);
1004        }
1005
1006        let build: mlua::Table = self.lua.globals().get("build")?;
1007        let spec: mlua::Table = build.get("spec")?;
1008        let tree: mlua::Table = spec.get("tree")?;
1009
1010        // Read ascendancy class names
1011        let primary_name = lua_get!(spec, "curAscendClassName" => String, "None".to_owned());
1012        let secondary_name =
1013            lua_get!(spec, "curSecondaryAscendClassName" => String, "None".to_owned());
1014
1015        // Build set of secondary ascendancy names for classification
1016        let secondary_asc_names: HashSet<String> =
1017            if let Ok(map) = tree.get::<mlua::Table>("secondaryAscendNameMap") {
1018                map.pairs::<String, mlua::Value>()
1019                    .filter_map(|pair| pair.ok().map(|(k, _)| k))
1020                    .collect()
1021            } else {
1022                HashSet::new()
1023            };
1024
1025        // Collect allocated node IDs for fast lookup
1026        let alloc_nodes: mlua::Table = spec.get("allocNodes")?;
1027        let mut allocated_ids = HashSet::new();
1028        for pair in alloc_nodes.pairs::<mlua::Value, mlua::Table>() {
1029            let (key, _) = pair?;
1030            if let Some(id) = lua_value_to_i64(&key) {
1031                allocated_ids.insert(id);
1032            }
1033        }
1034
1035        // Determine which ascendancy names belong to this build
1036        let has_primary = primary_name != "None" && !primary_name.is_empty();
1037        let has_secondary = secondary_name != "None" && !secondary_name.is_empty();
1038
1039        let mut primary_nodes = Vec::new();
1040        let mut secondary_nodes = Vec::new();
1041        let mut primary_points_spent: u32 = 0;
1042        let mut secondary_points_spent: u32 = 0;
1043
1044        // Iterate all nodes, filter for ascendancy nodes belonging to this build
1045        let all_nodes: mlua::Table = spec.get("nodes")?;
1046        for pair in all_nodes.pairs::<mlua::Value, mlua::Table>() {
1047            let (key, node) = pair?;
1048            let node_id = lua_value_to_i64(&key).unwrap_or(0);
1049
1050            let asc_name = match node.get::<String>("ascendancyName") {
1051                Ok(name) => name,
1052                Err(_) => continue, // Not an ascendancy node
1053            };
1054
1055            // Skip nodes that don't belong to this build's ascendancies
1056            let is_secondary = secondary_asc_names.contains(&asc_name);
1057            let belongs = if is_secondary {
1058                has_secondary && asc_name == secondary_name
1059            } else {
1060                has_primary && asc_name == primary_name
1061            };
1062            if !belongs {
1063                continue;
1064            }
1065
1066            // Skip start nodes — they're auto-allocated and don't cost points
1067            let node_type = lua_get!(node, "type" => String);
1068            if node_type == "AscendClassStart" {
1069                continue;
1070            }
1071
1072            let name = lua_get!(node, "dn" => String);
1073            let stats = read_node_stats(&node);
1074            let is_multiple_choice_option = lua_get!(node, "isMultipleChoiceOption" => bool);
1075            let is_allocated = allocated_ids.contains(&node_id);
1076
1077            let mut entry = serde_json::json!({
1078                "name": name,
1079                "type": node_type,
1080                "allocated": is_allocated,
1081            });
1082            if !stats.is_empty() {
1083                entry["stats"] = serde_json::Value::Array(
1084                    stats.into_iter().map(serde_json::Value::String).collect(),
1085                );
1086            }
1087
1088            if is_secondary {
1089                if is_allocated && !is_multiple_choice_option {
1090                    secondary_points_spent += 1;
1091                }
1092                secondary_nodes.push(entry);
1093            } else {
1094                if is_allocated && !is_multiple_choice_option {
1095                    primary_points_spent += 1;
1096                }
1097                primary_nodes.push(entry);
1098            }
1099        }
1100
1101        let mut result = serde_json::json!({
1102            "primary_ascendancy": primary_name,
1103            "primary_nodes": primary_nodes,
1104            "primary_points_spent": primary_points_spent,
1105        });
1106
1107        if has_secondary {
1108            result["secondary_ascendancy"] = serde_json::Value::String(secondary_name);
1109            result["secondary_nodes"] = serde_json::Value::Array(secondary_nodes);
1110            result["secondary_points_spent"] = serde_json::json!(secondary_points_spent);
1111        }
1112
1113        Ok(result)
1114    }
1115
1116    /// Query detailed DPS breakdown for a specific skill.
1117    ///
1118    /// Finds the skill by name in `activeSkillList`, sets it as main skill,
1119    /// recalculates, and reads the detailed output table. Since each query
1120    /// loads the build fresh, modifying main skill selection is safe.
1121    ///
1122    /// Requires CWD to be the PoB `src/` directory because `LoadModule("Calcs")`
1123    /// uses `loadfile` with a relative path.
1124    pub fn query_skill_breakdown(&self, skill_name: &str) -> Result<serde_json::Value, PobError> {
1125        if !self.initialized {
1126            return Err(PobError::NotInitialized);
1127        }
1128
1129        let skill_name = skill_name.to_owned();
1130        // Run with CWD set to PoB src/ since LoadModule("Calcs") needs it
1131        self.with_pob_cwd(|lua| {
1132            lua.load(LUA_SKILL_BREAKDOWN).exec()?;
1133            let breakdown_fn: mlua::Function = lua.globals().get("getSkillBreakdown")?;
1134            let result_str: String = breakdown_fn.call(skill_name)?;
1135            Ok(result_str)
1136        })
1137        .and_then(|result_str| {
1138            serde_json::from_str(&result_str).map_err(|e| {
1139                PobError::CalculationFailed(format!("failed to parse Lua JSON result: {e}"))
1140            })
1141        })
1142    }
1143
1144    /// Search item bases by type and/or name substring.
1145    ///
1146    /// Queries `data.itemBases` in the Lua runtime. Returns base name, type,
1147    /// implicit, level req, and weapon/armour stats. Caps results at 20.
1148    pub fn query_search_bases(
1149        &self,
1150        item_type: Option<&str>,
1151        query: Option<&str>,
1152    ) -> Result<serde_json::Value, PobError> {
1153        if !self.initialized {
1154            return Err(PobError::NotInitialized);
1155        }
1156
1157        self.lua.load(LUA_SEARCH_BASES).exec()?;
1158        let search_fn: mlua::Function = self.lua.globals().get("searchBases")?;
1159
1160        let args = self.lua.create_table()?;
1161        if let Some(t) = item_type {
1162            args.set("item_type", t)?;
1163        }
1164        if let Some(q) = query {
1165            args.set("query", q)?;
1166        }
1167
1168        let result_str: String = search_fn.call(args)?;
1169        serde_json::from_str(&result_str).map_err(|e| {
1170            PobError::CalculationFailed(format!("failed to parse Lua JSON result: {e}"))
1171        })
1172    }
1173
1174    /// Search item mods by stat text, item type tag, and/or mod type.
1175    ///
1176    /// Queries `data.itemMods.Item` in the Lua runtime. Returns mod ID, affix name,
1177    /// stat text with ranges, level, group. Caps results at 20.
1178    pub fn query_search_mods(
1179        &self,
1180        query: Option<&str>,
1181        item_type_tag: Option<&str>,
1182        mod_type: Option<&str>,
1183    ) -> Result<serde_json::Value, PobError> {
1184        if !self.initialized {
1185            return Err(PobError::NotInitialized);
1186        }
1187
1188        self.lua.load(LUA_SEARCH_MODS).exec()?;
1189        let search_fn: mlua::Function = self.lua.globals().get("searchMods")?;
1190
1191        let args = self.lua.create_table()?;
1192        if let Some(q) = query {
1193            args.set("query", q)?;
1194        }
1195        if let Some(t) = item_type_tag {
1196            args.set("item_type_tag", t)?;
1197        }
1198        if let Some(m) = mod_type {
1199            args.set("mod_type", m)?;
1200        }
1201
1202        let result_str: String = search_fn.call(args)?;
1203        serde_json::from_str(&result_str).map_err(|e| {
1204            PobError::CalculationFailed(format!("failed to parse Lua JSON result: {e}"))
1205        })
1206    }
1207
1208    /// Search the gem database by name, type (active/support), and/or tags.
1209    ///
1210    /// Iterates `data.gems` in the Lua runtime. Deduplicates support gem tiers
1211    /// by `gemFamily`, keeping only the highest tier variant. Caps results at 15.
1212    pub fn query_search_gems(
1213        &self,
1214        query: Option<&str>,
1215        gem_type: Option<&str>,
1216        tags: &[String],
1217    ) -> Result<serde_json::Value, PobError> {
1218        if !self.initialized {
1219            return Err(PobError::NotInitialized);
1220        }
1221
1222        self.lua.load(LUA_SEARCH_GEMS).exec()?;
1223        let search_fn: mlua::Function = self.lua.globals().get("searchGems")?;
1224
1225        // Build args table for Lua
1226        let args = self.lua.create_table()?;
1227        if let Some(q) = query {
1228            args.set("query", q)?;
1229        }
1230        if let Some(t) = gem_type {
1231            args.set("gem_type", t)?;
1232        }
1233        if !tags.is_empty() {
1234            let tags_table = self.lua.create_table()?;
1235            for (i, tag) in tags.iter().enumerate() {
1236                tags_table.set(i as i64 + 1, tag.as_str())?;
1237            }
1238            args.set("tags", tags_table)?;
1239        }
1240
1241        let result_str: String = search_fn.call(args)?;
1242        serde_json::from_str(&result_str).map_err(|e| {
1243            PobError::CalculationFailed(format!("failed to parse Lua JSON result: {e}"))
1244        })
1245    }
1246
1247    /// Search the unique item database by name, slot, and/or level range.
1248    ///
1249    /// Iterates `data.uniques` in the Lua runtime. Parses raw item text to
1250    /// extract name, base, mods, and variant info. Caps results at 15.
1251    pub fn query_search_uniques(
1252        &self,
1253        query: Option<&str>,
1254        slot: Option<&str>,
1255        min_level: Option<u32>,
1256        max_level: Option<u32>,
1257    ) -> Result<serde_json::Value, PobError> {
1258        if !self.initialized {
1259            return Err(PobError::NotInitialized);
1260        }
1261
1262        self.lua.load(LUA_SEARCH_UNIQUES).exec()?;
1263        let search_fn: mlua::Function = self.lua.globals().get("searchUniques")?;
1264
1265        // Build args table for Lua
1266        let args = self.lua.create_table()?;
1267        if let Some(q) = query {
1268            args.set("query", q)?;
1269        }
1270        if let Some(s) = slot {
1271            args.set("slot", s)?;
1272        }
1273        if let Some(min) = min_level {
1274            args.set("min_level", min)?;
1275        }
1276        if let Some(max) = max_level {
1277            args.set("max_level", max)?;
1278        }
1279
1280        let result_str: String = search_fn.call(args)?;
1281        serde_json::from_str(&result_str).map_err(|e| {
1282            PobError::CalculationFailed(format!("failed to parse Lua JSON result: {e}"))
1283        })
1284    }
1285
1286    /// List all charm bases with trigger condition, buff effect, duration, and charges.
1287    ///
1288    /// Iterates `data.itemBases` in the Lua runtime, filtering for items with
1289    /// `base.type == "Charm"`. Returns all 13 charms sorted by level requirement.
1290    pub fn query_list_charms(&self) -> Result<serde_json::Value, PobError> {
1291        if !self.initialized {
1292            return Err(PobError::NotInitialized);
1293        }
1294
1295        self.lua.load(LUA_LIST_CHARMS).exec()?;
1296        let list_fn: mlua::Function = self.lua.globals().get("listCharms")?;
1297
1298        let result_str: String = list_fn.call(())?;
1299        serde_json::from_str(&result_str).map_err(|e| {
1300            PobError::CalculationFailed(format!("failed to parse Lua JSON result: {e}"))
1301        })
1302    }
1303
1304    /// Search the rune and soul core database by name, stat text, and/or slot.
1305    ///
1306    /// Iterates `data.itemMods.Runes` in the Lua runtime. Runes give different
1307    /// bonuses per equipment slot. Caps results at 15.
1308    pub fn query_search_runes(
1309        &self,
1310        query: Option<&str>,
1311        slot: Option<&str>,
1312    ) -> Result<serde_json::Value, PobError> {
1313        if !self.initialized {
1314            return Err(PobError::NotInitialized);
1315        }
1316
1317        self.lua.load(LUA_SEARCH_RUNES).exec()?;
1318        let search_fn: mlua::Function = self.lua.globals().get("searchRunes")?;
1319
1320        let args = self.lua.create_table()?;
1321        if let Some(q) = query {
1322            args.set("query", q)?;
1323        }
1324        if let Some(s) = slot {
1325            args.set("slot", s)?;
1326        }
1327
1328        let result_str: String = search_fn.call(args)?;
1329        serde_json::from_str(&result_str).map_err(|e| {
1330            PobError::CalculationFailed(format!("failed to parse Lua JSON result: {e}"))
1331        })
1332    }
1333
1334    /// Create an item from PoB item text, equip it in the given slot, and return
1335    /// the created item details plus a before/after stat delta.
1336    ///
1337    /// The item text must follow PoB's newline-delimited format:
1338    /// `Rarity: RARE\nTitle\nBase Type\nItem Level: 86\nImplicits: 1\n+10 to Str\n+50 Life`
1339    ///
1340    /// The mutation is applied to the in-memory Lua state only. The build XML is
1341    /// not modified — subsequent queries that reload XML will see the original build.
1342    pub fn create_item(&self, slot: &str, item_text: &str) -> Result<serde_json::Value, PobError> {
1343        if !self.initialized {
1344            return Err(PobError::NotInitialized);
1345        }
1346
1347        let result_str = self.with_pob_cwd(|lua| {
1348            lua.load(LUA_CREATE_ITEM).exec()?;
1349            let create_fn: mlua::Function = lua.globals().get("createItemInSlot")?;
1350
1351            let args = lua.create_table()?;
1352            args.set("slot", slot)?;
1353            args.set("item_text", item_text)?;
1354
1355            let result: String = create_fn.call(args)?;
1356            Ok(result)
1357        })?;
1358
1359        serde_json::from_str(&result_str).map_err(|e| {
1360            PobError::CalculationFailed(format!("failed to parse Lua JSON result: {e}"))
1361        })
1362    }
1363
1364    /// Analyze gear mods: tier info, roll quality, and upgrade potential.
1365    ///
1366    /// Runs a Lua-side analysis that leverages PoB's native data structures
1367    /// (`item.affixes`, `item.prefixes`/`item.suffixes`) to compute tier
1368    /// position, roll quality, and best available tier at item level.
1369    ///
1370    /// Returns JSON serialized by dkjson on the Lua side.
1371    pub fn query_gear_mod_analysis(&self, slot: &str) -> Result<serde_json::Value, PobError> {
1372        if !self.initialized {
1373            return Err(PobError::NotInitialized);
1374        }
1375
1376        let build: mlua::Table = self.lua.globals().get("build")?;
1377        let items_tab: mlua::Table = build.get("itemsTab")?;
1378        let active_item_set: mlua::Table = items_tab.get("activeItemSet")?;
1379
1380        // Look up the slot in the active item set
1381        let slot_entry: mlua::Table = match active_item_set.get::<mlua::Table>(slot) {
1382            Ok(t) => t,
1383            Err(_) => return Ok(serde_json::json!({ "slot": slot, "empty": true })),
1384        };
1385
1386        let sel_item_id: i64 = lua_get!(slot_entry, "selItemId" => i64);
1387        if sel_item_id == 0 {
1388            return Ok(serde_json::json!({ "slot": slot, "empty": true }));
1389        }
1390
1391        let items: mlua::Table = items_tab.get("items")?;
1392        let item: mlua::Table = match items.get::<mlua::Table>(sel_item_id) {
1393            Ok(t) => t,
1394            Err(_) => return Ok(serde_json::json!({ "slot": slot, "empty": true })),
1395        };
1396
1397        // Register and call the Lua analysis function
1398        self.lua.load(LUA_GEAR_MOD_ANALYSIS).exec()?;
1399        let analyze_fn: mlua::Function = self.lua.globals().get("analyzeItemMods")?;
1400        let result_str: String = analyze_fn.call(item)?;
1401
1402        // Parse the JSON result and inject the slot name
1403        let mut result: serde_json::Value = serde_json::from_str(&result_str).map_err(|e| {
1404            PobError::CalculationFailed(format!("failed to parse Lua JSON result: {e}"))
1405        })?;
1406
1407        if let Some(obj) = result.as_object_mut() {
1408            obj.insert(
1409                "slot".to_owned(),
1410                serde_json::Value::String(slot.to_owned()),
1411            );
1412        }
1413
1414        Ok(result)
1415    }
1416
1417    /// Run a closure with CWD set to the PoB `src/` directory,
1418    /// restoring the original CWD afterwards.
1419    fn with_pob_cwd<F, R>(&self, f: F) -> Result<R, PobError>
1420    where
1421        F: FnOnce(&Lua) -> LuaResult<R>,
1422    {
1423        let pob_src = self.pob_src_path.as_ref().ok_or(PobError::NotInitialized)?;
1424        let original_cwd = std::env::current_dir()?;
1425        std::env::set_current_dir(pob_src)?;
1426        let result = f(&self.lua);
1427        std::env::set_current_dir(original_cwd)?;
1428        Ok(result?)
1429    }
1430
1431    /// Export current build to PoB code.
1432    pub fn export_build(&self) -> Result<String, PobError> {
1433        if !self.initialized {
1434            return Err(PobError::NotInitialized);
1435        }
1436
1437        // TODO: Generate PoB export code
1438        Ok(String::new())
1439    }
1440
1441    /// Modify the passive tree.
1442    pub fn set_passive_tree(&self, _tree_data: &str) -> Result<(), PobError> {
1443        if !self.initialized {
1444            return Err(PobError::NotInitialized);
1445        }
1446        // TODO: Update passive tree in PoB
1447        Ok(())
1448    }
1449
1450    /// Set the main skill.
1451    pub fn set_main_skill(&self, _skill_name: &str) -> Result<(), PobError> {
1452        if !self.initialized {
1453            return Err(PobError::NotInitialized);
1454        }
1455        // TODO: Set main skill in PoB
1456        Ok(())
1457    }
1458}
1459
1460impl Default for PobHeadless {
1461    fn default() -> Self {
1462        Self::new().expect("Failed to create Lua runtime")
1463    }
1464}
1465
1466// SAFETY: PobHeadless is !Send because mlua::Lua with LuaJIT is !Send.
1467// PobParser handles this by pinning it to a dedicated OS thread.
1468
1469/// Lua function that computes a detailed DPS breakdown for a specific skill.
1470///
1471/// Finds the skill in the active skill list by case-insensitive substring match,
1472/// sets it as the main skill, recalculates via `calcs.perform`, and reads the
1473/// detailed output fields. Uses dkjson for JSON serialization.
1474const LUA_SKILL_BREAKDOWN: &str = r#"
1475local dkjson = require("dkjson")
1476
1477function getSkillBreakdown(skillName)
1478    local calcs = LoadModule("Calcs")
1479    local env = calcs.buildOutput(build, "CALCS")
1480
1481    if not env or not env.player or not env.player.activeSkillList then
1482        return dkjson.encode({ error = "Failed to build calculation environment" })
1483    end
1484
1485    -- Find the skill by case-insensitive substring match
1486    local searchName = skillName:lower()
1487    local matchedSkill = nil
1488
1489    for _, skill in ipairs(env.player.activeSkillList) do
1490        if skill.activeEffect and skill.activeEffect.grantedEffect then
1491            local name = skill.activeEffect.grantedEffect.name or ""
1492            if name:lower():find(searchName, 1, true) then
1493                matchedSkill = skill
1494                break
1495            end
1496        end
1497    end
1498
1499    if not matchedSkill then
1500        -- List available skills for the error message
1501        local available = {}
1502        for _, skill in ipairs(env.player.activeSkillList) do
1503            if skill.activeEffect and skill.activeEffect.grantedEffect then
1504                local name = skill.activeEffect.grantedEffect.name
1505                if name and name ~= "" then
1506                    table.insert(available, name)
1507                end
1508            end
1509        end
1510        return dkjson.encode({
1511            error = "Skill not found: " .. skillName,
1512            available_skills = available,
1513        })
1514    end
1515
1516    -- Set as main skill and recalculate
1517    env.player.mainSkill = matchedSkill
1518    env.player.output = {}
1519    calcs.perform(env)
1520
1521    local output = env.player.output or {}
1522    local skillName = matchedSkill.activeEffect.grantedEffect.name or skillName
1523
1524    -- Helper to safely read a number
1525    local function num(key)
1526        local v = output[key]
1527        if type(v) == "number" then return v end
1528        return nil
1529    end
1530
1531    -- Read damage type min/max
1532    local damageTypes = {}
1533    for _, dtype in ipairs({"Physical", "Fire", "Cold", "Lightning", "Chaos"}) do
1534        local min = num(dtype .. "Min")
1535        local max = num(dtype .. "Max")
1536        if min and max and (min > 0 or max > 0) then
1537            damageTypes[dtype:lower()] = { min = min, max = max }
1538        end
1539    end
1540
1541    -- Read conversion table if present
1542    local conversions = {}
1543    if matchedSkill.conversionTable then
1544        for fromType, convTable in pairs(matchedSkill.conversionTable) do
1545            if type(convTable) == "table" then
1546                for toType, fraction in pairs(convTable) do
1547                    if type(fraction) == "number" and fraction > 0 and toType ~= "mult" then
1548                        local key = fromType:lower() .. "_to_" .. toType:lower()
1549                        conversions[key] = math.floor(fraction * 100 + 0.5)
1550                    end
1551                end
1552            end
1553        end
1554    end
1555
1556    -- Build flags from skillData
1557    local flags = {}
1558    local skillData = matchedSkill.skillData or {}
1559    if matchedSkill.skillFlags then
1560        flags.is_attack = matchedSkill.skillFlags.attack or false
1561        flags.is_spell = matchedSkill.skillFlags.spell or false
1562        flags.is_projectile = matchedSkill.skillFlags.projectile or false
1563        flags.is_area = matchedSkill.skillFlags.area or false
1564        flags.is_melee = matchedSkill.skillFlags.melee or false
1565        flags.is_totem = matchedSkill.skillFlags.totem or false
1566        flags.is_trap = matchedSkill.skillFlags.trap or false
1567        flags.is_mine = matchedSkill.skillFlags.mine or false
1568    end
1569
1570    local result = {
1571        skill_name = skillName,
1572        total_dps = num("TotalDPS"),
1573        average_hit = num("AverageHit"),
1574        crit_chance = num("CritChance"),
1575        crit_multiplier = num("CritMultiplier"),
1576        hit_chance = num("HitChance"),
1577        speed = num("Speed"),
1578        damage_types = damageTypes,
1579        conversions = conversions,
1580        ailments = {
1581            bleed_dps = num("BleedDPS"),
1582            ignite_dps = num("IgniteDPS"),
1583            poison_dps = num("PoisonDPS"),
1584        },
1585        combined_dps = num("CombinedDPS"),
1586        flags = flags,
1587    }
1588
1589    return dkjson.encode(result)
1590end
1591"#;
1592
1593/// Lua function that analyzes an item's mods against the full affix database.
1594///
1595/// Lua function that searches the gem database (`data.gems`).
1596///
1597/// Accepts a table with optional `query` (name substring), `gem_type`
1598/// Lua function that searches item bases (`data.itemBases`).
1599///
1600/// Accepts a table with optional `item_type` (e.g. "Bow", "Helmet") and `query`
1601/// (case-insensitive name substring). At least one must be provided.
1602/// Returns up to 20 results sorted by level_req ascending.
1603const LUA_SEARCH_BASES: &str = r#"
1604local dkjson = require("dkjson")
1605
1606function searchBases(args)
1607    local itemType = args.item_type or nil
1608    local query = args.query and args.query:lower() or nil
1609
1610    local matches = {}
1611
1612    for baseName, base in pairs(data.itemBases) do
1613        local dominated = false
1614
1615        -- Type filter
1616        if itemType and base.type ~= itemType then
1617            dominated = true
1618        end
1619
1620        -- Name substring filter
1621        if not dominated and query and not baseName:lower():find(query, 1, true) then
1622            dominated = true
1623        end
1624
1625        if not dominated then
1626            local entry = {
1627                name = baseName,
1628                type = base.type or "",
1629                level_req = base.req and base.req.level or 0,
1630            }
1631
1632            -- Implicit mods
1633            if base.implicit then
1634                entry.implicit = base.implicit
1635            end
1636
1637            -- Weapon stats
1638            if base.weapon then
1639                local w = base.weapon
1640                entry.weapon = {
1641                    type = w.type or "",
1642                    min = w.min or 0,
1643                    max = w.max or 0,
1644                    base_speed = w.attackRateBase or 0,
1645                    crit_chance = w.CritChance or 0,
1646                }
1647            end
1648
1649            -- Armour stats
1650            if base.armour then
1651                local a = base.armour
1652                local armourInfo = {}
1653                if (a.ArmourBase or 0) > 0 then armourInfo.armour = a.ArmourBase end
1654                if (a.EvasionBase or 0) > 0 then armourInfo.evasion = a.EvasionBase end
1655                if (a.EnergyShieldBase or 0) > 0 then armourInfo.energy_shield = a.EnergyShieldBase end
1656                if (a.WardBase or 0) > 0 then armourInfo.ward = a.WardBase end
1657                if next(armourInfo) then
1658                    entry.armour = armourInfo
1659                end
1660            end
1661
1662            -- Tags (useful for mod searches)
1663            if base.tags then
1664                local tagList = {}
1665                for tag, _ in pairs(base.tags) do
1666                    table.insert(tagList, tag)
1667                end
1668                if #tagList > 0 then
1669                    table.sort(tagList)
1670                    entry.tags = tagList
1671                end
1672            end
1673
1674            table.insert(matches, entry)
1675        end
1676    end
1677
1678    local totalResults = #matches
1679
1680    -- Sort by level_req ascending
1681    table.sort(matches, function(a, b)
1682        if a.level_req ~= b.level_req then
1683            return a.level_req < b.level_req
1684        end
1685        return a.name < b.name
1686    end)
1687
1688    -- Cap at 20 results
1689    local results = {}
1690    local cap = 20
1691    for i = 1, math.min(cap, #matches) do
1692        table.insert(results, matches[i])
1693    end
1694
1695    return dkjson.encode({
1696        results = results,
1697        total_results = totalResults,
1698    })
1699end
1700"#;
1701
1702/// Lua function that searches item mods (`data.itemMods.Item`).
1703///
1704/// Accepts a table with optional `query` (stat text substring), `item_type_tag`
1705/// (filter to mods with weight > 0 for this tag), and `mod_type` ("prefix"/"suffix").
1706/// At least one of `query` or `item_type_tag` must be provided.
1707/// Returns up to 20 results sorted by level descending (highest tier first).
1708const LUA_SEARCH_MODS: &str = r#"
1709local dkjson = require("dkjson")
1710
1711function searchMods(args)
1712    local query = args.query and args.query:lower() or nil
1713    local itemTypeTag = args.item_type_tag and args.item_type_tag:lower() or nil
1714    local modTypeFilter = args.mod_type and args.mod_type:lower() or nil
1715
1716    local matches = {}
1717
1718    -- data.itemMods.Item is keyed by mod group (prefix/suffix pools)
1719    -- Each entry: { [1] = "stat line", [2] = "stat line", ..., level = N,
1720    --   group = "...", type = "Prefix"/"Suffix", affix = "...",
1721    --   weightKey = {...}, weightVal = {...} }
1722    for modId, mod in pairs(data.itemMods.Item) do
1723        local dominated = false
1724
1725        -- Mod type filter (prefix/suffix)
1726        if modTypeFilter then
1727            local mtype = (mod.type or ""):lower()
1728            if mtype ~= modTypeFilter then
1729                dominated = true
1730            end
1731        end
1732
1733        -- Item type tag filter: check weightKey/weightVal arrays
1734        if not dominated and itemTypeTag then
1735            local hasWeight = false
1736            if mod.weightKey and mod.weightVal then
1737                for i, key in ipairs(mod.weightKey) do
1738                    if key:lower() == itemTypeTag and (mod.weightVal[i] or 0) > 0 then
1739                        hasWeight = true
1740                        break
1741                    end
1742                end
1743            end
1744            if not hasWeight then
1745                dominated = true
1746            end
1747        end
1748
1749        -- Stat text query filter
1750        if not dominated and query then
1751            local found = false
1752            -- Check affix name
1753            if mod.affix and mod.affix:lower():find(query, 1, true) then
1754                found = true
1755            end
1756            -- Check stat lines (numeric array entries)
1757            if not found then
1758                local i = 1
1759                while mod[i] do
1760                    if mod[i]:lower():find(query, 1, true) then
1761                        found = true
1762                        break
1763                    end
1764                    i = i + 1
1765                end
1766            end
1767            if not found then
1768                dominated = true
1769            end
1770        end
1771
1772        if not dominated then
1773            -- Collect stat lines
1774            local statLines = {}
1775            local i = 1
1776            while mod[i] do
1777                table.insert(statLines, mod[i])
1778                i = i + 1
1779            end
1780
1781            local entry = {
1782                mod_id = modId,
1783                affix = mod.affix or "",
1784                type = mod.type or "",
1785                level = mod.level or 0,
1786                group = mod.group or "",
1787                stats = statLines,
1788            }
1789
1790            table.insert(matches, entry)
1791        end
1792    end
1793
1794    local totalResults = #matches
1795
1796    -- Sort by level descending (highest tier first)
1797    table.sort(matches, function(a, b)
1798        if a.level ~= b.level then
1799            return a.level > b.level
1800        end
1801        return a.mod_id < b.mod_id
1802    end)
1803
1804    -- Cap at 20 results
1805    local results = {}
1806    local cap = 20
1807    for i = 1, math.min(cap, #matches) do
1808        table.insert(results, matches[i])
1809    end
1810
1811    return dkjson.encode({
1812        results = results,
1813        total_results = totalResults,
1814    })
1815end
1816"#;
1817
1818/// ("active"/"support"), and `tags` (array of tag names, AND logic).
1819/// Deduplicates support gem tiers by `gemFamily`, keeping only the highest tier.
1820/// Returns up to 15 results sorted alphabetically, with `total_results` count.
1821const LUA_SEARCH_GEMS: &str = r#"
1822local dkjson = require("dkjson")
1823
1824function searchGems(args)
1825    local query = args.query and args.query:lower() or nil
1826    local gemType = args.gem_type and args.gem_type:lower() or nil
1827    local tags = args.tags or {}
1828
1829    local matches = {}
1830
1831    for gemId, gem in pairs(data.gems) do
1832        local dominated = false
1833
1834        -- Name filter
1835        if query and not (gem.name or ""):lower():find(query, 1, true) then
1836            dominated = true
1837        end
1838
1839        -- Type filter
1840        if not dominated and gemType then
1841            if gemType == "active" then
1842                if not (gem.tags and gem.tags.grants_active_skill) then
1843                    dominated = true
1844                end
1845            elseif gemType == "support" then
1846                if gem.gemType ~= "Support" then
1847                    dominated = true
1848                end
1849            end
1850        end
1851
1852        -- Tag filter (AND logic)
1853        if not dominated and #tags > 0 then
1854            for _, tag in ipairs(tags) do
1855                if not (gem.tags and gem.tags[tag:lower()]) then
1856                    dominated = true
1857                    break
1858                end
1859            end
1860        end
1861
1862        if not dominated then
1863            table.insert(matches, gem)
1864        end
1865    end
1866
1867    -- Deduplicate support gem tiers: keep highest tier per gemFamily
1868    local deduped = {}
1869    local familySeen = {}
1870    for _, gem in ipairs(matches) do
1871        if gem.gemType == "Support" and gem.gemFamily and gem.gemFamily ~= "" then
1872            local existing = familySeen[gem.gemFamily]
1873            if existing then
1874                if (gem.Tier or 1) > (existing.Tier or 1) then
1875                    familySeen[gem.gemFamily] = gem
1876                end
1877            else
1878                familySeen[gem.gemFamily] = gem
1879            end
1880        else
1881            table.insert(deduped, gem)
1882        end
1883    end
1884    for _, gem in pairs(familySeen) do
1885        table.insert(deduped, gem)
1886    end
1887
1888    local totalResults = #deduped
1889
1890    -- Sort alphabetically by name
1891    table.sort(deduped, function(a, b) return (a.name or "") < (b.name or "") end)
1892
1893    -- Cap at 15 results
1894    local results = {}
1895    local cap = 15
1896    for i = 1, math.min(cap, #deduped) do
1897        local gem = deduped[i]
1898        local entry = {
1899            name = gem.name,
1900            gem_type = gem.gemType,
1901            tags = gem.tagString or "",
1902            tier = gem.Tier or 1,
1903            max_level = gem.naturalMaxLevel or 20,
1904            requirements = {
1905                str = gem.reqStr or 0,
1906                dex = gem.reqDex or 0,
1907                int = gem.reqInt or 0,
1908            },
1909        }
1910        if gem.gemFamily and gem.gemFamily ~= "" and gem.gemFamily ~= gem.name then
1911            entry.family = gem.gemFamily
1912        end
1913        table.insert(results, entry)
1914    end
1915
1916    return dkjson.encode({
1917        results = results,
1918        total_results = totalResults,
1919    })
1920end
1921"#;
1922
1923/// Lua function that searches the unique item database (`data.uniques`).
1924///
1925/// Accepts a table with optional `query` (name/mod substring), `slot` (slot type
1926/// substring), `min_level`, and `max_level`. Parses each unique item's raw text
1927/// to extract name, base, mods, and variant info. Resolves slot type and level
1928/// requirement from `data.itemBases`. Returns up to 15 results sorted
1929/// alphabetically, with `total_results` count.
1930const LUA_SEARCH_UNIQUES: &str = r#"
1931local dkjson = require("dkjson")
1932
1933function searchUniques(args)
1934    local query = args.query and args.query:lower() or nil
1935    local slotFilter = args.slot and args.slot:lower() or nil
1936    local minLevel = args.min_level
1937    local maxLevel = args.max_level
1938
1939    local matches = {}
1940
1941    for slotKey, items in pairs(data.uniques) do
1942        if slotKey ~= "generated" then
1943            for _, rawText in ipairs(items) do
1944                -- Parse the multi-line raw string
1945                local lines = {}
1946                for line in rawText:gmatch("[^\n]+") do
1947                    table.insert(lines, line)
1948                end
1949
1950                if #lines < 2 then
1951                    goto continue
1952                end
1953
1954                local itemName = lines[1]
1955                local baseName = lines[2]
1956
1957                -- Collect metadata and mods from remaining lines
1958                local variantNames = {}
1959                local variantCount = 0
1960                local explicitLevelReq = nil
1961                local modLines = {}  -- { text = "...", variants = {1,2} or nil }
1962
1963                for i = 3, #lines do
1964                    local line = lines[i]
1965
1966                    -- Variant tracking
1967                    local varName = line:match("^Variant: (.+)$")
1968                    if varName then
1969                        variantCount = variantCount + 1
1970                        table.insert(variantNames, varName)
1971                        goto nextline
1972                    end
1973
1974                    -- Skip metadata lines
1975                    if line:match("^Implicits: %d+") then goto nextline end
1976                    if line:match("^League:") then goto nextline end
1977                    if line:match("^Source:") then goto nextline end
1978                    if line:match("^Limited to:") then goto nextline end
1979                    if line:match("^Sockets:") then goto nextline end
1980                    if line:match("^Has Alt Variant:") then goto nextline end
1981
1982                    -- Explicit level requirement
1983                    local lvl = line:match("^Requires Level (%d+)")
1984                    if lvl then
1985                        explicitLevelReq = tonumber(lvl)
1986                        goto nextline
1987                    end
1988
1989                    -- Check for variant-gated mod: {variant:N} or {variant:N,M,...}
1990                    local variantGate = nil
1991                    local afterVariant = line:match("^{variant:([^}]+)}(.+)$")
1992                    if afterVariant then
1993                        variantGate = {}
1994                        local nums = line:match("^{variant:([^}]+)}")
1995                        for n in nums:gmatch("%d+") do
1996                            table.insert(variantGate, tonumber(n))
1997                        end
1998                        line = afterVariant
1999                    end
2000
2001                    -- Strip {tags:...} prefix
2002                    line = line:gsub("^{tags:[^}]+}", "")
2003
2004                    -- Strip {range:...} prefix
2005                    line = line:gsub("^{range:[^}]+}", "")
2006
2007                    if line ~= "" then
2008                        table.insert(modLines, { text = line, variants = variantGate })
2009                    end
2010
2011                    ::nextline::
2012                end
2013
2014                -- Variant handling: select "current" variant (last one)
2015                local currentVariant = variantCount > 0 and variantCount or nil
2016                local displayMods = {}
2017                for _, mod in ipairs(modLines) do
2018                    if mod.variants then
2019                        -- Only include if current variant is in the list
2020                        if currentVariant then
2021                            local included = false
2022                            for _, v in ipairs(mod.variants) do
2023                                if v == currentVariant then
2024                                    included = true
2025                                    break
2026                                end
2027                            end
2028                            if included then
2029                                table.insert(displayMods, mod.text)
2030                            end
2031                        end
2032                    else
2033                        -- No variant gate: always included
2034                        table.insert(displayMods, mod.text)
2035                    end
2036                end
2037
2038                -- Resolve slot type and level requirement from item base data
2039                local baseData = data.itemBases[baseName]
2040                local slotType = baseData and baseData.type or "Unknown"
2041                local levelReq = baseData and baseData.req and baseData.req.level or 0
2042                if explicitLevelReq then
2043                    levelReq = explicitLevelReq
2044                end
2045
2046                -- Apply filters
2047                -- Query: match against name or any mod text
2048                if query then
2049                    local matched = false
2050                    if itemName:lower():find(query, 1, true) then
2051                        matched = true
2052                    end
2053                    if not matched then
2054                        for _, modText in ipairs(displayMods) do
2055                            if modText:lower():find(query, 1, true) then
2056                                matched = true
2057                                break
2058                            end
2059                        end
2060                    end
2061                    if not matched then goto continue end
2062                end
2063
2064                -- Slot filter
2065                if slotFilter then
2066                    if not slotType:lower():find(slotFilter, 1, true) then
2067                        goto continue
2068                    end
2069                end
2070
2071                -- Level range filter
2072                if minLevel and levelReq < minLevel then goto continue end
2073                if maxLevel and levelReq > maxLevel then goto continue end
2074
2075                table.insert(matches, {
2076                    name = itemName,
2077                    base = baseName,
2078                    slot = slotType,
2079                    level_req = levelReq,
2080                    mods = displayMods,
2081                })
2082
2083                ::continue::
2084            end
2085        end
2086    end
2087
2088    local totalResults = #matches
2089
2090    -- Sort alphabetically by name
2091    table.sort(matches, function(a, b) return a.name < b.name end)
2092
2093    -- Cap at 15 results
2094    local results = {}
2095    for i = 1, math.min(15, #matches) do
2096        table.insert(results, matches[i])
2097    end
2098
2099    return dkjson.encode({
2100        results = results,
2101        total_results = totalResults,
2102    })
2103end
2104"#;
2105
2106/// Lua function that lists all charm bases from `data.itemBases`.
2107///
2108/// No parameters. Filters for `base.type == "Charm"` and extracts trigger
2109/// condition (`implicit`), buff effect (`charm.buff[1]`), duration, and charge
2110/// info. Returns all 13 charms sorted by level requirement ascending.
2111const LUA_LIST_CHARMS: &str = r#"
2112local dkjson = require("dkjson")
2113
2114function listCharms()
2115    local charms = {}
2116
2117    for name, base in pairs(data.itemBases) do
2118        if base.type == "Charm" then
2119            local charm = base.charm or {}
2120            local buff = ""
2121            if charm.buff and charm.buff[1] then
2122                buff = charm.buff[1]
2123            end
2124            table.insert(charms, {
2125                name = name,
2126                trigger = base.implicit or "",
2127                buff = buff,
2128                duration = charm.duration or 0,
2129                charges_used = charm.chargesUsed or 0,
2130                charges_max = charm.chargesMax or 0,
2131                level_req = base.req and base.req.level or 0,
2132            })
2133        end
2134    end
2135
2136    -- Sort by level requirement ascending
2137    table.sort(charms, function(a, b) return a.level_req < b.level_req end)
2138
2139    return dkjson.encode({ charms = charms })
2140end
2141"#;
2142
2143/// Lua function that creates an item from PoB text format, equips it in a slot,
2144/// recalculates, and returns item details + stat delta + exported XML.
2145///
2146/// Accepts a table `{ slot = "Weapon 1", item_text = "Rarity: RARE\n..." }`.
2147/// Returns JSON with `slot`, `item`, `delta`, and `xml` fields on success,
2148/// or `{ error = "..." }` on failure.
2149const LUA_CREATE_ITEM: &str = r#"
2150local dkjson = require("dkjson")
2151
2152function createItemInSlot(args)
2153    local slotName = args.slot
2154    local itemText = args.item_text
2155    local mainOutput = build.calcsTab.mainOutput
2156
2157    local STAT_FIELDS = {
2158        -- Offense
2159        "TotalDPS", "CombinedDPS", "AverageDamage", "Speed",
2160        "CritChance", "CritMultiplier",
2161        -- Resources
2162        "Life", "EnergyShield", "Mana", "Spirit",
2163        -- Defense
2164        "Armour", "Evasion", "Ward",
2165        -- Resistances
2166        "FireResist", "ColdResist", "LightningResist", "ChaosResist",
2167    }
2168
2169    -- 1. Snapshot stats before
2170    local before = {}
2171    for _, field in ipairs(STAT_FIELDS) do
2172        before[field] = mainOutput[field] or 0
2173    end
2174
2175    -- 2. Parse item from PoB text format
2176    local item = new("Item", itemText)
2177
2178    -- 3. Validate: base type must be recognized
2179    if not item.base then
2180        -- Try to extract the attempted base name from item text for fuzzy matching
2181        local suggestions = {}
2182        local attemptedBase = nil
2183        local lines = {}
2184        for line in itemText:gmatch("[^\n]+") do
2185            table.insert(lines, line)
2186        end
2187        -- For RARE/UNIQUE: base is line after title (3rd content line after Rarity)
2188        -- For NORMAL/MAGIC: base is 2nd content line after Rarity
2189        local contentLines = {}
2190        for _, line in ipairs(lines) do
2191            local trimmed = line:match("^%s*(.-)%s*$")
2192            if trimmed ~= "" and not trimmed:match("^Rarity:") then
2193                table.insert(contentLines, trimmed)
2194            end
2195        end
2196        if #contentLines >= 2 then
2197            -- Check rarity to determine which line is the base
2198            local rarityLine = ""
2199            for _, line in ipairs(lines) do
2200                if line:match("^Rarity:") then
2201                    rarityLine = line:upper()
2202                    break
2203                end
2204            end
2205            if rarityLine:find("RARE") or rarityLine:find("UNIQUE") then
2206                attemptedBase = contentLines[2]
2207            else
2208                attemptedBase = contentLines[1]
2209            end
2210        end
2211
2212        -- Search for similar base names
2213        if attemptedBase then
2214            local searchLower = attemptedBase:lower()
2215            for baseName, _ in pairs(data.itemBases) do
2216                if baseName:lower():find(searchLower, 1, true) or searchLower:find(baseName:lower(), 1, true) then
2217                    table.insert(suggestions, baseName)
2218                    if #suggestions >= 5 then break end
2219                end
2220            end
2221            -- If no substring match, try matching individual words
2222            if #suggestions == 0 then
2223                for word in searchLower:gmatch("%S+") do
2224                    if #word >= 3 then
2225                        for baseName, _ in pairs(data.itemBases) do
2226                            if baseName:lower():find(word, 1, true) then
2227                                table.insert(suggestions, baseName)
2228                                if #suggestions >= 5 then break end
2229                            end
2230                        end
2231                        if #suggestions >= 5 then break end
2232                    end
2233                end
2234            end
2235        end
2236
2237        local msg = "Invalid item text: base type not recognized by PoB."
2238        if attemptedBase then
2239            msg = msg .. " Attempted base: '" .. attemptedBase .. "'."
2240        end
2241        if #suggestions > 0 then
2242            table.sort(suggestions)
2243            msg = msg .. " Did you mean: " .. table.concat(suggestions, ", ") .. "?"
2244        end
2245        msg = msg .. " Use the search_bases tool to find valid base type names."
2246        return dkjson.encode({ error = msg })
2247    end
2248    if not item.type then
2249        return dkjson.encode({
2250            error = "Item parsed but type could not be determined. " ..
2251                    "Ensure the base type is a valid PoE2 item base.",
2252        })
2253    end
2254
2255    -- 4. Add to build item list (noAutoEquip = true)
2256    local ok, err = pcall(function() build.itemsTab:AddItem(item, true) end)
2257    if not ok then
2258        return dkjson.encode({
2259            error = "Failed to add item to build (headless GUI issue): " .. tostring(err),
2260        })
2261    end
2262
2263    -- 5. Equip in the named slot
2264    local activeSet = build.itemsTab.activeItemSet
2265    if activeSet[slotName] == nil then
2266        return dkjson.encode({
2267            error = "Unknown slot: '" .. slotName .. "'. " ..
2268                    "Valid slots: Weapon 1, Weapon 2, Helmet, Body Armour, Gloves, Boots, " ..
2269                    "Amulet, Ring 1, Ring 2, Ring 3, Belt, Charm 1, Charm 2, Charm 3, " ..
2270                    "Flask 1, Flask 2.",
2271        })
2272    end
2273    activeSet[slotName].selItemId = item.id
2274
2275    -- 6. Trigger recalculation (headless has no frame loop; must call manually)
2276    build.buildFlag = true
2277    runCallback("OnFrame")
2278
2279    -- 7. Snapshot stats after and compute delta
2280    local after = {}
2281    local changed = {}
2282    for _, field in ipairs(STAT_FIELDS) do
2283        local a = mainOutput[field] or 0
2284        after[field] = a
2285        local b = before[field]
2286        if a ~= b then
2287            changed[field] = { before = b, after = a, delta = a - b }
2288        end
2289    end
2290
2291    -- 8. Export the mutated build XML
2292    local xmlText = build:SaveDB("code")
2293    if not xmlText then
2294        return dkjson.encode({ error = "Failed to export build XML after mutation." })
2295    end
2296
2297    -- 9. Read back item details
2298    local implicits = {}
2299    for _, line in ipairs(item.implicitModLines or {}) do
2300        table.insert(implicits, line.line or "")
2301    end
2302    local explicits = {}
2303    local matchedMods = 0
2304    local unmatchedMods = {}
2305    for _, line in ipairs(item.explicitModLines or {}) do
2306        table.insert(explicits, line.line or "")
2307        -- Check if modList is populated (non-empty = mod was recognized by PoB)
2308        if line.modList and #line.modList > 0 then
2309            matchedMods = matchedMods + 1
2310        else
2311            table.insert(unmatchedMods, line.line or "")
2312        end
2313    end
2314
2315    return dkjson.encode({
2316        slot = slotName,
2317        item = {
2318            name = item.name or "",
2319            base = item.baseName or "",
2320            rarity = ({"NORMAL","MAGIC","RARE","UNIQUE"})[item.rarity + 1] or "UNKNOWN",
2321            type = item.type or "",
2322            quality = item.quality or 0,
2323            sockets = item.itemSocketCount or 0,
2324            implicits = implicits,
2325            explicits = explicits,
2326        },
2327        matched_mods = matchedMods,
2328        unmatched_mods = unmatchedMods,
2329        delta = {
2330            changed = changed,
2331        },
2332        -- Consumed by execute_tool to queue the mutation; stripped before LLM sees it
2333        xml = xmlText,
2334    })
2335end
2336"#;
2337
2338/// Lua function that searches the rune and soul core database (`data.itemMods.Runes`).
2339///
2340/// Accepts a table with optional `query` (name/stat substring) and `slot` (slot key
2341/// substring). Rune mods are organized per-slot with stat lines as array entries.
2342/// Level requirement comes from `data.itemBases[runeName]`.
2343/// Returns up to 15 results sorted alphabetically, with `total_results` count.
2344const LUA_SEARCH_RUNES: &str = r#"
2345local dkjson = require("dkjson")
2346
2347function searchRunes(args)
2348    local query = args.query and args.query:lower() or nil
2349    local slotFilter = args.slot and args.slot:lower() or nil
2350
2351    local runeMap = {}
2352
2353    for runeName, slotTable in pairs(data.itemMods.Runes) do
2354        for slotKey, mod in pairs(slotTable) do
2355            -- Extract stat lines (numeric array entries on the mod table)
2356            local stats = {}
2357            local i = 1
2358            while mod[i] do
2359                table.insert(stats, mod[i])
2360                i = i + 1
2361            end
2362
2363            -- Check query match: rune name or any stat line in this slot
2364            local queryMatch = true
2365            if query then
2366                queryMatch = false
2367                if runeName:lower():find(query, 1, true) then
2368                    queryMatch = true
2369                else
2370                    for _, stat in ipairs(stats) do
2371                        if stat:lower():find(query, 1, true) then
2372                            queryMatch = true
2373                            break
2374                        end
2375                    end
2376                end
2377            end
2378
2379            if queryMatch then
2380                if not runeMap[runeName] then
2381                    runeMap[runeName] = {}
2382                end
2383                runeMap[runeName][slotKey] = stats
2384            end
2385        end
2386    end
2387
2388    -- Build results from runeMap, applying slot filter to displayed slots
2389    local matches = {}
2390    for runeName, slots in pairs(runeMap) do
2391        -- If slot filter is provided, only include matching slot entries
2392        local displaySlots = {}
2393        local hasSlot = false
2394        for slotKey, stats in pairs(slots) do
2395            if slotFilter then
2396                if slotKey:lower():find(slotFilter, 1, true) then
2397                    displaySlots[slotKey] = stats
2398                    hasSlot = true
2399                end
2400            else
2401                displaySlots[slotKey] = stats
2402                hasSlot = true
2403            end
2404        end
2405
2406        if hasSlot then
2407            local baseData = data.itemBases[runeName]
2408            local levelReq = baseData and baseData.req and baseData.req.level or 0
2409
2410            table.insert(matches, {
2411                name = runeName,
2412                level_req = levelReq,
2413                slots = displaySlots,
2414            })
2415        end
2416    end
2417
2418    local totalResults = #matches
2419
2420    -- Sort alphabetically by name
2421    table.sort(matches, function(a, b) return a.name < b.name end)
2422
2423    -- Cap at 15 results
2424    local results = {}
2425    for i = 1, math.min(15, #matches) do
2426        table.insert(results, matches[i])
2427    end
2428
2429    return dkjson.encode({
2430        results = results,
2431        total_results = totalResults,
2432    })
2433end
2434"#;
2435
2436/// Runs entirely in Lua to avoid costly cross-boundary iteration over ~1700 affix
2437/// entries. Uses PoB's `dkjson` library for JSON serialization.
2438///
2439/// Handles two item paths:
2440/// - **Crafted items** (`item.crafted`): reads `item.prefixes[i].modId` + `.range`
2441///   directly for exact tier + roll quality.
2442/// - **Imported items**: reverse-matches `item.explicitModLines[i].line` against
2443///   all mods in `item.affixes` by building patterns from mod templates.
2444const LUA_GEAR_MOD_ANALYSIS: &str = r#"
2445local dkjson = require("dkjson")
2446
2447function analyzeItemMods(item)
2448    -- Guard: items without affixes (uniques, flasks, etc.)
2449    if not item.affixes then
2450        return dkjson.encode({
2451            not_applicable = true,
2452            reason = "Item has no affix database (unique, flask, or special item type)",
2453            item_name = item.name or "",
2454            rarity = item.rarity or "",
2455        })
2456    end
2457
2458    if item.rarity == "UNIQUE" then
2459        return dkjson.encode({
2460            not_applicable = true,
2461            reason = "Unique items have fixed mods — tier analysis does not apply",
2462            item_name = item.name or "",
2463            rarity = "UNIQUE",
2464        })
2465    end
2466
2467    if item.rarity == "NORMAL" then
2468        return dkjson.encode({
2469            not_applicable = true,
2470            reason = "Normal items have no mods",
2471            item_name = item.name or "",
2472            rarity = "NORMAL",
2473        })
2474    end
2475
2476    -- Build group index: group name -> sorted list of mods (T1 = first = highest level)
2477    local groups = {}
2478    local modById = {}
2479    for modId, mod in pairs(item.affixes) do
2480        if type(mod) == "table" and mod.group then
2481            modById[modId] = mod
2482            if not groups[mod.group] then
2483                groups[mod.group] = {}
2484            end
2485            table.insert(groups[mod.group], { modId = modId, mod = mod })
2486        end
2487    end
2488    for _, mods in pairs(groups) do
2489        table.sort(mods, function(a, b) return a.mod.level > b.mod.level end)
2490    end
2491
2492    -- Helper: parse range notation from a template string.
2493    -- E.g. "+(40-59) to maximum Life" -> { {min=40, max=59} }
2494    local function parseRanges(template)
2495        local ranges = {}
2496        for sign, minStr, maxStr in template:gmatch("([%+-]?)%((%d+%.?%d*)%-(%d+%.?%d*)%)") do
2497            local minVal = tonumber(minStr)
2498            local maxVal = tonumber(maxStr)
2499            if sign == "-" then
2500                minVal = -minVal
2501                maxVal = -maxVal
2502                -- Swap so min < max
2503                minVal, maxVal = maxVal, minVal
2504            end
2505            table.insert(ranges, { min = minVal, max = maxVal })
2506        end
2507        return ranges
2508    end
2509
2510    -- Helper: get tier info for a mod within its group
2511    local function getTierInfo(modId, mod)
2512        local group = groups[mod.group]
2513        if not group then
2514            return nil
2515        end
2516        local totalTiers = #group
2517        local tierNum = nil
2518        for i, entry in ipairs(group) do
2519            if entry.modId == modId then
2520                tierNum = i
2521                break
2522            end
2523        end
2524        if not tierNum then
2525            return nil
2526        end
2527
2528        -- Best tier at item level (0 = unknown, treat as unlimited)
2529        local itemLevel = item.itemLevel or 0
2530        local bestTierAtIlvl = nil
2531        if itemLevel > 0 then
2532            for i, entry in ipairs(group) do
2533                if entry.mod.level <= itemLevel then
2534                    bestTierAtIlvl = i
2535                    break
2536                end
2537            end
2538        else
2539            -- No item level info — T1 is theoretically available
2540            bestTierAtIlvl = 1
2541        end
2542
2543        -- T1 range (first entry in group = highest tier)
2544        local t1Ranges = parseRanges(group[1].mod[1] or "")
2545        local t1RangeStr = nil
2546        if #t1Ranges > 0 then
2547            t1RangeStr = string.format("T1 %s [%g-%g]",
2548                group[1].mod.affix or "",
2549                t1Ranges[1].min, t1Ranges[1].max)
2550        end
2551
2552        -- Best tier at ilvl range
2553        local bestAtIlvlStr = nil
2554        if bestTierAtIlvl and bestTierAtIlvl < tierNum then
2555            local bestEntry = group[bestTierAtIlvl]
2556            local bestRanges = parseRanges(bestEntry.mod[1] or "")
2557            if #bestRanges > 0 then
2558                bestAtIlvlStr = string.format("T%d %s [%g-%g]",
2559                    bestTierAtIlvl, bestEntry.mod.affix or "",
2560                    bestRanges[1].min, bestRanges[1].max)
2561            end
2562        end
2563
2564        return {
2565            tier = tierNum,
2566            total_tiers = totalTiers,
2567            tier_label = string.format("T%d/T%d", tierNum, totalTiers),
2568            best_tier_at_ilvl = bestAtIlvlStr,
2569            upgradeable = bestTierAtIlvl ~= nil and bestTierAtIlvl < tierNum,
2570            max_tier_range = t1Ranges[1] and { t1Ranges[1].min, t1Ranges[1].max } or nil,
2571        }
2572    end
2573
2574    -- Helper: analyze a single mod entry (crafted path)
2575    local function analyzeCraftedMod(prefix, modType)
2576        if not prefix.modId or prefix.modId == "None" then
2577            return nil
2578        end
2579        local mod = modById[prefix.modId]
2580        if not mod then
2581            return nil
2582        end
2583
2584        local template = mod[1] or ""
2585        local ranges = parseRanges(template)
2586        local tierInfo = getTierInfo(prefix.modId, mod)
2587
2588        -- Compute roll quality from range parameter
2589        local rollPct = prefix.range or 0.5
2590        local rollValue = nil
2591        local currentRange = nil
2592        if #ranges > 0 then
2593            currentRange = { ranges[1].min, ranges[1].max }
2594            rollValue = ranges[1].min + rollPct * (ranges[1].max - ranges[1].min)
2595            rollValue = math.floor(rollValue + 0.5)
2596        end
2597
2598        -- Build the display line by applying the range
2599        local line = template
2600        if itemLib and itemLib.applyRange then
2601            line = itemLib.applyRange(template, prefix.range or 0.5)
2602        end
2603
2604        local result = {
2605            mod_id = prefix.modId,
2606            line = line,
2607            affix_name = mod.affix or "",
2608            type = modType,
2609            group = mod.group or "",
2610            required_level = mod.level or 0,
2611            current_range = currentRange,
2612            roll_value = rollValue,
2613            roll_pct = rollPct,
2614            tags = mod.modTags or {},
2615        }
2616        if tierInfo then
2617            for k, v in pairs(tierInfo) do
2618                result[k] = v
2619            end
2620        end
2621        return result
2622    end
2623
2624    -- Helper: reverse-match a mod line against the affix database (imported path)
2625    local function reverseMatchLine(lineText)
2626        -- Try each mod in the affix database
2627        for modId, mod in pairs(item.affixes) do
2628            if type(mod) == "table" and mod[1] then
2629                local template = mod[1]
2630
2631                -- Build a Lua pattern from the template:
2632                -- Replace "(min-max)" with a capture for the number
2633                -- Escape special pattern chars first
2634                local pattern = template
2635                -- Escape Lua pattern special chars (except parens which we handle)
2636                pattern = pattern:gsub("%%", "%%%%")
2637                pattern = pattern:gsub("%.", "%%.")
2638                pattern = pattern:gsub("%+", "%%+")
2639                pattern = pattern:gsub("%-", "%%-")
2640                pattern = pattern:gsub("%*", "%%*")
2641                pattern = pattern:gsub("%?", "%%?")
2642                pattern = pattern:gsub("%[", "%%[")
2643                pattern = pattern:gsub("%]", "%%]")
2644                pattern = pattern:gsub("%^", "%%^")
2645                pattern = pattern:gsub("%$", "%%$")
2646
2647                -- Now replace the range patterns "(min-max)" with number captures
2648                -- The ranges look like "%(min%%-max%)" after escaping
2649                pattern = pattern:gsub("%((%d+%%.?%d*)%%%-(%d+%%.?%d*)%)", "(%%d+%%.?%%d*)")
2650
2651                -- Try to match
2652                pattern = "^" .. pattern .. "$"
2653                local captures = { lineText:match(pattern) }
2654                if #captures > 0 then
2655                    local ranges = parseRanges(template)
2656                    local value = tonumber(captures[1])
2657
2658                    -- Verify the value falls within a valid range for this mod
2659                    if value and #ranges > 0 then
2660                        -- Check if value is in range (with some tolerance for rounding)
2661                        if value >= ranges[1].min - 0.5 and value <= ranges[1].max + 0.5 then
2662                            local tierInfo = getTierInfo(modId, mod)
2663                            local rollPct = nil
2664                            if ranges[1].max > ranges[1].min then
2665                                rollPct = (value - ranges[1].min) / (ranges[1].max - ranges[1].min)
2666                                rollPct = math.floor(rollPct * 100 + 0.5) / 100
2667                            else
2668                                rollPct = 1.0
2669                            end
2670
2671                            local result = {
2672                                mod_id = modId,
2673                                line = lineText,
2674                                affix_name = mod.affix or "",
2675                                type = mod.type or "",
2676                                group = mod.group or "",
2677                                required_level = mod.level or 0,
2678                                current_range = { ranges[1].min, ranges[1].max },
2679                                roll_value = value,
2680                                roll_pct = rollPct,
2681                                tags = mod.modTags or {},
2682                            }
2683                            if tierInfo then
2684                                for k, v in pairs(tierInfo) do
2685                                    result[k] = v
2686                                end
2687                            end
2688                            return result
2689                        end
2690                    end
2691                end
2692            end
2693        end
2694        return nil
2695    end
2696
2697    local prefixes = {}
2698    local suffixes = {}
2699    local unmatchedMods = {}
2700    local prefixCount = 0
2701    local suffixCount = 0
2702
2703    if item.crafted and item.prefixes and #item.prefixes > 0 then
2704        -- Crafted path: direct modId lookup
2705        for _, p in ipairs(item.prefixes) do
2706            if p.modId and p.modId ~= "None" then
2707                local info = analyzeCraftedMod(p, "Prefix")
2708                if info then
2709                    table.insert(prefixes, info)
2710                    prefixCount = prefixCount + 1
2711                end
2712            end
2713        end
2714        for _, s in ipairs(item.suffixes) do
2715            if s.modId and s.modId ~= "None" then
2716                local info = analyzeCraftedMod(s, "Suffix")
2717                if info then
2718                    table.insert(suffixes, info)
2719                    suffixCount = suffixCount + 1
2720                end
2721            end
2722        end
2723    else
2724        -- Imported path: reverse-match explicit mod lines
2725        if item.explicitModLines then
2726            for i = 1, #item.explicitModLines do
2727                local modLine = item.explicitModLines[i]
2728                if modLine and modLine.line then
2729                    local info = reverseMatchLine(modLine.line)
2730                    if info then
2731                        if info.type == "Prefix" then
2732                            table.insert(prefixes, info)
2733                            prefixCount = prefixCount + 1
2734                        else
2735                            table.insert(suffixes, info)
2736                            suffixCount = suffixCount + 1
2737                        end
2738                    else
2739                        table.insert(unmatchedMods, modLine.line)
2740                    end
2741                end
2742            end
2743        end
2744    end
2745
2746    -- Determine affix limits
2747    local maxPrefixes = 3
2748    local maxSuffixes = 3
2749    if item.rarity == "MAGIC" then
2750        maxPrefixes = 1
2751        maxSuffixes = 1
2752    end
2753    if item.type == "Jewel" then
2754        maxPrefixes = 2
2755        maxSuffixes = 2
2756    end
2757    if item.prefixes and item.prefixes.limit then
2758        maxPrefixes = item.prefixes.limit
2759    end
2760    if item.suffixes and item.suffixes.limit then
2761        maxSuffixes = item.suffixes.limit
2762    end
2763
2764    local baseName = item.baseName or ""
2765
2766    local result = {
2767        item_name = item.name or "",
2768        base = baseName,
2769        rarity = item.rarity or "",
2770        item_level = item.itemLevel or 0,
2771        crafted = item.crafted or false,
2772        prefixes = prefixes,
2773        suffixes = suffixes,
2774        prefix_count = prefixCount,
2775        suffix_count = suffixCount,
2776        max_prefixes = maxPrefixes,
2777        max_suffixes = maxSuffixes,
2778        open_prefixes = maxPrefixes - prefixCount,
2779        open_suffixes = maxSuffixes - suffixCount,
2780        unmatched_mods = unmatchedMods,
2781    }
2782
2783    return dkjson.encode(result)
2784end
2785"#;
2786
2787// ---------------------------------------------------------------------------
2788// Shared item-field extraction
2789// ---------------------------------------------------------------------------
2790
2791/// Common fields extracted from a PoB Lua item table.
2792///
2793/// Used by `query_item`, `query_jewel`, and `query_equipped_items` to avoid
2794/// duplicating the same get/unwrap chains for every item read.
2795struct ItemFields {
2796    name: String,
2797    base_name: String,
2798    rarity: String,
2799    quality: i64,
2800    spirit: i64,
2801    sockets: i64,
2802    implicits: Vec<String>,
2803    explicits: Vec<String>,
2804    enchants: Vec<String>,
2805    runes: Vec<String>,
2806}
2807
2808impl ItemFields {
2809    fn from_lua(item: &mlua::Table) -> Self {
2810        Self {
2811            name: lua_get!(item, "name" => String),
2812            base_name: item
2813                .get::<mlua::Table>("base")
2814                .and_then(|b| b.get::<String>("name"))
2815                .unwrap_or_default(),
2816            rarity: lua_get!(item, "rarity" => String),
2817            quality: lua_get!(item, "quality" => i64),
2818            spirit: lua_get!(item, "spiritValue" => i64),
2819            sockets: lua_get!(item, "itemSocketCount" => i64),
2820            implicits: read_mod_lines(item, "implicitModLines"),
2821            explicits: read_mod_lines(item, "explicitModLines"),
2822            enchants: read_mod_lines(item, "enchantModLines"),
2823            runes: read_mod_lines(item, "runeModLines"),
2824        }
2825    }
2826
2827    /// Combined implicit + explicit mod lines.
2828    fn all_mods(&self) -> Vec<String> {
2829        self.implicits
2830            .iter()
2831            .chain(self.explicits.iter())
2832            .cloned()
2833            .collect()
2834    }
2835}
2836
2837// ---------------------------------------------------------------------------
2838// Lua table helpers
2839// ---------------------------------------------------------------------------
2840
2841/// Read numeric fields from a Lua table into a JSON map.
2842/// Tries f64 first, then i64, skipping nil/missing values.
2843fn read_fields(table: &mlua::Table, fields: &[&str]) -> serde_json::Map<String, serde_json::Value> {
2844    let mut map = serde_json::Map::new();
2845    for &field in fields {
2846        if let Ok(v) = table.get::<f64>(field) {
2847            if v != 0.0 {
2848                if let Some(num) = serde_json::Number::from_f64(v) {
2849                    map.insert(field.to_owned(), serde_json::Value::Number(num));
2850                }
2851            }
2852        } else if let Ok(v) = table.get::<i64>(field) {
2853            if v != 0 {
2854                map.insert(field.to_owned(), serde_json::Value::Number(v.into()));
2855            }
2856        }
2857    }
2858    map
2859}
2860
2861/// Read mod line strings from an item's mod array (e.g. `explicitModLines`).
2862///
2863/// Each entry in the Lua array is a table with a `line` field.
2864/// Returns an empty vec if the field is missing or has no entries.
2865fn read_mod_lines(item: &mlua::Table, field: &str) -> Vec<String> {
2866    let table = match item.get::<mlua::Table>(field) {
2867        Ok(t) => t,
2868        Err(_) => return Vec::new(),
2869    };
2870    (1..=table.raw_len())
2871        .filter_map(|i| table.get::<mlua::Table>(i).ok())
2872        .filter_map(|entry| entry.get::<String>("line").ok())
2873        .collect()
2874}
2875
2876/// Merge entries from `src` into `dst`, preferring values already in `dst`.
2877fn merge_fields(
2878    dst: &mut serde_json::Map<String, serde_json::Value>,
2879    src: &serde_json::Map<String, serde_json::Value>,
2880) {
2881    for (key, value) in src {
2882        dst.entry(key.clone()).or_insert_with(|| value.clone());
2883    }
2884}
2885
2886/// Read the `sd` (stat description) array from a passive tree node table.
2887///
2888/// Returns an empty vec if the field is missing or has no entries.
2889fn read_node_stats(node: &mlua::Table) -> Vec<String> {
2890    let table = match node.get::<mlua::Table>("sd") {
2891        Ok(t) => t,
2892        Err(_) => return Vec::new(),
2893    };
2894    (1..=table.raw_len())
2895        .filter_map(|i| table.get::<String>(i).ok())
2896        .collect()
2897}
2898
2899/// Extract an i64 from a Lua value (Integer or Number).
2900fn lua_value_to_i64(val: &mlua::Value) -> Option<i64> {
2901    match val {
2902        mlua::Value::Integer(n) => Some(*n),
2903        mlua::Value::Number(n) => Some(*n as i64),
2904        _ => None,
2905    }
2906}
2907
2908/// Check a node's `sd` lines for case-insensitive substring matches against `pattern`.
2909///
2910/// Returns the matching stat line strings and the sum of extracted numeric values.
2911fn match_node_stats(node: &mlua::Table, pattern: &str) -> (Vec<String>, f64) {
2912    let sd = match node.get::<mlua::Table>("sd") {
2913        Ok(t) => t,
2914        Err(_) => return (Vec::new(), 0.0),
2915    };
2916
2917    let mut matching = Vec::new();
2918    let mut total = 0.0;
2919
2920    for i in 1..=sd.raw_len() {
2921        if let Ok(line) = sd.get::<String>(i) {
2922            if line.to_lowercase().contains(pattern) {
2923                total += extract_stat_value(&line);
2924                matching.push(line);
2925            }
2926        }
2927    }
2928
2929    (matching, total)
2930}
2931
2932/// Extract the first numeric value from a stat description line.
2933///
2934/// Handles integers and decimals, e.g. "+25% increased Fire Damage" → 25.0,
2935/// "0.5% of Fire Damage Leeched as Life" → 0.5. Returns 0.0 if no number found.
2936fn extract_stat_value(line: &str) -> f64 {
2937    let mut start = None;
2938    let mut has_dot = false;
2939
2940    for (i, ch) in line.char_indices() {
2941        match ch {
2942            '0'..='9' => {
2943                if start.is_none() {
2944                    start = Some(i);
2945                }
2946            }
2947            '.' if start.is_some() && !has_dot => {
2948                has_dot = true;
2949            }
2950            _ => {
2951                if let Some(s) = start {
2952                    if let Ok(val) = line[s..i].parse::<f64>() {
2953                        return val;
2954                    }
2955                    start = None;
2956                    has_dot = false;
2957                }
2958            }
2959        }
2960    }
2961
2962    // Check if the number extends to end of string
2963    if let Some(s) = start {
2964        line[s..].parse::<f64>().unwrap_or(0.0)
2965    } else {
2966        0.0
2967    }
2968}