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.

//! # Data Models
//!
//! This module contains the core domain types used throughout `arc-agi-rs`.
//!
//! ## Type Overview
//!
//! | Type | Python Equivalent | Description |
//! |------|-------------------|-------------|
//! | [`EnvironmentInfo`] | `models.EnvironmentInfo` | Metadata for a single game environment |
//! | [`OperationMode`] | `base.OperationMode` | How the client discovers and runs environments |
//! | [`GameState`] | `arcengine.GameState` | Current lifecycle state of a running game |
//! | [`ActionInput`] | `arcengine.ActionInput` | Action payload sent to or echoed by the server |
//! | [`FrameData`] | `arcengine.FrameData` | Full step response returned by every API command |
//!
//! ## See Also
//!
//! - [ARC-AGI-3 API Reference](https://three.arcprize.org)

use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::fmt;
use std::str::FromStr;

/// Metadata for a single ARC-AGI-3 game environment.
///
/// An `EnvironmentInfo` is returned by [`crate::client::Client::list_environments`]
/// and [`crate::client::Client::get_environment`].  The `local_dir` field is
/// never serialised; it is populated at runtime when environments are discovered on disk.
///
/// # Example
/// ```rust
/// use arc_agi_rs::models::EnvironmentInfo;
///
/// let json = r#"{"game_id":"ls20","title":"Level Shift 20"}"#;
/// let info: EnvironmentInfo = serde_json::from_str(json).unwrap();
/// assert_eq!(info.game_id, "ls20");
/// ```
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct EnvironmentInfo {
    /// Unique game identifier, e.g. `"ls20"` or `"ls20-1234abcd"`.
    pub game_id: String,
    /// Human-readable title of the environment.
    pub title: Option<String>,
    /// Default frames-per-second for rendering (default `5` when absent).
    pub default_fps: Option<u32>,
    /// Public classification tags.
    pub tags: Option<Vec<String>>,
    /// Private tags not exposed in public API responses.
    #[serde(skip_serializing)]
    pub private_tags: Option<Vec<String>>,
    /// Per-level private classification tags.
    #[serde(skip_serializing)]
    pub level_tags: Option<Vec<Vec<String>>>,
    /// Number of actions a human baseline player typically takes per level.
    pub baseline_actions: Option<Vec<u32>>,
    /// ISO-8601 timestamp of when this environment was downloaded locally.
    pub date_downloaded: Option<DateTime<Utc>>,
    /// Name of the Python game class used by the local runner.
    pub class_name: Option<String>,
    /// Filesystem path to the local environment directory (runtime-only, not serialised).
    #[serde(skip)]
    pub local_dir: Option<String>,
}

impl EnvironmentInfo {
    /// Returns the base game identifier, stripping any version suffix.
    ///
    /// For `"ls20-1234abcd"` this returns `"ls20"`.  For `"ls20"` it returns
    /// the original string unchanged.
    ///
    /// # Example
    /// ```rust
    /// use arc_agi_rs::models::EnvironmentInfo;
    ///
    /// let info = EnvironmentInfo { game_id: "ls20-abc".to_string(), ..Default::default() };
    /// assert_eq!(info.base_id(), "ls20");
    /// ```
    pub fn base_id(&self) -> &str {
        self.game_id
            .split_once('-')
            .map(|(base, _)| base)
            .unwrap_or(&self.game_id)
    }

    /// Returns the version component of the game identifier, if present.
    ///
    /// # Example
    /// ```rust
    /// use arc_agi_rs::models::EnvironmentInfo;
    ///
    /// let info = EnvironmentInfo { game_id: "ls20-abc123".to_string(), ..Default::default() };
    /// assert_eq!(info.version(), Some("abc123"));
    /// ```
    pub fn version(&self) -> Option<&str> {
        self.game_id.split_once('-').map(|(_, ver)| ver)
    }

    /// Returns the configured default FPS, falling back to `5` when the
    /// field is absent (mirroring the Python `set_defaults` validator).
    pub fn effective_fps(&self) -> u32 {
        self.default_fps.unwrap_or(5)
    }
}

/// Controls how [`crate::client::Client`] discovers and runs environments.
///
/// The value can be supplied directly or overridden via the `OPERATION_MODE`
/// environment variable (values: `"normal"`, `"online"`, `"offline"`,
/// `"competition"`).
///
/// # Example
/// ```rust
/// use arc_agi_rs::models::OperationMode;
/// use std::str::FromStr;
///
/// assert_eq!(OperationMode::from_str("online").ok(), Some(OperationMode::Online));
/// assert_eq!(OperationMode::from_str("unknown").ok(), None);
/// ```
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub enum OperationMode {
    /// Use locally available environments and fall through to the remote API
    /// when not found locally (default).
    #[default]
    Normal,
    /// Use only the remote ARC-AGI-3 API; never check local files.
    Online,
    /// Use only locally available environments; never contact the API.
    Offline,
    /// Competition mode - enforces one-way scorecard semantics.
    Competition,
}

