ck3-regions 0.1.2

Generates title-based region textures for use with the custom dynamic terrain shader system implemented in some CK3 mods.
Documentation
mod titles;

use std::{
    path::{Path, PathBuf},
    io::{BufReader, BufRead},
    fs::File,
    str::FromStr,
    num::ParseIntError,
    sync::{Arc, Mutex},
};

use csv::{
    ReaderBuilder as CsvReaderBuilder,
    StringRecord
};
use glob::{glob, GlobError};
use image::{
    io::Reader as ImageReader,
    ImageError, GenericImageView, Pixel,
};
use grid::Grid;

use crate::model::game::{
    MapData,
    MapDataBuilder,
    ProvinceId,
    Rgb,
    ProvincesMap,
};

//
// Exports
//

pub use self::error::{
    MapDataLoadingError,
    ProvincesMapLoadingError
};

//
// Constants
//

const PROVINCE_DEFINITION_PATH_STR: &str = "map_data/definition.csv";
const LANDED_TITLES_DIR_PATH_STR:   &str = "common/landed_titles";
const PROVINCES_MAP_IMAGE_PATH_STR: &str = "map_data/provinces.png";

const CSV_FIELD_DELIMITER:  u8 = b';';
const CSV_LINE_COMMENT_STR: u8 = b'#';

const MIN_PROVINCE_DEFINITION_COLUMNS: usize = 4;


//
// Interface
//

pub fn load_map_data_from_mod_path(mod_root_path: &Path) -> Result<MapData, MapDataLoadingError> {
    let mod_root_path          = mod_root_path.to_path_buf();
    let definition_file_path   = mod_root_path.join(PROVINCE_DEFINITION_PATH_STR);
    let landed_titles_dir_path = mod_root_path.join(LANDED_TITLES_DIR_PATH_STR);

    let map_data_builder = Arc::new(Mutex::new(MapDataBuilder::new()));

    let map_data_builder_arc = Arc::clone(&map_data_builder);
    let provinces_thread     = std::thread::spawn(move || {
        add_provinces_from_definition_file(&map_data_builder_arc, &definition_file_path)
    });

    let map_data_builder_arc = Arc::clone(&map_data_builder);
    let titles_thread        = std::thread::spawn(move || {
        add_titles_from_landed_titles_dir(&map_data_builder_arc, &landed_titles_dir_path)
    });

    provinces_thread.join().expect("province loading thread must not panic")?;
    titles_thread.join().expect("titles loading thread must not panic")?;

    let builder = Arc::try_unwrap(map_data_builder).unwrap().into_inner().unwrap();
    builder.build().map_err(MapDataLoadingError::from)
}

pub fn load_provinces_map_from_mod_path(mod_root_path: &Path, map_data: &MapData) -> Result<ProvincesMap, ProvincesMapLoadingError> {
    let provinces_map_image      = ImageReader::open(mod_root_path.join(PROVINCES_MAP_IMAGE_PATH_STR))?.decode()?;
    let provinces_map_dimensions = provinces_map_image.dimensions();

    let mut provinces_map_grid = Grid::init(provinces_map_dimensions.1 as usize, provinces_map_dimensions.0 as usize, ProvinceId::NULL_ID);

    for y in 0..provinces_map_dimensions.1 {
        for x in 0..provinces_map_dimensions.0 {
            let province_rgb = provinces_map_image.get_pixel(x, y).to_rgb();
            let province_id  = map_data.get_province_id_by_rgb(province_rgb)
                .ok_or_else(|| ProvincesMapLoadingError::UnrecognizedProvinceRgb(province_rgb, (x, y)))?;

            *provinces_map_grid.get_mut(y as usize, x as usize).unwrap() = province_id;
        }
    }

    Ok(ProvincesMap(provinces_map_grid))
}

//
// Service
//

fn add_provinces_from_definition_file(
    map_data_builder:     &Arc<Mutex<MapDataBuilder>>,
    definition_file_path: &Path
) -> Result<(), MapDataLoadingError> {
    let     definition_file = File::open(definition_file_path)?;
    let mut csv_reader      = CsvReaderBuilder::new()
        .has_headers(false)
        .delimiter(CSV_FIELD_DELIMITER)
        .comment(Some(CSV_LINE_COMMENT_STR))
        .from_reader(definition_file);

    for result in csv_reader.records() {
        let record = result
            .map_err(|csv_err| MapDataLoadingError::CsvFormatError(definition_file_path.to_path_buf(), csv_err))?;

        if record.len() < MIN_PROVINCE_DEFINITION_COLUMNS {
            return Err(MapDataLoadingError::TooFewProvinceDefinitionColumns(record.len()));
        }

        let province_id  = ProvinceId(parse_from_csv_cell(&record, 0)?);
        let province_rgb = Rgb::from([
            parse_from_csv_cell(&record, 1)?,
            parse_from_csv_cell(&record, 2)?,
            parse_from_csv_cell(&record, 3)?
        ]);

        map_data_builder.lock().unwrap().add_province(province_id, province_rgb);
    }

    Ok(())
}

