/* 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 recipes; pub mod registry; use crate::{ book::book, entities::EntityDecl, recipes::{RecipeDecl, load_recipes}, registry::{ItemTileRegistry, filter_unused_tiles_and_items}, }; use anyhow::{Context, Result, anyhow, bail}; use clap::Parser; use filter_demands::filter_demands_and_recipes; use hurrycurry_protocol::{ Gamedata, GamedataFlags, ItemIndex, MapMetadata, Recipe, TileIndex, book::Book, glam::{IVec2, Vec2}, }; use log::debug; use serde::Deserialize; use std::{ collections::{BTreeMap, BTreeSet, HashMap, HashSet}, fs::read_to_string, path::Path, time::Duration, }; #[derive(Debug, Deserialize)] pub struct DataIndex { pub maps: HashMap, pub recipes: HashSet, } #[rustfmt::skip] #[derive(Debug, Clone, Deserialize)] pub struct MapDecl { map: Vec, #[serde(default)] tiles: HashMap, #[serde(default)] use_palettes: Vec, #[serde(default = "default_recipes")] recipes: String, #[serde(default)] hand_count: Option, #[serde(default)] entities: Vec, #[serde(default)] score_baseline: i64, #[serde(default)] default_timer: Option, #[serde(default)] flags: GamedataFlags, } fn default_recipes() -> String { "default".to_string() } #[derive(Parser)] struct TileArgs { tiles: Vec, #[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, 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>, } pub fn build_data( data_path: &Path, map_name: &str, generate_book: bool, ) -> Result<(Gamedata, Serverdata)> { debug!("Preparing gamedata for {map_name}"); // Load index let index = read_to_string(data_path.join("index.yaml")).context("Failed reading data index")?; let index = serde_yaml_ng::from_str::(&index)?; // Load map if map_name.contains("..") || map_name.starts_with("/") || map_name.contains("//") { bail!("illegal map path"); } let map_in = read_to_string(data_path.join(format!("maps/{map_name}.yaml"))) .context("Failed reading map file")?; let map_in = serde_yaml_ng::from_str::(&map_in)?; // Load recipes if !index.recipes.contains(&map_in.recipes) { bail!("unknown recipes: {:?}", map_in.recipes); } let recipes_in = read_to_string(data_path.join(format!("recipes/{}.yaml", map_in.recipes)))?; let recipes_in = serde_yaml_ng::from_str::>(&recipes_in)?; // Load tile flags let tile_flags = read_to_string(data_path.join("tiles.yaml")).context("Failed reading tile flags")?; let tile_flags = serde_yaml_ng::from_str::>(&tile_flags)?; let palette = load_palette(data_path, &map_in)?; let reg = ItemTileRegistry::default(); let (mut recipes, mut demands, recipe_groups) = load_recipes(recipes_in, ®)?; let mut entities = Vec::new(); let mut chef_spawn = None; let mut customer_spawn = None; let mut initial_map = HashMap::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 ts = palette .get(&char) .ok_or(anyhow!("tile {char} is undefined"))?; let tiles = ts .tiles .iter() .cloned() .map(|t| reg.register_tile(t)) .collect(); let item = ts.item.clone().map(|i| reg.register_item(i)); initial_map.insert(pos, (tiles, item)); if ts.chef_spawn { chef_spawn = Some(pos.as_vec2() + Vec2::splat(0.5)); } if ts.customer_spawn { customer_spawn = Some(pos.as_vec2() + Vec2::splat(0.5)); } if ts.book { entities.push(EntityDecl::Book { pos }); } if ts.demand_sink { entities.push(EntityDecl::DemandSink { pos }); } if let Some(off) = &ts.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 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()); } 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()); } EntityDecl::CtfMinigame { items, item_indices, .. } => { item_indices.extend(items.iter().cloned().map(|name| reg.register_item(name))); } _ => (), } entities.push(e); } debug!("{} entities created", entities.len()); filter_demands_and_recipes(&initial_map, &mut demands, &mut recipes); let (items, tiles) = 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.to_string(), maps: map_listing(&index), tile_collide: tiles_flagged(&tile_flags, &tiles, 'c'), tile_placeable_items: tile_placeable_items( &initial_map, &recipes, tiles_flagged(&tile_flags, &tiles, 'x'), ), tile_placeable_any: tiles_flagged(&tile_flags, &tiles, 'a'), tile_interactable_empty: tiles_flagged(&tile_flags, &tiles, 'e') .union(&tile_interactable_empty_bc_recipe(&recipes)) .copied() .collect(), flags: map_in.flags, recipes, item_names: items, demands, tile_names: tiles, 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); if generate_book { serverdata.book = book(&data, &serverdata).context("within book")?; } Ok((data, serverdata)) } fn tile_placeable_items( initial_map: &HashMap, Option)>, recipes: &[Recipe], extiles: HashSet, ) -> BTreeMap> { let mut tile_placeable_items = BTreeMap::new(); for tile in extiles { let initially_placed = initial_map .values() .filter(|(t, _)| t.contains(&tile)) .flat_map(|(_, i)| i) .copied(); let used_in_recipe = recipes .iter() .filter(|r| r.tile() == Some(tile)) .flat_map(|e| e.inputs()); tile_placeable_items.insert(tile, used_in_recipe.chain(initially_placed).collect()); } tile_placeable_items } fn tile_interactable_empty_bc_recipe(recipes: &[Recipe]) -> HashSet { recipes .iter() .filter(|r| r.inputs().next().is_none()) .flat_map(|r| r.tile()) .collect() } fn map_listing(index: &DataIndex) -> Vec<(String, MapMetadata)> { let mut maps = index .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); maps } fn load_palette(data_path: &Path, map_in: &MapDecl) -> Result> { // Load palette let palettes = read_to_string(data_path.join("palettes.yaml")).context("Failed reading palettes")?; let palettes = serde_yaml_ng::from_str::>>(&palettes)?; let mut raw_args = HashMap::new(); for p in &map_in.use_palettes { raw_args.extend( palettes .get(p) .cloned() .ok_or(anyhow!("palette {p:?} is undefined"))?, ) } raw_args.extend(map_in.tiles.clone()); let mut palette = HashMap::new(); for (k, raw) in raw_args { let mut toks = shlex::split(&raw).ok_or(anyhow!("tile stack quoting invalid"))?; toks.insert(0, "tilestack".to_string()); // exe name let args = TileArgs::try_parse_from(toks) .context(anyhow!("tile declaration for {k:?} is invalid"))?; palette.insert(k, args); } Ok(palette) } fn tiles_flagged( tile_flags: &HashMap, tiles: &[String], flag: char, ) -> HashSet { let mut out = HashSet::new(); for (i, tile) in tiles.iter().enumerate() { if let Some(flags) = tile_flags.get(tile) { if flags.contains(flag) { out.insert(TileIndex(i)); } } } out }