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:
Steven Wroblewski
2026-10-01 19:20:34 +02:00
parent 155d90f3a5
commit 2faa3ccce3
5 changed files with 671 additions and 128 deletions
+300 -127
View File
@@ -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);
}
}