codecraft 0.1.2

A minimalist 3D game engine built on parts of Bevy (ECS, color) with wgpu and winit: OpenPBR materials, clustered lighting, a yakui-drawn UI, audio and gamepad haptics; its binary maps any folder, and the symbols of its Rust files, as a 3D wall of boxes
Documentation
//! A small loopback command port (screenshots, keys, pointer) so the running
//! game can be driven from outside; on only when `RENDERER_CONTROL_PORT` is set.
use std::io::{BufRead, BufReader, Write};
use std::net::{Ipv4Addr, SocketAddr, TcpListener, TcpStream};
use std::path::PathBuf;
use std::sync::mpsc::{Receiver, Sender, TryRecvError, channel};

use crate::input::KeyCode;

/// The environment variable that turns the port on; `0` lets the OS choose.
pub const PORT_VAR: &str = "RENDERER_CONTROL_PORT";

/// What the game has been asked to do.
#[derive(Clone, PartialEq, Debug)]
pub enum Command {
    /// Write a PNG of the next frame to this path.
    Screenshot { path: PathBuf },
    /// Press and release a key, as though somebody had.
    Key { code: KeyCode },
    /// Hold a key down until it is let go with `Up`.
    Down { code: KeyCode },
    /// Let a held key go.
    Up { code: KeyCode },
    /// Move the pointer, in pixels from the top-left of the window.
    Cursor { x: f32, y: f32 },
    /// Click the left button, optionally moving the pointer there first.
    Click { at: Option<(f32, f32)> },
    /// Hold the left button down until a `release`, optionally moving the pointer there first.
    Press { at: Option<(f32, f32)> },
    /// Let the left button go.
    Release,
    /// Shut the game down.
    Quit,
}

pub struct Request {
    pub command: Command,
    reply: Sender<Result<String, String>>,
}

impl Request {
    /// Answers the caller; an unanswered request leaves the caller waiting until it gives up.
    pub fn answer(self, answer: Result<String, String>) {
        let _ = self.reply.send(answer);
    }

    pub fn command(&self) -> &Command {
        &self.command
    }
}

pub struct Control {
    requests: Receiver<Request>,
    port: u16,
}

impl Control {
    /// Starts a port if [`PORT_VAR`] asks for one.
    pub fn from_env() -> Option<Self> {
        let value = std::env::var(PORT_VAR).ok()?;
        let port: u16 = match value.trim().parse() {
            Ok(port) => port,
            Err(_) => {
                log::error!("{PORT_VAR}={value:?} is not a port number");
                return None;
            }
        };
        match Self::listen(port) {
            Ok(control) => Some(control),
            Err(error) => {
                log::error!("could not open the control port: {error}");
                None
            }
        }
    }

    /// Listens on loopback. Port `0` lets the OS choose one.
    pub fn listen(port: u16) -> std::io::Result<Self> {
        let listener = TcpListener::bind(SocketAddr::from((Ipv4Addr::LOCALHOST, port)))?;
        let port = listener.local_addr()?.port();
        let (sender, requests) = channel();

        std::thread::Builder::new()
            .name("control".into())
            .spawn(move || serve(listener, sender))?;

        log::info!("control port listening on 127.0.0.1:{port}");
        Ok(Self { requests, port })
    }

    pub fn port(&self) -> u16 {
        self.port
    }

    /// The next request, if one is waiting. Never blocks.
    pub fn poll(&self) -> Option<Request> {
        match self.requests.try_recv() {
            Ok(request) => Some(request),
            Err(TryRecvError::Empty | TryRecvError::Disconnected) => None,
        }
    }
}

fn serve(listener: TcpListener, sender: Sender<Request>) {
    for stream in listener.incoming() {
        let Ok(mut stream) = stream else {
            continue;
        };
        if let Err(error) = handle(&mut stream, &sender) {
            log::debug!("control connection ended: {error}");
        }
    }
}

