/* 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 . */ 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, BTreeSet, 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 { tile: Option, #[serde(default)] inputs: Vec, #[serde(default)] outputs: Vec, #[serde(default)] action: RecipeDeclAction, #[serde(default)] warn: bool, revert_duration: Option, duration: Option, points: Option, group: Option, #[serde(default)] group_hidden: bool, } #[rustfmt::skip] #[derive(Debug, Clone, Deserialize)] pub struct MapDecl { map: Vec, tiles: HashMap, #[serde(default)] recipes: Option, #[serde(default)] hand_count: Option, #[serde(default)] entities: Vec, #[serde(default)] score_baseline: i64, #[serde(default)] default_timer: Option, #[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, #[clap(long)] conveyor: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct DemandDecl { from: String, to: Option, duration: f32, points: i64, } #[derive(Debug, Clone, Default)] #[rustfmt::skip] pub struct Serverdata { pub initial_map: HashMap)>, pub chef_spawn: Vec2, pub customer_spawn: Option, pub score_baseline: i64, pub default_timer: Option, pub book: Book, pub flags: ServerdataFlags, pub entity_decls: Vec, pub recipe_groups: BTreeMap>, } #[rustfmt::skip] #[derive(Debug, Clone, Default, Deserialize)] pub struct ServerdataFlags { #[serde(default)] pub disable_unknown_orders: bool, } fn build_data( maps: &HashMap, map_name: String, map_in: MapDecl, recipes_in: Vec, ) -> Result<(Gamedata, Serverdata)> { let reg = ItemTileRegistry::default(); let mut recipes = Vec::new(); let mut entities = Vec::new(); let mut raw_demands = Vec::new(); let mut recipe_groups = BTreeMap::>::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)); if let Some(g) = r.group { if !r.group_hidden { recipe_groups.entry(g).or_default().extend(inputs.clone()); } } 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::>::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(®.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::>(); 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(®); } 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, recipe_groups, }, )) } #[derive(Default)] pub(crate) struct ItemTileRegistry { tiles: RwLock>, items: RwLock>, } 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>, 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 } } }