//! A game session for GDScript, as host or as guest of a shared world. use godot::prelude::*; use grovewright_core::guest::Arrival; use grovewright_core::host::Peer; use grovewright_core::inventory::ItemId; use grovewright_core::map::{Pos, Terrain}; use grovewright_core::mapgen::spawn_pos; use grovewright_core::player::{PlayerId, PlayerKey}; use grovewright_core::protocol::{Action, Refusal}; use grovewright_core::world::World; use crate::driver::{Driver, HOST_PEER}; use crate::events; /// A session. The host runs the ticks and GDScript calls /// [`GwSession::step`] once per tick; a guest runs the ticks its host sends, /// one [`GwSession::step`] per tick that arrived. GDScript carries the /// messages between the machines and reads the world back for display. #[derive(GodotClass)] #[class(base = RefCounted, init)] pub struct GwSession { driver: Option, last_error: GString, } #[godot_api] impl GwSession { /// Simulation ticks per second. #[constant] const TICKS_PER_SECOND: i64 = grovewright_core::world::TICKS_PER_SECOND as i64; /// Ticks a player waits between two pickaxe hits. #[constant] const DIG_COOLDOWN_TICKS: i64 = grovewright_core::world::DIG_COOLDOWN_TICKS as i64; #[constant] const TERRAIN_GROUND: i64 = Terrain::Ground as i64; #[constant] const TERRAIN_SOFT_ROCK: i64 = Terrain::SoftRock as i64; #[constant] const TERRAIN_HARD_ROCK: i64 = Terrain::HardRock as i64; #[constant] const TERRAIN_BEDROCK: i64 = Terrain::Bedrock as i64; #[constant] const ITEM_STONE: i64 = grovewright_core::inventory::ITEM_STONE.0 as i64; /// Most players online at the same time. #[constant] const MAX_ONLINE_PLAYERS: i64 = grovewright_core::player::MAX_ONLINE_PLAYERS as i64; /// The connection number of the host, as a guest sees it. #[constant] const HOST_PEER: i64 = HOST_PEER as i64; /// Reasons a host turns a guest away, as in `refused` notices. #[constant] const REFUSED_OTHER_PROTOCOL: i64 = Refusal::OtherProtocol as i64; #[constant] const REFUSED_OTHER_RULES: i64 = Refusal::OtherRules as i64; #[constant] const REFUSED_WORLD_FULL: i64 = Refusal::WorldFull as i64; #[constant] const REFUSED_TOO_MANY_PLAYERS: i64 = Refusal::TooManyPlayers as i64; #[constant] const REFUSED_ALREADY_PLAYING: i64 = Refusal::AlreadyPlaying as i64; /// Hosts a fresh world from `seed`, replacing any running session. #[func] fn start_new(&mut self, seed: i64) { self.driver = Some(Driver::host(World::new(seed as u64))); self.last_error = GString::new(); } /// Hosts a saved world. Returns false and keeps any running session if /// the save cannot be used; [`GwSession::last_error`] then says why. #[func] fn start_loaded(&mut self, save: PackedByteArray) -> bool { match World::load(save.as_slice()) { Ok(world) => { self.driver = Some(Driver::host(world)); self.last_error = GString::new(); true } Err(error) => { self.fail(&error.to_string()); false } } } /// Becomes the guest of a host as the player `key` (16 bytes) named /// `name`, replacing any running session. The greeting waits in /// [`GwSession::take_messages`]; the world comes with the host's answer. #[func] fn start_guest(&mut self, key: PackedByteArray, name: GString) -> bool { let Some(key) = self.player_key(&key) else { return false; }; self.driver = Some(Driver::guest(key, &name.to_string())); self.last_error = GString::new(); true } /// Why the last call failed, or an empty text. #[func] fn last_error(&self) -> GString { self.last_error.clone() } /// True once there is a world: at once for a host, for a guest after /// the host's world arrived. #[func] fn is_running(&self) -> bool { self.world().is_some() } #[func] fn is_host(&self) -> bool { self.driver.as_ref().is_some_and(Driver::is_host) } /// True after the core stopped with an internal error. #[func] fn is_broken(&self) -> bool { self.driver.as_ref().is_some_and(Driver::is_broken) } /// Queues the entry of the host's own player for the next tick. `key` /// identifies the player across sessions and must have 16 bytes. A /// guest enters with [`GwSession::start_guest`] instead. #[func] fn join(&mut self, key: PackedByteArray, name: GString) -> bool { let Some(key) = self.player_key(&key) else { return false; }; let Some(driver) = self.driver.as_mut() else { return false; }; driver.join_local(key, &name.to_string()) } /// A pickaxe hit of this machine's player on tile (`x`, `y`): the host /// queues it for the next tick, a guest sends it to the host. Does /// nothing before the player is in the world. #[func] fn dig(&mut self, x: i64, y: i64) { let (Some(driver), Some(pos)) = (self.driver.as_mut(), to_pos(x, y)) else { return; }; driver.act(Action::Dig { pos }); } /// Runs one tick and returns what happened, as dictionaries with a /// `type` entry. A host runs its next tick; a guest runs the oldest tick /// that arrived, or nothing if none is waiting. #[func] fn step(&mut self) -> Array { let mut happened = Array::new(); let Some(driver) = self.driver.as_mut() else { return happened; }; if let Some(events) = driver.step() { for event in &events { happened.push(&events::to_dictionary(event)); } } if driver.is_broken() { self.fail("the simulation stopped after an internal error"); } happened } /// Ticks that arrived at a guest and have not run yet. #[func] fn pending_ticks(&self) -> i64 { self.driver .as_ref() .map_or(0, |driver| driver.pending_ticks() as i64) } /// Hands over a message that arrived from `peer`. A guest answers with /// notices: `world_received` (entry `resync`), `refused` (entry /// `reason`, a `REFUSED_*` constant) or `protocol_error`, after which /// [`GwSession::last_error`] says what went wrong. #[func] fn receive(&mut self, peer: i64, bytes: PackedByteArray) -> Array { let mut notices = Array::new(); let (Some(driver), Some(peer)) = (self.driver.as_mut(), to_peer(peer)) else { return notices; }; match driver.receive(peer, bytes.as_slice()) { Ok(Arrival::Nothing) => {} Ok(Arrival::World { replaced }) => notices.push(&vdict! { "type" => "world_received", "resync" => replaced, }), Ok(Arrival::Refused(refusal)) => notices.push(&vdict! { "type" => "refused", "reason" => i64::from(refusal.code()), }), Err(error) => { self.fail(&error.to_string()); notices.push(&vdict! { "type" => "protocol_error" }); } } if self.is_broken() { self.fail("the simulation stopped after an internal error"); } notices } /// The host learned that a guest connected as `peer`. #[func] fn connect_peer(&mut self, peer: i64) { if let (Some(driver), Some(peer)) = (self.driver.as_mut(), to_peer(peer)) { driver.connect(peer); } } /// The host learned that the connection `peer` closed. #[func] fn disconnect_peer(&mut self, peer: i64) { if let (Some(driver), Some(peer)) = (self.driver.as_mut(), to_peer(peer)) { driver.disconnect(peer); } } /// Messages to send, oldest first, as dictionaries with the entries /// `peer` and `bytes`. They must arrive reliably and in this order. #[func] fn take_messages(&mut self) -> Array { let mut messages = Array::new(); if let Some(driver) = self.driver.as_mut() { for (peer, bytes) in driver.take_messages() { messages.push(&vdict! { "peer" => i64::from(peer), "bytes" => &PackedByteArray::from(bytes.as_slice()), }); } } messages } /// Connections the host has given up. Close them after sending the /// messages that are waiting for them. #[func] fn take_closing(&mut self) -> PackedInt64Array { let peers: Vec = self .driver .as_mut() .map(|driver| driver.take_closing().into_iter().map(i64::from).collect()) .unwrap_or_default(); PackedInt64Array::from(peers.as_slice()) } /// Divergences the host's guests reported, as dictionaries with the /// entries `peer`, `tick` and `hash` (16 hexadecimal digits). #[func] fn take_divergences(&mut self) -> Array { let mut reports = Array::new(); if let Some(driver) = self.driver.as_mut() { for divergence in driver.take_divergences() { reports.push(&vdict! { "peer" => i64::from(divergence.peer), "tick" => divergence.tick as i64, "hash" => format!("{:016x}", divergence.hash), }); } } reports } /// The recording of a guest's world that diverged from the host's, or /// empty. Taking it empties it. #[func] fn take_diverged_recording(&mut self) -> PackedByteArray { self.driver .as_mut() .and_then(Driver::take_diverged_recording) .map_or_else(PackedByteArray::new, |bytes| { PackedByteArray::from(bytes.as_slice()) }) } /// Number of the host's guests. #[func] fn guest_count(&self) -> i64 { self.driver .as_ref() .map_or(0, |driver| driver.guest_count() as i64) } /// The player of the host's guest at `peer`, or -1. #[func] fn player_of_peer(&self, peer: i64) -> i64 { let (Some(driver), Some(peer)) = (self.driver.as_ref(), to_peer(peer)) else { return -1; }; driver .player_of(peer) .map_or(-1, |player| i64::from(player.0)) } /// Number of ticks the world has run. #[func] fn tick(&self) -> i64 { self.world().map_or(0, |world| world.tick() as i64) } #[func] fn map_width(&self) -> i64 { self.world() .map_or(0, |world| i64::from(world.map().width())) } #[func] fn map_height(&self) -> i64 { self.world() .map_or(0, |world| i64::from(world.map().height())) } /// The terrain of every tile, row by row (`TERRAIN_*` constants). #[func] fn terrain_codes(&self) -> PackedByteArray { self.tile_bytes(|terrain, _| terrain as u8) } /// The hits every tile has taken, row by row. #[func] fn damage_codes(&self) -> PackedByteArray { self.tile_bytes(|_, damage| damage) } /// The terrain of tile (`x`, `y`), or -1 outside the map. #[func] fn terrain_at(&self, x: i64, y: i64) -> i64 { self.tile(x, y).map_or(-1, |(terrain, _)| terrain as i64) } /// Whether a figure can stand on tile (`x`, `y`). #[func] fn is_walkable(&self, x: i64, y: i64) -> bool { self.tile(x, y) .is_some_and(|(terrain, _)| terrain.is_walkable()) } /// The tile where figures appear. #[func] fn spawn_point(&self) -> Vector2i { self.world().map_or(Vector2i::ZERO, |world| { let spawn = spawn_pos(world.map()); Vector2i::new(spawn.x, spawn.y) }) } /// Index of this machine's player in the world, or -1 before it is in. #[func] fn local_player(&self) -> i64 { self.driver .as_ref() .and_then(Driver::local_player) .map_or(-1, |id| i64::from(id.0)) } /// The players who are online, by index. #[func] fn online_players(&self) -> PackedInt64Array { let online: Vec = self.world().map_or_else(Vec::new, |world| { world .players() .iter() .enumerate() .filter(|(_, player)| player.online) .map(|(index, _)| index as i64) .collect() }); PackedInt64Array::from(online.as_slice()) } /// The name of player `player`, or an empty text. #[func] fn player_name(&self, player: i64) -> GString { let name = u8::try_from(player) .ok() .and_then(|index| self.world()?.player(PlayerId(index))) .map_or("", |player| player.name.as_str()); GString::from(name) } /// How many of `item` the local player carries. #[func] fn item_count(&self, item: i64) -> i64 { let (Some(world), Some(player), Ok(item)) = ( self.world(), self.driver.as_ref().and_then(Driver::local_player), u16::try_from(item), ) else { return 0; }; world .player(player) .map_or(0, |player| i64::from(player.inventory.count(ItemId(item)))) } /// The world as a save, or empty. Only a healthy host saves. #[func] fn save(&self) -> PackedByteArray { self.driver .as_ref() .and_then(Driver::save) .map_or_else(PackedByteArray::new, |bytes| { PackedByteArray::from(bytes.as_slice()) }) } /// The recording of this machine's session, or empty if there is no /// healthy session with a world. #[func] fn recording_bytes(&self) -> PackedByteArray { self.driver .as_ref() .and_then(Driver::recording_bytes) .map_or_else(PackedByteArray::new, |bytes| { PackedByteArray::from(bytes.as_slice()) }) } /// The checksum of the world state, as 16 hexadecimal digits. #[func] fn state_hash_hex(&self) -> GString { self.world().map_or_else(GString::new, |world| { GString::from(format!("{:016x}", world.state_hash()).as_str()) }) } } impl GwSession { fn world(&self) -> Option<&World> { self.driver.as_ref()?.world() } fn fail(&mut self, reason: &str) { self.last_error = GString::from(reason); } /// A player key from GDScript bytes, or `None` with a reason. fn player_key(&mut self, bytes: &PackedByteArray) -> Option { match PlayerKey::try_from(bytes.as_slice()) { Ok(key) => Some(key), Err(_) => { self.fail("a player key has 16 bytes"); None } } } fn tile(&self, x: i64, y: i64) -> Option<(Terrain, u8)> { let tile = self.world()?.map().get(to_pos(x, y)?)?; Some((tile.terrain, tile.damage)) } fn tile_bytes(&self, byte: impl Fn(Terrain, u8) -> u8) -> PackedByteArray { let Some(world) = self.world() else { return PackedByteArray::new(); }; let bytes: Vec = world .map() .tiles() .iter() .map(|tile| byte(tile.terrain, tile.damage)) .collect(); PackedByteArray::from(bytes.as_slice()) } } /// A tile position from GDScript integers, or `None` if it cannot exist. fn to_pos(x: i64, y: i64) -> Option { Some(Pos::new(i32::try_from(x).ok()?, i32::try_from(y).ok()?)) } /// A connection number from GDScript, or `None` if it cannot exist. fn to_peer(peer: i64) -> Option { Peer::try_from(peer).ok() } #[cfg(test)] mod tests { use super::*; #[test] fn positions_outside_the_i32_range_are_refused() { assert_eq!(to_pos(3, -4), Some(Pos::new(3, -4))); assert_eq!(to_pos(i64::from(i32::MAX) + 1, 0), None); assert_eq!(to_pos(0, i64::from(i32::MIN) - 1), None); } #[test] fn connection_numbers_outside_the_u32_range_are_refused() { assert_eq!(to_peer(1), Some(1)); assert_eq!(to_peer(-1), None); assert_eq!(to_peer(i64::from(u32::MAX) + 1), None); } }