arc-agi-rs 0.1.0

🤖 A Rust client SDK for the ARC-AGI-3 API.
Documentation
// Copyright 2026 Mahmoud Harmouch.
//
// Licensed under the MIT license
// <LICENSE-MIT or http://opensource.org/licenses/MIT>, at your
// option. This file may not be copied, modified, or distributed
// except according to those terms.

//! # Node.js Bindings
//!
//! Exposes the `arc-agi-rs` library to Node.js via [`napi-derive`].
//! Every type and function is gated behind the `node` cargo feature.
//!
//! The bindings provide **synchronous** wrappers around the async Rust API by
//! driving a temporary, single-threaded [`tokio`] runtime inside each call.
//! This keeps the JavaScript API simple and avoids Promise boilerplate for
//! one-off API calls.
//!
//! # See Also
//!
//! - [ARC-AGI-3 API Reference](https://three.arcprize.org)
//! - [`napi-rs` documentation](https://napi.rs/)

use std::fmt::Display;
use std::future::Future;

use napi::Error as NapiError;
use napi_derive::napi;
use serde_json::Value;
use tokio::runtime::Builder as RuntimeBuilder;

use crate::client::Client;
use crate::params::{MakeParams, ScorecardParams, StepParams};

/// Drives an async future on a fresh single-threaded Tokio runtime.
///
/// This function is adapted from the `duckduckgo` crate's Node.js binding helper:
/// <https://github.com/wiseaidotdev/duckduckgo/blob/main/src/node.rs>
fn block_on<F, T, E>(future: F) -> napi::Result<T>
where
    F: Future<Output = Result<T, E>>,
    E: Display,
{
    RuntimeBuilder::new_current_thread()
        .enable_all()
        .build()
        .map_err(|e| NapiError::from_reason(e.to_string()))?
        .block_on(future)
        .map_err(|e| NapiError::from_reason(e.to_string()))
}

/// Metadata for a single ARC-AGI-3 game environment.
#[napi(object)]
pub struct EnvironmentInfo {
    /// Unique game identifier (e.g. `"ls20"`).
    pub game_id: String,
    /// Human-readable title of the environment.
    pub title: Option<String>,
    /// Default frames-per-second for rendering.
    pub default_fps: Option<u32>,
    /// Classification tags.
    pub tags: Option<Vec<String>>,
}

/// The current state of a game run returned by reset and step calls.
#[napi(object)]
pub struct FrameData {
    /// Game identifier.
    pub game_id: String,
    /// Unique run identifier assigned by the server.
    pub guid: Option<String>,
    /// Current lifecycle state (e.g. ``"NOT_FINISHED"``, ``"WIN"``).
    pub state: String,
    /// Number of levels completed in this run.
    pub levels_completed: u32,
    /// Total levels that must be completed to win.
    pub win_levels: u32,
    /// Action IDs the agent may send on the next step.
    pub available_actions: Vec<u32>,
    /// Whether this response corresponds to a full game reset.
    pub full_reset: bool,
}

/// The full scored scorecard returned by scorecard endpoints.
#[napi(object)]
pub struct EnvironmentScorecard {
    /// Unique scorecard identifier.
    pub card_id: String,
    /// Aggregate score across all environments (0.0–115.0).
    pub score: f64,
    /// Whether the scorecard is in competition mode.
    pub competition_mode: Option<bool>,
    /// Total environments completed.
    pub total_environments_completed: Option<u32>,
    /// Total environments.
    pub total_environments: Option<u32>,
    /// Total levels completed.
    pub total_levels_completed: Option<u32>,
    /// Total levels.
    pub total_levels: Option<u32>,
    /// Total actions taken.
    pub total_actions: Option<u32>,
}

