#![cfg(feature = "env-atari")]
use std::{
io::{BufReader, Read, Write},
net::{TcpListener, TcpStream},
path::{Path, PathBuf},
thread,
};
use thrust_rl::env::{
Environment,
games::atari::{
AtariEnv, AtariEnvError,
protocol::{self, Command, OBS_CHANNELS, OBS_HEIGHT, OBS_LEN, OBS_WIDTH, Response},
},
};
fn obs(marker: f32, reward: f32, terminated: bool) -> Response {
let mut pixels = vec![0.0f32; OBS_LEN];
pixels[0] = marker;
Response::Obs { terminated, truncated: false, reward, lives: 0, pixels }
}
fn mock_env<F>(seed: u64, serve: F) -> AtariEnv
where
F: FnOnce(TcpStream) + Send + 'static,
{
let listener = TcpListener::bind("127.0.0.1:0").expect("bind loopback");
let addr = listener.local_addr().expect("local addr");
thread::spawn(move || {
if let Ok((stream, _)) = listener.accept() {
serve(stream);
}
});
let stream = TcpStream::connect(addr).expect("connect to mock worker");
let reader: Box<dyn Read + Send> = Box::new(stream.try_clone().expect("clone stream"));
let writer: Box<dyn Write + Send> = Box::new(stream);
AtariEnv::from_transport(reader, writer, "pong", seed)
}
fn full_worker(stream: TcpStream) {
let mut reader = BufReader::new(stream.try_clone().expect("clone stream"));
let mut writer = stream;
loop {
match protocol::read_command(&mut reader) {
Ok(Command::Reset(seed)) => {
let resp = obs(seed as f32, 0.0, false);
protocol::write_response(&mut writer, &resp).unwrap();
}
Ok(Command::Step(action)) => {
let resp = obs(action as f32, action as f32, action == 9);
protocol::write_response(&mut writer, &resp).unwrap();
}
Ok(Command::CloneState) => {
let resp = Response::State(vec![0xAB, 0xCD, 0xEF, 0x01]);
protocol::write_response(&mut writer, &resp).unwrap();
}
Ok(Command::RestoreState(bytes)) => {
let marker = bytes.first().copied().unwrap_or(0) as f32;
let resp = obs(marker, 0.0, false);
protocol::write_response(&mut writer, &resp).unwrap();
}
Ok(Command::Close) | Err(_) => break,
}
}
}
fn error_worker(message: &'static str) -> impl FnOnce(TcpStream) + Send + 'static {
move |stream: TcpStream| {
let mut reader = BufReader::new(stream.try_clone().expect("clone stream"));
let mut writer = stream;
if protocol::read_command(&mut reader).is_ok() {
let _ = protocol::write_response(&mut writer, &Response::Error(message.to_string()));
}
}
}
#[test]
fn mock_subprocess_reset_step() {
let mut env = mock_env(42, full_worker);
env.try_reset().expect("reset over mock worker");
let obs = env.get_observation();
assert_eq!(obs.len(), OBS_LEN, "raw obs must be 210*160*3 elements");
assert_eq!(obs[0], 42.0, "reset marker should carry the seed");
let space = env.observation_space();
assert_eq!(space.shape, vec![OBS_HEIGHT, OBS_WIDTH, OBS_CHANNELS]);
let result = env.try_step(3).expect("step over mock worker");
assert_eq!(result.observation.len(), OBS_LEN);
assert_eq!(result.reward, 3.0);
assert!(!result.terminated);
assert!(!result.truncated);
assert_eq!(result.observation[0], 3.0, "step marker should carry the action");
}
#[test]
fn mock_subprocess_clone_restore_round_trip() {
let mut env = mock_env(7, full_worker);
env.try_reset().expect("reset");
let state = env.try_clone_state().expect("clone_state");
assert_eq!(state, vec![0xAB, 0xCD, 0xEF, 0x01]);
env.try_restore_state(&state).expect("restore_state");
assert_eq!(env.get_observation()[0], 0xAB as f32);
}
#[test]
fn error_missing_python() {
let err = AtariEnv::spawn_with(
"/nonexistent/definitely-not-python-42",
Path::new("envs/atari/ale_worker.py"),
"pong",
0,
)
.expect_err("spawning a non-existent interpreter must fail");
assert!(matches!(err, AtariEnvError::MissingPython { .. }));
let msg = err.to_string();
assert!(msg.contains("ATARI_PYTHON"), "message should name the remedy: {msg}");
}
#[test]
fn error_missing_ale_py() {
let mut env =
mock_env(0, error_worker("ale-py could not be imported (No module named ale_py)"));
let err = env.try_reset().expect_err("worker reported missing ale-py");
assert!(matches!(err, AtariEnvError::MissingAlePy(_)), "got {err:?}");
assert!(
err.to_string().contains("pip install ale-py"),
"message should name the remedy: {err}"
);
}
#[test]
fn error_missing_rom() {
let mut env = mock_env(0, error_worker("ROM 'pong' not found in ale-py's ROM registry"));
let err = env.try_reset().expect_err("worker reported missing ROM");
assert!(matches!(err, AtariEnvError::MissingRom(_)), "got {err:?}");
assert!(
err.to_string().contains("ALE_ROM_PATH"),
"message should name the ALE_ROM_PATH remedy: {err}"
);
}
#[test]
fn mock_action_space_is_pong_minimal_set() {
let env = mock_env(0, full_worker);
let space = env.action_space();
assert_eq!(space.shape, vec![6]);
matches!(space.space_type, thrust_rl::env::SpaceType::Discrete(6))
.then_some(())
.expect("Pong action space is Discrete(6)");
}
fn python() -> String {
std::env::var("ATARI_PYTHON").unwrap_or_else(|_| "python3".to_string())
}
fn worker_script() -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR")).join("envs/atari/ale_worker.py")
}
fn ale_py_available() -> bool {
std::process::Command::new(python())
.args(["-c", "import ale_py"])
.status()
.map(|s| s.success())
.unwrap_or(false)
}
fn real_env(seed: u64) -> Option<AtariEnv> {
if !ale_py_available() {
eprintln!("SKIP: ale-py not installed");
return None;
}
let mut env = match AtariEnv::spawn_with(python(), worker_script(), "pong", seed) {
Ok(env) => env,
Err(e) => {
eprintln!("SKIP: could not spawn ale-py worker ({e})");
return None;
}
};
match env.try_reset() {
Ok(()) => Some(env),
Err(e) => {
eprintln!("SKIP: could not reset ALE Pong ({e})");
None
}
}
}
#[test]
fn ale_obs_shape() {
let Some(env) = real_env(42) else { return };
let obs = env.get_observation();
assert_eq!(obs.len(), OBS_LEN);
assert_eq!(env.observation_space().shape, vec![OBS_HEIGHT, OBS_WIDTH, OBS_CHANNELS]);
assert!(obs.iter().all(|&p| (0.0..=255.0).contains(&p)));
}
#[test]
fn ale_seeded_determinism() {
let Some(a) = real_env(42) else { return };
let Some(b) = real_env(42) else { return };
assert_eq!(a.get_observation(), b.get_observation(), "same seed => same first frame");
}
#[test]
fn ale_episode_terminates() {
let Some(mut env) = real_env(1) else { return };
let mut terminated = false;
for _ in 0..100_000 {
let result = env.step(0);
if result.terminated {
terminated = true;
break;
}
}
assert!(terminated, "a Pong episode should end within 100k NOOP frames");
}
#[test]
fn ale_clone_restore() {
let Some(mut env) = real_env(7) else { return };
for _ in 0..5 {
env.step(0);
}
let snapshot = env.clone_state();
let actions = [2_i64, 3, 2];
let baseline: Vec<_> = actions.iter().map(|&a| env.step(a)).collect();
env.restore_state(&snapshot);
let replay: Vec<_> = actions.iter().map(|&a| env.step(a)).collect();
for (i, (x, y)) in baseline.iter().zip(replay.iter()).enumerate() {
assert_eq!(x.observation, y.observation, "obs mismatch after restore at step {i}");
assert_eq!(x.reward, y.reward, "reward mismatch after restore at step {i}");
assert_eq!(x.terminated, y.terminated, "terminated mismatch at step {i}");
}
}