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, import_state}; use std::{ collections::{HashMap, HashSet}, fs::{File, read_to_string}, io::Write, net::SocketAddr, process::exit, thread::{sleep, spawn}, time::{Duration, Instant}, }; use tokio::net::{TcpListener, TcpStream}; #[derive(Parser)] struct Args { /// Print version, then exit #[arg(short, long)] version: bool, #[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 the map on the game yerver #[clap(alias = "p")] Play, /// Save current map to permanent storage Save { name: Option }, /// Load map from storage Load { name: Option }, /// Teleport to spawnpoint Spawn { #[arg(short, long)] customer: bool, }, } #[tokio::main] async fn main() -> Result<()> { env_logger::init_from_env("LOG"); let args = Args::parse(); if args.version { println!("{}", env!("CARGO_PKG_VERSION")); exit(0); } let ws_listener = TcpListener::bind((args.bind_addr, args.port)).await?; let mut ms = None; loop { let (sock, addr) = ws_listener.accept().await?; if let Err(e) = handle_conn(sock, addr, &mut ms).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", 'i', 2), ("tree", 'j', 2), ("counter-window", 'k', 0), ("tomato-crate", 'l', 0), ("leek-crate", 'm', 0), ("lettuce-crate", 'n', 0), ("cheese-crate", 'o', 0), ("potato-crate", 'p', 0), ("chili-crate", 'q', 0), ("fish-crate", 'r', 0), ("steak-crate", 's', 0), ("flour-crate", 't', 0), ("coconut-crate", 'u', 0), ("strawberry-crate", 'v', 0), ("rice-crate", 'w', 0), ("wall-window", 'x', 2), ("freezer", 'y', 0), ("trash", 'z', 0), ("sink", 'A', 0), ("street", 'B', 1), ("conveyor", 'C', 0), ("lamp", 'D', 1), ("fence", 'E', 2), ("door", 'F', 1), ("path", 'G', 1), ("book", 'H', 1), ("house-wall", 'I', 2), ("house-side", 'J', 2), ("house-balcony", 'K', 2), ("house-door", 'L', 2), ("house-oriel", 'M', 2), ("house-roof", 'N', 2), ("house-roof-chimney", 'O', 2), ]; #[allow(unused_assignments)] async fn handle_conn( sock: TcpStream, addr: SocketAddr, mapname_save: &mut Option, ) -> 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), mapname: "editor".to_string(), dirty_tiles: HashSet::new(), }; 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 }); if let Some(name) = mapname_save.clone() { state.load(&name)?; } else { state.build_start_platform(); } state.flush(); 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().into(), )) .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, }); } state.flush(); *mapname_save = Some(state.mapname.clone()); } 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, dirty_tiles: HashSet, mapname: String, } impl State { pub fn set_tile(&mut self, pos: IVec2, tile: TileIndex) { self.tiles.insert(pos, tile); self.walkable.insert(pos); self.dirty_tiles.insert(pos); } pub fn clear(&mut self) { self.dirty_tiles.extend(self.tiles.keys()); self.tiles.clear(); } pub fn flush(&mut self) { if !self.dirty_tiles.is_empty() { for p in self.dirty_tiles.drain() { self.out.push(PacketC::UpdateMap { tile: p, kind: self.tiles.get(&p).copied(), neighbors: [ self.tiles.get(&(p + IVec2::NEG_Y)).copied(), self.tiles.get(&(p + IVec2::NEG_X)).copied(), self.tiles.get(&(p + IVec2::Y)).copied(), self.tiles.get(&(p + 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)); } } } 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; self.spawn(false); } 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 spawn(&mut self, c: bool) { self.movement.position = if c { self.customer_spawn } else { self.chef_spawn } .as_vec2(); self.out.push(PacketC::Movement { player: PlayerID(0), pos: self.movement.position, rot: 0., dir: Vec2::X, boost: false, }); self.out.push(PacketC::MovementSync { player: PlayerID(0), }); } pub fn save(&mut self, name: &str) -> Result<()> { let e = export_state(&self); File::create(format!("data/maps/{name}.yaml"))?.write_all(e.as_bytes())?; self.out.push(PacketC::ServerMessage { message: Message::Text(format!("Map saved.")), error: false, }); self.mapname = name.to_owned(); Ok(()) } pub fn load(&mut self, name: &str) -> Result<()> { let e = read_to_string(format!("data/maps/{name}.yaml"))?; self.clear(); import_state(self, &e)?; self.out.push(PacketC::ServerMessage { message: Message::Text(format!("Map loaded.")), error: false, }); self.mapname = name.to_owned(); Ok(()) } pub fn handle_command(&mut self, command: Command) -> Result<()> { match command { Command::Play => { self.save(&self.mapname.clone())?; let mapname = self.mapname.clone(); spawn(move || { if let Err(e) = start_map_bot("ws://127.0.0.1:27032", "ws://127.0.0.1:27035", &mapname) { warn!("editor bot: {e}") } }); self.out.push(PacketC::Redirect { uri: vec!["ws://127.0.0.1:27032".to_string()], }); } Command::Save { name } => { self.save(name.as_ref().unwrap_or(&self.mapname.clone()))?; } Command::Load { name } => { self.load(name.as_ref().unwrap_or(&self.mapname.clone()))?; } Command::Spawn { customer } => { self.spawn(customer); } } Ok(()) } } fn start_map_bot(address: &str, own_addr: &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, position: 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!( "/set-editor-address {own_addr}\n/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(()) }