Brücke für Host und Gast
GwSession führt eine Sitzung als Host oder als Gast und reicht die Nachrichten des Kerns an GDScript weiter. Nur der Host speichert. Die Behandlung interner Fehler liegt jetzt in einem reinen Rust-Teil und hat einen Test, und das Ereignis join_refused nennt den Schlüssel. Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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;
|
||||
|
||||
|
||||
+300
-127
@@ -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;
|
||||
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<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