fn handle(stream: &mut TcpStream, sender: &Sender<Request>) -> std::io::Result<()> {
    let mut lines = BufReader::new(stream.try_clone()?).lines();
    while let Some(line) = lines.next().transpose()? {
        let answer = match parse(&line) {
            Err(problem) => Err(problem),
            Ok(command) => {
                let (reply, answer) = channel();
                if sender.send(Request { command, reply }).is_err() {
                    Err("the game is gone".to_string())
                } else {
                    // Timeout so a frame that never comes does not hang the caller forever.
                    answer
                        .recv_timeout(std::time::Duration::from_secs(10))
                        .unwrap_or_else(|_| Err("the game did not answer".to_string()))
                }
            }
        };

        let line = match answer {
            Ok(message) => format!("ok {message}\n"),
            Err(problem) => format!("error {problem}\n"),
        };
        stream.write_all(line.as_bytes())?;
        stream.flush()?;
    }
    Ok(())
}

fn key_named(name: &str) -> Result<KeyCode, String> {
    let wanted = name.trim();
    KEYS.iter()
        .find(|(spelling, _)| spelling.eq_ignore_ascii_case(wanted))
        .map(|(_, code)| *code)
        .ok_or_else(|| format!("no such key: {wanted}"))
}

fn point(rest: &str) -> Result<(f32, f32), String> {
    let mut parts = rest.split_whitespace();
    let mut next = || {
        parts
            .next()
            .and_then(|p| p.parse::<f32>().ok())
            .ok_or_else(|| format!("expected `x y` in pixels, got {rest:?}"))
    };
    let (x, y) = (next()?, next()?);
    match parts.next() {
        Some(extra) => Err(format!("only x and y, but there is also {extra:?}")),
        None => Ok((x, y)),
    }
}

/// The keys that can be pressed from outside, spelled as winit spells them.
const KEYS: &[(&str, KeyCode)] = &[
    ("Escape", KeyCode::Escape),
    ("Enter", KeyCode::Enter),
    ("Space", KeyCode::Space),
    ("Tab", KeyCode::Tab),
    ("Backspace", KeyCode::Backspace),
    ("Home", KeyCode::Home),
    ("Slash", KeyCode::Slash),
    ("Minus", KeyCode::Minus),
    ("ShiftLeft", KeyCode::ShiftLeft),
    ("Left", KeyCode::ArrowLeft),
    ("Right", KeyCode::ArrowRight),
    ("Up", KeyCode::ArrowUp),
    ("Down", KeyCode::ArrowDown),
    ("KeyA", KeyCode::KeyA),
    ("KeyB", KeyCode::KeyB),
    ("KeyC", KeyCode::KeyC),
    ("KeyD", KeyCode::KeyD),
    ("KeyE", KeyCode::KeyE),
    ("KeyF", KeyCode::KeyF),
    ("KeyG", KeyCode::KeyG),
    ("KeyH", KeyCode::KeyH),
    ("KeyI", KeyCode::KeyI),
    ("KeyJ", KeyCode::KeyJ),
    ("KeyK", KeyCode::KeyK),
    ("KeyL", KeyCode::KeyL),
    ("KeyM", KeyCode::KeyM),
    ("KeyN", KeyCode::KeyN),
    ("KeyO", KeyCode::KeyO),
    ("KeyP", KeyCode::KeyP),
    ("KeyQ", KeyCode::KeyQ),
    ("KeyR", KeyCode::KeyR),
    ("KeyS", KeyCode::KeyS),
    ("KeyT", KeyCode::KeyT),
    ("KeyU", KeyCode::KeyU),
    ("KeyV", KeyCode::KeyV),
    ("KeyW", KeyCode::KeyW),
    ("KeyX", KeyCode::KeyX),
    ("KeyY", KeyCode::KeyY),
    ("KeyZ", KeyCode::KeyZ),
    ("Digit0", KeyCode::Digit0),
    ("Digit1", KeyCode::Digit1),
    ("Digit2", KeyCode::Digit2),
    ("Digit3", KeyCode::Digit3),
    ("Digit4", KeyCode::Digit4),
    ("Digit5", KeyCode::Digit5),
    ("Digit6", KeyCode::Digit6),
    ("Digit7", KeyCode::Digit7),
    ("Digit8", KeyCode::Digit8),
    ("Digit9", KeyCode::Digit9),
    ("F1", KeyCode::F1),
    ("F2", KeyCode::F2),
    ("F3", KeyCode::F3),
    ("F4", KeyCode::F4),
    ("F5", KeyCode::F5),
    ("F6", KeyCode::F6),
    ("F7", KeyCode::F7),
    ("F8", KeyCode::F8),
    ("F9", KeyCode::F9),
    ("F10", KeyCode::F10),
    ("F11", KeyCode::F11),
    ("F12", KeyCode::F12),
];

