aboutsummaryrefslogtreecommitdiff
path: root/server/tools/src
diff options
context:
space:
mode:
authormetamuffin <metamuffin@disroot.org>2025-09-30 20:31:58 +0200
committermetamuffin <metamuffin@disroot.org>2025-09-30 20:31:58 +0200
commit080672a5fee18336971fa18ab35bb82fb62a0225 (patch)
tree526daff5cb4a8891e586f7f2810bbda102839e6c /server/tools/src
parent9753f61af5e236dbc650145eb498cf369afc59d0 (diff)
downloadhurrycurry-080672a5fee18336971fa18ab35bb82fb62a0225.tar
hurrycurry-080672a5fee18336971fa18ab35bb82fb62a0225.tar.bz2
hurrycurry-080672a5fee18336971fa18ab35bb82fb62a0225.tar.zst
Add map linter
Diffstat (limited to 'server/tools/src')
-rw-r--r--server/tools/src/main.rs14
-rw-r--r--server/tools/src/map_linter.rs321
2 files changed, 335 insertions, 0 deletions
diff --git a/server/tools/src/main.rs b/server/tools/src/main.rs
index a18051b0..03de90b3 100644
--- a/server/tools/src/main.rs
+++ b/server/tools/src/main.rs
@@ -22,6 +22,7 @@ pub mod diagram_dot;
pub mod diagram_layout;
pub mod graph;
pub mod graph_summary;
+pub mod map_linter;
pub mod recipe_diagram;
use crate::{
@@ -30,6 +31,7 @@ use crate::{
diagram_dot::diagram_dot,
graph::graph,
graph_summary::graph_summary,
+ map_linter::check_map,
recipe_diagram::recipe_diagram,
};
use anyhow::Result;
@@ -46,6 +48,7 @@ enum Action {
MapDemands { map: String },
MapItems { map: String },
MapTiles { map: String },
+ CheckMap { map: String },
}
fn main() -> Result<()> {
@@ -99,6 +102,17 @@ fn main() -> Result<()> {
println!("{name}")
}
}
+ Action::CheckMap { map } => {
+ if map == "all" {
+ let mut index = DataIndex::default();
+ index.reload()?;
+ for map in index.maps.keys() {
+ check_map(&map)?;
+ }
+ } else {
+ check_map(&map)?;
+ }
+ }
}
Ok(())
}
diff --git a/server/tools/src/map_linter.rs b/server/tools/src/map_linter.rs
new file mode 100644
index 00000000..70a170f3
--- /dev/null
+++ b/server/tools/src/map_linter.rs
@@ -0,0 +1,321 @@
+/*
+ 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 <https://www.gnu.org/licenses/>.
+
+*/
+
+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<HashMap<String, TileMode>> = 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::<BTreeSet<_>>();
+
+ 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<IVec2> {
+ 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
+}