pub mod save; use anyhow::{Result, anyhow}; use clap::Parser; use futures_util::{SinkExt, StreamExt}; use hurrycurry_client_lib::network::sync::Network; use hurrycurry_protocol::{ Gamedata, Hand, Message, PacketC, PacketS, PlayerClass, PlayerID, TileIndex, VERSION, glam::{IVec2, Vec2, ivec2}, movement::MovementBase, }; use log::{debug, info, warn}; use save::export_state; use std::{ collections::{HashMap, HashSet}, fs::File, io::Write, net::SocketAddr, thread::{sleep, spawn}, time::{Duration, Instant}, }; use tokio::net::{TcpListener, TcpStream}; #[derive(Parser)] struct Args { #[arg(short, long, default_value = "127.0.0.1")] bind_addr: String, #[arg(short, long, default_value = "27035")] port: u16, } #[derive(Parser)] #[clap(multicall = true)] pub enum Command { Play, Save, } #[tokio::main] async fn main() -> Result<()> { env_logger::init_from_env("LOG"); let args = Args::parse(); let ws_listener = TcpListener::bind((args.bind_addr, args.port)).await?; loop { let (sock, addr) = ws_listener.accept().await?; if let Err(e) = handle_conn(sock, addr).await { warn!("client error: {e}"); } } } const TILES: &[(&str, char, u8)] = &[ ("grass", 'a', 1), ("floor", 'b', 1), ("counter", 'c', 0), ("oven", 'd', 0), ("stove", 'e', 0), ("cuttingboard", 'f', 0), ("chair", 'g', 1), ("table", 'h', 0), ("wall", 'h', 2), ("counter-window", 'h', 0), ]; #[allow(unused_assignments)] async fn handle_conn(sock: TcpStream, addr: SocketAddr) -> Result<()> { let sock = tokio_tungstenite::accept_async(sock).await?; info!("{addr} connected via websocket"); let mut state = State { out: Vec::new(), tiles: HashMap::new(), walkable: HashSet::new(), joined: false, movement: MovementBase::new(Vec2::ZERO), last_movement: Instant::now(), tile: TileIndex(0), chef_spawn: ivec2(0, 0), customer_spawn: ivec2(0, 0), }; state.out.push(PacketC::Version { major: VERSION.0, minor: VERSION.1, supports_bincode: false, }); state.out.push(PacketC::Data { data: Gamedata { tile_collide: TILES.iter().map(|_| false).collect(), tile_interact: TILES.iter().map(|_| true).collect(), tile_names: TILES.iter().map(|(name, _, _)| name.to_string()).collect(), current_map: "editor".to_owned(), ..Default::default() }, }); state.out.push(PacketC::SetIngame { state: true, lobby: false, // very ironic }); state.build_start_platform(); let (mut write, mut read) = sock.split(); loop { for p in state.out.drain(..) { debug!("-> {p:?}"); write .send(tokio_tungstenite::tungstenite::Message::Text( serde_json::to_string(&p).unwrap(), )) .await?; } let Some(message) = read.next().await.transpose()? else { break; }; let packet = match message { tokio_tungstenite::tungstenite::Message::Text(line) => { match serde_json::from_str::(&line) { Ok(p) => p, Err(e) => { warn!("Invalid json packet: {e}"); break; } } } tokio_tungstenite::tungstenite::Message::Close(_) => break, _ => continue, }; debug!("<- {packet:?}"); if let Err(e) = state.handle_packet(packet) { state.out.push(PacketC::ServerMessage { message: Message::Text(format!("{e}")), error: true, }); } } Ok(()) } pub struct State { tiles: HashMap, walkable: HashSet, out: Vec, joined: bool, movement: MovementBase, last_movement: Instant, tile: TileIndex, customer_spawn: IVec2, chef_spawn: IVec2, } impl State { pub fn set_tile(&mut self, pos: IVec2, tile: TileIndex) { self.tiles.insert(pos, tile); self.walkable.insert(pos); self.out.push(PacketC::UpdateMap { tile: pos, kind: Some(tile), neighbors: [ self.tiles.get(&(pos + IVec2::NEG_Y)).copied(), self.tiles.get(&(pos + IVec2::NEG_X)).copied(), self.tiles.get(&(pos + IVec2::Y)).copied(), self.tiles.get(&(pos + IVec2::X)).copied(), ], }) } pub fn flush(&mut self) { // send every existing tile once move because client does not remember // TODO remove when client is fixed for (tile, _) in &self.tiles { self.out.push(PacketC::UpdateMap { tile: *tile, kind: None, neighbors: [None; 4], }) } for (tile, kind) in &self.tiles { self.out.push(PacketC::UpdateMap { tile: *tile, kind: Some(*kind), neighbors: [ self.tiles.get(&(tile + IVec2::NEG_Y)).copied(), self.tiles.get(&(tile + IVec2::NEG_X)).copied(), self.tiles.get(&(tile + IVec2::Y)).copied(), self.tiles.get(&(tile + IVec2::X)).copied(), ], }) } self.out.push(PacketC::FlushMap); } pub fn build_start_platform(&mut self) { for x in 0..10 { for y in 0..10 { self.set_tile(ivec2(x, y), TileIndex(1)); } } self.flush(); } pub fn handle_packet(&mut self, packet: PacketS) -> Result<()> { match packet { PacketS::Join { character, name, .. } if !self.joined => { self.out.push(PacketC::Joined { id: PlayerID(0) }); self.out.push(PacketC::AddPlayer { id: PlayerID(0), position: self.movement.position, class: PlayerClass::Chef, character, name, }); self.joined = true; } PacketS::Leave { .. } if self.joined => { self.out.push(PacketC::RemovePlayer { id: PlayerID(0) }); self.joined = false; } PacketS::Movement { player, dir, boost, pos, } => { let dt = self.last_movement.elapsed(); self.last_movement += dt; self.movement.position = pos.unwrap_or(self.movement.position); self.movement.input(dir, boost); self.movement.update(&self.walkable, dt.as_secs_f32()); self.out.push(self.movement.movement_packet_c(player)); } PacketS::Communicate { message: Some(Message::Text(t)), .. } => { let t = t.strip_prefix("/").unwrap_or(&t); self.handle_command( Command::try_parse_from( shlex::split(&t) .ok_or(anyhow!("invalid quoting"))? .into_iter(), ) .map_err(|e| anyhow!("{e}"))?, )?; } PacketS::Interact { hand: Hand(1), pos: Some(_), .. } => { self.tile.0 += 1; self.tile.0 %= TILES.len(); self.out.push(PacketC::ServerMessage { message: Message::Text(format!("tile brush: {}", TILES[self.tile.0].0)), error: false, }); } PacketS::Interact { hand: Hand(0), pos: Some(_), .. } => { let bpos = self.movement.position.floor().as_ivec2(); self.set_tile(bpos, self.tile); for off in [IVec2::NEG_X, IVec2::NEG_Y, IVec2::X, IVec2::Y] { if !self.tiles.contains_key(&(bpos + off)) { self.set_tile(bpos + off, self.tile); } } self.flush(); } _ => (), } Ok(()) } pub fn save(&mut self) -> Result<()> { let e = export_state(&self); File::create("data/maps/editor.yaml")?.write_all(e.as_bytes())?; self.out.push(PacketC::ServerMessage { message: Message::Text(format!("Map saved.")), error: false, }); Ok(()) } pub fn handle_command(&mut self, command: Command) -> Result<()> { match command { Command::Play => { self.save()?; spawn(move || { if let Err(e) = start_map_bot("ws://127.0.0.1:27032", "editor") { warn!("editor bot: {e}") } }); self.out.push(PacketC::Redirect { uri: vec!["ws://127.0.0.1:27032".to_string()], }); } Command::Save => { self.save()?; } } Ok(()) } } fn start_map_bot(address: &str, mapname: &str) -> Result<()> { let mut network = Network::connect(address)?; network.queue_out.push_back(PacketS::Join { name: "editor-bot".to_owned(), character: 0, class: PlayerClass::Bot, id: None, }); let mut timer = 10.; loop { let dt = 1. / 50.; network.poll()?; while let Some(packet) = network.queue_in.pop_front() { match &packet { PacketC::Joined { id } => { network.queue_out.push_back(PacketS::Communicate { player: *id, message: Some(Message::Text(format!("/start {mapname}"))), timeout: None, pin: None, }); } PacketC::ServerMessage { message, error: true, } => { warn!("server error message: {message:?}"); } _ => (), } } timer -= dt; if timer < 0. { break; } sleep(Duration::from_secs_f32(dt)); } Ok(()) }