M1c: Gleichschritt im Netz #9

Merged
steven merged 20 commits from m1c-gleichschritt-im-netz into main 2026-10-02 06:41:02 +00:00
5 changed files with 671 additions and 128 deletions
Showing only changes of commit 2faa3ccce3 - Show all commits
+272
View File
@@ -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");
}
}
+2 -1
View File
@@ -14,8 +14,9 @@ pub fn to_dictionary(event: &Event) -> VarDictionary {
"type" => "player_left", "type" => "player_left",
"player" => i64::from(player.0), "player" => i64::from(player.0),
}, },
Event::JoinRefused { .. } => vdict! { Event::JoinRefused { key } => vdict! {
"type" => "join_refused", "type" => "join_refused",
"key" => &PackedByteArray::from(key.as_slice()),
}, },
Event::TileHit { player, pos } => vdict! { Event::TileHit { player, pos } => vdict! {
"type" => "tile_hit", "type" => "tile_hit",
+1
View File
@@ -3,6 +3,7 @@
//! The bridge only translates. Every rule lives in `grovewright-core`, and //! The bridge only translates. Every rule lives in `grovewright-core`, and
//! everything that is shown, heard or typed lives in the Godot project. //! everything that is shown, heard or typed lives in the Godot project.
mod driver;
mod events; mod events;
mod session; mod session;
+300 -127
View File
@@ -1,56 +1,26 @@
//! A solo game session for GDScript: the shared world, its recording and the //! A game session for GDScript, as host or as guest of a shared world.
//! commands waiting for the next tick.
use std::panic::{catch_unwind, AssertUnwindSafe};
use godot::prelude::*; 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::inventory::ItemId;
use grovewright_core::map::{Pos, Terrain}; use grovewright_core::map::{Pos, Terrain};
use grovewright_core::mapgen::spawn_pos; use grovewright_core::mapgen::spawn_pos;
use grovewright_core::player::{PlayerId, PlayerKey}; use grovewright_core::player::{PlayerId, PlayerKey};
use grovewright_core::replay::Recording; use grovewright_core::protocol::{Action, Refusal};
use grovewright_core::world::World; use grovewright_core::world::World;
use crate::driver::{Driver, HOST_PEER};
use crate::events; use crate::events;
/// Everything a running session holds. /// A session. The host runs the ticks and GDScript calls
struct Game { /// [`GwSession::step`] once per tick; a guest runs the ticks its host sends,
world: World, /// one [`GwSession::step`] per tick that arrived. GDScript carries the
recording: Recording, /// messages between the machines and reads the world back for display.
/// 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.
#[derive(GodotClass)] #[derive(GodotClass)]
#[class(base = RefCounted, init)] #[class(base = RefCounted, init)]
pub struct GwSession { pub struct GwSession {
game: Option<Game>, driver: Option<Driver>,
last_error: GString, last_error: GString,
} }
@@ -72,133 +42,277 @@ impl GwSession {
const TERRAIN_BEDROCK: i64 = Terrain::Bedrock as i64; const TERRAIN_BEDROCK: i64 = Terrain::Bedrock as i64;
#[constant] #[constant]
const ITEM_STONE: i64 = grovewright_core::inventory::ITEM_STONE.0 as i64; 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] #[func]
fn start_new(&mut self, seed: i64) { 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(); self.last_error = GString::new();
} }
/// Starts from a save. Returns false and keeps any running game if the /// Hosts a saved world. Returns false and keeps any running session if
/// save cannot be used; [`GwSession::last_error`] then says why. /// the save cannot be used; [`GwSession::last_error`] then says why.
#[func] #[func]
fn start_loaded(&mut self, save: PackedByteArray) -> bool { fn start_loaded(&mut self, save: PackedByteArray) -> bool {
match World::load(save.as_slice()) { match World::load(save.as_slice()) {
Ok(world) => { Ok(world) => {
self.game = Some(Game::new(world)); self.driver = Some(Driver::host(world));
self.last_error = GString::new(); self.last_error = GString::new();
true true
} }
Err(error) => { Err(error) => {
self.last_error = GString::from(error.to_string().as_str()); self.fail(&error.to_string());
false 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. /// Why the last call failed, or an empty text.
#[func] #[func]
fn last_error(&self) -> GString { fn last_error(&self) -> GString {
self.last_error.clone() 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] #[func]
fn is_running(&self) -> bool { 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. /// True after the core stopped with an internal error.
#[func] #[func]
fn is_broken(&self) -> bool { 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` /// Queues the entry of the host's own player for the next tick. `key`
/// identifies the player across sessions and must have 16 bytes. /// identifies the player across sessions and must have 16 bytes. A
/// guest enters with [`GwSession::start_guest`] instead.
#[func] #[func]
fn join(&mut self, key: PackedByteArray, name: GString) -> bool { 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; return false;
}; };
let Ok(key) = PlayerKey::try_from(key.as_slice()) else { let Some(driver) = self.driver.as_mut() else {
self.last_error = GString::from("a player key has 16 bytes");
return false; return false;
}; };
game.local_key = Some(key); driver.join_local(key, &name.to_string())
game.local = None;
game.pending.push(Command::Join {
key,
name: name.to_string(),
});
true
} }
/// Queues a pickaxe hit of the local player on tile (`x`, `y`) for the /// A pickaxe hit of this machine's player on tile (`x`, `y`): the host
/// next tick. Does nothing before the local player has joined. /// queues it for the next tick, a guest sends it to the host. Does
/// nothing before the player is in the world.
#[func] #[func]
fn dig(&mut self, x: i64, y: i64) { 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; return;
}; };
let (Some(player), Some(pos)) = (game.local, to_pos(x, y)) else { driver.act(Action::Dig { pos });
return;
};
game.pending.push(Command::Dig { player, pos });
} }
/// Runs one tick with the queued commands and returns what happened, as /// Runs one tick and returns what happened, as dictionaries with a
/// dictionaries with a `type` entry. /// `type` entry. A host runs its next tick; a guest runs the oldest tick
/// that arrived, or nothing if none is waiting.
#[func] #[func]
fn step(&mut self) -> Array<VarDictionary> { fn step(&mut self) -> Array<VarDictionary> {
let mut happened = Array::new(); let mut happened = Array::new();
let Some(game) = self.game.as_mut() else { let Some(driver) = self.driver.as_mut() else {
return happened; return happened;
}; };
if game.broken { if let Some(events) = driver.step() {
return happened; for event in &events {
happened.push(&events::to_dictionary(event));
}
} }
let commands = std::mem::take(&mut game.pending); if driver.is_broken() {
let Game { self.fail("the simulation stopped after an internal error");
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");
}
} }
happened 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. /// Number of ticks the world has run.
#[func] #[func]
fn tick(&self) -> i64 { fn tick(&self) -> i64 {
self.game self.world().map_or(0, |world| world.tick() as i64)
.as_ref()
.map_or(0, |game| game.world.tick() as i64)
} }
#[func] #[func]
fn map_width(&self) -> i64 { fn map_width(&self) -> i64 {
self.game self.world()
.as_ref() .map_or(0, |world| i64::from(world.map().width()))
.map_or(0, |game| i64::from(game.world.map().width()))
} }
#[func] #[func]
fn map_height(&self) -> i64 { fn map_height(&self) -> i64 {
self.game self.world()
.as_ref() .map_or(0, |world| i64::from(world.map().height()))
.map_or(0, |game| i64::from(game.world.map().height()))
} }
/// The terrain of every tile, row by row (`TERRAIN_*` constants). /// The terrain of every tile, row by row (`TERRAIN_*` constants).
@@ -229,76 +343,123 @@ impl GwSession {
/// The tile where figures appear. /// The tile where figures appear.
#[func] #[func]
fn spawn_point(&self) -> Vector2i { fn spawn_point(&self) -> Vector2i {
self.game.as_ref().map_or(Vector2i::ZERO, |game| { self.world().map_or(Vector2i::ZERO, |world| {
let spawn = spawn_pos(game.world.map()); let spawn = spawn_pos(world.map());
Vector2i::new(spawn.x, spawn.y) 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] #[func]
fn local_player(&self) -> i64 { fn local_player(&self) -> i64 {
self.game self.driver
.as_ref() .as_ref()
.and_then(|game| game.local) .and_then(Driver::local_player)
.map_or(-1, |id| i64::from(id.0)) .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. /// How many of `item` the local player carries.
#[func] #[func]
fn item_count(&self, item: i64) -> i64 { 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; return 0;
}; };
game.local_player() world
.player(player)
.map_or(0, |player| i64::from(player.inventory.count(ItemId(item)))) .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] #[func]
fn save(&self) -> PackedByteArray { fn save(&self) -> PackedByteArray {
match self.game.as_ref() { self.driver
Some(game) if !game.broken => { .as_ref()
PackedByteArray::from(game.world.to_snapshot().as_slice()) .and_then(Driver::save)
} .map_or_else(PackedByteArray::new, |bytes| {
_ => PackedByteArray::new(), PackedByteArray::from(bytes.as_slice())
} })
} }
/// The recording of this session, or empty if there is no healthy /// The recording of this machine's session, or empty if there is no
/// session. /// healthy session with a world.
#[func] #[func]
fn recording_bytes(&self) -> PackedByteArray { fn recording_bytes(&self) -> PackedByteArray {
match self.game.as_ref() { self.driver
Some(game) if !game.broken => { .as_ref()
PackedByteArray::from(game.recording.to_bytes(&game.world).as_slice()) .and_then(Driver::recording_bytes)
} .map_or_else(PackedByteArray::new, |bytes| {
_ => PackedByteArray::new(), PackedByteArray::from(bytes.as_slice())
} })
} }
/// The checksum of the world state, as 16 hexadecimal digits. /// The checksum of the world state, as 16 hexadecimal digits.
#[func] #[func]
fn state_hash_hex(&self) -> GString { fn state_hash_hex(&self) -> GString {
self.game.as_ref().map_or_else(GString::new, |game| { self.world().map_or_else(GString::new, |world| {
GString::from(format!("{:016x}", game.world.state_hash()).as_str()) GString::from(format!("{:016x}", world.state_hash()).as_str())
}) })
} }
} }
impl GwSession { 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)> { fn tile(&self, x: i64, y: i64) -> Option<(Terrain, u8)> {
let game = self.game.as_ref()?; let tile = self.world()?.map().get(to_pos(x, y)?)?;
let tile = game.world.map().get(to_pos(x, y)?)?;
Some((tile.terrain, tile.damage)) Some((tile.terrain, tile.damage))
} }
fn tile_bytes(&self, byte: impl Fn(Terrain, u8) -> u8) -> PackedByteArray { 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(); return PackedByteArray::new();
}; };
let bytes: Vec<u8> = game let bytes: Vec<u8> = world
.world
.map() .map()
.tiles() .tiles()
.iter() .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()?)) 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)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@@ -323,4 +489,11 @@ mod tests {
assert_eq!(to_pos(i64::from(i32::MAX) + 1, 0), None); assert_eq!(to_pos(i64::from(i32::MAX) + 1, 0), None);
assert_eq!(to_pos(0, i64::from(i32::MIN) - 1), 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);
}
} }
+96
View File
@@ -138,3 +138,99 @@ func test_the_state_hash_changes_with_every_tick() -> void:
session.step() session.step()
check_eq(first.length(), 16, "sixteen hexadecimal digits") check_eq(first.length(), 16, "sixteen hexadecimal digits")
check(session.state_hash_hex() != first, "the tick is part of the state") 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")