poe2-agent 0.3.1

AI agent for Path of Exile 2 build analysis
Documentation
//! Thread-safe PoB XML parser.
//!
//! Wraps `PobHeadless` on a dedicated OS thread (mlua LuaJIT is `!Send`)
//! and communicates via channels.

use std::path::Path;
use std::sync::mpsc;
use std::thread;

use tokio::sync::oneshot;

use crate::pob::PobHeadless;

/// Which query to run against a loaded build.
#[derive(Debug, Clone)]
pub enum PobQuery {
    /// Extended stats (~40 fields) grouped by category.
    BuildStats,
    /// Per-skill DPS + gem links.
    SkillList,
    /// Configuration flags.
    Config,
    /// Item equipped in the given slot.
    Item(String),
    /// Jewel socketed in the given passive tree socket node.
    Jewel(i64),
    /// Allocated passive tree nodes.
    PassiveTree,
    /// Stat contribution from allocated passives and nearby unallocated nodes.
    PassiveStats { stats: Vec<String>, radius: u32 },
    /// All equipped items with compact mod summaries, jewels, and empty slots.
    EquippedItems,
    /// Ascendancy nodes: allocated vs available for primary and secondary ascendancies.
    UnallocatedAscendancy,
    /// Detailed DPS breakdown for a specific skill.
    SkillBreakdown(String),
    /// Gear mod analysis: tier info, roll quality, upgrade potential.
    GearModAnalysis(String),
    /// Search gem database by name, type, and/or tags.
    SearchGems {
        query: Option<String>,
        gem_type: Option<String>,
        tags: Vec<String>,
    },
    /// Search unique item database by name, slot, and/or level range.
    SearchUniques {
        query: Option<String>,
        slot: Option<String>,
        min_level: Option<u32>,
        max_level: Option<u32>,
    },
    /// List all charm bases with trigger, buff, duration, and charges.
    ListCharms,
    /// Search rune/soul core database by name, stat text, and/or slot.
    SearchRunes {
        query: Option<String>,
        slot: Option<String>,
    },
    /// Create an item from PoB text format, equip it in a slot, and return stat delta.
    CreateItem { slot: String, item_text: String },
}

/// Request sent to the dedicated parser thread.
enum PobRequest {
    Parse {
        xml: String,
        reply: oneshot::Sender<Result<Vec<u8>, PobParseError>>,
    },
    Query {
        xml: String,
        query: PobQuery,
        reply: oneshot::Sender<Result<serde_json::Value, PobParseError>>,
    },
}

/// Errors from build parsing.
#[derive(Debug, thiserror::Error)]
pub enum PobParseError {
    /// PoB couldn't parse the XML (bad data from the user).
    #[error("invalid build: {0}")]
    InvalidBuild(String),

    /// The parser thread died or is unreachable.
    #[error("parser unavailable")]
    Unavailable,
}

/// Thread-safe handle to a `PobHeadless` instance running on a dedicated OS thread.
///
/// `mlua::Lua` with LuaJIT is `!Send`, so we keep it pinned to one thread and
/// communicate via channels. This handle is `Send + Sync` and cheap to clone.
pub struct PobParser {
    sender: Option<mpsc::Sender<PobRequest>>,
    _thread: Option<thread::JoinHandle<()>>,
}

impl PobParser {
    /// Spawn the parser thread and initialize `PobHeadless`.
    ///
    /// Awaits until PoB is fully initialized. Returns an error if
    /// initialization fails so the server can fail-fast at startup.
    pub async fn new(pob_path: &Path) -> Result<Self, anyhow::Error> {
        let (tx, rx) = mpsc::channel::<PobRequest>();
        let (init_tx, init_rx) = oneshot::channel::<Result<(), String>>();

        let pob_path_abs = pob_path
            .canonicalize()
            .map_err(|e| anyhow::anyhow!("pob_path {}: {e}", pob_path.display()))?;
        let pob_path_str = pob_path_abs
            .to_str()
            .ok_or_else(|| anyhow::anyhow!("pob_path is not valid UTF-8"))?
            .to_owned();

        let handle = thread::spawn(move || {
            run_parser_thread(&pob_path_str, init_tx, rx);
        });

        let init_result = init_rx
            .await
            .map_err(|_| anyhow::anyhow!("parser thread died during init"))?;

        init_result.map_err(|e| anyhow::anyhow!("PobHeadless init failed: {e}"))?;

        tracing::info!("PobParser ready");
        Ok(Self {
            sender: Some(tx),
            _thread: Some(handle),
        })
    }

    /// Parse a PoB XML export, returning the `BuildStats` as JSON bytes.
    pub async fn parse(&self, xml: &[u8]) -> Result<Vec<u8>, PobParseError> {
        let xml_str =
            std::str::from_utf8(xml).map_err(|e| PobParseError::InvalidBuild(e.to_string()))?;

        let (reply_tx, reply_rx) = oneshot::channel();

        self.sender
            .as_ref()
            .ok_or(PobParseError::Unavailable)?
            .send(PobRequest::Parse {
                xml: xml_str.to_owned(),
                reply: reply_tx,
            })
            .map_err(|_| PobParseError::Unavailable)?;

        reply_rx.await.map_err(|_| PobParseError::Unavailable)?
    }

