diff options
-rw-r--r-- | Cargo.lock | 1 | ||||
-rw-r--r-- | data/maps/station.yaml | 2 | ||||
-rw-r--r-- | locale/en.ini | 13 | ||||
-rw-r--r-- | server/locale/src/lib.rs | 15 | ||||
-rw-r--r-- | server/locale/src/message.rs | 76 | ||||
-rw-r--r-- | server/tools/Cargo.toml | 1 | ||||
-rw-r--r-- | server/tools/src/main.rs | 14 | ||||
-rw-r--r-- | server/tools/src/map_linter.rs | 321 |
8 files changed, 437 insertions, 6 deletions
@@ -1123,6 +1123,7 @@ dependencies = [ "anyhow", "clap", "env_logger", + "hurrycurry-locale", "hurrycurry-protocol", "hurrycurry-server", "log", diff --git a/data/maps/station.yaml b/data/maps/station.yaml index 2e3aa818..6be15641 100644 --- a/data/maps/station.yaml +++ b/data/maps/station.yaml @@ -49,7 +49,7 @@ tiles: "L": leek-crate -x "X": trash -x - "c": chair + "c": chair -w ".": floor -w "'": grass -w "*": tree -c diff --git a/locale/en.ini b/locale/en.ini index 20e5718f..ab2888cc 100644 --- a/locale/en.ini +++ b/locale/en.ini @@ -282,6 +282,19 @@ s.state.game_aborted=Game was aborted by {0}. s.state.overflow_resubscribe=Lagging behind. Some clientbound packets were dropped. s.state.paused.all_idle=Game paused s.state.paused.any_not_ready=Waiting for {0} players... +s.tool.map_linter.chair_no_customer_access=Chair at {0} is not accessible by customers. +s.tool.map_linter.chair_no_table=Chair at {0} has no interactable tile around. +s.tool.map_linter.exclusive_no_recipe=Tile {0} is exclusive but is missing recipes. +s.tool.map_linter.no_chef_access=Tile {0} at {1} is not accessible by chefs. +s.tool.map_linter.no_demands=No demands are possible. +s.tool.map_linter.no_easy_access=Tile {0} at {1} is not easily accessible. +s.tool.map_linter.not_collider=Tile {0} can be interacted with. +s.tool.map_linter.not_exclusive=Tile {0} is not recipe exclusive. +s.tool.map_linter.not_walkable=Tile {0} has collision. +s.tool.map_linter.ok=Map {0} looks alright. +s.tool.map_linter.title=Map {0} has {1} potential problems: +s.tool.map_linter.unknown_tile=Tile {0} unknown. +s.tool.map_linter.walkable=Tile {0} has no collision. s.tutorial.accept_order=Approach the customer take their order s.tutorial.active_cutting_board=Cut the item to slices here s.tutorial.active=Interact here for {0}s diff --git a/server/locale/src/lib.rs b/server/locale/src/lib.rs index 1d01e4e1..9d55c7cd 100644 --- a/server/locale/src/lib.rs +++ b/server/locale/src/lib.rs @@ -16,6 +16,8 @@ */ +pub mod message; + use anyhow::anyhow; use hurrycurry_protocol::Message; use std::{ @@ -122,15 +124,15 @@ macro_rules! tre_param { }; } -pub struct Strings(HashMap<String, String>); -impl Index<&'static str> for Strings { +pub struct Locale(HashMap<String, String>); +impl Index<&'static str> for Locale { type Output = str; fn index(&self, index: &'static str) -> &Self::Output { self.0.get(index).map(|s| s.as_str()).unwrap_or(index) } } -impl Strings { +impl Locale { pub fn load() -> anyhow::Result<Self> { Ok(Self( include_str!("../../../locale/en.ini") @@ -146,9 +148,12 @@ impl Strings { .collect::<anyhow::Result<HashMap<_, _>>>()?, )) } + pub fn get(&self, id: &str) -> Option<&str> { + self.0.get(id).map(|x| x.as_str()) + } } -static TR: LazyLock<Strings> = LazyLock::new(|| Strings::load().unwrap()); +pub static FALLBACK_LOCALE: LazyLock<Locale> = LazyLock::new(|| Locale::load().unwrap()); pub fn tr(s: &'static str) -> &'static str { - &TR[s] + &FALLBACK_LOCALE[s] } diff --git a/server/locale/src/message.rs b/server/locale/src/message.rs new file mode 100644 index 00000000..bedf45fd --- /dev/null +++ b/server/locale/src/message.rs @@ -0,0 +1,76 @@ +/* + 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 crate::Locale; +use hurrycurry_protocol::{Gamedata, Message}; + +pub trait MessageDisplayExt { + fn display_message(&self, locale: &Locale, data: &Gamedata, style: &DisplayStyle) -> String; +} +impl MessageDisplayExt for Message { + fn display_message(&self, locale: &Locale, data: &Gamedata, style: &DisplayStyle) -> String { + display_message_inner(self, locale, data, style) + } +} +fn display_message_inner( + message: &Message, + locale: &Locale, + data: &Gamedata, + style: &DisplayStyle, +) -> String { + let DisplayStyle { + default, + error, + tile_item, + highlighted, + } = style; + match message { + Message::Translation { id, params } => { + let Some(template) = locale.get(&id) else { + return format!("[translation missing: {error}{id}{default}]"); + }; + let mut s = template.to_string(); + for (i, p) in params.iter().enumerate() { + s = s.replace(&format!("{{{i}}}"), &p.display_message(locale, data, style)); + } + s + } + Message::Text(s) => format!("{highlighted}{s}{default}"), + Message::Item(item_index) => format!("{tile_item}{}{default}", data.item_name(*item_index)), + Message::Tile(tile_index) => format!("{tile_item}{}{default}", data.tile_name(*tile_index)), + } +} + +pub struct DisplayStyle<'a> { + default: &'a str, + error: &'a str, + tile_item: &'a str, + highlighted: &'a str, +} +pub static PLAIN: DisplayStyle = DisplayStyle { + default: "", + error: "", + tile_item: "", + highlighted: "", +}; +pub static COLORED: DisplayStyle = DisplayStyle { + default: "\x1b[0m", + error: "\x1b[31m", + tile_item: "\x1b[34m", + highlighted: "\x1b[1m", +}; diff --git a/server/tools/Cargo.toml b/server/tools/Cargo.toml index 8c26cdfe..db1c5ebf 100644 --- a/server/tools/Cargo.toml +++ b/server/tools/Cargo.toml @@ -10,6 +10,7 @@ env_logger = "0.11.8" clap = { version = "4.5.47", features = ["derive"] } hurrycurry-protocol = { path = "../protocol" } hurrycurry-server = { path = ".." } +hurrycurry-locale = { path = "../locale" } serde_json = "1.0.145" serde = { version = "1.0.225", features = ["derive"] } markup = "0.15.0" 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 +} |