Skip to main content

poe2_agent/
pob_parser.rs

1//! Thread-safe PoB XML parser.
2//!
3//! Wraps `PobHeadless` on a dedicated OS thread (mlua LuaJIT is `!Send`)
4//! and communicates via channels.
5
6use std::path::Path;
7use std::sync::mpsc;
8use std::thread;
9
10use tokio::sync::oneshot;
11
12use crate::pob::PobHeadless;
13
14/// Which query to run against a loaded build.
15#[derive(Debug, Clone)]
16pub enum PobQuery {
17    /// Extended stats (~40 fields) grouped by category.
18    BuildStats,
19    /// Per-skill DPS + gem links.
20    SkillList,
21    /// Configuration flags.
22    Config,
23    /// Item equipped in the given slot.
24    Item(String),
25    /// Jewel socketed in the given passive tree socket node.
26    Jewel(i64),
27    /// Allocated passive tree nodes.
28    PassiveTree,
29    /// Stat contribution from allocated passives and nearby unallocated nodes.
30    PassiveStats { stats: Vec<String>, radius: u32 },
31    /// All equipped items with compact mod summaries, jewels, and empty slots.
32    EquippedItems,
33    /// Ascendancy nodes: allocated vs available for primary and secondary ascendancies.
34    UnallocatedAscendancy,
35    /// Detailed DPS breakdown for a specific skill.
36    SkillBreakdown(String),
37    /// Gear mod analysis: tier info, roll quality, upgrade potential.
38    GearModAnalysis(String),
39    /// Search gem database by name, type, and/or tags.
40    SearchGems {
41        query: Option<String>,
42        gem_type: Option<String>,
43        tags: Vec<String>,
44    },
45    /// Search unique item database by name, slot, and/or level range.
46    SearchUniques {
47        query: Option<String>,
48        slot: Option<String>,
49        min_level: Option<u32>,
50        max_level: Option<u32>,
51    },
52    /// List all charm bases with trigger, buff, duration, and charges.
53    ListCharms,
54    /// Search rune/soul core database by name, stat text, and/or slot.
55    SearchRunes {
56        query: Option<String>,
57        slot: Option<String>,
58    },
59    /// Search item bases by type and/or name substring.
60    SearchBases {
61        item_type: Option<String>,
62        query: Option<String>,
63    },
64    /// Search item mods by stat text, item type tag, and/or mod type.
65    SearchMods {
66        query: Option<String>,
67        item_type_tag: Option<String>,
68        mod_type: Option<String>,
69    },
70    /// Create an item from PoB text format, equip it in a slot, and return stat delta.
71    CreateItem { slot: String, item_text: String },
72}
73
74/// Request sent to the dedicated parser thread.
75enum PobRequest {
76    Parse {
77        xml: String,
78        reply: oneshot::Sender<Result<Vec<u8>, PobParseError>>,
79    },
80    Query {
81        xml: String,
82        query: PobQuery,
83        reply: oneshot::Sender<Result<serde_json::Value, PobParseError>>,
84    },
85}
86
87/// Errors from build parsing.
88#[derive(Debug, thiserror::Error)]
89pub enum PobParseError {
90    /// PoB couldn't parse the XML (bad data from the user).
91    #[error("invalid build: {0}")]
92    InvalidBuild(String),
93
94    /// The parser thread died or is unreachable.
95    #[error("parser unavailable")]
96    Unavailable,
97}
98
99/// Thread-safe handle to a `PobHeadless` instance running on a dedicated OS thread.
100///
101/// `mlua::Lua` with LuaJIT is `!Send`, so we keep it pinned to one thread and
102/// communicate via channels. This handle is `Send + Sync` and cheap to clone.
103pub struct PobParser {
104    sender: Option<mpsc::Sender<PobRequest>>,
105    _thread: Option<thread::JoinHandle<()>>,
106}
107
108impl PobParser {
109    /// Spawn the parser thread and initialize `PobHeadless`.
110    ///
111    /// Awaits until PoB is fully initialized. Returns an error if
112    /// initialization fails so the server can fail-fast at startup.
113    pub async fn new(pob_path: &Path) -> Result<Self, anyhow::Error> {
114        let (tx, rx) = mpsc::channel::<PobRequest>();
115        let (init_tx, init_rx) = oneshot::channel::<Result<(), String>>();
116
117        let pob_path_abs = pob_path
118            .canonicalize()
119            .map_err(|e| anyhow::anyhow!("pob_path {}: {e}", pob_path.display()))?;
120        let pob_path_str = pob_path_abs
121            .to_str()
122            .ok_or_else(|| anyhow::anyhow!("pob_path is not valid UTF-8"))?
123            .to_owned();
124
125        let handle = thread::spawn(move || {
126            run_parser_thread(&pob_path_str, init_tx, rx);
127        });
128
129        let init_result = init_rx
130            .await
131            .map_err(|_| anyhow::anyhow!("parser thread died during init"))?;
132
133        init_result.map_err(|e| anyhow::anyhow!("PobHeadless init failed: {e}"))?;
134
135        tracing::info!("PobParser ready");
136        Ok(Self {
137            sender: Some(tx),
138            _thread: Some(handle),
139        })
140    }
141
142    /// Parse a PoB XML export, returning the `BuildStats` as JSON bytes.
143    pub async fn parse(&self, xml: &[u8]) -> Result<Vec<u8>, PobParseError> {
144        let xml_str =
145            std::str::from_utf8(xml).map_err(|e| PobParseError::InvalidBuild(e.to_string()))?;
146
147        let (reply_tx, reply_rx) = oneshot::channel();
148
149        self.sender
150            .as_ref()
151            .ok_or(PobParseError::Unavailable)?
152            .send(PobRequest::Parse {
153                xml: xml_str.to_owned(),
154                reply: reply_tx,
155            })
156            .map_err(|_| PobParseError::Unavailable)?;
157
158        reply_rx.await.map_err(|_| PobParseError::Unavailable)?
159    }
160
161    /// Run a query against a build. The build XML is loaded fresh each time
162    /// to avoid interleaving problems with concurrent callers.
163    pub async fn query(
164        &self,
165        xml: &[u8],
166        query: PobQuery,
167    ) -> Result<serde_json::Value, PobParseError> {
168        let xml_str =
169            std::str::from_utf8(xml).map_err(|e| PobParseError::InvalidBuild(e.to_string()))?;
170
171        let (reply_tx, reply_rx) = oneshot::channel();
172
173        self.sender
174            .as_ref()
175            .ok_or(PobParseError::Unavailable)?
176            .send(PobRequest::Query {
177                xml: xml_str.to_owned(),
178                query,
179                reply: reply_tx,
180            })
181            .map_err(|_| PobParseError::Unavailable)?;
182
183        reply_rx.await.map_err(|_| PobParseError::Unavailable)?
184    }
185}
186
187impl Drop for PobParser {
188    fn drop(&mut self) {
189        // Drop sender first to close the channel so the thread's recv loop exits.
190        // Field auto-drop happens *after* drop() returns, so we must do this
191        // explicitly -- otherwise join() deadlocks waiting for a channel that
192        // won't close until after join() returns.
193        self.sender.take();
194        if let Some(handle) = self._thread.take() {
195            let _ = handle.join();
196        }
197    }
198}
199
200/// Entry point for the dedicated parser thread.
201fn run_parser_thread(
202    pob_path: &str,
203    init_tx: oneshot::Sender<Result<(), String>>,
204    rx: mpsc::Receiver<PobRequest>,
205) {
206    let mut pob = match PobHeadless::new() {
207        Ok(p) => p,
208        Err(e) => {
209            let _ = init_tx.send(Err(format!("failed to create Lua runtime: {e}")));
210            return;
211        }
212    };
213
214    if let Err(e) = pob.init(pob_path) {
215        let _ = init_tx.send(Err(e.to_string()));
216        return;
217    }
218
219    let _ = init_tx.send(Ok(()));
220
221    // Process requests until the channel is closed.
222    for req in &rx {
223        match req {
224            PobRequest::Parse { xml, reply } => {
225                let result = parse_one(&pob, &xml);
226                let _ = reply.send(result);
227            }
228            PobRequest::Query { xml, query, reply } => {
229                let result = load_and_query(&pob, &xml, &query);
230                let _ = reply.send(result);
231            }
232        }
233    }
234
235    tracing::info!("parser thread shutting down");
236}
237
238/// Execute a single parse: load XML -> calculate -> serialize.
239fn parse_one(pob: &PobHeadless, xml: &str) -> Result<Vec<u8>, PobParseError> {
240    pob.load_build_xml(xml)
241        .map_err(|e| PobParseError::InvalidBuild(e.to_string()))?;
242
243    let stats = pob
244        .calculate()
245        .map_err(|e| PobParseError::InvalidBuild(e.to_string()))?;
246
247    serde_json::to_vec(&stats).map_err(|e| PobParseError::InvalidBuild(e.to_string()))
248}
249
250/// Load a build and run a query against it.
251fn load_and_query(
252    pob: &PobHeadless,
253    xml: &str,
254    query: &PobQuery,
255) -> Result<serde_json::Value, PobParseError> {
256    pob.load_build_xml(xml)
257        .map_err(|e| PobParseError::InvalidBuild(e.to_string()))?;
258
259    let result = match query {
260        PobQuery::BuildStats => pob.query_build_stats(),
261        PobQuery::SkillList => pob.query_skill_list(),
262        PobQuery::Config => pob.query_config(),
263        PobQuery::Item(ref slot) => pob.query_item(slot),
264        PobQuery::Jewel(node_id) => pob.query_jewel(*node_id),
265        PobQuery::PassiveTree => pob.query_passive_tree(),
266        PobQuery::PassiveStats { ref stats, radius } => pob.query_passive_stats(stats, *radius),
267        PobQuery::EquippedItems => pob.query_equipped_items(),
268        PobQuery::UnallocatedAscendancy => pob.query_unallocated_ascendancy(),
269        PobQuery::SkillBreakdown(ref skill) => pob.query_skill_breakdown(skill),
270        PobQuery::GearModAnalysis(ref slot) => pob.query_gear_mod_analysis(slot),
271        PobQuery::SearchGems {
272            ref query,
273            ref gem_type,
274            ref tags,
275        } => pob.query_search_gems(query.as_deref(), gem_type.as_deref(), tags),
276        PobQuery::SearchUniques {
277            ref query,
278            ref slot,
279            ref min_level,
280            ref max_level,
281        } => pob.query_search_uniques(query.as_deref(), slot.as_deref(), *min_level, *max_level),
282        PobQuery::ListCharms => pob.query_list_charms(),
283        PobQuery::SearchRunes {
284            ref query,
285            ref slot,
286        } => pob.query_search_runes(query.as_deref(), slot.as_deref()),
287        PobQuery::SearchBases {
288            ref item_type,
289            ref query,
290        } => pob.query_search_bases(item_type.as_deref(), query.as_deref()),
291        PobQuery::SearchMods {
292            ref query,
293            ref item_type_tag,
294            ref mod_type,
295        } => pob.query_search_mods(
296            query.as_deref(),
297            item_type_tag.as_deref(),
298            mod_type.as_deref(),
299        ),
300        PobQuery::CreateItem {
301            ref slot,
302            ref item_text,
303        } => pob.create_item(slot, item_text),
304    };
305
306    result.map_err(|e| PobParseError::InvalidBuild(e.to_string()))
307}