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
use std::{
    collections::HashMap,
    fs::File,
    io::{BufRead, BufReader},
    path::Path,
};

use image::io::Reader as ImageReader;

use crate::{
    config::ModPaths,
    metadata::REGION_TITLE_INDICES_KEY,
    model::game::{TitleTier, TitleId, MapData, ProvincesMap},
    loading::{self},
    texture::{self, RegionsMapImageBuffer},
    error::Error,
};

//
// Exports
//

pub use self::error::PredefinedRegionsLoadingError;

//
// pub struct RegionsMapData
//

pub struct RegionsMapData {
    pub image_buffer:                RegionsMapImageBuffer,
    pub region_title_indices:        Option<HashMap<TitleId, u32>>,
    pub max_region_index:            u32,
    pub region_initial_pixel_coords: HashMap<u32, (u32, u32)>,
}

//
// Interface
//

pub fn build_regions_map_from_mod_data(region_title_tier: TitleTier, mod_paths: &ModPaths) -> Result<RegionsMapData, Error> {
    let map_data = loading::load_map_data_from_mod_path(&mod_paths.mod_root)?;

    let mut region_title_indices        = HashMap::new();
    let mut current_region_title_index  = 0u32;
    let mut region_initial_pixel_coords = HashMap::new();

    let provinces_map  = loading::load_provinces_map_from_mod_path(&mod_paths.mod_root, &map_data)?;
    let map_dimensions = (provinces_map.0.cols() as u32, provinces_map.0.rows() as u32);

    let mut regions_map_image_buffer = RegionsMapImageBuffer::new(map_dimensions.0, map_dimensions.1);
    assert_eq!(regions_map_image_buffer.dimensions(), map_dimensions);

    for y in 0..regions_map_image_buffer.height() {
        for x in 0..regions_map_image_buffer.width() {
            let region_title_index = get_region_title_index_from_provinces_map(
                &provinces_map,
                &map_data,
                region_title_tier,
                (x, y),
                &mut region_title_indices,
                &mut current_region_title_index
            );

            regions_map_image_buffer.get_pixel_mut(x, y).0 = if let Some(region_title_index) = region_title_index {
                if !region_initial_pixel_coords.contains_key(&region_title_index) {
                    region_initial_pixel_coords.insert(region_title_index, (x, y));
                }

                texture::encode_index_as_rgba(region_title_index | (0xFF00 << 16))
            } else {
                [0, 0, 0, 0xFF]
            };
        }
    }

    Ok(RegionsMapData{
        image_buffer:           regions_map_image_buffer,
        region_title_indices:   Some(region_title_indices),
        max_region_index:       current_region_title_index,
        region_initial_pixel_coords
    })
}

pub fn load_predefined_regions_map(input_regions_map_path: &Path) -> Result<RegionsMapData, Error> {
    let regions_map_image        = ImageReader::open(input_regions_map_path).map_err(PredefinedRegionsLoadingError::from)?.decode()?;
    let regions_map_image_buffer = regions_map_image.to_rgba8();

    let mut max_region_index            = 0u32;
    let mut region_initial_pixel_coords = HashMap::new();

    for y in 0..regions_map_image_buffer.height() {
        for x in 0..regions_map_image_buffer.width() {
            let region_index = texture::decode_index_from_rgb(&regions_map_image_buffer.get_pixel(x, y).0[..3]);

            max_region_index = region_index.max(max_region_index);

            if !region_initial_pixel_coords.contains_key(&region_index) {
                region_initial_pixel_coords.insert(region_index, (x, y));
            }
        }
    }

    // TODO: Maybe accept discontiguous region indices?
    let missing_region_indices = (0..max_region_index).filter(|region_index| !region_initial_pixel_coords.contains_key(&region_index));
    if !missing_region_indices.clone().all(|region_index| region_index == 0) {
        let first_missing_index = missing_region_indices.filter(|&region_index| region_index != 0)
            .min()
            .expect("missing region indices range can't be empty at this point");

        return Err(PredefinedRegionsLoadingError::DiscontiguousRegionIndices(first_missing_index).into());
    }

    Ok(RegionsMapData{
        image_buffer: regions_map_image_buffer,
        region_title_indices: None,
        max_region_index,
        region_initial_pixel_coords
    })
}

