Skip to main content

arcane_core/agent/
mod.rs

1pub mod inspector;
2pub mod mcp;
3
4use std::sync::mpsc;
5
6/// Options for frame capture to control file size.
7#[derive(Debug, Clone)]
8pub struct CaptureFrameOptions {
9    /// Scale factor: 0.5 = 50% resolution. Default: 1.0.
10    pub scale: f32,
11    /// Region to capture: (x, y, width, height). If None, captures full frame.
12    pub region: Option<(u32, u32, u32, u32)>,
13}
14
15impl Default for CaptureFrameOptions {
16    fn default() -> Self {
17        Self {
18            scale: 1.0,
19            region: None,
20        }
21    }
22}
23
24impl CaptureFrameOptions {
25    /// Estimate PNG size in bytes (rough heuristic: 1-2 bytes per pixel after compression).
26    pub fn estimate_size(&self, base_width: u32, base_height: u32) -> usize {
27        let (w, h) = if let Some((_, _, rw, rh)) = self.region {
28            (rw, rh)
29        } else {
30            (base_width, base_height)
31        };
32        let scaled_w = (w as f32 * self.scale).ceil() as usize;
33        let scaled_h = (h as f32 * self.scale).ceil() as usize;
34        // Rough estimate: 4 bytes per pixel before compression, ~1-2 bytes after
35        (scaled_w * scaled_h * 2) + 1024 // +1KB for headers
36    }
37}
38
39/// Requests the inspector HTTP server can send to the game loop.
40#[derive(Debug)]
41pub enum InspectorRequest {
42    Health,
43    GetState { path: Option<String> },
44    Describe { verbosity: Option<String> },
45    ListActions,
46    ExecuteAction { name: String, payload: String },
47    Rewind { steps: u32 },
48    Simulate { action: String },
49    GetHistory,
50    GetFrameStats,
51    CaptureFrame { options: CaptureFrameOptions },
52}
53
54/// Response from the game loop back to the inspector HTTP server.
55#[derive(Debug)]
56pub struct InspectorResponse {
57    pub status: u16,
58    pub content_type: String,
59    pub body: String,
60}
61
62impl InspectorResponse {
63    pub fn json(body: String) -> Self {
64        Self {
65            status: 200,
66            content_type: "application/json".into(),
67            body,
68        }
69    }
70
71    pub fn text(body: String) -> Self {
72        Self {
73            status: 200,
74            content_type: "text/plain".into(),
75            body,
76        }
77    }
78
79    pub fn error(status: u16, message: String) -> Self {
80        Self {
81            status,
82            content_type: "application/json".into(),
83            body: format!("{{\"error\":\"{message}\"}}"),
84        }
85    }
86}
87
88/// Sender half: used by the game loop to respond to inspector requests.
89pub type ResponseSender = mpsc::Sender<InspectorResponse>;
90
91/// A request bundled with a channel to send the response back.
92pub type InspectorMessage = (InspectorRequest, ResponseSender);
93
94/// Sender half: used by the inspector HTTP server to send requests to the game loop.
95pub type RequestSender = mpsc::Sender<InspectorMessage>;
96
97/// Receiver half: used by the game loop to receive requests from the inspector.
98pub type RequestReceiver = mpsc::Receiver<InspectorMessage>;
99
100/// Create a new inspector channel pair.
101pub fn inspector_channel() -> (RequestSender, RequestReceiver) {
102    mpsc::channel()
103}
104
105#[cfg(test)]
106mod tests {
107    use super::*;
108
109    #[test]
110    fn inspector_channel_round_trip() {
111        let (tx, rx) = inspector_channel();
112
113        // Simulate inspector sending a request
114        let (resp_tx, resp_rx) = mpsc::channel();
115        tx.send((InspectorRequest::Health, resp_tx)).unwrap();
116
117        // Simulate game loop receiving and responding
118        let (req, sender) = rx.recv().unwrap();
119        assert!(matches!(req, InspectorRequest::Health));
120        sender
121            .send(InspectorResponse::json("{\"status\":\"ok\"}".into()))
122            .unwrap();
123
124        // Verify inspector gets the response
125        let response = resp_rx.recv().unwrap();
126        assert_eq!(response.status, 200);
127        assert_eq!(response.content_type, "application/json");
128        assert!(response.body.contains("ok"));
129    }
130
131    #[test]
132    fn inspector_response_constructors() {
133        let json = InspectorResponse::json("{\"key\":1}".into());
134        assert_eq!(json.status, 200);
135        assert_eq!(json.content_type, "application/json");
136
137        let text = InspectorResponse::text("hello".into());
138        assert_eq!(text.status, 200);
139        assert_eq!(text.content_type, "text/plain");
140
141        let err = InspectorResponse::error(404, "not found".into());
142        assert_eq!(err.status, 404);
143        assert!(err.body.contains("not found"));
144    }
145
146    #[test]
147    fn inspector_request_variants() {
148        // Ensure all variants construct correctly
149        let requests = vec![
150            InspectorRequest::Health,
151            InspectorRequest::GetState { path: None },
152            InspectorRequest::GetState {
153                path: Some("player.hp".into()),
154            },
155            InspectorRequest::Describe {
156                verbosity: Some("full".into()),
157            },
158            InspectorRequest::ListActions,
159            InspectorRequest::ExecuteAction {
160                name: "move".into(),
161                payload: "{}".into(),
162            },
163            InspectorRequest::Rewind { steps: 3 },
164            InspectorRequest::Simulate {
165                action: "attack".into(),
166            },
167            InspectorRequest::GetHistory,
168            InspectorRequest::GetFrameStats,
169            InspectorRequest::CaptureFrame { options: CaptureFrameOptions::default() },
170        ];
171        assert_eq!(requests.len(), 11);
172    }
173
174    #[test]
175    fn capture_frame_options_estimate_size() {
176        let opts = CaptureFrameOptions { scale: 0.5, region: None };
177        // 800x600 at 50% = 400x300 = ~240k + 1KB
178        let size = opts.estimate_size(800, 600);
179        assert!(size > 200_000 && size < 300_000);
180    }
181
182    #[test]
183    fn capture_frame_options_with_region() {
184        let opts = CaptureFrameOptions { scale: 1.0, region: Some((0, 0, 400, 300)) };
185        // 400x300 = ~240k + 1KB
186        let size = opts.estimate_size(800, 600);
187        assert!(size > 200_000 && size < 300_000);
188    }
189}