/// An HTTP client for interacting with the ARC-AGI-3 REST API.
///
/// Construct with ``new ArcAgiClient()`` for a zero-configuration default that
/// reads credentials from the ``ARC_API_KEY`` and ``ARC_BASE_URL`` environment
/// variables, or supply optional arguments to configure them explicitly. All
/// network methods are **synchronous** on the JavaScript side; they drive an
/// internal single-threaded Tokio runtime per call.
///
/// See the [ARC-AGI-3 API Reference](https://three.arcprize.org) for details.
#[napi(js_name = "ArcAgiClient")]
pub struct NapiArcAgiClient {
    inner: Client,
}

#[napi]
impl NapiArcAgiClient {
    /// Create a new ``ArcAgiClient``.
    ///
    /// ``apiKey``      - Optional API key string.  Falls back to the ``ARC_API_KEY``
    ///                   environment variable and then to an empty string.
    /// ``baseUrl``     - Optional server base URL.  Falls back to ``ARC_BASE_URL``
    ///                   and then to ``"https://three.arcprize.org"``.
    /// ``cookieStore`` - Enable cookie persistence (default ``false``).
    /// ``proxy``       - Optional proxy URL, e.g. ``"socks5://127.0.0.1:9050"``.
    #[napi(constructor)]
    pub fn new(
        api_key: Option<String>,
        base_url: Option<String>,
        cookie_store: Option<bool>,
        proxy: Option<String>,
    ) -> napi::Result<Self> {
        let mut builder = Client::builder();
        if let Some(key) = api_key {
            builder = builder.api_key(key);
        }
        if let Some(url) = base_url {
            builder = builder.base_url(url);
        }
        if cookie_store.unwrap_or(false) {
            builder = builder.cookie_store(true);
        }
        if let Some(proxy_url) = proxy {
            builder = builder.proxy(proxy_url);
        }
        let inner = builder
            .build()
            .map_err(|e| NapiError::from_reason(e.to_string()))?;
        Ok(Self { inner })
    }

    /// Retrieve an anonymous API key from the server.
    ///
    /// Returns the anonymous API key string.
    #[napi]
    pub fn get_anonymous_key(&self) -> napi::Result<String> {
        block_on(self.inner.get_anonymous_key())
    }

    /// List all available game environments.
    ///
    /// Returns an array of ``EnvironmentInfo`` objects.
    #[napi]
    pub fn list_environments(&self) -> napi::Result<Vec<EnvironmentInfo>> {
        let envs = block_on(self.inner.list_environments())?;
        Ok(envs
            .into_iter()
            .map(|e| EnvironmentInfo {
                game_id: e.game_id,
                title: e.title,
                default_fps: e.default_fps,
                tags: e.tags,
            })
            .collect())
    }

    /// Return metadata for a single game environment.
    ///
    /// ``gameId`` - The game identifier (e.g. ``"ls20"``).
    ///
    /// Returns an ``EnvironmentInfo`` object.
    #[napi]
    pub fn get_environment(&self, game_id: String) -> napi::Result<EnvironmentInfo> {
        let info = block_on(self.inner.get_environment(&game_id))?;
        Ok(EnvironmentInfo {
            game_id: info.game_id,
            title: info.title,
            default_fps: info.default_fps,
            tags: info.tags,
        })
    }

    /// Create a new scorecard and return its ID.
    ///
    /// ``sourceUrl``       - Optional URL linking to the agent being evaluated.
    /// ``tags``            - Optional array of classification tag strings.
    /// ``competitionMode`` - When ``true``, enables one-way competition semantics.
    ///
    /// Returns the ``card_id`` string.
    #[napi]
    pub fn open_scorecard(
        &self,
        source_url: Option<String>,
        tags: Option<Vec<String>>,
        competition_mode: Option<bool>,
    ) -> napi::Result<String> {
        let mut params = ScorecardParams::new();
        if let Some(url) = source_url {
            params = params.source_url(url);
        }
        if let Some(t) = tags {
            params = params.tags(t);
        }
        if let Some(cm) = competition_mode {
            params = params.competition_mode(cm);
        }
        block_on(self.inner.open_scorecard(Some(params)))
    }

