M1c: Gleichschritt im Netz #9
@@ -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<PlayerId> {
|
||||
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<R>(&mut self, f: impl FnOnce(&mut Role) -> R) -> Option<R> {
|
||||
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<Vec<Event>> {
|
||||
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<Arrival, GuestError> {
|
||||
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<u8>)> {
|
||||
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<Peer> {
|
||||
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<Divergence> {
|
||||
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<Vec<u8>> {
|
||||
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<PlayerId> {
|
||||
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<Vec<u8>> {
|
||||
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<Vec<u8>> {
|
||||
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");
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
+297
-124
@@ -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<Command>,
|
||||
local_key: Option<PlayerKey>,
|
||||
local: Option<PlayerId>,
|
||||
/// 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<Game>,
|
||||
driver: Option<Driver>,
|
||||
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<VarDictionary> {
|
||||
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;
|
||||
}
|
||||
let commands = std::mem::take(&mut game.pending);
|
||||
let Game {
|
||||
world, recording, ..
|
||||
} = game;
|
||||
match catch_unwind(AssertUnwindSafe(|| recording.step(world, &commands))) {
|
||||
Ok(events) => {
|
||||
if let Some(events) = driver.step() {
|
||||
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<VarDictionary> {
|
||||
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<VarDictionary> {
|
||||
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<i64> = 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<VarDictionary> {
|
||||
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<i64> = 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<PlayerKey> {
|
||||
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<u8> = game
|
||||
.world
|
||||
let bytes: Vec<u8> = world
|
||||
.map()
|
||||
.tiles()
|
||||
.iter()
|
||||
@@ -313,6 +474,11 @@ fn to_pos(x: i64, y: i64) -> Option<Pos> {
|
||||
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> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user