pub fn is_regions_data_current(
    raw_regions_map_path:           &Path,
    region_title_indices_yaml_path: &Path,
    raw_regions_map_image_buffer:   &RegionsMapImageBuffer,
    region_title_indices:           &HashMap<TitleId, u32>
) -> bool {
    let are_region_title_indices_current = try_read_region_title_indices_yaml(region_title_indices_yaml_path)
        .map_or(false, |read_region_title_indices| &read_region_title_indices == region_title_indices);
    if !are_region_title_indices_current {
        println!("region title indices are out of date");

        return false;
    }

    let is_raw_regions_map_current = try_read_regions_map_image(raw_regions_map_path)
        .map_or(false, |read_raw_regions_map_image_buffer| &read_raw_regions_map_image_buffer == raw_regions_map_image_buffer);
    if !is_raw_regions_map_current {
        println!("raw regions map is out of date");

        return false;
    }

    true
}

//
// Service
//

fn get_region_title_index_from_provinces_map(
    provinces_map:              &ProvincesMap,
    map_data:                   &MapData,
    region_title_tier:          TitleTier,
    coords:                     (u32, u32),
    region_title_indices_cache: &mut HashMap<TitleId, u32>,
    next_available_title_index: &mut u32
) -> Option<u32> {
    let province_id = *provinces_map.0.get(coords.1 as usize, coords.0 as usize)
        .expect("expected map coordinates to be in bounds");

    if let Some(region_title_id) = map_data.get_containing_title_id_by_province_id(province_id, region_title_tier) {
        let region_title_index = region_title_indices_cache.get(region_title_id)
            .copied()
            .unwrap_or_else(|| {
                *next_available_title_index += 1;
                region_title_indices_cache.insert(region_title_id.clone(), *next_available_title_index);

                *next_available_title_index
            });

        Some(region_title_index)
    } else {
        None
    }
}

fn try_read_region_title_indices_yaml(region_title_indices_yaml_path: &Path) -> Option<HashMap<TitleId, u32>> {
    let yaml_file = File::open(region_title_indices_yaml_path).ok()?;
    let reader    = BufReader::new(yaml_file);

    Some(reader.lines()
        .skip_while(|line| line.as_ref().map_or(true, |line| !line.starts_with(&format!("{}:", REGION_TITLE_INDICES_KEY))))
        .skip(1)
        .filter_map(
            |line| line.map_or(
                None,
                |line| {
                    let substring_before_comment = line.split('#').next();
                    if let Some(kvp_string) = substring_before_comment {
                        let tokens = kvp_string.split(':').collect::<Vec<&str>>();
                        if tokens.len() == 2 {
                            Some((TitleId::try_from(tokens[0].trim()).ok()?, tokens[1].trim().parse::<u32>().ok()?))
                        } else {
                            None
                        }
                    } else {
                        None
                    }
                }
            )

        )
        .collect())
}

fn try_read_regions_map_image(regions_map_path: &Path) -> Option<RegionsMapImageBuffer> {
    ImageReader::open(regions_map_path)
        .ok()?
        .decode()
        .map(|dynamic_image| dynamic_image.to_rgba8())
        .ok()
}

//
// Errors
//

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

    use thiserror::Error;
    use image::ImageError;

    #[derive(Error, Debug)]
    pub enum PredefinedRegionsLoadingError {
        #[error("IO error while loading predefined regions map: {0}")]
        IoError(#[from] IoError),
        #[error("failed to decode predefined regions image: {0}")]
        ImageDecodingFailed(#[from] ImageError),
        #[error("predefined region indices are discontiguous; first missing index is {0}")]
        DiscontiguousRegionIndices(u32),
    }
}