    /// Retrieve an existing scorecard by its ID.
    ///
    /// ``cardId`` - The scorecard identifier.
    ///
    /// Returns an ``EnvironmentScorecard`` object.
    #[napi]
    pub fn get_scorecard(&self, card_id: String) -> napi::Result<EnvironmentScorecard> {
        let card = block_on(self.inner.get_scorecard(&card_id))?;
        Ok(EnvironmentScorecard {
            card_id: card.card_id,
            score: card.score,
            competition_mode: card.competition_mode,
            total_environments_completed: card.total_environments_completed,
            total_environments: card.total_environments,
            total_levels_completed: card.total_levels_completed,
            total_levels: card.total_levels,
            total_actions: card.total_actions,
        })
    }

    /// Close and finalise a scorecard.
    ///
    /// ``cardId`` - The scorecard identifier.
    ///
    /// Returns an ``EnvironmentScorecard`` with the final scores.
    #[napi]
    pub fn close_scorecard(&self, card_id: String) -> napi::Result<EnvironmentScorecard> {
        let card = block_on(self.inner.close_scorecard(&card_id))?;
        Ok(EnvironmentScorecard {
            card_id: card.card_id,
            score: card.score,
            competition_mode: card.competition_mode,
            total_environments_completed: card.total_environments_completed,
            total_environments: card.total_environments,
            total_levels_completed: card.total_levels_completed,
            total_levels: card.total_levels,
            total_actions: card.total_actions,
        })
    }

    /// Reset (or start) a game environment.
    ///
    /// ``gameId``      - The game identifier.
    /// ``scorecardId`` - The scorecard to record this run under.
    /// ``guid``        - Optional existing run GUID to re-use.
    /// ``seed``        - Random seed for reproducible level ordering (default ``0``).
    ///
    /// Returns a ``FrameData`` object with the initial game state.
    #[napi]
    pub fn reset(
        &self,
        game_id: String,
        scorecard_id: String,
        guid: Option<String>,
        seed: Option<u32>,
    ) -> napi::Result<FrameData> {
        let mut params = MakeParams::new(&game_id, &scorecard_id).seed(seed.unwrap_or(0));
        if let Some(g) = guid {
            params = params.guid(g);
        }
        let frame = block_on(self.inner.reset(params))?;
        Ok(FrameData {
            game_id: frame.game_id,
            guid: frame.guid,
            state: frame.state.as_str().to_string(),
            levels_completed: frame.levels_completed,
            win_levels: frame.win_levels,
            available_actions: frame.available_actions,
            full_reset: frame.full_reset,
        })
    }

    /// Send one game action and receive the resulting frame.
    ///
    /// ``gameId``      - The game identifier.
    /// ``scorecardId`` - The scorecard identifier.
    /// ``guid``        - The run GUID from a prior ``reset`` call.
    /// ``actionId``    - Numeric action ID (0 = RESET).
    /// ``dataX``       - Optional X-coordinate for positional actions.
    /// ``dataY``       - Optional Y-coordinate for positional actions.
    ///
    /// Returns a ``FrameData`` object with the updated game state.
    #[napi]
    pub fn step(
        &self,
        game_id: String,
        scorecard_id: String,
        guid: String,
        action_id: u32,
        data: Option<Value>,
        reasoning: Option<Value>,
    ) -> napi::Result<FrameData> {
        let mut params = StepParams::new(&game_id, &scorecard_id, &guid, action_id);
        if let Some(d) = data {
            params = params.data(d);
        }
        if let Some(r) = reasoning {
            params = params.reasoning(r);
        }
        let frame = block_on(self.inner.step(params))?;
        Ok(FrameData {
            game_id: frame.game_id,
            guid: frame.guid,
            state: frame.state.as_str().to_string(),
            levels_completed: frame.levels_completed,
            win_levels: frame.win_levels,
            available_actions: frame.available_actions,
            full_reset: frame.full_reset,
        })
    }
}

// Copyright 2026 Mahmoud Harmouch.
//
// Licensed under the MIT license
// <LICENSE-MIT or http://opensource.org/licenses/MIT>, at your
// option. This file may not be copied, modified, or distributed
// except according to those terms.