aboutsummaryrefslogtreecommitdiff
path: root/server/data
diff options
context:
space:
mode:
authormetamuffin <metamuffin@disroot.org>2025-10-06 23:03:32 +0200
committermetamuffin <metamuffin@disroot.org>2025-10-06 23:03:40 +0200
commit176e6bc6c4c29bea3be2aceca99743b997c76c97 (patch)
tree1161e7a966843324756340da4b6452492902fa07 /server/data
parentea86b11b682500160f37b35ea8f06b081cd05036 (diff)
downloadhurrycurry-176e6bc6c4c29bea3be2aceca99743b997c76c97.tar
hurrycurry-176e6bc6c4c29bea3be2aceca99743b997c76c97.tar.bz2
hurrycurry-176e6bc6c4c29bea3be2aceca99743b997c76c97.tar.zst
Move data code to own crate + general data refactor
Diffstat (limited to 'server/data')
-rw-r--r--server/data/Cargo.toml17
-rw-r--r--server/data/src/book/diagram_layout.rs78
-rw-r--r--server/data/src/book/mod.rs128
-rw-r--r--server/data/src/book/recipe_diagram.rs239
-rw-r--r--server/data/src/demands.rs88
-rw-r--r--server/data/src/entities.rs99
-rw-r--r--server/data/src/index.rs94
-rw-r--r--server/data/src/lib.rs357
8 files changed, 1100 insertions, 0 deletions
diff --git a/server/data/Cargo.toml b/server/data/Cargo.toml
new file mode 100644
index 00000000..5ba266a8
--- /dev/null
+++ b/server/data/Cargo.toml
@@ -0,0 +1,17 @@
+[package]
+name = "hurrycurry-data"
+version = "0.1.0"
+edition = "2024"
+
+[dependencies]
+hurrycurry-protocol = { path = "../protocol" }
+hurrycurry-locale = { path = "../locale" }
+anyhow = "1.0.100"
+serde_json = "1.0.145"
+serde = { version = "1.0.225", features = ["derive"] }
+shlex = "1.3.0"
+clap = { version = "4.5.47", features = ["derive"] }
+serde_yml = "0.0.12"
+
+[features]
+fast_recipes = []
diff --git a/server/data/src/book/diagram_layout.rs b/server/data/src/book/diagram_layout.rs
new file mode 100644
index 00000000..0ea26a69
--- /dev/null
+++ b/server/data/src/book/diagram_layout.rs
@@ -0,0 +1,78 @@
+/*
+ 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, anyhow};
+use hurrycurry_protocol::{book::Diagram, glam::Vec2};
+use serde::Deserialize;
+use std::{
+ collections::BTreeMap,
+ fmt::Write as W2,
+ io::Write,
+ process::{Command, Stdio},
+};
+
+pub struct Layout {
+ pub nodes: BTreeMap<String, Vec2>,
+ pub edges: Vec<(usize, usize)>,
+}
+
+pub fn diagram_layout(diagram: &mut Diagram) -> Result<()> {
+ let mut child = Command::new("dot")
+ .arg("-Tjson")
+ .arg("-Kdot")
+ .stdin(Stdio::piped())
+ .stdout(Stdio::piped())
+ .spawn()?;
+
+ let mut out = String::new();
+ writeln!(out, "digraph {{")?;
+ for (i, _) in diagram.nodes.iter().enumerate() {
+ writeln!(out, "k{i} [width=1, height=1]")?;
+ }
+ for edge in &diagram.edges {
+ writeln!(out, "k{} -> k{}", edge.src, edge.dst)?;
+ }
+ writeln!(out, "}}")?;
+
+ child.stdin.as_mut().unwrap().write_all(out.as_bytes())?;
+ let output = child.wait_with_output()?;
+
+ #[derive(Deserialize)]
+ struct Out {
+ objects: Vec<Object>,
+ }
+ #[derive(Deserialize)]
+ struct Object {
+ name: String,
+ pos: String,
+ }
+ let graph: Out = serde_json::from_slice(&output.stdout)?;
+ for o in graph.objects {
+ let pos = o.pos.split_once(",").ok_or(anyhow!("malformed position"))?;
+ let pos = Vec2::new(pos.0.parse()?, pos.1.parse()?);
+
+ let index = o
+ .name
+ .strip_prefix("k")
+ .ok_or(anyhow!("invalid node name"))?
+ .parse::<usize>()?;
+ diagram.nodes[index].position = pos
+ }
+
+ Ok(())
+}
diff --git a/server/data/src/book/mod.rs b/server/data/src/book/mod.rs
new file mode 100644
index 00000000..b52779f3
--- /dev/null
+++ b/server/data/src/book/mod.rs
@@ -0,0 +1,128 @@
+/*
+ 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/>.
+
+*/
+
+pub mod diagram_layout;
+pub mod recipe_diagram;
+
+use crate::{
+ Serverdata,
+ book::{diagram_layout::diagram_layout, recipe_diagram::recipe_diagram},
+};
+use anyhow::Result;
+use hurrycurry_locale::trm;
+use hurrycurry_protocol::{
+ Gamedata, Message,
+ book::{Book, BookPage},
+};
+
+struct RecipePageParams<'a> {
+ name: &'a str,
+ repr_items: &'a [&'a str],
+}
+static RECIPE_PAGES: &[RecipePageParams] = &[
+ RecipePageParams {
+ name: "cheese-leek-soup",
+ repr_items: &["plate:cheese-leek-soup"],
+ },
+ RecipePageParams {
+ name: "tomato-soup",
+ repr_items: &["plate:tomato-soup"],
+ },
+ RecipePageParams {
+ name: "mushroom-soup",
+ repr_items: &["plate:mushroom-soup"],
+ },
+ RecipePageParams {
+ name: "burger",
+ repr_items: &[
+ "plate:seared-patty,sliced-bun,sliced-lettuce,sliced-tomato",
+ "plate:seared-patty,sliced-bun,sliced-cheese,sliced-tomato",
+ ],
+ },
+ RecipePageParams {
+ name: "noodles",
+ repr_items: &["plate:cooked-noodles,sliced-cheese,tomato-juice"],
+ },
+ RecipePageParams {
+ name: "pizza",
+ repr_items: &["plate:baked-rolled-dough:sliced-cheese,sliced-mushroom,tomato-juice"],
+ },
+ RecipePageParams {
+ name: "curry",
+ repr_items: &["plate:cooked-rice,curry"],
+ },
+ RecipePageParams {
+ name: "drinks",
+ repr_items: &[
+ "glass:water",
+ "glass:tomato-juice",
+ "glass:strawberry-shake",
+ ],
+ },
+ RecipePageParams {
+ name: "mochi",
+ repr_items: &["plate:strawberry-mochi"],
+ },
+ RecipePageParams {
+ name: "doughnut",
+ repr_items: &["plate:doughnut"],
+ },
+ RecipePageParams {
+ name: "doughnut",
+ repr_items: &["plate:doughnut"],
+ },
+];
+
+pub fn book(data: &Gamedata, serverdata: &Serverdata) -> Result<Book> {
+ let mut pages = Vec::new();
+
+ pages.push(BookPage::Contents {
+ title: trm!("b.toc.title"),
+ table: vec![],
+ });
+ let mut toc = Vec::new();
+
+ for &RecipePageParams { name, repr_items } in RECIPE_PAGES {
+ let mut diagram = recipe_diagram(data, serverdata, repr_items)?;
+ diagram_layout(&mut diagram)?;
+ let title = Message::Translation {
+ id: format!("b.{name}.title"),
+ params: vec![],
+ };
+ toc.push((title.clone(), pages.len()));
+ pages.push(BookPage::Recipe {
+ title,
+ description: Message::Translation {
+ id: format!("b.{name}.desc"),
+ params: vec![],
+ },
+ diagram,
+ });
+ }
+
+ if let BookPage::Contents { table, .. } = &mut pages[0] {
+ *table = toc;
+ }
+ Ok(Book { pages })
+}
+
+pub fn print_book(data: &Gamedata, serverdata: &Serverdata) -> Result<()> {
+ let book = book(data, serverdata)?;
+ println!("{}", serde_json::to_string_pretty(&book).unwrap());
+ Ok(())
+}
diff --git a/server/data/src/book/recipe_diagram.rs b/server/data/src/book/recipe_diagram.rs
new file mode 100644
index 00000000..2ec92b68
--- /dev/null
+++ b/server/data/src/book/recipe_diagram.rs
@@ -0,0 +1,239 @@
+/*
+ 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::Serverdata;
+use anyhow::Result;
+use hurrycurry_protocol::{
+ Gamedata, ItemIndex, Message, Recipe, RecipeIndex,
+ book::{Diagram, DiagramEdge, DiagramNode, NodeStyle},
+ glam::Vec2,
+};
+use std::{
+ cmp::Reverse,
+ collections::{BTreeMap, BTreeSet, HashSet},
+};
+
+pub fn recipe_diagram(
+ data: &Gamedata,
+ serverdata: &Serverdata,
+ target_items: &[&str],
+) -> Result<Diagram> {
+ let ambient_items = serverdata
+ .initial_map
+ .iter()
+ .flat_map(|(_, (_, item))| item)
+ .copied()
+ .collect::<HashSet<_>>();
+
+ let mut need = BTreeSet::from_iter(
+ target_items
+ .iter()
+ .map(|name| data.get_item_by_name(name).unwrap()),
+ );
+ let mut have = BTreeSet::<ItemIndex>::new();
+ let mut recipes = BTreeSet::new();
+
+ #[derive(PartialEq, PartialOrd, Eq, Ord)]
+ struct GraphRecipe {
+ index: RecipeIndex,
+ inputs: Vec<ItemIndex>,
+ outputs: Vec<ItemIndex>,
+ }
+
+ while let Some(item) = need.pop_last() {
+ for (ri, r) in data.recipes() {
+ if r.outputs().contains(&item) {
+ let gr = GraphRecipe {
+ inputs: r
+ .inputs()
+ .iter()
+ .filter(|i| !ambient_items.contains(i))
+ .copied()
+ .collect(),
+ outputs: r
+ .outputs()
+ .iter()
+ .filter(|i| !ambient_items.contains(i))
+ .copied()
+ .collect(),
+ index: ri,
+ };
+ need.extend(gr.inputs.iter().filter(|i| !have.contains(&i)));
+ have.extend(&gr.outputs);
+ recipes.insert(gr);
+ }
+ }
+ }
+
+ let mut diag = Diagram::default();
+ let mut item_index = BTreeMap::new();
+ for i in have {
+ item_index.insert(i, diag.nodes.len());
+ diag.nodes.push(DiagramNode {
+ label: Message::Item(i),
+ position: Vec2::ZERO,
+ style: if target_items.contains(&data.item_name(i)) {
+ NodeStyle::FinalProduct
+ } else {
+ NodeStyle::IntermediateProduct
+ },
+ });
+ }
+ for r in recipes {
+ let index = diag.nodes.len();
+ let recipe = data.recipe(r.index);
+
+ if matches!(recipe, Recipe::Instant { .. }) && r.inputs.len() >= 1 && r.outputs.len() >= 1 {
+ for i in r.inputs {
+ diag.edges.push(DiagramEdge {
+ src: item_index[&i],
+ dst: item_index[&r.outputs[0]],
+ label: None,
+ });
+ }
+ continue;
+ }
+ if matches!(recipe, Recipe::Passive { tile: None, .. })
+ && r.inputs.len() == 1
+ && r.outputs.len() == 1
+ {
+ diag.nodes[item_index[&r.inputs[0]]].style = NodeStyle::ProcessPassive;
+ diag.edges.push(DiagramEdge {
+ src: item_index[&r.inputs[0]],
+ dst: item_index[&r.outputs[0]],
+ label: None,
+ });
+ continue;
+ }
+
+ let (kind, style) = match recipe {
+ Recipe::Passive { .. } => ("Passive", NodeStyle::ProcessPassive),
+ Recipe::Active { .. } => ("Active", NodeStyle::ProcessActive),
+ Recipe::Instant { .. } => ("Instant", NodeStyle::ProcessInstant),
+ };
+ diag.nodes.push(DiagramNode {
+ position: Vec2::ZERO,
+ label: if let Some(tile) = recipe.tile() {
+ Message::Tile(tile)
+ } else {
+ Message::Text(kind.to_string())
+ },
+ style,
+ });
+
+ for i in r.inputs {
+ diag.edges.push(DiagramEdge {
+ src: item_index[&i],
+ dst: index,
+ label: None,
+ });
+ }
+ for o in r.outputs {
+ diag.edges.push(DiagramEdge {
+ src: index,
+ dst: item_index[&o],
+ label: None,
+ });
+ }
+ }
+
+ merge_combine_clusters(&mut diag);
+ remove_orphan_nodes(&mut diag);
+
+ Ok(diag)
+}
+
+fn merge_combine_clusters(diag: &mut Diagram) {
+ let instant_nodes = diag
+ .nodes
+ .iter()
+ .enumerate()
+ .filter(|(_, n)| matches!(n.style, NodeStyle::IntermediateProduct))
+ .map(|(i, _)| i)
+ .collect::<Vec<_>>();
+
+ for ni in instant_nodes {
+ let inputs = diag
+ .edges
+ .iter()
+ .enumerate()
+ .filter(|(_, e)| e.dst == ni)
+ .map(|(i, e)| (i, e.src))
+ .collect::<Vec<_>>();
+ let outputs = diag
+ .edges
+ .iter()
+ .enumerate()
+ .filter(|(_, e)| e.src == ni)
+ .map(|(i, e)| (i, e.dst))
+ .collect::<Vec<_>>();
+
+ if outputs
+ .iter()
+ .all(|&(_, ni)| diag.nodes[ni].style.is_procuct())
+ && inputs
+ .iter()
+ .all(|&(_, ni)| diag.nodes[ni].style.is_procuct())
+ {
+ let mut to_remove = inputs
+ .iter()
+ .map(|&(i, _)| i)
+ .chain(outputs.iter().map(|&(i, _)| i))
+ .collect::<Vec<_>>();
+ to_remove.sort_by_key(|x| Reverse(*x));
+ for i in to_remove {
+ diag.edges.remove(i);
+ }
+
+ for &input in &inputs {
+ for &output in &outputs {
+ if !diag
+ .edges
+ .iter()
+ .any(|e| e.src == input.1 && e.dst == output.1)
+ {
+ diag.edges.push(DiagramEdge {
+ src: input.1,
+ dst: output.1,
+ label: None,
+ });
+ }
+ }
+ }
+ }
+ }
+}
+
+fn remove_orphan_nodes(diag: &mut Diagram) {
+ let mut to_remove = diag
+ .nodes
+ .iter()
+ .enumerate()
+ .filter(|&(i, _)| !diag.edges.iter().any(|e| e.src == i || e.dst == i))
+ .map(|(i, _)| i)
+ .collect::<Vec<_>>();
+ to_remove.sort_by_key(|x| Reverse(*x));
+
+ for e in &mut diag.edges {
+ e.src -= to_remove.iter().filter(|&&i| i < e.src).count();
+ e.dst -= to_remove.iter().filter(|&&i| i < e.dst).count();
+ }
+ for i in to_remove {
+ diag.nodes.remove(i);
+ }
+}
diff --git a/server/data/src/demands.rs b/server/data/src/demands.rs
new file mode 100644
index 00000000..77e187af
--- /dev/null
+++ b/server/data/src/demands.rs
@@ -0,0 +1,88 @@
+/*
+ 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 hurrycurry_protocol::{Demand, ItemIndex, Recipe, TileIndex};
+use std::collections::{HashMap, HashSet};
+
+pub fn generate_demands(
+ tiles: &HashSet<TileIndex>,
+ items: &HashSet<ItemIndex>,
+ raw_demands: &[(ItemIndex, Option<ItemIndex>, f32)],
+ recipes: &[Recipe],
+) -> Vec<Demand> {
+ let recipes = recipes
+ .iter()
+ .filter(|r| r.tile().map(|t| tiles.contains(&t)).unwrap_or(true))
+ .collect::<Vec<_>>();
+
+ let mut producable = HashMap::new();
+
+ for i in items {
+ producable.insert(*i, 0.0);
+ }
+
+ loop {
+ let prod_count = producable.len();
+
+ for r in &recipes {
+ let output_count = r.outputs().iter().filter(|o| !items.contains(o)).count();
+ let Some(ingred_cost) = r
+ .inputs()
+ .iter()
+ .map(|i| producable.get(i).copied())
+ .reduce(|a, b| {
+ if let (Some(a), Some(b)) = (a, b) {
+ Some(a + b)
+ } else {
+ None
+ }
+ })
+ .unwrap_or(Some(0.))
+ else {
+ continue;
+ };
+
+ let base_cost = match r {
+ Recipe::Passive { speed, .. } => 2. + (1. / speed) * 0.1,
+ Recipe::Active { speed, .. } => 2. + (1. / speed),
+ Recipe::Instant { .. } => 1.,
+ };
+
+ let output_cost = (ingred_cost + base_cost) / output_count as f32;
+ for o in r.outputs() {
+ let cost = producable.entry(o).or_insert(f32::INFINITY);
+ *cost = cost.min(output_cost);
+ }
+ }
+
+ if prod_count == producable.len() {
+ break;
+ }
+ }
+
+ raw_demands
+ .iter()
+ .filter_map(|(i, o, d)| {
+ producable.get(i).map(|cost| Demand {
+ input: *i,
+ output: *o,
+ duration: *d,
+ points: *cost as i64,
+ })
+ })
+ .collect()
+}
diff --git a/server/data/src/entities.rs b/server/data/src/entities.rs
new file mode 100644
index 00000000..68dbe479
--- /dev/null
+++ b/server/data/src/entities.rs
@@ -0,0 +1,99 @@
+/*
+ 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 hurrycurry_protocol::glam::{IVec2, Vec2};
+use serde::{Deserialize, Serialize};
+
+use crate::ItemTileRegistry;
+
+#[derive(Debug, Clone, Deserialize, Serialize)]
+#[serde(rename_all = "snake_case")]
+pub enum EntityDecl {
+ Conveyor {
+ from: IVec2,
+ to: IVec2,
+ speed: Option<f32>,
+ },
+ ItemPortal {
+ from: IVec2,
+ to: IVec2,
+ },
+ PlayerPortal {
+ from: Vec2,
+ to: Vec2,
+ },
+ Customers {
+ scaling_factor: Option<f32>,
+ },
+ Map {
+ name: String,
+ pos: Vec2,
+ },
+ EnvironmentEffect(EnvironmentEffect),
+ Environment(Vec<String>),
+ Gate {
+ condition: GateCondition,
+ pos: IVec2,
+ },
+ Tram {
+ length: usize,
+ color: Option<i32>,
+ points: Vec<Vec2>,
+ spacing: f32,
+ smoothing: f32,
+ },
+ Book {
+ pos: IVec2,
+ },
+ Pedestrians {
+ spawn_delay: f32,
+ spawn_delay_stdev: Option<f32>,
+ speed: Option<f32>,
+ points: Vec<Vec2>,
+ },
+}
+
+impl EntityDecl {
+ pub(crate) fn run_register(&self, reg: &ItemTileRegistry) {
+ match self {
+ Self::Gate { .. } => drop(reg.register_tile("fence".into())),
+ Self::Customers { .. } => drop(reg.register_item("unknown-order".into())),
+ _ => (),
+ }
+ }
+}
+
+#[derive(Debug, Clone, Deserialize, Serialize)]
+#[serde(rename_all = "kebab-case")]
+pub enum GateCondition {
+ All(Vec<GateCondition>),
+ Any(Vec<GateCondition>),
+ Stars(String, u8),
+}
+
+#[derive(Clone, Debug, Deserialize, Serialize, Default)]
+pub struct EnvironmentEffect {
+ pub name: String,
+ #[serde(default = "default_onoff")]
+ pub on: f32,
+ #[serde(default = "default_onoff")]
+ pub off: f32,
+}
+fn default_onoff() -> f32 {
+ 40.
+}
diff --git a/server/data/src/index.rs b/server/data/src/index.rs
new file mode 100644
index 00000000..a5ec8d97
--- /dev/null
+++ b/server/data/src/index.rs
@@ -0,0 +1,94 @@
+/*
+ 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::{Context, Result, anyhow, bail};
+use hurrycurry_protocol::{Gamedata, MapMetadata};
+use serde::Deserialize;
+use std::{
+ collections::{HashMap, HashSet},
+ fs::{File, read_to_string},
+ path::PathBuf,
+ str::FromStr,
+ sync::Mutex,
+};
+
+use crate::{MapDecl, Serverdata, book::book, build_data};
+
+#[derive(Debug, Deserialize, Default)]
+pub struct DataIndex {
+ pub maps: HashMap<String, MapMetadata>,
+ pub recipes: HashSet<String>,
+}
+
+pub static DATA_DIR: Mutex<Option<PathBuf>> = Mutex::new(None);
+fn data_dir() -> PathBuf {
+ DATA_DIR
+ .lock()
+ .unwrap()
+ .to_owned()
+ .unwrap_or_else(|| PathBuf::from_str("data").unwrap())
+}
+
+impl DataIndex {
+ pub fn load() -> Result<Self> {
+ let mut s = Self::default();
+ s.reload()?;
+ Ok(s)
+ }
+ pub fn reload(&mut self) -> Result<()> {
+ *self = serde_yml::from_reader(File::open(data_dir().join("index.yaml"))?)?;
+ Ok(())
+ }
+
+ pub fn read_map(&self, name: &str) -> Result<String> {
+ // Scary!
+ if name.contains("..") || name.starts_with("/") || name.contains("//") {
+ bail!("illegal map path");
+ }
+ let path = data_dir().join(format!("maps/{name}.yaml"));
+ Ok(read_to_string(path)?)
+ }
+ pub fn read_recipes(&self, name: &str) -> Result<String> {
+ if !self.recipes.contains(name) {
+ bail!("unknown recipes: {name:?}");
+ }
+ let path = data_dir().join(format!("recipes/{name}.yaml"));
+ Ok(read_to_string(path)?)
+ }
+ pub fn generate(&self, map: &str) -> Result<(Gamedata, Serverdata)> {
+ let map_in: MapDecl = serde_yml::from_str(
+ &self
+ .read_map(map)
+ .context(anyhow!("Failed to read map file ({map})"))?,
+ )
+ .context(anyhow!("Failed to parse map file ({map})"))?;
+ let recipes_in = serde_yml::from_str(
+ &self
+ .read_recipes(map_in.recipes.as_deref().unwrap_or("default"))
+ .context("Failed read recipe file")?,
+ )
+ .context("Failed to parse recipe file")?;
+
+ build_data(&self.maps, map.to_string(), map_in, recipes_in)
+ }
+ pub fn generate_with_book(&self, map: &str) -> Result<(Gamedata, Serverdata)> {
+ let (gd, mut sd) = self.generate(map)?;
+ sd.book = book(&gd, &sd).context("within book")?;
+ Ok((gd, sd))
+ }
+}
diff --git a/server/data/src/lib.rs b/server/data/src/lib.rs
new file mode 100644
index 00000000..822d6997
--- /dev/null
+++ b/server/data/src/lib.rs
@@ -0,0 +1,357 @@
+/*
+ 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/>.
+
+*/
+
+pub mod book;
+pub mod demands;
+pub mod entities;
+pub mod index;
+
+use anyhow::{Result, anyhow, bail};
+use clap::Parser;
+use demands::generate_demands;
+use hurrycurry_protocol::{
+ Gamedata, ItemIndex, MapMetadata, Recipe, TileIndex,
+ book::Book,
+ glam::{IVec2, Vec2},
+};
+use serde::{Deserialize, Serialize};
+use std::{
+ collections::{BTreeMap, HashMap, HashSet},
+ sync::RwLock,
+ time::Duration,
+};
+
+use crate::entities::EntityDecl;
+
+#[derive(Debug, Deserialize, Serialize, Clone, Copy, Default)]
+#[serde(rename_all = "snake_case")]
+pub enum RecipeDeclAction {
+ #[default]
+ Never,
+ Passive,
+ Active,
+ Instant,
+ Demand,
+}
+
+#[rustfmt::skip]
+#[derive(Debug, Clone, Deserialize, Serialize)]
+pub struct RecipeDecl {
+ #[serde(default)] tile: Option<String>,
+ #[serde(default)] inputs: Vec<String>,
+ #[serde(default)] outputs: Vec<String>,
+ #[serde(default)] action: RecipeDeclAction,
+ #[serde(default)] warn: bool,
+ #[serde(default)] revert_duration: Option<f32>,
+ #[serde(default)] duration: Option<f32>,
+ #[serde(default)] points: Option<i64>,
+}
+
+#[rustfmt::skip]
+#[derive(Debug, Clone, Deserialize)]
+pub struct MapDecl {
+ map: Vec<String>,
+ tiles: HashMap<char, String>,
+ #[serde(default)] recipes: Option<String>,
+ #[serde(default)] hand_count: Option<usize>,
+ #[serde(default)] entities: Vec<EntityDecl>,
+ #[serde(default)] score_baseline: i64,
+ #[serde(default)] default_timer: Option<u64>,
+ #[serde(default)] flags: ServerdataFlags,
+}
+
+#[derive(Parser)]
+struct TileArgs {
+ tile_name: String,
+ #[clap(short = 'c', long)]
+ collider: bool,
+ #[clap(short = 'x', long)]
+ exclusive: bool,
+ #[clap(short = 'w', long)]
+ walkable: bool,
+ #[clap(long)]
+ book: bool,
+ #[clap(long)]
+ chef_spawn: bool,
+ #[clap(long)]
+ customer_spawn: bool,
+ #[clap(short = 'i', long)]
+ item: Option<String>,
+ #[clap(long)]
+ conveyor: Option<String>,
+}
+
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct DemandDecl {
+ from: String,
+ to: Option<String>,
+ duration: f32,
+ points: i64,
+}
+
+#[derive(Debug, Clone, Default)]
+#[rustfmt::skip]
+pub struct Serverdata {
+ pub initial_map: HashMap<IVec2, (TileIndex, Option<ItemIndex>)>,
+ pub chef_spawn: Vec2,
+ pub customer_spawn: Option<Vec2>,
+ pub score_baseline: i64,
+ pub default_timer: Option<Duration>,
+ pub book: Book,
+ pub flags: ServerdataFlags,
+ pub entity_decls: Vec<EntityDecl>
+}
+
+#[rustfmt::skip]
+#[derive(Debug, Clone, Default, Deserialize)]
+pub struct ServerdataFlags {
+ #[serde(default)] pub disable_unknown_orders: bool,
+}
+
+fn build_data(
+ maps: &HashMap<String, MapMetadata>,
+ map_name: String,
+ map_in: MapDecl,
+ recipes_in: Vec<RecipeDecl>,
+) -> Result<(Gamedata, Serverdata)> {
+ let reg = ItemTileRegistry::default();
+ let mut recipes = Vec::new();
+ let mut entities = Vec::new();
+ let mut raw_demands = Vec::new();
+
+ for mut r in recipes_in {
+ #[cfg(feature = "fast_recipes")]
+ match r.action {
+ RecipeDeclAction::Passive | RecipeDeclAction::Active => {
+ if !r.warn {
+ r.duration = Some(0.5)
+ }
+ }
+ _ => (),
+ }
+
+ let r2 = r.clone();
+ let mut inputs = r.inputs.into_iter().map(|i| reg.register_item(i));
+ let mut outputs = r.outputs.into_iter().map(|o| reg.register_item(o));
+ let tile = r.tile.map(|t| reg.register_tile(t));
+ match r.action {
+ RecipeDeclAction::Never => {}
+ RecipeDeclAction::Passive => recipes.push(Recipe::Passive {
+ speed: 1. / r.duration.ok_or(anyhow!("duration for passive missing"))?,
+ warn: r.warn,
+ tile,
+ revert_speed: r.revert_duration.map(|d| 1. / d),
+ input: inputs
+ .next()
+ .ok_or(anyhow!("passive recipe without input"))?,
+ output: outputs.next(),
+ }),
+ RecipeDeclAction::Active => recipes.push(Recipe::Active {
+ speed: 1. / r.duration.ok_or(anyhow!("duration for active missing"))?,
+ tile,
+ input: inputs
+ .next()
+ .ok_or(anyhow!("active recipe without input"))?,
+ outputs: [outputs.next(), outputs.next()],
+ }),
+ RecipeDeclAction::Instant => {
+ recipes.push(Recipe::Instant {
+ points: r.points.take().unwrap_or(0),
+ tile,
+ inputs: [inputs.next(), inputs.next()],
+ outputs: [outputs.next(), outputs.next()],
+ });
+ }
+ RecipeDeclAction::Demand => raw_demands.push((
+ inputs.next().ok_or(anyhow!("demand needs inputs"))?,
+ outputs.next(),
+ r.duration.unwrap_or(10.),
+ )),
+ }
+ assert_eq!(inputs.next(), None, "{r2:?} inputs left over");
+ assert_eq!(outputs.next(), None, "{r2:?} outputs left over");
+ assert_eq!(r.points, None, "points specified where not possible")
+ }
+
+ let mut tile_specs = BTreeMap::new();
+ for (char, tile_spec_raw) in map_in.tiles {
+ let mut toks = shlex::split(&tile_spec_raw).ok_or(anyhow!("tile spec quoting invalid"))?;
+ toks.insert(0, "tile-spec".to_string()); // exe name
+ tile_specs.insert(char, TileArgs::try_parse_from(toks)?);
+ }
+
+ let mut chef_spawn = None;
+ let mut customer_spawn = None;
+ let mut initial_map = HashMap::new();
+ let mut tiles_used = HashSet::new();
+ let mut items_used = HashSet::new();
+ let mut tile_walkable = HashSet::new();
+ let mut exclusive_tiles = BTreeMap::<TileIndex, HashSet<ItemIndex>>::new();
+ for (y, line) in map_in.map.iter().enumerate() {
+ for (x, char) in line.chars().enumerate() {
+ if char == ' ' {
+ continue; // space is empty space
+ }
+ let pos = IVec2::new(x as i32, y as i32);
+
+ let tile_spec = tile_specs
+ .get(&char)
+ .ok_or(anyhow!("tile {char} is undefined"))?;
+
+ let tile = reg.register_tile(tile_spec.tile_name.clone());
+ tiles_used.insert(tile);
+ let item = tile_spec.item.clone().map(|i| reg.register_item(i));
+ items_used.extend(item);
+ initial_map.insert(pos, (tile, item));
+
+ if tile_spec.chef_spawn {
+ chef_spawn = Some(pos.as_vec2() + Vec2::splat(0.5));
+ }
+ if tile_spec.customer_spawn {
+ customer_spawn = Some(pos.as_vec2() + Vec2::splat(0.5));
+ }
+ if tile_spec.walkable {
+ tile_walkable.insert(tile);
+ }
+ if tile_spec.walkable || tile_spec.collider || tile_spec.exclusive {
+ exclusive_tiles.entry(tile).or_default().extend(item);
+ }
+ if tile_spec.book {
+ entities.push(EntityDecl::Book { pos });
+ }
+ if let Some(off) = &tile_spec.conveyor {
+ let (x, y) = off
+ .split_once(",")
+ .ok_or(anyhow!("conveyor offset invalid format"))?;
+ let dir = IVec2::new(x.parse()?, y.parse()?);
+ entities.push(EntityDecl::Conveyor {
+ from: pos,
+ speed: None,
+ to: pos + dir,
+ });
+ }
+ }
+ }
+
+ for tile in tile_specs.values() {
+ if !tiles_used.contains(&reg.register_tile(tile.tile_name.clone())) {
+ bail!("tile {:?} is unused", tile.tile_name)
+ }
+ }
+
+ let chef_spawn = chef_spawn.ok_or(anyhow!("map has no chef spawn"))?;
+
+ entities.extend(map_in.entities.clone());
+
+ let demands = generate_demands(&tiles_used, &items_used, &raw_demands, &recipes);
+
+ let mut maps = maps
+ .iter()
+ .filter(|(_, v)| v.players > 0)
+ .map(|(k, v)| (k.to_owned(), v.to_owned()))
+ .collect::<Vec<(String, MapMetadata)>>();
+ maps.sort_unstable_by_key(|(_, m)| m.difficulty);
+ maps.sort_by_key(|(_, m)| m.players);
+
+ let mut tile_placeable_items = BTreeMap::new();
+ let mut tile_interactable_empty = HashSet::new();
+ for (tile, used_items) in exclusive_tiles {
+ let whitelist = recipes
+ .iter()
+ .filter(|r| r.tile() == Some(tile))
+ .flat_map(|e| e.inputs())
+ .chain(used_items)
+ .collect();
+ let int_empty = recipes
+ .iter()
+ .any(|r| r.tile() == Some(tile) && r.inputs().is_empty());
+ tile_placeable_items.insert(tile, whitelist);
+ if int_empty {
+ tile_interactable_empty.insert(tile);
+ }
+ }
+
+ for e in &entities {
+ e.run_register(&reg);
+ }
+
+ let item_names = reg.items.into_inner().unwrap();
+ let tile_names = reg.tiles.into_inner().unwrap();
+
+ let default_timer = if map_name.ends_with("lobby") {
+ None
+ } else {
+ Some(Duration::from_secs(map_in.default_timer.unwrap_or(420)))
+ };
+
+ Ok((
+ Gamedata {
+ current_map: map_name,
+ maps,
+ tile_walkable,
+ tile_placeable_items,
+ tile_interactable_empty,
+ recipes,
+ item_names,
+ demands,
+ tile_names,
+ bot_algos: vec![
+ "waiter".to_string(),
+ "simple".to_string(),
+ "dishwasher".to_string(),
+ "frank".to_string(),
+ ],
+ hand_count: map_in.hand_count.unwrap_or(1),
+ },
+ Serverdata {
+ initial_map,
+ chef_spawn,
+ flags: map_in.flags,
+ customer_spawn,
+ default_timer,
+ book: Book::default(),
+ score_baseline: map_in.score_baseline,
+ entity_decls: entities,
+ },
+ ))
+}
+
+#[derive(Default)]
+pub(crate) struct ItemTileRegistry {
+ tiles: RwLock<Vec<String>>,
+ items: RwLock<Vec<String>>,
+}
+
+impl ItemTileRegistry {
+ pub fn register_tile(&self, name: String) -> TileIndex {
+ TileIndex(Self::register(&self.tiles, name))
+ }
+ pub fn register_item(&self, name: String) -> ItemIndex {
+ ItemIndex(Self::register(&self.items, name))
+ }
+ fn register(db: &RwLock<Vec<String>>, name: String) -> usize {
+ let mut db = db.write().unwrap();
+ if let Some(index) = db.iter().position(|e| e == &name) {
+ index
+ } else {
+ let index = db.len();
+ db.push(name);
+ index
+ }
+ }
+}