impl OperationMode {
    /// Returns the canonical lowercase string for this mode.
    pub fn as_str(&self) -> &'static str {
        match self {
            OperationMode::Normal => "normal",
            OperationMode::Online => "online",
            OperationMode::Offline => "offline",
            OperationMode::Competition => "competition",
        }
    }
}

impl FromStr for OperationMode {
    type Err = ();

    fn from_str(s: &str) -> Result<Self, Self::Err> {
        match s.trim().to_lowercase().as_str() {
            "normal" => Ok(OperationMode::Normal),
            "online" => Ok(OperationMode::Online),
            "offline" => Ok(OperationMode::Offline),
            "competition" => Ok(OperationMode::Competition),
            _ => Err(()),
        }
    }
}

impl fmt::Display for OperationMode {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        f.write_str(self.as_str())
    }
}

/// Lifecycle state of a running game environment.
///
/// # Example
/// ```rust
/// use arc_agi_rs::models::GameState;
///
/// assert_eq!(GameState::Win.as_str(), "WIN");
/// ```
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
pub enum GameState {
    /// The game has not been played yet.
    #[default]
    NotPlayed,
    /// The game is in progress.
    NotFinished,
    /// The player has won all levels.
    Win,
    /// The player has exhausted all attempts.
    GameOver,
}

impl GameState {
    /// Returns the uppercase API string representation.
    pub fn as_str(&self) -> &'static str {
        match self {
            GameState::NotPlayed => "NOT_PLAYED",
            GameState::NotFinished => "NOT_FINISHED",
            GameState::Win => "WIN",
            GameState::GameOver => "GAME_OVER",
        }
    }
}

impl FromStr for GameState {
    type Err = ();

    fn from_str(s: &str) -> Result<Self, Self::Err> {
        match s {
            "NOT_PLAYED" => Ok(GameState::NotPlayed),
            "NOT_FINISHED" => Ok(GameState::NotFinished),
            "WIN" => Ok(GameState::Win),
            "GAME_OVER" => Ok(GameState::GameOver),
            _ => Err(()),
        }
    }
}

impl fmt::Display for GameState {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        f.write_str(self.as_str())
    }
}

/// The action that was last applied to a running game.
///
/// This is echoed back inside every [`FrameData`] response so callers can
/// confirm which action was processed.
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct ActionInput {
    /// Numeric action identifier (0 = RESET).
    pub id: u32,
    /// Optional key-value data attached to the action (e.g. `{"x": 3, "y": 4}`).
    pub data: Option<Value>,
    /// Optional freeform reasoning attached by the agent.
    pub reasoning: Option<Value>,
}

/// Full response returned by every ARC-AGI-3 game command endpoint.
///
/// Deserialised from the JSON body of `POST /api/cmd/RESET`,
/// `POST /api/cmd/ACTION{n}`, etc.
///
/// # Example
/// ```rust
/// use arc_agi_rs::models::{FrameData, GameState};
///
/// let json = r#"{
///     "game_id": "ls20",
///     "guid": "abc-123",
///     "frame": [],
///     "state": "NOT_FINISHED",
///     "levels_completed": 0,
///     "win_levels": 0,
///     "available_actions": [0, 1],
///     "action_input": {"id": 0}
/// }"#;
/// let frame: FrameData = serde_json::from_str(json).unwrap();
/// assert_eq!(frame.game_id, "ls20");
/// assert_eq!(frame.state, GameState::NotFinished);
/// ```
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct FrameData {
    /// The game identifier this frame belongs to.
    pub game_id: String,
    /// Unique run identifier assigned by the server.
    pub guid: Option<String>,
    /// Pixel frame layers encoded as nested arrays.
    pub frame: Vec<Value>,
    /// Current lifecycle state.
    pub state: GameState,
    /// Number of levels the agent has 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>,
    /// The action that produced this frame.
    pub action_input: Option<ActionInput>,
    /// Whether this response corresponds to a full game reset.
    #[serde(default)]
    pub full_reset: bool,
}

impl FrameData {
    /// Returns `true` when no meaningful frame data is present (empty frame and
    /// default state), indicating the game has not been started yet.
    pub fn is_empty(&self) -> bool {
        self.frame.is_empty() && self.state == GameState::NotPlayed
    }
}

// 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.