    /// Run a query against a build. The build XML is loaded fresh each time
    /// to avoid interleaving problems with concurrent callers.
    pub async fn query(
        &self,
        xml: &[u8],
        query: PobQuery,
    ) -> Result<serde_json::Value, PobParseError> {
        let xml_str =
            std::str::from_utf8(xml).map_err(|e| PobParseError::InvalidBuild(e.to_string()))?;

        let (reply_tx, reply_rx) = oneshot::channel();

        self.sender
            .as_ref()
            .ok_or(PobParseError::Unavailable)?
            .send(PobRequest::Query {
                xml: xml_str.to_owned(),
                query,
                reply: reply_tx,
            })
            .map_err(|_| PobParseError::Unavailable)?;

        reply_rx.await.map_err(|_| PobParseError::Unavailable)?
    }
}

impl Drop for PobParser {
    fn drop(&mut self) {
        // Drop sender first to close the channel so the thread's recv loop exits.
        // Field auto-drop happens *after* drop() returns, so we must do this
        // explicitly -- otherwise join() deadlocks waiting for a channel that
        // won't close until after join() returns.
        self.sender.take();
        if let Some(handle) = self._thread.take() {
            let _ = handle.join();
        }
    }
}

/// Entry point for the dedicated parser thread.
fn run_parser_thread(
    pob_path: &str,
    init_tx: oneshot::Sender<Result<(), String>>,
    rx: mpsc::Receiver<PobRequest>,
) {
    let mut pob = match PobHeadless::new() {
        Ok(p) => p,
        Err(e) => {
            let _ = init_tx.send(Err(format!("failed to create Lua runtime: {e}")));
            return;
        }
    };

    if let Err(e) = pob.init(pob_path) {
        let _ = init_tx.send(Err(e.to_string()));
        return;
    }

    let _ = init_tx.send(Ok(()));

    // Process requests until the channel is closed.
    for req in &rx {
        match req {
            PobRequest::Parse { xml, reply } => {
                let result = parse_one(&pob, &xml);
                let _ = reply.send(result);
            }
            PobRequest::Query { xml, query, reply } => {
                let result = load_and_query(&pob, &xml, &query);
                let _ = reply.send(result);
            }
        }
    }

    tracing::info!("parser thread shutting down");
}

/// Execute a single parse: load XML -> calculate -> serialize.
fn parse_one(pob: &PobHeadless, xml: &str) -> Result<Vec<u8>, PobParseError> {
    pob.load_build_xml(xml)
        .map_err(|e| PobParseError::InvalidBuild(e.to_string()))?;

    let stats = pob
        .calculate()
        .map_err(|e| PobParseError::InvalidBuild(e.to_string()))?;

    serde_json::to_vec(&stats).map_err(|e| PobParseError::InvalidBuild(e.to_string()))
}

/// Load a build and run a query against it.
fn load_and_query(
    pob: &PobHeadless,
    xml: &str,
    query: &PobQuery,
) -> Result<serde_json::Value, PobParseError> {
    pob.load_build_xml(xml)
        .map_err(|e| PobParseError::InvalidBuild(e.to_string()))?;

    let result = match query {
        PobQuery::BuildStats => pob.query_build_stats(),
        PobQuery::SkillList => pob.query_skill_list(),
        PobQuery::Config => pob.query_config(),
        PobQuery::Item(ref slot) => pob.query_item(slot),
        PobQuery::Jewel(node_id) => pob.query_jewel(*node_id),
        PobQuery::PassiveTree => pob.query_passive_tree(),
        PobQuery::PassiveStats { ref stats, radius } => pob.query_passive_stats(stats, *radius),
        PobQuery::EquippedItems => pob.query_equipped_items(),
        PobQuery::UnallocatedAscendancy => pob.query_unallocated_ascendancy(),
        PobQuery::SkillBreakdown(ref skill) => pob.query_skill_breakdown(skill),
        PobQuery::GearModAnalysis(ref slot) => pob.query_gear_mod_analysis(slot),
        PobQuery::SearchGems {
            ref query,
            ref gem_type,
            ref tags,
        } => pob.query_search_gems(query.as_deref(), gem_type.as_deref(), tags),
        PobQuery::SearchUniques {
            ref query,
            ref slot,
            ref min_level,
            ref max_level,
        } => pob.query_search_uniques(query.as_deref(), slot.as_deref(), *min_level, *max_level),
        PobQuery::ListCharms => pob.query_list_charms(),
        PobQuery::SearchRunes {
            ref query,
            ref slot,
        } => pob.query_search_runes(query.as_deref(), slot.as_deref()),
        PobQuery::CreateItem {
            ref slot,
            ref item_text,
        } => pob.create_item(slot, item_text),
    };

    result.map_err(|e| PobParseError::InvalidBuild(e.to_string()))
}