bevy_grimoire_core 0.8.0

Engine-free world-building core: constraint IR, solvers, WFC, deterministic RNG.
Documentation
//! `play.ron`: how a project runs its game (#97).
//!
//! The editor never links the game. A project says how to start it, and the editor starts that
//! command as a child process on the open map. Engine-free: this is the file's schema and the
//! substitution of the map and camera into the command.

use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};

pub const PLAY_VERSION: u32 = 1;

/// The port `bevy_fairy` serves BRP on unless told otherwise.
pub const DEFAULT_PORT: u16 = 15702;

fn default_port() -> u16 {
    DEFAULT_PORT
}

#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct PlayConfig {
    pub version: u32,
    /// The program and its arguments. `{map}`, `{root}` and `{camera}` inside an argument are
    /// replaced by the map's name, the project root and `x,y,z`.
    pub command: Vec<String>,
    /// The directory the command runs in, relative to the project root. Default: the root.
    #[serde(default)]
    pub cwd: Option<String>,
    /// The port the game's `bevy_fairy` answers on (BRP over HTTP, localhost).
    #[serde(default = "default_port")]
    pub port: u16,
}

/// `<project>/play.ron`.
pub fn path_for_project(root: &Path) -> PathBuf {
    root.join("play.ron")
}

impl PlayConfig {
    pub fn from_ron(text: &str) -> Result<PlayConfig, String> {
        let c: PlayConfig = ron::from_str(text).map_err(|e| format!("play: parse: {e}"))?;
        if c.version != PLAY_VERSION {
            return Err(format!("play: version {} is not the {PLAY_VERSION} this build reads", c.version));
        }
        if c.command.first().is_none_or(|p| p.trim().is_empty()) {
            return Err("play: `command` is empty, so there is no game to start".to_owned());
        }
        Ok(c)
    }

    /// Reads the project's `play.ron`. A project without one has no way to play, and says so.
    pub fn load(root: &Path) -> Result<PlayConfig, String> {
        let path = path_for_project(root);
        match std::fs::read_to_string(&path) {
            Ok(text) => PlayConfig::from_ron(&text).map_err(|e| format!("{}: {e}", path.display())),
            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Err(format!(
                "{} does not exist. Write `(version: 1, command: [\"cargo\", \"run\", \"--\", \"{{map}}\"])` there: how to run this game.",
                path.display()
            )),
            Err(e) => Err(format!("play: {}: {e}", path.display())),
        }
    }

    /// The command with `{map}`, `{root}` and `{camera}` filled in.
    pub fn argv(&self, root: &Path, map: &str, camera: Option<[f32; 3]>) -> Vec<String> {
        let cam = camera.map(|c| format!("{},{},{}", c[0], c[1], c[2])).unwrap_or_default();
        self.command
            .iter()
            .map(|a| {
                a.replace("{map}", map).replace("{root}", &root.to_string_lossy()).replace("{camera}", &cam)
            })
            .collect()
    }

    /// The command as a person reads and trusts it: the template, with `{map}`, `{root}` and `{camera}`
    /// unfilled, and the working directory as the file writes it.
    pub fn spec(&self) -> crate::trust::CommandSpec {
        crate::trust::CommandSpec {
            kind: crate::trust::CommandKind::Play,
            program: self.command.first().cloned().unwrap_or_default(),
            args: self.command.iter().skip(1).cloned().collect(),
            cwd: self.cwd.clone(),
        }
    }

    pub fn dir(&self, root: &Path) -> PathBuf {
        match &self.cwd {
            Some(c) => root.join(c),
            None => root.to_path_buf(),
        }
    }
}

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

    #[test]
    fn the_command_takes_the_map_and_camera() {
        let c = PlayConfig::from_ron(r#"(version: 1, command: ["g", "--map", "{map}", "--at={camera}"])"#).unwrap();
        assert_eq!(c.port, DEFAULT_PORT);
        assert_eq!(c.argv(Path::new("/p"), "town", Some([1.0, 2.5, -3.0])), ["g", "--map", "town", "--at=1,2.5,-3"]);
    }

    #[test]
    fn an_empty_command_is_refused() {
        assert!(PlayConfig::from_ron("(version: 1, command: [])").is_err());
    }
}