pub fn parse(line: &str) -> Result<Command, String> {
    let line = line.trim();
    let (name, rest) = line.split_once(char::is_whitespace).unwrap_or((line, ""));
    let rest = rest.trim();

    match name {
        "screenshot" if rest.is_empty() => Err("screenshot needs a path".to_string()),
        "screenshot" => Ok(Command::Screenshot {
            path: PathBuf::from(rest),
        }),
        "key" if rest.is_empty() => Err("key needs a key to press".to_string()),
        "key" => key_named(rest).map(|code| Command::Key { code }),
        "down" if rest.is_empty() => Err("down needs a key to hold".to_string()),
        "down" => key_named(rest).map(|code| Command::Down { code }),
        "up" if rest.is_empty() => Err("up needs a key to let go".to_string()),
        "up" => key_named(rest).map(|code| Command::Up { code }),
        "cursor" => point(rest).map(|(x, y)| Command::Cursor { x, y }),
        "click" if rest.is_empty() => Ok(Command::Click { at: None }),
        "click" => point(rest).map(|(x, y)| Command::Click { at: Some((x, y)) }),
        "press" if rest.is_empty() => Ok(Command::Press { at: None }),
        "press" => point(rest).map(|(x, y)| Command::Press { at: Some((x, y)) }),
        "release" => Ok(Command::Release),
        "quit" => Ok(Command::Quit),
        "" => Err("say something".to_string()),
        other => Err(format!("no such command: {other}")),
    }
}

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

    #[test]
    fn a_key_is_named_the_way_winit_names_it() {
        assert_eq!(parse("key F12"), Ok(Command::Key { code: KeyCode::F12 }));
        assert_eq!(
            parse("key KeyV"),
            Ok(Command::Key {
                code: KeyCode::KeyV
            })
        );
        assert_eq!(
            parse("key escape"),
            Ok(Command::Key {
                code: KeyCode::Escape
            })
        );
    }

    #[test]
    fn a_key_can_be_held_and_let_go() {
        assert_eq!(
            parse("down KeyW"),
            Ok(Command::Down {
                code: KeyCode::KeyW
            })
        );
        assert_eq!(
            parse("up KeyW"),
            Ok(Command::Up {
                code: KeyCode::KeyW
            })
        );
        assert!(parse("down").is_err());
        assert!(parse("up Nope").is_err());
    }

    #[test]
    fn a_key_nobody_has_is_refused_by_name() {
        let refusal = parse("key Banana").unwrap_err();
        assert!(refusal.contains("Banana"), "{refusal}");
        assert!(parse("key").is_err(), "and a key command needs a key");
    }

    #[test]
    fn a_screenshot_command_carries_its_path() {
        assert_eq!(
            parse("screenshot /tmp/board.png"),
            Ok(Command::Screenshot {
                path: PathBuf::from("/tmp/board.png"),
            }),
        );
    }

    #[test]
    fn surrounding_whitespace_does_not_matter() {
        assert_eq!(
            parse("  screenshot   /tmp/a b.png \r\n"),
            Ok(Command::Screenshot {
                path: PathBuf::from("/tmp/a b.png"),
            }),
        );
    }

    #[test]
    fn a_screenshot_without_a_path_is_refused() {
        assert!(parse("screenshot").is_err());
        assert!(parse("screenshot   ").is_err());
    }

    #[test]
    fn quit_needs_nothing_else() {
        assert_eq!(parse("quit"), Ok(Command::Quit));
        assert_eq!(parse(" quit \n"), Ok(Command::Quit));
    }

    #[test]
    fn nonsense_is_refused_by_name() {
        assert_eq!(parse("wibble"), Err("no such command: wibble".to_string()));
        assert!(parse("").is_err());
    }

    #[test]
    fn a_request_can_be_answered_once() {
        let (reply, answer) = channel();
        let request = Request {
            command: Command::Screenshot {
                path: PathBuf::from("/tmp/x.png"),
            },
            reply,
        };
        request.answer(Ok("/tmp/x.png".into()));

        assert_eq!(answer.recv().unwrap(), Ok("/tmp/x.png".to_string()));
    }
}