poe2-agent 0.2.0

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,
}

/// 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(),
    };

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