1use mlua::{Lua, Result as LuaResult};
7use std::collections::{HashMap, HashSet, VecDeque};
8use std::path::Path;
9use thiserror::Error;
10
11macro_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
30macro_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
69macro_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
116pub struct PobHeadless {
118 lua: Lua,
119 initialized: bool,
120 pob_src_path: Option<std::path::PathBuf>,
122}
123
124#[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 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 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 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 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 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 self.lua.load("arg = {}").exec()?;
206
207 let result = self.lua.load("dofile('HeadlessWrapper.lua')").exec();
209
210 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 pub fn load_build_xml(&self, xml: &str) -> Result<(), PobError> {
223 if !self.initialized {
224 return Err(PobError::NotInitialized);
225 }
226
227 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 pub fn import_build(&self, _code: &str) -> Result<(), PobError> {
240 if !self.initialized {
241 return Err(PobError::NotInitialized);
242 }
243
244 Err(PobError::InvalidBuildCode(
246 "Build code import not yet implemented (requires Inflate)".to_owned(),
247 ))
248 }
249
250 pub fn calculate(&self) -> Result<BuildStats, PobError> {
252 if !self.initialized {
253 return Err(PobError::NotInitialized);
254 }
255
256 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 let calcs_output: mlua::Table = calcs_tab.get("calcsOutput")?;
263
264 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 _ => {}
812 }
813 }
814
815 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 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 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 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 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 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(¤t_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 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 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 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 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 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 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 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 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 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, };
1054
1055 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 pub fn export_build(&self) -> Result<String, PobError> {
1433 if !self.initialized {
1434 return Err(PobError::NotInitialized);
1435 }
1436
1437 Ok(String::new())
1439 }
1440
1441 pub fn set_passive_tree(&self, _tree_data: &str) -> Result<(), PobError> {
1443 if !self.initialized {
1444 return Err(PobError::NotInitialized);
1445 }
1446 Ok(())
1448 }
1449
1450 pub fn set_main_skill(&self, _skill_name: &str) -> Result<(), PobError> {
1452 if !self.initialized {
1453 return Err(PobError::NotInitialized);
1454 }
1455 Ok(())
1457 }
1458}
1459
1460impl Default for PobHeadless {
1461 fn default() -> Self {
1462 Self::new().expect("Failed to create Lua runtime")
1463 }
1464}
1465
1466const 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
1593const 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
1702const 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
1818const 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
1923const 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
2106const 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
2143const 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
2338const 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
2436const 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
2787struct 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 fn all_mods(&self) -> Vec<String> {
2829 self.implicits
2830 .iter()
2831 .chain(self.explicits.iter())
2832 .cloned()
2833 .collect()
2834 }
2835}
2836
2837fn 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
2861fn 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
2876fn 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
2886fn 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
2899fn 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
2908fn 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
2932fn 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 if let Some(s) = start {
2964 line[s..].parse::<f64>().unwrap_or(0.0)
2965 } else {
2966 0.0
2967 }
2968}