hiraku-engine 0.1.0

Hiraku visual-novel engine
use std::collections::BTreeMap;

use bevy::prelude::Resource;
use hiraku_script::hson;
use serde::Deserialize;
use thiserror::Error;

use crate::vfs::{HdpVfs, VfsError};

#[derive(Clone, Debug, Default, Resource)]
pub struct MovieCatalog {
    movies: BTreeMap<String, MovieDefinition>,
}

#[derive(Clone, Debug)]
pub struct MovieDefinition {
    pub path: String,
}

impl MovieCatalog {
    pub fn resolve(&self, name: &str) -> Option<&MovieDefinition> {
        self.movies.get(name)
    }
}

#[derive(Debug, Deserialize)]
struct MovieFile {
    name: String,
    video: String,
}

#[derive(Debug, Error)]
pub enum MovieCatalogError {
    #[error("failed to read movie data: {0}")]
    Read(#[from] VfsError),
    #[error("failed to load movie data `{path}`: {message}")]
    Data { path: String, message: String },
}

pub fn load_movie_catalog(vfs: &HdpVfs) -> Result<MovieCatalog, MovieCatalogError> {
    let directory = vfs.load_movies_dir_path(None)?;
    let mut descriptor_paths = match vfs.list_files_recursive(&directory) {
        Ok(paths) => paths,
        Err(VfsError::NotFound(_)) => return Ok(MovieCatalog::default()),
        Err(error) => return Err(error.into()),
    };
    descriptor_paths.retain(|path| path.ends_with(".movie.hson"));
    descriptor_paths.sort();

    let mut movies = BTreeMap::new();
    for descriptor_path in descriptor_paths {
        let source = vfs.read_text(&descriptor_path)?;
        let file: MovieFile = hson::from_str(&source).map_err(|error| MovieCatalogError::Data {
            path: descriptor_path.clone(),
            message: error.render_with_options(
                &descriptor_path,
                &source,
                hiraku_script::RenderOptions::terminal(),
            ),
        })?;
        if file.name.trim().is_empty() {
            return Err(MovieCatalogError::Data {
                path: descriptor_path,
                message: "movie name must not be empty".into(),
            });
        }
        let lower = file.video.to_ascii_lowercase();
        if !lower.ends_with(".mkv") && !lower.ends_with(".webm") {
            return Err(MovieCatalogError::Data {
                path: descriptor_path,
                message: "movie video must be a `.mkv` or `.webm` AV1 + Opus asset".into(),
            });
        }
        let definition = MovieDefinition {
            path: vfs.resolve_path(Some(&descriptor_path), &file.video),
        };
        if movies.insert(file.name.clone(), definition).is_some() {
            return Err(MovieCatalogError::Data {
                path: descriptor_path,
                message: format!("movie `{}` is defined more than once", file.name),
            });
        }
    }
    Ok(MovieCatalog { movies })
}

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

    #[test]
    fn loads_named_movie_descriptors() {
        let root =
            std::env::temp_dir().join(format!("hiraku-movie-catalog-test-{}", std::process::id()));
        std::fs::create_dir_all(root.join("movies")).expect("test directory must be created");
        std::fs::write(root.join("settings.hson"), ".{}").expect("test settings must be written");
        std::fs::write(
            root.join("movies/intro.movie.hson"),
            ".{ name: \"intro\", video: \"intro.webm\" }",
        )
        .expect("test descriptor must be written");

        let vfs = HdpVfs::new_with_config(&root, "settings.hson", "startup.hks");
        let catalog = load_movie_catalog(&vfs).expect("movie catalog must load");
        assert_eq!(
            catalog
                .resolve("intro")
                .expect("named movie must resolve")
                .path,
            "movies/intro.webm"
        );

        std::fs::remove_dir_all(root).expect("test directory must be removed");
    }
}