Files
Grovewright/bridge/src/session.rs
T
Steven Wroblewski 2faa3ccce3 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>
2026-10-01 19:20:34 +02:00

500 lines
16 KiB
Rust

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