/*
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::{
ConnectionID,
entity::{Entities, EntityContext, construct_entity},
random_float,
scoreboard::ScoreboardStore,
};
use anyhow::{Context, Result};
use hurrycurry_data::{Serverdata, index::DataIndex};
use hurrycurry_game_core::{Game, Involvement, Item, Player, Tile};
use hurrycurry_locale::{
GLOBAL_LOCALE, TrError,
message::{COLORED, MessageDisplayExt},
tre,
};
use hurrycurry_protocol::{
Character, Gamedata, Hand, ItemLocation, Menu, MessageTimeout, PacketC, PacketS, PlayerClass,
PlayerID, Score,
glam::{IVec2, Vec2},
movement::MovementBase,
};
use log::{info, warn};
use std::{
collections::{HashMap, HashSet, VecDeque},
path::PathBuf,
sync::Arc,
time::{Duration, Instant},
};
use tokio::sync::{broadcast, mpsc};
#[derive(Debug)]
pub struct ConnectionData {
pub players: HashSet,
pub idle: bool,
pub ready: bool,
pub keepalive_timer: f32,
pub replies: mpsc::Sender,
}
pub enum AnnounceState {
Queued,
Running(f32),
Done,
}
pub struct ServerConfig {
pub inactivity_timeout: f32,
pub lobby: String,
}
pub struct Server {
pub config: ServerConfig,
pub tick_perf: (Duration, usize),
pub broadcast: broadcast::Sender,
pub connections: HashMap,
pub paused: bool,
pub announce_state: AnnounceState,
pub game: Game,
pub data: Arc,
pub entities: Entities,
pub score_changed: bool,
pub packet_loopback: VecDeque,
pub last_movement_update: HashMap,
pub player_inactivity_timers: HashMap,
pub index: DataIndex,
pub packet_out: VecDeque,
pub scoreboard: ScoreboardStore,
pub editor_address: Option,
}
impl Server {
pub fn new(
data_path: PathBuf,
config: ServerConfig,
tx: broadcast::Sender,
) -> Result {
Ok(Self {
config,
game: Game::default(),
tick_perf: (Duration::ZERO, 0),
index: DataIndex::new(data_path).context("Failed to load data index")?,
broadcast: tx,
announce_state: AnnounceState::Done,
packet_out: VecDeque::new(),
connections: HashMap::new(),
data: Serverdata::default().into(),
entities: Vec::new(),
score_changed: false,
packet_loopback: VecDeque::new(),
last_movement_update: HashMap::default(),
player_inactivity_timers: HashMap::new(),
scoreboard: ScoreboardStore::load().context("Failed to load scoreboards")?,
editor_address: None,
paused: false,
})
}
}
impl Default for ServerConfig {
fn default() -> Self {
Self {
inactivity_timeout: 60.,
lobby: "lobby".to_string(),
}
}
}
pub trait GameServerExt {
fn unload(&mut self, packet_out: &mut VecDeque);
fn load(
&mut self,
gamedata: Gamedata,
serverdata: &Serverdata,
timer: Option,
packet_out: &mut VecDeque,
is_lobby: bool,
);
#[allow(clippy::too_many_arguments)]
fn join_player(
&mut self,
id: PlayerID,
name: String,
character: Character,
class: PlayerClass,
serverdata: &Serverdata,
custom_position: Option,
packet_out: Option<&mut VecDeque>,
);
fn prime_client(&self) -> Vec;
}
impl GameServerExt for Game {
fn unload(&mut self, packet_out: &mut VecDeque) {
packet_out.push_back(PacketC::SetIngame {
state: false,
lobby: false,
});
for (id, _) in self.players.drain() {
self.players_spatial_index.remove_entry(id);
packet_out.push_back(PacketC::RemovePlayer { id })
}
for (pos, _) in self.tiles.drain() {
packet_out.push_back(PacketC::UpdateMap {
tile: pos,
kind: None,
neighbors: [None, None, None, None],
})
}
self.score = Score::default();
self.environment_effects.clear();
self.walkable.clear();
self.tile_index.clear();
self.item_locations_index.clear();
}
fn load(
&mut self,
gamedata: Gamedata,
serverdata: &Serverdata,
timer: Option,
packet_out: &mut VecDeque,
is_lobby: bool,
) {
let players = self
.players
.iter()
.filter(|(_, p)| p.class == PlayerClass::Chef)
.map(|(id, p)| (*id, (p.name.to_owned(), p.character, p.class)))
.collect::>();
self.unload(packet_out);
self.lobby = is_lobby;
self.data = gamedata.into();
self.data_index.update(&self.data);
self.score = Score {
time_remaining: timer.map(|dur| dur.as_secs_f64()).unwrap_or(0.),
..Default::default()
};
for (&p, (tile, item)) in &serverdata.initial_map {
self.tiles.insert(
p,
Tile {
kind: *tile,
item: item.map(|i| Item {
kind: i,
active: None,
}),
},
);
if !self.data_index.tile_collide[tile.0] {
self.walkable.insert(p);
}
self.tile_index.entry(*tile).or_default().insert(p);
if item.is_some() {
self.item_locations_index.insert(ItemLocation::Tile(p));
}
}
for (id, (name, character, class)) in players {
self.join_player(id, name, character, class, serverdata, None, None);
}
packet_out.extend(self.prime_client());
}
fn prime_client(&self) -> Vec {
let mut out = Vec::new();
out.push(PacketC::Data {
data: {
let mut k = self.data.as_ref().to_owned();
k.recipes.clear();
k.demands.clear();
Box::new(k)
},
});
out.push(PacketC::Environment {
effects: self.environment_effects.clone(),
});
for (&id, player) in &self.players {
out.push(PacketC::AddPlayer {
id,
class: player.class,
position: player.movement.position,
character: player.character,
name: player.name.clone(),
});
for (i, item) in player.items.iter().enumerate() {
if let Some(item) = &item {
out.push(PacketC::SetItem {
location: ItemLocation::Player(id, Hand(i)),
item: Some(item.kind),
});
if let Some(Involvement {
players: player,
position,
speed,
warn,
..
}) = item.active.clone()
{
out.push(PacketC::SetProgress {
players: player,
item: ItemLocation::Player(id, Hand(i)),
position,
speed,
warn,
});
}
}
}
if let Some((message, timeout)) = &player.communicate_persist {
out.push(PacketC::Communicate {
player: id,
message: Some(message.to_owned()),
timeout: Some(*timeout),
})
}
}
for (&tile, tdata) in &self.tiles {
out.push(PacketC::UpdateMap {
tile,
neighbors: [
self.tiles.get(&(tile + IVec2::NEG_Y)).map(|e| e.kind),
self.tiles.get(&(tile + IVec2::NEG_X)).map(|e| e.kind),
self.tiles.get(&(tile + IVec2::Y)).map(|e| e.kind),
self.tiles.get(&(tile + IVec2::X)).map(|e| e.kind),
],
kind: Some(tdata.kind),
});
if let Some(item) = &tdata.item {
out.push(PacketC::SetItem {
location: ItemLocation::Tile(tile),
item: Some(item.kind),
});
if let Some(Involvement {
players,
position,
speed,
warn,
..
}) = item.active.clone()
{
out.push(PacketC::SetProgress {
players: players.to_owned(),
item: ItemLocation::Tile(tile),
position,
speed,
warn,
});
}
}
}
out.push(PacketC::FlushMap);
out.push(PacketC::Score(self.score.clone()));
out.push(PacketC::SetIngame {
state: true,
lobby: self.lobby,
});
out
}
fn join_player(
&mut self,
id: PlayerID,
name: String,
character: Character,
class: PlayerClass,
serverdata: &Serverdata,
custom_position: Option,
packet_out: Option<&mut VecDeque>,
) {
let position = custom_position.unwrap_or(match class {
PlayerClass::Customer => serverdata.customer_spawn.unwrap_or(serverdata.chef_spawn),
PlayerClass::Bot | PlayerClass::Chef => serverdata.chef_spawn,
PlayerClass::Tram => Vec2::ZERO, // should always have custom location
}) + (Vec2::new(random_float(), random_float()) - 0.5);
self.players.insert(
id,
Player {
items: (0..self.data.hand_count).map(|_| None).collect(),
character,
class,
movement: MovementBase::new(position),
communicate_persist: None,
interacting: None,
name: name.clone(),
},
);
self.score.players = self.score.players.max(self.players.len());
if let Some(packet_out) = packet_out {
packet_out.push_back(PacketC::AddPlayer {
id,
name,
class,
position,
character,
});
}
}
}
impl Server {
pub fn load(
&mut self,
(gamedata, serverdata): (Gamedata, Serverdata),
timer: Option,
) {
info!("Changing map to {:?}", gamedata.current_map);
for mut e in self.entities.drain(..) {
e.destructor(EntityContext {
game: &mut self.game,
packet_out: &mut self.packet_out,
packet_in: &mut self.packet_loopback,
score_changed: &mut self.score_changed,
scoreboard: &self.scoreboard,
serverdata: self.data.as_ref(),
replies: None,
dt: 0.,
load_map: &mut None,
});
}
// Need to process loopback packets for entity despawn
while let Some(p) = self.packet_loopback.pop_front() {
if let Err(e) = self.packet_in(None, p, &mut vec![]) {
warn!("Internal entity destructor packet errored: {e}");
}
}
let is_lobby = gamedata.current_map == self.config.lobby;
self.game.load(
gamedata,
&serverdata,
timer.or(serverdata.default_timer),
&mut self.packet_out,
is_lobby,
);
self.entities.clear();
for ed in &serverdata.entity_decls {
self.entities.push(construct_entity(ed));
}
self.data = serverdata.into();
for e in &mut self.entities {
e.constructor(EntityContext {
game: &mut self.game,
packet_out: &mut self.packet_out,
packet_in: &mut self.packet_loopback,
score_changed: &mut self.score_changed,
load_map: &mut None,
serverdata: &self.data,
scoreboard: &self.scoreboard,
replies: None,
dt: 0.,
});
}
self.connections.values_mut().for_each(|c| c.ready = false);
self.announce_state = if self.game.lobby {
AnnounceState::Done
} else {
AnnounceState::Queued
};
self.update_paused();
}
pub fn packet_in(
&mut self,
conn: Option,
packet: PacketS,
replies: &mut Vec,
) -> Result<(), TrError> {
match packet {
PacketS::Join {
name,
character,
class,
position,
id,
} => {
if name.chars().count() > 32 || name.len() > 64 {
return Err(tre!(
"s.error.username_length_limit",
s = "32".to_string(),
s = "64".to_string()
));
}
if self.game.players.len() > 64 {
return Err(tre!("s.error.too_many_players"));
}
let player = id.unwrap_or_else(|| self.game.get_unused_player_id());
self.game.join_player(
player,
name,
character,
class,
&self.data,
position,
Some(&mut self.packet_out),
);
if let Some(conn) = conn {
info!("{player} joined (owned by {conn})");
} else {
info!("Server adds {player}");
}
replies.push(PacketC::Joined { id: player })
}
PacketS::Leave { player } => {
if let Some(conn) = conn {
info!("{player} left (owned by {conn})");
} else {
info!("Server removes {player}");
}
let p = self
.game
.players
.remove(&player)
.ok_or(tre!("s.error.no_player"))?;
self.game.players_spatial_index.remove_entry(player);
// ! if holding two, one is destroyed
for (hand, item) in p.items.into_iter().enumerate() {
if let Some(item) = item {
self.game
.item_locations_index
.remove(&ItemLocation::Player(player, Hand(hand)));
let pos = p.movement.position.floor().as_ivec2();
if let Some(tile) = self.game.tiles.get_mut(&pos)
&& tile.item.is_none()
{
self.packet_out.push_back(PacketC::SetItem {
location: ItemLocation::Tile(pos),
item: Some(item.kind),
});
tile.item = Some(item);
}
}
}
self.packet_out
.push_back(PacketC::RemovePlayer { id: player })
}
PacketS::Effect { player, name } => {
self.packet_out.push_back(PacketC::Effect2 {
name,
location: ItemLocation::Player(player, Hand(0)),
});
}
PacketS::Movement {
pos,
boost,
dir,
player,
} => {
let pd = self
.game
.players
.get_mut(&player)
.ok_or(tre!("s.error.no_player"))?;
pd.movement.input(dir, boost);
if let Some(pos) = pos {
let last_position_update = self
.last_movement_update
.entry(player)
.or_insert_with(Instant::now);
let dt = last_position_update.elapsed();
*last_position_update += dt;
let diff = pos - pd.movement.position;
pd.movement.position += diff.clamp_length_max(dt.as_secs_f32());
if diff.length() > 1. {
replies.push(PacketC::MovementSync { player });
}
}
}
PacketS::Interact {
target,
player,
hand,
} => {
for e in &mut self.entities {
if e.interact(
EntityContext {
game: &mut self.game,
packet_out: &mut self.packet_out,
packet_in: &mut self.packet_loopback,
score_changed: &mut self.score_changed,
load_map: &mut None,
serverdata: &self.data,
scoreboard: &self.scoreboard,
replies: Some(replies),
dt: 0.,
},
target,
player,
)? {
return Ok(());
}
}
let pid = player;
let player = self
.game
.players
.get_mut(&pid)
.ok_or(tre!("s.error.no_player"))?;
let loc = target.map(|p| (p, hand));
let ((loc, hand), edge) = match (loc, player.interacting) {
(None, None) => return Ok(()), // this is silent because of auto release
(None, Some(t)) => (t, false),
(Some(t), None) => (t, true),
(Some(_), Some(_)) => return Err(tre!("s.error.already_interacting")),
};
let pos = match loc {
ItemLocation::Tile(pos) => pos.as_vec2() + Vec2::splat(0.5),
ItemLocation::Player(p, _) => {
self.game
.players
.get(&p)
.ok_or_else(|| tre!("s.error.no_player"))?
.movement
.position
}
};
let player = self
.game
.players
.get_mut(&pid)
.ok_or(tre!("s.error.no_player"))?;
if edge && pos.distance(player.movement.position) > 2. {
return Err(tre!("s.error.interacting_too_far"));
}
player.interacting = if edge { Some((loc, hand)) } else { None };
self.game
.interact(loc, ItemLocation::Player(pid, hand), edge)?;
}
PacketS::Communicate {
message,
timeout,
player,
pin,
} => {
let Some(player_data) = self.game.players.get_mut(&player) else {
return Ok(());
};
if let Some(message) = &message {
let body = message.display_message(&GLOBAL_LOCALE, &self.game.data, &COLORED);
if !player_data.name.is_empty() {
info!("[{player} {:?}] {body}", player_data.name);
} else {
info!("[{player}] {body}",);
}
}
let pin = pin.unwrap_or(false);
let timeout = if let Some(timeout) = timeout {
let mut timeout = MessageTimeout {
initial: timeout,
remaining: timeout,
pinned: pin,
};
if let Some((_, t)) = &player_data.communicate_persist {
timeout.initial = t.initial;
};
player_data.communicate_persist = message.clone().map(|m| (m, timeout));
Some(timeout)
} else {
None
};
self.packet_out.push_back(PacketC::Communicate {
player,
message,
timeout,
});
}
PacketS::ReplaceHand { item, player, hand } => {
let pdata = self.game.players.get_mut(&player).ok_or(tre!(""))?;
if let Some(slot) = pdata.items.get_mut(hand.0) {
*slot = item.map(|i| Item {
kind: i,
active: None,
});
}
self.packet_out.push_back(PacketC::SetItem {
location: ItemLocation::Player(player, hand),
item,
})
}
PacketS::ApplyScore(score) => {
self.game.score.demands_completed += score.demands_completed;
self.game.score.demands_failed += score.demands_failed;
self.game.score.points += score.points;
self.score_changed = true;
}
PacketS::ReplayTick { .. } => return Err(tre!("s.error.packet_not_supported")),
PacketS::Idle { .. } | PacketS::Ready | PacketS::Keepalive => (),
PacketS::Debug(d) => self.packet_out.push_back(PacketC::Debug(d)),
}
Ok(())
}
/// Returns Some(map) if the game should end
pub fn tick(&mut self, dt: f32) -> Option<(String, Option)> {
if self.score_changed {
self.score_changed = false;
self.packet_out
.push_back(PacketC::Score(self.game.score.clone()));
}
for loc in self.game.item_locations_index.clone() {
if let Err(e) = self.game.tick_slot(loc, dt) {
warn!("Slot tick failed: {}", e);
}
}
for (&pid, player) in &mut self.game.players {
player.movement.update(&self.game.walkable, dt);
self.game
.players_spatial_index
.update_entry(pid, player.movement.position);
}
self.game.players_spatial_index.all(|p1, pos1| {
self.game
.players_spatial_index
.query(pos1, 2., |p2, _pos2| {
if p1 != p2
&& let [Some(a), Some(b)] = self.game.players.get_disjoint_mut([&p1, &p2])
{
a.movement.collide(&mut b.movement, dt)
}
})
});
for (&pid, player) in &mut self.game.players {
self.packet_out.push_back(PacketC::Movement {
player: pid,
pos: player.movement.position,
dir: player.movement.input_direction,
boost: player.movement.boosting,
rot: player.movement.rotation,
});
}
let mut players_auto_release = Vec::new();
for (pid, player) in &mut self.game.players {
if let Some((_, timeout)) = &mut player.communicate_persist {
timeout.remaining -= dt;
if timeout.remaining < 0. {
player.communicate_persist = None;
}
}
if let Some((loc, hand)) = player.interacting {
match loc {
ItemLocation::Tile(pos) => {
if let Some(tile) = self.game.tiles.get(&pos)
&& let Some(item) = &tile.item
&& let Some(involvement) = &item.active
&& involvement.position >= 1.
{
players_auto_release.push((*pid, hand));
}
}
ItemLocation::Player(_pid, _hand) => {
// TODO
}
}
}
}
for (player, hand) in players_auto_release.drain(..) {
let _ = self.packet_in(
None,
PacketS::Interact {
target: None,
player,
hand,
},
&mut vec![],
);
}
let mut load_map = None;
for entity in self.entities.iter_mut() {
if let Err(e) = entity.tick(EntityContext {
game: &mut self.game,
load_map: &mut load_map,
packet_out: &mut self.packet_out,
score_changed: &mut self.score_changed,
packet_in: &mut self.packet_loopback,
scoreboard: &self.scoreboard,
serverdata: &self.data,
replies: None,
dt,
}) {
warn!("Entity tick failed: {e}")
}
}
self.entities.retain_mut(|e| {
if e.finished() {
e.destructor(EntityContext {
game: &mut self.game,
load_map: &mut load_map,
packet_out: &mut self.packet_out,
score_changed: &mut self.score_changed,
packet_in: &mut self.packet_loopback,
scoreboard: &self.scoreboard,
serverdata: &self.data,
replies: None,
dt: 0.,
});
false
} else {
true
}
});
if let Some(map) = load_map {
return Some((map, None));
}
while let Some(p) = self.packet_loopback.pop_front() {
if let Err(e) = self.packet_in(None, p, &mut vec![]) {
warn!("Internal packet errored: {e}");
}
}
if !self.game.lobby {
self.game.score.time_remaining -= dt as f64;
if self.game.score.time_remaining < 0. {
let relative_score =
(self.game.score.points * 100) / self.data.score_baseline.max(1);
self.game.score.stars = match relative_score {
100.. => 3,
70.. => 2,
40.. => 1,
_ => 0,
};
self.packet_out
.push_back(PacketC::Menu(Menu::Score(self.game.score.clone())));
self.scoreboard.insert(
&self.game.data.current_map,
self.game
.players
.values()
.filter(|m| m.class == PlayerClass::Chef)
.map(|m| m.name.clone())
.collect(),
self.game.score.clone(),
);
Some((self.config.lobby.to_string(), None))
} else {
None
}
} else {
None
}
}
pub fn count_chefs(&self) -> usize {
self.game
.players
.values()
.map(|p| if p.class == PlayerClass::Chef { 1 } else { 0 })
.sum()
}
}