/*
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
}