/*
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_data::{Serverdata, index::DataIndex};
use hurrycurry_locale::{
FALLBACK_LOCALE,
message::{COLORED, MessageDisplayExt},
trm,
};
use hurrycurry_protocol::{
Gamedata, TileIndex,
glam::{IVec2, ivec2},
};
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)
.is_some_and(|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))
&& !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)
&& !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))
.is_some_and(|(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)
.is_some_and(|(x, _)| d.tile_walkable.contains(x))
&& !visited.contains(&npos)
{
open.insert(npos);
}
}
}
visited
}