aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--Cargo.lock1
-rw-r--r--data/maps/station.yaml2
-rw-r--r--locale/en.ini13
-rw-r--r--server/locale/src/lib.rs15
-rw-r--r--server/locale/src/message.rs76
-rw-r--r--server/tools/Cargo.toml1
-rw-r--r--server/tools/src/main.rs14
-rw-r--r--server/tools/src/map_linter.rs321
8 files changed, 437 insertions, 6 deletions
diff --git a/Cargo.lock b/Cargo.lock
index afc0d4a6..2d166b39 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -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
+}