poe2-agent 0.1.0

AI agent for Path of Exile 2 build analysis
Documentation
//! Path of Building 2 headless integration.
//!
//! Provides a Rust interface to Path of Building 2's
//! calculation engine via embedded Lua.

use mlua::{Lua, Result as LuaResult};
use std::path::Path;
use thiserror::Error;

#[derive(Error, Debug)]
pub enum PobError {
    #[error("Lua error: {0}")]
    Lua(#[from] mlua::Error),

    #[error("PoB not initialized")]
    NotInitialized,

    #[error("Invalid build code: {0}")]
    InvalidBuildCode(String),

    #[error("Calculation failed: {0}")]
    CalculationFailed(String),

    #[error("IO error: {0}")]
    Io(#[from] std::io::Error),
}

/// Path of Building headless instance.
pub struct PobHeadless {
    lua: Lua,
    initialized: bool,
}

/// Build statistics from PoB calculations.
#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
pub struct BuildStats {
    pub total_dps: f64,
    pub effective_hp: f64,
    pub life: f64,
    pub energy_shield: f64,
    pub armour: f64,
    pub evasion: f64,
    pub fire_res: i32,
    pub cold_res: i32,
    pub lightning_res: i32,
    pub chaos_res: i32,
}

impl PobHeadless {
    /// Create a new PoB headless instance.
    pub fn new() -> LuaResult<Self> {
        let lua = Lua::new();
        Ok(Self {
            lua,
            initialized: false,
        })
    }

    /// Initialize PoB with the path to PoB2 installation.
    ///
    /// `pob_path` should point to the PoB2 root directory (containing `src/`).
    pub fn init(&mut self, pob_path: &str) -> Result<(), PobError> {
        let pob_path = Path::new(pob_path);
        let pob_src_path = pob_path.join("src");
        let pob_runtime_lua = pob_path.join("runtime/lua");

        // PoB's Lua files use relative dofile() calls, so we must change to src/
        let original_cwd = std::env::current_dir()?;
        std::env::set_current_dir(&pob_src_path)?;

        tracing::info!("Initializing PoB from {:?}", pob_src_path);

        // Set up Lua package.path to include PoB's runtime/lua directory
        // This is where xml.lua and other dependencies live
        let runtime_lua_path = pob_runtime_lua
            .to_str()
            .ok_or_else(|| PobError::CalculationFailed("Invalid path".to_owned()))?;

        self.lua
            .load(format!(
                r#"package.path = package.path .. ";{0}/?.lua;{0}/?/init.lua""#,
                runtime_lua_path
            ))
            .exec()?;

        // Provide a minimal lua-utf8 stub since it's a C module we can't load in safe mode
        // This provides basic string operations that fall back to regular string functions
        self.lua
            .load(
                r#"
                package.preload['lua-utf8'] = function()
                    local utf8 = {}
                    utf8.reverse = string.reverse
                    utf8.gsub = string.gsub
                    utf8.find = string.find
                    utf8.sub = string.sub
                    utf8.match = string.match
                    utf8.len = string.len
                    function utf8.next(s, i, offset)
                        if offset == -1 then
                            return i > 1 and i - 1 or nil
                        else
                            return i < #s and i + 1 or nil
                        end
                    end
                    return utf8
                end
                "#,
            )
            .exec()?;

        // Provide empty arg table (command line arguments)
        self.lua.load("arg = {}").exec()?;

        // Load HeadlessWrapper.lua which bootstraps everything
        let result = self.lua.load("dofile('HeadlessWrapper.lua')").exec();

        // Always restore the original working directory
        std::env::set_current_dir(original_cwd)?;

        result?;

        self.initialized = true;
        tracing::info!("PoB headless initialized successfully");
        Ok(())
    }

    /// Load a build from XML export.
    pub fn load_build_xml(&self, xml: &str) -> Result<(), PobError> {
        if !self.initialized {
            return Err(PobError::NotInitialized);
        }

        // Call the loadBuildFromXML function defined in HeadlessWrapper.lua
        let load_fn: mlua::Function = self.lua.globals().get("loadBuildFromXML")?;
        load_fn.call::<()>((xml, "imported_build"))?;

        tracing::debug!("Build loaded from XML");
        Ok(())
    }

    /// Import a build from a PoB code (base64 encoded).
    pub fn import_build(&self, _code: &str) -> Result<(), PobError> {
        if !self.initialized {
            return Err(PobError::NotInitialized);
        }

        // TODO: Decode and import build (requires Inflate support)
        Err(PobError::InvalidBuildCode(
            "Build code import not yet implemented (requires Inflate)".to_owned(),
        ))
    }

    /// Calculate build statistics from the currently loaded build.
    pub fn calculate(&self) -> Result<BuildStats, PobError> {
        if !self.initialized {
            return Err(PobError::NotInitialized);
        }

        // Navigate: build.calcsTab.mainOutput
        let build: mlua::Table = self.lua.globals().get("build")?;
        let calcs_tab: mlua::Table = build.get("calcsTab")?;
        let main_output: mlua::Table = calcs_tab.get("mainOutput")?;

        // For EHP, we need calcsOutput
        let calcs_output: mlua::Table = calcs_tab.get("calcsOutput")?;

        // Extract stats with safe defaults
        let total_dps = main_output
            .get::<f64>("TotalDPS")
            .or_else(|_| main_output.get::<f64>("CombinedDPS"))
            .unwrap_or(0.0);

        let life = main_output.get::<f64>("Life").unwrap_or(0.0);
        let energy_shield = main_output.get::<f64>("EnergyShield").unwrap_or(0.0);
        let armour = main_output.get::<f64>("Armour").unwrap_or(0.0);
        let evasion = main_output.get::<f64>("Evasion").unwrap_or(0.0);

        // Resistances
        let fire_res = main_output.get::<i32>("FireResist").unwrap_or(0);
        let cold_res = main_output.get::<i32>("ColdResist").unwrap_or(0);
        let lightning_res = main_output.get::<i32>("LightningResist").unwrap_or(0);
        let chaos_res = main_output.get::<i32>("ChaosResist").unwrap_or(0);

        // EHP from calcsOutput
        let effective_hp = calcs_output
            .get::<f64>("PhysicalMaximumHitTaken")
            .unwrap_or(0.0);

        Ok(BuildStats {
            total_dps,
            effective_hp,
            life,
            energy_shield,
            armour,
            evasion,
            fire_res,
            cold_res,
            lightning_res,
            chaos_res,
        })
    }

    /// Export current build to PoB code.
    pub fn export_build(&self) -> Result<String, PobError> {
        if !self.initialized {
            return Err(PobError::NotInitialized);
        }

        // TODO: Generate PoB export code
        Ok(String::new())
    }

    /// Modify the passive tree.
    pub fn set_passive_tree(&self, _tree_data: &str) -> Result<(), PobError> {
        if !self.initialized {
            return Err(PobError::NotInitialized);
        }
        // TODO: Update passive tree in PoB
        Ok(())
    }

    /// Set the main skill.
    pub fn set_main_skill(&self, _skill_name: &str) -> Result<(), PobError> {
        if !self.initialized {
            return Err(PobError::NotInitialized);
        }
        // TODO: Set main skill in PoB
        Ok(())
    }
}

impl Default for PobHeadless {
    fn default() -> Self {
        Self::new().expect("Failed to create Lua runtime")
    }
}