/* 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 entities; pub mod filter_demands; pub mod index; pub mod registry; use anyhow::{Result, anyhow, bail}; use clap::Parser; use filter_demands::filter_demands_and_recipes; use hurrycurry_protocol::{ Demand, Gamedata, GamedataFlags, ItemIndex, MapMetadata, Recipe, TileIndex, book::Book, glam::{IVec2, Vec2}, }; use log::debug; use serde::{Deserialize, Serialize}; use std::{ collections::{BTreeMap, BTreeSet, HashMap, HashSet}, time::Duration, }; use crate::{ entities::EntityDecl, registry::{ItemTileRegistry, filter_unused_tiles_and_items}, }; #[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: GamedataFlags, } #[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, #[clap(long)] demand_sink: bool, } #[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, Option)>, pub chef_spawn: Vec2, pub customer_spawn: Option, pub score_baseline: i64, pub default_timer: Option, pub book: Book, pub entity_decls: Vec, pub recipe_groups: BTreeMap>, } fn build_data( maps: &HashMap, map_name: String, map_in: MapDecl, recipes_in: Vec, ) -> Result<(Gamedata, Serverdata)> { debug!("Preparing gamedata for {map_name}"); let reg = ItemTileRegistry::default(); let (mut recipes, mut demands, recipe_groups) = load_recipes(recipes_in, ®)?; 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 entities = Vec::new(); 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(); let mut tile_placeable_items = BTreeMap::new(); let mut tile_placeable_any = HashSet::new(); let mut tile_interactable_empty = HashSet::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, (vec![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 }); tile_interactable_empty.insert(tile); // if it doesnt have a dedicated tile all of its kind will be interactable } if tile_spec.demand_sink { entities.push(EntityDecl::DemandSink { 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, }); } } } let chef_spawn = chef_spawn.ok_or(anyhow!("map has no chef spawn"))?; for tile in tile_specs.values() { if !tiles_used.contains(®.register_tile(tile.tile_name.clone())) { bail!("tile {:?} is unused", tile.tile_name) } } for mut e in map_in.entities.clone() { match &mut e { EntityDecl::Customers { unknown_order, .. } => { *unknown_order = reg.register_item("unknown-order".to_owned()) } EntityDecl::TagMinigame { tag_item, blocker_tile, } => { *tag_item = reg.register_item("lettuce".to_owned()); *blocker_tile = reg.register_tile("conveyor".to_owned()); exclusive_tiles.entry(*blocker_tile).or_default(); } EntityDecl::PlayerPortalPair { in_tile, out_tile, neutral_tile, .. } => { *in_tile = reg.register_tile("black-hole".to_owned()); *neutral_tile = reg.register_tile("grey-hole".to_owned()); *out_tile = reg.register_tile("white-hole".to_owned()); tile_walkable.extend([*in_tile, *neutral_tile, *out_tile]); } EntityDecl::CtfMinigame { items, item_indices, .. } => { item_indices.extend(items.iter().cloned().map(|name| reg.register_item(name))); } _ => (), } entities.push(e); } debug!("{} entites created", entities.len()); filter_demands_and_recipes(&tiles_used, &items_used, &mut demands, &mut 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); 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().next().is_none()); tile_placeable_items.insert(tile, whitelist); if int_empty { tile_interactable_empty.insert(tile); } } let (item_names, tile_names) = reg.finish(); let default_timer = if map_name.ends_with("lobby") { None } else { Some(Duration::from_secs(map_in.default_timer.unwrap_or(420))) }; let mut data = Gamedata { current_map: map_name, maps, tile_collide: tile_walkable, tile_placeable_items, tile_placeable_any, tile_interactable_empty, flags: map_in.flags, 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), }; let mut serverdata = Serverdata { initial_map, chef_spawn, customer_spawn, default_timer, book: Book::default(), score_baseline: map_in.score_baseline, entity_decls: entities, recipe_groups, }; filter_unused_tiles_and_items(&mut data, &mut serverdata); Ok((data, serverdata)) } #[allow(clippy::type_complexity)] fn load_recipes( recipes_in: Vec, reg: &ItemTileRegistry, ) -> Result<( Vec, Vec, BTreeMap>, )> { let mut recipes = Vec::new(); let mut 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 && !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 => demands.push(Demand { input: inputs.next().ok_or(anyhow!("demand needs inputs"))?, output: outputs.next(), duration: r.duration.unwrap_or(10.), points: 0, // assigned later when filtering }), } 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") } Ok((recipes, demands, recipe_groups)) }