/*
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 .
*/
use crate::{
BotAlgo, PacketSink,
pathfinding::{Path, find_path}, random_usize,
};
use hurrycurry_game_core::Game;
use hurrycurry_protocol::{
DemandIndex, Hand, ItemLocation, Message, PacketS, PlayerClass, PlayerID, Score,
glam::{IVec2, Vec2},
};
use log::debug;
use rand::{random, seq::IndexedRandom};
#[derive(Debug, Clone)]
pub struct Customer {
me: PlayerID,
state: CustomerState,
}
#[derive(Debug, Clone, Default)]
pub struct CustomerConfig {
pub unknown_order: bool,
}
#[derive(Debug, Clone, Default)]
enum CustomerState {
#[default]
New,
Entering {
path: Path,
chair: IVec2,
origin: IVec2,
ticks: usize,
},
Waiting {
demand: DemandIndex,
chair: IVec2,
facing: Vec2,
timeout: f32,
origin: IVec2,
check: u8,
pinned: bool,
},
Eating {
demand: DemandIndex,
table: IVec2,
progress: f32,
chair: IVec2,
origin: IVec2,
},
Finishing {
table: IVec2,
origin: IVec2,
cooldown: f32,
},
Exiting {
path: Path,
},
Exited,
}
impl Customer {
pub fn new(me: PlayerID) -> Self {
Customer {
me,
state: CustomerState::default(),
}
}
}
impl BotAlgo for Customer {
fn tick(&mut self, mut out: PacketSink, game: &Game, dt: f32) {
self.state.tick(&mut out, self.me, game, dt);
}
fn is_finished(&self) -> bool {
matches!(self.state, CustomerState::Exited)
}
}
impl CustomerState {
fn tick(&mut self, out: &mut PacketSink, me: PlayerID, game: &Game, dt: f32) {
let Some(playerdata) = game.players.get(&me) else {
return;
};
let pos = playerdata.movement.position;
match self {
CustomerState::New => {
if !game.data.demands.is_empty() {
let chairs = game
.tiles
.iter()
.filter(|(_, t)| game.data.tile_name(t.kind) == "chair")
.map(|(p, _)| *p)
.collect::>();
if let Some(&chair) = chairs.choose(&mut rand::rng())
&& let Some(path) = find_path(game, pos.as_ivec2(), chair)
{
debug!("{me:?} -> entering");
*self = CustomerState::Entering {
path,
chair,
origin: pos.as_ivec2(),
ticks: 0,
};
}
}
}
CustomerState::Entering {
path,
chair,
origin,
ticks,
} => {
*ticks += 1;
let check = *ticks % 10 == 0;
if path.is_done() {
let demand = DemandIndex(random_usize(&mut rand::rng()) % game.data.demands.len());
let requested_item = game.data.demands[demand.0].input;
debug!("{me:?} -> waiting");
let timeout = 90. + random::() * 60.;
let mut facing = Vec2::ZERO;
for off in [IVec2::NEG_X, IVec2::NEG_Y, IVec2::X, IVec2::Y] {
if game.tiles.get(&(off + *chair)).is_some_and(|t| {
game.data
.tile_placeable_items
.get(&t.kind)
.is_none_or(|p| p.contains(&requested_item))
}) {
facing = off.as_vec2();
}
}
*self = CustomerState::Waiting {
chair: *chair,
timeout,
demand,
facing,
origin: *origin,
check: 0,
pinned: false,
};
let message_item = if !game.data.flags.disable_unknown_orders {
game.data
.get_item_by_name("unknown-order")
.unwrap_or(requested_item)
} else {
requested_item
};
out.push(PacketS::Communicate {
message: Some(Message::Item(message_item)),
timeout: Some(timeout),
player: me,
pin: Some(false),
});
} else if check
&& path.remaining_segments() < 5
&& game.players.iter().any(|(id, p)| {
p.class == PlayerClass::Customer
&& *id != me
&& p.movement.position.distance(chair.as_vec2() + 0.5) < 1.
})
{
*self = CustomerState::New;
} else if path.is_stuck(3.) {
if let Some(path) = find_path(game, pos.as_ivec2(), *origin) {
*self = CustomerState::Exiting { path };
}
} else {
#[cfg(feature = "debug_events")]
out.push(PacketS::Debug(path.debug(me)));
out.push(PacketS::Movement {
player: me,
dir: path.next_direction(pos, dt) * 0.6,
boost: false,
pos: None,
});
}
}
CustomerState::Waiting {
chair,
demand,
timeout,
origin,
check,
pinned,
facing,
} => {
*timeout -= dt;
*check += 1;
if *timeout <= 0. {
let path = find_path(game, pos.as_ivec2(), *origin).expect("no path to exit");
debug!("{me:?} -> exiting");
out.push(PacketS::Communicate {
message: None,
timeout: Some(0.),
player: me,
pin: Some(false),
});
out.push(PacketS::ApplyScore(Score {
points: -1,
demands_failed: 1,
..Default::default()
}));
out.push(PacketS::Effect {
name: "angry".to_string(),
player: me,
});
*self = CustomerState::Exiting { path };
return;
} else if *check > 10 {
let demand_data = &game.data.demands[demand.0];
*check = 0;
if !*pinned {
let mut pin = false;
if !game.data.flags.disable_unknown_orders {
game.players_spatial_index.query(pos, 3., |pid, _| {
if game
.players
.get(&pid)
.is_some_and(|p| p.class.is_cheflike())
{
pin = true
}
});
} else {
pin = true;
}
if pin {
*pinned = true;
out.push(PacketS::Communicate {
player: me,
message: Some(Message::Item(demand_data.input)),
timeout: Some(*timeout),
pin: Some(true),
});
}
}
let demand_pos = [IVec2::NEG_X, IVec2::NEG_Y, IVec2::X, IVec2::Y]
.into_iter()
.find_map(|off| {
let pos = *chair + off;
if game
.tiles
.get(&pos)
.map(|t| {
t.item
.as_ref()
.map(|i| i.kind == demand_data.input)
.unwrap_or_default()
})
.unwrap_or_default()
{
Some(pos)
} else {
None
}
});
if let Some(pos) = demand_pos {
debug!("{me:?} -> eating");
let points = game.data.demands[demand.0].points;
out.push(PacketS::Communicate {
message: None,
timeout: Some(0.),
player: me,
pin: Some(false),
});
out.push(PacketS::Communicate {
message: None,
timeout: Some(0.),
player: me,
pin: Some(true),
});
out.push(PacketS::Effect {
name: "satisfied".to_string(),
player: me,
});
out.push(PacketS::Interact {
target: Some(ItemLocation::Tile(pos)),
player: me,
hand: Hand(0),
});
out.push(PacketS::ApplyScore(Score {
demands_completed: 1,
points,
..Default::default()
}));
out.push(PacketS::Interact {
target: None,
player: me,
hand: Hand(0),
});
*self = CustomerState::Eating {
demand: *demand,
table: pos,
progress: 0.,
chair: *chair,
origin: *origin,
};
return;
}
}
out.push(PacketS::Movement {
player: me,
dir: dir_input(pos, *chair, *facing),
boost: false,
pos: None,
});
}
CustomerState::Eating {
demand,
table,
progress,
chair,
origin,
} => {
let demand = &game.data.demands[demand.0];
*progress += dt / demand.duration;
if *progress >= 1. {
debug!("{me:?} -> finishing");
*self = CustomerState::Finishing {
table: *table,
origin: *origin,
cooldown: 0.5,
};
out.push(PacketS::ReplaceHand {
player: me,
item: demand.output,
hand: Hand(0),
});
return;
}
out.push(PacketS::Movement {
player: me,
dir: dir_input(pos, *chair, (*table - *chair).as_vec2()),
boost: false,
pos: None,
});
}
CustomerState::Finishing {
table,
origin,
cooldown,
} => {
*cooldown -= dt;
if game
.players
.get(&me)
.is_some_and(|pl| pl.items[0].is_none())
// TODO index out of bounds?
{
if let Some(path) = find_path(game, pos.as_ivec2(), *origin) {
*self = CustomerState::Exiting { path };
}
} else {
if *cooldown < 0. {
*cooldown += 1.;
out.push(PacketS::Interact {
player: me,
target: Some(ItemLocation::Tile(*table)),
hand: Hand(0),
});
out.push(PacketS::Interact {
player: me,
target: None,
hand: Hand(0),
});
}
out.push(PacketS::Movement {
player: me,
dir: (table.as_vec2() + 0.5) - pos,
boost: false,
pos: None,
});
}
}
CustomerState::Exiting { path } => {
if path.is_done() || path.is_stuck(3.) {
debug!("{me:?} -> leave");
*self = CustomerState::Exited
} else {
#[cfg(feature = "debug_events")]
out.push(PacketS::Debug(path.debug(me)));
out.push(PacketS::Movement {
player: me,
dir: path.next_direction(pos, dt) * 0.6,
boost: false,
pos: None,
});
}
}
CustomerState::Exited => (),
}
}
}
fn dir_input(pos: Vec2, target: IVec2, facing: Vec2) -> Vec2 {
let diff = (target.as_vec2() + 0.5) - pos;
(if diff.length() > 0.3 {
diff.normalize()
} else {
diff * 0.5
}) + facing.clamp_length_max(1.) * 0.3
}