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