/* Hurry Curry! - a game about cooking Copyright (C) 2025 Hurry Curry! Contributors This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, version 3 of the License only. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. You should have received a copy of the GNU Affero General Public License along with this program. If not, see . */ use anyhow::Result; use hurrycurry_locale::{ FALLBACK_LOCALE, message::{COLORED, MessageDisplayExt}, trm, }; use hurrycurry_protocol::{ Gamedata, TileIndex, glam::{IVec2, ivec2}, }; use hurrycurry_server::data::{DataIndex, Serverdata}; use std::{ collections::{BTreeSet, HashMap, HashSet}, sync::LazyLock, }; #[derive(PartialEq, Clone, Copy)] enum TileMode { Normal, Exclusive, Collider, Walkable, } use TileMode::*; static NORMAL: &[&str] = &[ "counter-window", "counter", "sink", "cutting-board", "rolling-board", "stove", "table", "oven", "freezer", "black-hole-counter", "white-hole-counter", "book", "conveyor", ]; static EXCLUSIVE: &[&str] = &[ "steak-crate", "coconut-crate", "strawberry-crate", "fish-crate", "rice-crate", "tomato-crate", "flour-crate", "leek-crate", "lettuce-crate", "cheese-crate", "mushroom-crate", "potato-crate", "bun-crate", "trash", "deep-fryer", ]; static COLLIDER: &[&str] = &[ "wall", "wall-window", "white-hole", "tree", "lamp", "fence", "house-roof", "house-roof-chimney", "house-side", "house-wall", "house-balcony", "house-oriel", "house-door", ]; static WALKABLE: &[&str] = &[ "floor", "grass", "door", "chair", "path", "street", "chandelier", "black-hole", "ceiling-lamp", ]; static NEED_ACCESS: &[&str] = &[ "oven", "freezer", "steak-crate", "coconut-crate", "strawberry-crate", "fish-crate", "rice-crate", "tomato-crate", "flour-crate", "leek-crate", "lettuce-crate", "cheese-crate", "mushroom-crate", "potato-crate", "bun-crate", "trash", "deep-fryer", ]; static NEED_EASY_ACCESS: &[&str] = &[ "sink", "cutting-board", "rolling-board", "stove", "deep-fryer", ]; static TILE_MODE: LazyLock> = LazyLock::new(|| { let mut out = HashMap::new(); for tile in EXCLUSIVE { out.insert(tile.to_string(), Exclusive); } for tile in COLLIDER { out.insert(tile.to_string(), Collider); } for tile in NORMAL { out.insert(tile.to_string(), Normal); } for tile in WALKABLE { out.insert(tile.to_string(), Walkable); } out }); pub fn check_map(map: &str) -> Result<()> { let style = &COLORED; let locale = &*FALLBACK_LOCALE; let mut index = DataIndex::default(); index.reload()?; let (data, serverdata, _) = index.generate(&map)?; let mut warnings = Vec::new(); let tiles_used = serverdata .initial_map .values() .map(|t| t.0) .collect::>(); for tile in tiles_used { let tile_name = data.tile_name(tile); let tile_name_base = tile_name.split(":").next().unwrap(); let Some(&mode) = TILE_MODE.get(tile_name_base) else { warnings.push(trm!("s.tool.map_linter.unknown_tile", t = tile)); continue; }; match mode { Normal => { if data.tile_walkable.contains(&tile) { warnings.push(trm!("s.tool.map_linter.walkable", t = tile)); } } Exclusive => { if !data.tile_placeable_items.contains_key(&tile) { warnings.push(trm!("s.tool.map_linter.not_exclusive", t = tile)); } if data .tile_placeable_items .get(&tile) .map_or(false, |e| e.is_empty()) { warnings.push(trm!("s.tool.map_linter.exclusive_no_recipe", t = tile)); } } Collider => { if !data.tile_placeable_items.contains_key(&tile) { warnings.push(trm!("s.tool.map_linter.not_collider", t = tile)); } } Walkable => { if !data.tile_walkable.contains(&tile) { warnings.push(trm!("s.tool.map_linter.not_walkable", t = tile)); } } } } let chef_access = fill_walkable(&data, &serverdata, serverdata.chef_spawn.as_ivec2()); let customer_access = fill_walkable( &data, &serverdata, serverdata .customer_spawn .unwrap_or(serverdata.chef_spawn) .as_ivec2(), ); for (&pos, &(tile, _item)) in &serverdata.initial_map { let tile_name = data.tile_name(tile); if NEED_EASY_ACCESS.contains(&tile_name) || NEED_ACCESS.contains(&tile_name) { if !has_neighbour_diag(pos, |t| chef_access.contains(&t)) { warnings.push(trm!( "s.tool.map_linter.no_chef_access", t = tile, s = pos.to_string() )); } } if NEED_EASY_ACCESS.contains(&tile_name) { if !has_neighbour(&serverdata, pos, |t| data.tile_walkable.contains(&t)) { warnings.push(trm!( "s.tool.map_linter.no_easy_access", t = tile, s = pos.to_string() )); } } if tile_name == "chair" { if !has_neighbour(&serverdata, pos, |t| { !data.tile_placeable_items.contains_key(&t) }) { warnings.push(trm!( "s.tool.map_linter.chair_no_table", s = pos.to_string() )); } if !customer_access.contains(&pos) { warnings.push(trm!( "s.tool.map_linter.chair_no_customer_access", s = pos.to_string() )); } } } if data.demands.is_empty() { warnings.push(trm!("s.tool.map_linter.no_demands")); } if warnings.is_empty() { println!( "{}", trm!("s.tool.map_linter.ok", s = map.to_string()).display_message(locale, &data, style) ); return Ok(()); } println!( "{}", trm!( "s.tool.map_linter.title", s = map.to_string(), s = warnings.len().to_string() ) .display_message(locale, &data, style) ); for warning in warnings { println!("- {}", warning.display_message(locale, &data, style)); } Ok(()) } fn has_neighbour(sd: &Serverdata, pos: IVec2, pred: impl Fn(TileIndex) -> bool) -> bool { [IVec2::X, IVec2::Y, IVec2::NEG_X, IVec2::NEG_Y] .into_iter() .any(|off| { sd.initial_map .get(&(pos + off)) .map_or(false, |(tile, _)| pred(*tile)) }) } fn has_neighbour_diag(pos: IVec2, pred: impl Fn(IVec2) -> bool) -> bool { [ ivec2(-1, -1), ivec2(0, -1), ivec2(1, -1), ivec2(-1, 0), ivec2(1, 0), ivec2(-1, 1), ivec2(0, 1), ivec2(1, 1), ] .into_iter() .any(|off| pred(pos + off)) } fn fill_walkable(d: &Gamedata, sd: &Serverdata, start: IVec2) -> HashSet { let mut visited = HashSet::new(); let mut open = HashSet::new(); open.insert(start); while let Some(pos) = open.iter().next().copied() { open.remove(&pos); visited.insert(pos); for off in [IVec2::X, IVec2::Y, IVec2::NEG_X, IVec2::NEG_Y] { let npos = pos + off; if sd .initial_map .get(&npos) .map_or(false, |(x, _)| d.tile_walkable.contains(x)) { if !visited.contains(&npos) { open.insert(npos); } } } } visited }