fn add_titles_from_landed_titles_dir(
    map_data_builder:       &Arc<Mutex<MapDataBuilder>>,
    landed_titles_dir_path: &Path
) -> Result<(), MapDataLoadingError> {
    const LANDED_TITLES_FILENAME_PATTERN: &str = "*.txt";

    let landed_titles_file_path_pattern = landed_titles_dir_path
        .join(LANDED_TITLES_FILENAME_PATTERN)
        .to_str()
        .expect("assuming unicode paths")
        .to_string();

    let landed_titles_file_paths = glob(&landed_titles_file_path_pattern)
        .expect("landed titles glob pattern must be valid");

    for landed_titles_file_path in landed_titles_file_paths {
        add_titles_from_landed_titles_file(
            map_data_builder,
            &landed_titles_file_path.map_err(GlobError::into_error)?
        )?;
    }

    Ok(())
}

fn add_titles_from_landed_titles_file(
    map_data_builder:        &Arc<Mutex<MapDataBuilder>>,
    landed_titles_file_path: &Path
) -> Result<(), MapDataLoadingError> {
    let landed_titles_file = File::open(landed_titles_file_path)?;
    let lines = BufReader::new(landed_titles_file).lines();
    let mut map_data_builder = map_data_builder.lock().unwrap();
    titles::read_landed_titles_from_file_lines(&mut map_data_builder, lines)
        .map_err(|err| MapDataLoadingError::from_landed_titles_reading_err(landed_titles_file_path, err))
}

fn parse_from_csv_cell<T>(record: &StringRecord, column_index: usize) -> Result<T, MapDataLoadingError>
    where T: FromStr<Err = ParseIntError> {
    record[column_index].parse::<T>()
        .map_err(MapDataLoadingError::from)
}

//
// Errors
//

mod error {
    use std::io::Error as IoError;

    use thiserror::Error;
    use csv::Error as CsvError;

    use crate::model::game::{TitleIdError, MapDataError};

    use super::{
        titles::{LandedTitlesReadingError, LandedTitlesParsingError},
        *
    };

    //
    // pub enum MapDataLoadingError
    //

    #[derive(Error, Debug)]
    pub enum MapDataLoadingError {
        #[error("IO error while loading map data: {0}")]
        IoError(#[from] IoError),
        // TODO: Extract definition.csv parsing errors into a separate error enum?
        #[error("failed to parse {0} as CSV: {1}")]
        CsvFormatError(PathBuf, #[source] CsvError),
        #[error(
            "province definition has {0} columns, expected at least {}; make sure you use {:?} as CSV delimeter",
            MIN_PROVINCE_DEFINITION_COLUMNS,
            char::from(CSV_FIELD_DELIMITER)
        )]
        TooFewProvinceDefinitionColumns(usize),
        #[error("failed to parse a value from province definition: {0}")]
        ProvinceDefinitionValueError(#[from] ParseIntError),
        #[error("{0}")]
        InvalidTitleId(#[from] TitleIdError),
        #[error("encountered {} map data error{}", .0.len(), if .0.len() != 1 { "s" } else { "" } )]
        InvalidMapData(Vec<MapDataError>),
        #[error("failed to parse landed titles from {0}: {1}")]
        LandedTitlesParsingFailed(PathBuf, LandedTitlesParsingError)
    }

    impl From<Vec<MapDataError>> for MapDataLoadingError {
        fn from(value: Vec<MapDataError>) -> Self {
            Self::InvalidMapData(value)
        }
    }

    impl MapDataLoadingError {
        pub fn from_landed_titles_reading_err(landed_titles_file_path: &Path, err: LandedTitlesReadingError) -> Self {
            match err {
                LandedTitlesReadingError::IoError(io_err)           => Self::from(io_err),
                LandedTitlesReadingError::MapDataError(data_err)    => Self::from(vec![data_err]),
                LandedTitlesReadingError::ParsingError(parsing_err) => {
                    Self::LandedTitlesParsingFailed(landed_titles_file_path.to_path_buf(), parsing_err)
                }
            }
        }
    }

    //
    // pub enum ProvincesMapLoadingError
    //

    #[derive(Error, Debug)]
    pub enum ProvincesMapLoadingError {
        #[error("IO error while loading provinces map: {0}")]
        IoError(#[from] IoError),
        #[error("failed to decode provinces map: {0}")]
        ImageDecodingFailed(#[from] ImageError),
        #[error("unrecognized RGB {0:?} at {1:?} in provinces map image")]
        UnrecognizedProvinceRgb(Rgb, (u32, u32))
    }
}