diff --git a/bridge/src/driver.rs b/bridge/src/driver.rs new file mode 100644 index 0000000..2ea095d --- /dev/null +++ b/bridge/src/driver.rs @@ -0,0 +1,272 @@ +//! The running session behind `GwSession`, without Godot types, so that its +//! handling of internal errors can be tested in plain Rust. + +use std::panic::{catch_unwind, AssertUnwindSafe}; + +use grovewright_core::guest::{Arrival, Guest, GuestError}; +use grovewright_core::host::{Divergence, Host, Peer}; +use grovewright_core::player::{PlayerId, PlayerKey}; +use grovewright_core::protocol::Action; +use grovewright_core::world::{Event, World}; + +/// The connection number under which a guest reaches its host. The shell's +/// network layer gives the host this number. +pub const HOST_PEER: Peer = 1; + +/// The machine's part in the session. +pub enum Role { + Host(Host), + Guest(Guest), +} + +/// A session in either role. After an internal error of the core it is +/// broken: the world may be half-updated, so nothing runs, saves or records +/// any more. +pub struct Driver { + role: Role, + broken: bool, +} + +impl Driver { + /// Hosts `world`, new or loaded. + pub fn host(world: World) -> Self { + Self { + role: Role::Host(Host::new(world)), + broken: false, + } + } + + /// Joins a host as the player `key` named `name`. The greeting waits in + /// the outbox. + pub fn guest(key: PlayerKey, name: &str) -> Self { + Self { + role: Role::Guest(Guest::new(key, name)), + broken: false, + } + } + + pub fn is_host(&self) -> bool { + matches!(self.role, Role::Host(_)) + } + + pub fn is_broken(&self) -> bool { + self.broken + } + + /// The world, once there is one. A guest has it after the host sent it. + pub fn world(&self) -> Option<&World> { + match &self.role { + Role::Host(host) => Some(host.world()), + Role::Guest(guest) => guest.world(), + } + } + + /// The player of this machine, once it is in the world. + pub fn local_player(&self) -> Option { + match &self.role { + Role::Host(host) => host.local_player(), + Role::Guest(guest) => guest.local_player(), + } + } + + /// Runs `f` on the role unless the session is broken. A panic inside + /// marks the session as broken and gives `None`. + pub fn guarded(&mut self, f: impl FnOnce(&mut Role) -> R) -> Option { + if self.broken { + return None; + } + let role = &mut self.role; + match catch_unwind(AssertUnwindSafe(|| f(role))) { + Ok(result) => Some(result), + Err(_) => { + self.broken = true; + None + } + } + } + + /// Queues the entry of the host's own player. Returns false for a guest, + /// which enters through its greeting. + pub fn join_local(&mut self, key: PlayerKey, name: &str) -> bool { + self.guarded(|role| match role { + Role::Host(host) => { + host.join_local(key, name); + true + } + Role::Guest(_) => false, + }) + .unwrap_or(false) + } + + /// Carries out an action of this machine's player: the host queues it + /// for the next tick, a guest sends it to the host. + pub fn act(&mut self, action: Action) { + self.guarded(|role| match role { + Role::Host(host) => host.act_local(action), + Role::Guest(guest) => guest.act(action), + }); + } + + /// Runs one tick: the host its next tick, a guest the oldest tick that + /// arrived. `None` if a guest has no tick to run or the session broke. + pub fn step(&mut self) -> Option> { + self.guarded(|role| match role { + Role::Host(host) => Some(host.step()), + Role::Guest(guest) => guest.step(), + }) + .flatten() + } + + /// Ticks that arrived at a guest and have not run yet. + pub fn pending_ticks(&self) -> usize { + match &self.role { + Role::Host(_) => 0, + Role::Guest(guest) => guest.pending_ticks(), + } + } + + /// Hands a message to the session. A host learns from `peer` who sent + /// it; a guest only hears from its host. + pub fn receive(&mut self, peer: Peer, bytes: &[u8]) -> Result { + self.guarded(|role| match role { + Role::Host(host) => { + host.receive(peer, bytes); + Ok(Arrival::Nothing) + } + Role::Guest(guest) => guest.receive(bytes), + }) + .unwrap_or(Ok(Arrival::Nothing)) + } + + /// A guest connected to the host as `peer`. + pub fn connect(&mut self, peer: Peer) { + if let Role::Host(host) = &mut self.role { + host.connect(peer); + } + } + + /// The connection `peer` of the host closed. + pub fn disconnect(&mut self, peer: Peer) { + if let Role::Host(host) = &mut self.role { + host.disconnect(peer); + } + } + + /// Messages to send, as (peer, bytes). A guest's messages go to + /// [`HOST_PEER`]. + pub fn take_messages(&mut self) -> Vec<(Peer, Vec)> { + match &mut self.role { + Role::Host(host) => host.take_outbox(), + Role::Guest(guest) => guest + .take_outbox() + .into_iter() + .map(|bytes| (HOST_PEER, bytes)) + .collect(), + } + } + + /// Connections the host has given up. + pub fn take_closing(&mut self) -> Vec { + match &mut self.role { + Role::Host(host) => host.take_closing(), + Role::Guest(_) => Vec::new(), + } + } + + /// Divergences the host's guests reported. + pub fn take_divergences(&mut self) -> Vec { + match &mut self.role { + Role::Host(host) => host.take_divergences(), + Role::Guest(_) => Vec::new(), + } + } + + /// The encoded recording of a guest's world that diverged. + pub fn take_diverged_recording(&mut self) -> Option> { + match &mut self.role { + Role::Host(_) => None, + Role::Guest(guest) => guest.take_diverged_recording(), + } + } + + /// Number of the host's accepted guests. + pub fn guest_count(&self) -> usize { + match &self.role { + Role::Host(host) => host.guest_count(), + Role::Guest(_) => 0, + } + } + + /// The player of the host's guest at `peer`. + pub fn player_of(&self, peer: Peer) -> Option { + match &self.role { + Role::Host(host) => host.player_of(peer), + Role::Guest(_) => None, + } + } + + /// The world as a save. Only the host saves: the world belongs to the + /// host, and a guest must never overwrite its own save with it. + pub fn save(&self) -> Option> { + match &self.role { + Role::Host(host) if !self.broken => Some(host.world().to_snapshot()), + _ => None, + } + } + + /// The recording of this machine's session. + pub fn recording_bytes(&self) -> Option> { + if self.broken { + return None; + } + match &self.role { + Role::Host(host) => Some(host.recording().to_bytes(host.world())), + Role::Guest(guest) => Some(guest.recording()?.to_bytes(guest.world()?)), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::panic; + + #[test] + fn a_host_saves_and_records_its_world() { + let mut driver = Driver::host(World::new(1)); + assert!(driver.join_local([1; 16], "Ada")); + assert!(driver.step().is_some()); + assert!(driver.local_player().is_some()); + assert!(driver.save().is_some()); + assert!(driver.recording_bytes().is_some()); + } + + #[test] + fn a_guest_never_saves_the_world_of_its_host() { + let mut driver = Driver::guest([2; 16], "Grace"); + assert!(!driver.join_local([2; 16], "Grace")); + assert_eq!(driver.take_messages().len(), 1, "the greeting"); + assert!(driver.take_messages().is_empty()); + assert_eq!(driver.step(), None, "no tick has arrived"); + assert_eq!(driver.save(), None); + assert_eq!(driver.recording_bytes(), None, "no world yet"); + } + + #[test] + fn a_panic_breaks_the_session_for_good() { + // Keep the expected panic from printing a message into the output. + let previous = panic::take_hook(); + panic::set_hook(Box::new(|_| {})); + let mut driver = Driver::host(World::new(1)); + let result = driver.guarded(|_| -> u32 { panic!("an internal error") }); + panic::set_hook(previous); + + assert_eq!(result, None); + assert!(driver.is_broken()); + assert_eq!(driver.guarded(|_| 7), None, "nothing runs any more"); + assert_eq!(driver.step(), None); + assert_eq!(driver.save(), None); + assert_eq!(driver.recording_bytes(), None); + assert!(driver.world().is_some(), "the world can still be shown"); + } +} diff --git a/bridge/src/events.rs b/bridge/src/events.rs index 25c055a..4de6741 100644 --- a/bridge/src/events.rs +++ b/bridge/src/events.rs @@ -14,8 +14,9 @@ pub fn to_dictionary(event: &Event) -> VarDictionary { "type" => "player_left", "player" => i64::from(player.0), }, - Event::JoinRefused { .. } => vdict! { + Event::JoinRefused { key } => vdict! { "type" => "join_refused", + "key" => &PackedByteArray::from(key.as_slice()), }, Event::TileHit { player, pos } => vdict! { "type" => "tile_hit", diff --git a/bridge/src/lib.rs b/bridge/src/lib.rs index 9075ffb..31e66a0 100644 --- a/bridge/src/lib.rs +++ b/bridge/src/lib.rs @@ -3,6 +3,7 @@ //! The bridge only translates. Every rule lives in `grovewright-core`, and //! everything that is shown, heard or typed lives in the Godot project. +mod driver; mod events; mod session; diff --git a/bridge/src/session.rs b/bridge/src/session.rs index 908a68c..94bdc5f 100644 --- a/bridge/src/session.rs +++ b/bridge/src/session.rs @@ -1,56 +1,26 @@ -//! A solo game session for GDScript: the shared world, its recording and the -//! commands waiting for the next tick. - -use std::panic::{catch_unwind, AssertUnwindSafe}; +//! A game session for GDScript, as host or as guest of a shared world. use godot::prelude::*; -use grovewright_core::command::Command; +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::replay::Recording; +use grovewright_core::protocol::{Action, Refusal}; use grovewright_core::world::World; +use crate::driver::{Driver, HOST_PEER}; use crate::events; -/// Everything a running session holds. -struct Game { - world: World, - recording: Recording, - /// Commands for the next tick, in the order they were queued. - pending: Vec, - local_key: Option, - local: Option, - /// Set when the core panicked. The world may be half-updated, so the - /// session neither steps nor saves any more. - broken: bool, -} - -impl Game { - fn new(world: World) -> Self { - let recording = Recording::start(&world); - Self { - world, - recording, - pending: Vec::new(), - local_key: None, - local: None, - broken: false, - } - } - - fn local_player(&self) -> Option<&grovewright_core::player::Player> { - self.local.and_then(|id| self.world.player(id)) - } -} - -/// A solo session. GDScript queues commands, calls [`GwSession::step`] once -/// per tick and reads the world back for display. +/// 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 { - game: Option, + driver: Option, last_error: GString, } @@ -72,133 +42,277 @@ impl GwSession { 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; - /// Starts a fresh world from `seed`, replacing any running game. + /// Hosts a fresh world from `seed`, replacing any running session. #[func] fn start_new(&mut self, seed: i64) { - self.game = Some(Game::new(World::new(seed as u64))); + self.driver = Some(Driver::host(World::new(seed as u64))); self.last_error = GString::new(); } - /// Starts from a save. Returns false and keeps any running game if the - /// save cannot be used; [`GwSession::last_error`] then says why. + /// 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.game = Some(Game::new(world)); + self.driver = Some(Driver::host(world)); self.last_error = GString::new(); true } Err(error) => { - self.last_error = GString::from(error.to_string().as_str()); + 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.game.is_some() + 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.game.as_ref().is_some_and(|game| game.broken) + self.driver.as_ref().is_some_and(Driver::is_broken) } - /// Queues the entry of the local player for the next tick. `key` - /// identifies the player across sessions and must have 16 bytes. + /// 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(game) = self.game.as_mut() else { + let Some(key) = self.player_key(&key) else { return false; }; - let Ok(key) = PlayerKey::try_from(key.as_slice()) else { - self.last_error = GString::from("a player key has 16 bytes"); + let Some(driver) = self.driver.as_mut() else { return false; }; - game.local_key = Some(key); - game.local = None; - game.pending.push(Command::Join { - key, - name: name.to_string(), - }); - true + driver.join_local(key, &name.to_string()) } - /// Queues a pickaxe hit of the local player on tile (`x`, `y`) for the - /// next tick. Does nothing before the local player has joined. + /// 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(game) = self.game.as_mut() else { + let (Some(driver), Some(pos)) = (self.driver.as_mut(), to_pos(x, y)) else { return; }; - let (Some(player), Some(pos)) = (game.local, to_pos(x, y)) else { - return; - }; - game.pending.push(Command::Dig { player, pos }); + driver.act(Action::Dig { pos }); } - /// Runs one tick with the queued commands and returns what happened, as - /// dictionaries with a `type` entry. + /// 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(game) = self.game.as_mut() else { + let Some(driver) = self.driver.as_mut() else { return happened; }; - if game.broken { - return happened; + if let Some(events) = driver.step() { + for event in &events { + happened.push(&events::to_dictionary(event)); + } } - let commands = std::mem::take(&mut game.pending); - let Game { - world, recording, .. - } = game; - match catch_unwind(AssertUnwindSafe(|| recording.step(world, &commands))) { - Ok(events) => { - for event in &events { - happened.push(&events::to_dictionary(event)); - } - if game.local.is_none() { - game.local = game.local_key.and_then(|key| game.world.find_player(&key)); - } - } - Err(_) => { - game.broken = true; - self.last_error = GString::from("the simulation stopped after an internal error"); - } + 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.game - .as_ref() - .map_or(0, |game| game.world.tick() as i64) + self.world().map_or(0, |world| world.tick() as i64) } #[func] fn map_width(&self) -> i64 { - self.game - .as_ref() - .map_or(0, |game| i64::from(game.world.map().width())) + self.world() + .map_or(0, |world| i64::from(world.map().width())) } #[func] fn map_height(&self) -> i64 { - self.game - .as_ref() - .map_or(0, |game| i64::from(game.world.map().height())) + self.world() + .map_or(0, |world| i64::from(world.map().height())) } /// The terrain of every tile, row by row (`TERRAIN_*` constants). @@ -229,76 +343,123 @@ impl GwSession { /// The tile where figures appear. #[func] fn spawn_point(&self) -> Vector2i { - self.game.as_ref().map_or(Vector2i::ZERO, |game| { - let spawn = spawn_pos(game.world.map()); + self.world().map_or(Vector2i::ZERO, |world| { + let spawn = spawn_pos(world.map()); Vector2i::new(spawn.x, spawn.y) }) } - /// Index of the local player in the world, or -1 before joining. + /// Index of this machine's player in the world, or -1 before it is in. #[func] fn local_player(&self) -> i64 { - self.game + self.driver .as_ref() - .and_then(|game| game.local) + .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(game), Ok(item)) = (self.game.as_ref(), u16::try_from(item)) else { + 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; }; - game.local_player() + world + .player(player) .map_or(0, |player| i64::from(player.inventory.count(ItemId(item)))) } - /// The world as a save, or empty if there is no healthy session. + /// The world as a save, or empty. Only a healthy host saves. #[func] fn save(&self) -> PackedByteArray { - match self.game.as_ref() { - Some(game) if !game.broken => { - PackedByteArray::from(game.world.to_snapshot().as_slice()) - } - _ => PackedByteArray::new(), - } + self.driver + .as_ref() + .and_then(Driver::save) + .map_or_else(PackedByteArray::new, |bytes| { + PackedByteArray::from(bytes.as_slice()) + }) } - /// The recording of this session, or empty if there is no healthy - /// session. + /// The recording of this machine's session, or empty if there is no + /// healthy session with a world. #[func] fn recording_bytes(&self) -> PackedByteArray { - match self.game.as_ref() { - Some(game) if !game.broken => { - PackedByteArray::from(game.recording.to_bytes(&game.world).as_slice()) - } - _ => PackedByteArray::new(), - } + 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.game.as_ref().map_or_else(GString::new, |game| { - GString::from(format!("{:016x}", game.world.state_hash()).as_str()) + 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 game = self.game.as_ref()?; - let tile = game.world.map().get(to_pos(x, y)?)?; + 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(game) = self.game.as_ref() else { + let Some(world) = self.world() else { return PackedByteArray::new(); }; - let bytes: Vec = game - .world + let bytes: Vec = world .map() .tiles() .iter() @@ -313,6 +474,11 @@ 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::*; @@ -323,4 +489,11 @@ mod tests { 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); + } } diff --git a/game/tests/test_session.gd b/game/tests/test_session.gd index 0f798e7..ec44158 100644 --- a/game/tests/test_session.gd +++ b/game/tests/test_session.gd @@ -138,3 +138,99 @@ func test_the_state_hash_changes_with_every_tick() -> void: session.step() check_eq(first.length(), 16, "sixteen hexadecimal digits") check(session.state_hash_hex() != first, "the tick is part of the state") + + +var guest_key := PackedByteArray([2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2]) + + +## Carries every waiting message between `host` and `guest`, which is +## connected to the host as peer 5, until none are left. Returns the notices +## the guest got. +func _carry(host: GwSession, guest: GwSession) -> Array[Dictionary]: + var notices: Array[Dictionary] = [] + var moved := true + while moved: + moved = false + for message in guest.take_messages(): + check_eq(message["peer"], GwSession.HOST_PEER, "a guest writes to its host") + host.receive(5, message["bytes"]) + moved = true + for message in host.take_messages(): + if message["peer"] == 5: + notices.append_array(guest.receive(GwSession.HOST_PEER, message["bytes"])) + moved = true + return notices + + +## A host whose player Ada is in the world, and the guest Grace at peer 5, +## whose join has run. +func _host_and_guest() -> Array[GwSession]: + var host := _joined_session() + host.connect_peer(5) + var guest := GwSession.new() + guest.start_guest(guest_key, "Grace") + _carry(host, guest) + host.step() + _carry(host, guest) + while guest.pending_ticks() > 0: + guest.step() + return [host, guest] + + +func test_a_guest_follows_its_host_through_the_bridge() -> void: + var host := _joined_session() + host.connect_peer(5) + var guest := GwSession.new() + check(guest.start_guest(guest_key, "Grace"), "the guest starts") + check(not guest.is_host(), "as a guest") + check(not guest.is_running(), "without a world before the host answers") + check_eq(_carry(host, guest), [{"type": "world_received", "resync": false}], "the host's world arrives") + check_eq(guest.tick(), host.tick(), "at the host's tick") + + for tick in 25: + host.step() + _carry(host, guest) + check_eq(guest.pending_ticks(), 25, "the ticks wait until the guest runs them") + while guest.pending_ticks() > 0: + guest.step() + check_eq(guest.state_hash_hex(), host.state_hash_hex(), "both worlds are the same") + check_eq(guest.local_player(), 1, "the guest is player 1") + check_eq(host.player_of_peer(5), 1, "the host knows whose connection it is") + check_eq(host.guest_count(), 1, "one guest") + check_eq(guest.player_name(0), "Ada", "the guest knows the host's player") + check_eq(guest.online_players(), PackedInt64Array([0, 1]), "two players online") + check_eq(guest.save().size(), 0, "a guest never saves the host's world") + check(guest.recording_bytes().size() > 0, "but it records its session") + + +func test_the_hit_of_a_guest_becomes_a_message_to_the_host() -> void: + var pair := _host_and_guest() + var host := pair[0] + var guest := pair[1] + var rock := _first_soft_rock(host) + guest.dig(rock.x, rock.y) + _carry(host, guest) + host.step() + _carry(host, guest) + var events: Array[Dictionary] = [] + while guest.pending_ticks() > 0: + events.append_array(guest.step()) + check_eq(events.map(func(event: Dictionary) -> String: return event["type"]), ["tile_hit"], "the guest sees its hit") + check_eq(host.damage_codes()[rock.y * host.map_width() + rock.x], 1, "and so does the host") + + +func test_a_guest_learns_why_it_is_turned_away() -> void: + var host := _joined_session() + host.connect_peer(5) + var twin := GwSession.new() + twin.start_guest(key, "Ada") + check_eq(_carry(host, twin), [{"type": "refused", "reason": GwSession.REFUSED_ALREADY_PLAYING}], "Ada is in the world already") + check_eq(host.take_closing(), PackedInt64Array([5]), "the host gives up the connection") + + +func test_an_unreadable_message_from_the_host_is_reported() -> void: + var guest := GwSession.new() + guest.start_guest(guest_key, "Grace") + check_eq(guest.receive(GwSession.HOST_PEER, PackedByteArray([0xEE])), [{"type": "protocol_error"}], "a notice") + check(guest.last_error().contains("unreadable"), "with the reason: %s" % guest.last_error()) + check(not guest.start_guest(PackedByteArray([1, 2]), "Grace"), "a short key is refused")