arcane-core 0.26.1

Core library for Arcane - agent-native 2D game engine (TypeScript runtime, renderer, platform layer)
Documentation
pub mod inspector;
pub mod mcp;

use std::sync::mpsc;

/// Options for frame capture to control file size.
#[derive(Debug, Clone)]
pub struct CaptureFrameOptions {
    /// Scale factor: 0.5 = 50% resolution. Default: 1.0.
    pub scale: f32,
    /// Region to capture: (x, y, width, height). If None, captures full frame.
    pub region: Option<(u32, u32, u32, u32)>,
}

impl Default for CaptureFrameOptions {
    fn default() -> Self {
        Self {
            scale: 1.0,
            region: None,
        }
    }
}

impl CaptureFrameOptions {
    /// Estimate PNG size in bytes (rough heuristic: 1-2 bytes per pixel after compression).
    pub fn estimate_size(&self, base_width: u32, base_height: u32) -> usize {
        let (w, h) = if let Some((_, _, rw, rh)) = self.region {
            (rw, rh)
        } else {
            (base_width, base_height)
        };
        let scaled_w = (w as f32 * self.scale).ceil() as usize;
        let scaled_h = (h as f32 * self.scale).ceil() as usize;
        // Rough estimate: 4 bytes per pixel before compression, ~1-2 bytes after
        (scaled_w * scaled_h * 2) + 1024 // +1KB for headers
    }
}

/// Requests the inspector HTTP server can send to the game loop.
#[derive(Debug)]
pub enum InspectorRequest {
    Health,
    GetState { path: Option<String> },
    Describe { verbosity: Option<String> },
    ListActions,
    ExecuteAction { name: String, payload: String },
    Rewind { steps: u32 },
    Simulate { action: String },
    GetHistory,
    GetFrameStats,
    CaptureFrame { options: CaptureFrameOptions },
}

/// Response from the game loop back to the inspector HTTP server.
#[derive(Debug)]
pub struct InspectorResponse {
    pub status: u16,
    pub content_type: String,
    pub body: String,
}

impl InspectorResponse {
    pub fn json(body: String) -> Self {
        Self {
            status: 200,
            content_type: "application/json".into(),
            body,
        }
    }

    pub fn text(body: String) -> Self {
        Self {
            status: 200,
            content_type: "text/plain".into(),
            body,
        }
    }

    pub fn error(status: u16, message: String) -> Self {
        Self {
            status,
            content_type: "application/json".into(),
            body: format!("{{\"error\":\"{message}\"}}"),
        }
    }
}

/// Sender half: used by the game loop to respond to inspector requests.
pub type ResponseSender = mpsc::Sender<InspectorResponse>;

/// A request bundled with a channel to send the response back.
pub type InspectorMessage = (InspectorRequest, ResponseSender);

/// Sender half: used by the inspector HTTP server to send requests to the game loop.
pub type RequestSender = mpsc::Sender<InspectorMessage>;

/// Receiver half: used by the game loop to receive requests from the inspector.
pub type RequestReceiver = mpsc::Receiver<InspectorMessage>;

/// Create a new inspector channel pair.
pub fn inspector_channel() -> (RequestSender, RequestReceiver) {
    mpsc::channel()
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn inspector_channel_round_trip() {
        let (tx, rx) = inspector_channel();

        // Simulate inspector sending a request
        let (resp_tx, resp_rx) = mpsc::channel();
        tx.send((InspectorRequest::Health, resp_tx)).unwrap();

        // Simulate game loop receiving and responding
        let (req, sender) = rx.recv().unwrap();
        assert!(matches!(req, InspectorRequest::Health));
        sender
            .send(InspectorResponse::json("{\"status\":\"ok\"}".into()))
            .unwrap();

        // Verify inspector gets the response
        let response = resp_rx.recv().unwrap();
        assert_eq!(response.status, 200);
        assert_eq!(response.content_type, "application/json");
        assert!(response.body.contains("ok"));
    }

    #[test]
    fn inspector_response_constructors() {
        let json = InspectorResponse::json("{\"key\":1}".into());
        assert_eq!(json.status, 200);
        assert_eq!(json.content_type, "application/json");

        let text = InspectorResponse::text("hello".into());
        assert_eq!(text.status, 200);
        assert_eq!(text.content_type, "text/plain");

        let err = InspectorResponse::error(404, "not found".into());
        assert_eq!(err.status, 404);
        assert!(err.body.contains("not found"));
    }

    #[test]
    fn inspector_request_variants() {
        // Ensure all variants construct correctly
        let requests = vec![
            InspectorRequest::Health,
            InspectorRequest::GetState { path: None },
            InspectorRequest::GetState {
                path: Some("player.hp".into()),
            },
            InspectorRequest::Describe {
                verbosity: Some("full".into()),
            },
            InspectorRequest::ListActions,
            InspectorRequest::ExecuteAction {
                name: "move".into(),
                payload: "{}".into(),
            },
            InspectorRequest::Rewind { steps: 3 },
            InspectorRequest::Simulate {
                action: "attack".into(),
            },
            InspectorRequest::GetHistory,
            InspectorRequest::GetFrameStats,
            InspectorRequest::CaptureFrame { options: CaptureFrameOptions::default() },
        ];
        assert_eq!(requests.len(), 11);
    }

    #[test]
    fn capture_frame_options_estimate_size() {
        let opts = CaptureFrameOptions { scale: 0.5, region: None };
        // 800x600 at 50% = 400x300 = ~240k + 1KB
        let size = opts.estimate_size(800, 600);
        assert!(size > 200_000 && size < 300_000);
    }

    #[test]
    fn capture_frame_options_with_region() {
        let opts = CaptureFrameOptions { scale: 1.0, region: Some((0, 0, 400, 300)) };
        // 400x300 = ~240k + 1KB
        let size = opts.estimate_size(800, 600);
        assert!(size > 200_000 && size < 300_000);
    }
}