Brücke zu Godot und Godot-Projekt

Die GDExtension grovewright-bridge gibt den Kern als Klasse GwSession
an GDScript weiter: Befehle einreihen, einen Takt ausführen, Karte und
Inventar lesen, speichern, laden, aufzeichnen. Eine Panik im Kern macht
die Sitzung defekt, statt einen guten Speicherstand zu überschreiben.
Das Godot-Projekt bekommt einen Testläufer, der auch Laufzeitfehler als
Fehlschlag zählt.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
This commit is contained in:
Steven Wroblewski
2026-10-01 10:46:23 +02:00
parent 5dacbf2222
commit bd0f588714
16 changed files with 838 additions and 1 deletions
+3
View File
@@ -1,2 +1,5 @@
# Rust
/target/
# Godot
/game/.godot/
Generated
+155
View File
@@ -2,6 +2,161 @@
# It is not intended for manual editing.
version = 4
[[package]]
name = "gdextension-api"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3489a9e7463353467db3cfe3eb12de31c9b6200e4f928bde4959bf5db5b31d2f"
[[package]]
name = "glam"
version = "0.32.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f70749695b063ecbf6b62949ccccde2e733ec3ecbbd71d467dca4e5c6c97cca0"
[[package]]
name = "godot"
version = "0.5.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e21dfe3a0c8c253cb293a4f915f2359fdb4cf45a4d0557cb1841ea99aae4edfb"
dependencies = [
"godot-core",
"godot-macros",
]
[[package]]
name = "godot-bindings"
version = "0.5.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7ce6fed360f27cc44ee188246880bb842cdb96bf13aec1d0ec31cccfa50b1cf6"
dependencies = [
"gdextension-api",
]
[[package]]
name = "godot-cell"
version = "0.5.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "edc4695597110f27de8f0adbdf3afd01692e3596cc89de2aaa2a42d31b93bfc4"
[[package]]
name = "godot-codegen"
version = "0.5.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "08409103fba47d9cb5b64dcb0b679b06ba3153d9c96d5e918bb97f065d553c60"
dependencies = [
"godot-bindings",
"heck",
"nanoserde",
"proc-macro2",
"quote",
]
[[package]]
name = "godot-core"
version = "0.5.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "40ab630dbc7ce797f09eda29025ebadab1b700778c988ad19c47bddc51874a90"
dependencies = [
"glam",
"godot-bindings",
"godot-cell",
"godot-codegen",
"godot-ffi",
]
[[package]]
name = "godot-ffi"
version = "0.5.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "18c296047c042d74828b2cb2e71bbadd22dd4ff98346c696b6c0a4a5decbf57f"
dependencies = [
"godot-bindings",
"godot-codegen",
"libc",
]
[[package]]
name = "godot-macros"
version = "0.5.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c9ffca7ec0dfad5885a96b3819b3dafc1014c36d2931464f96d9ab0bee87b52e"
dependencies = [
"godot-bindings",
"proc-macro2",
"quote",
"venial",
]
[[package]]
name = "grovewright-bridge"
version = "0.1.0"
dependencies = [
"godot",
"grovewright-core",
]
[[package]]
name = "grovewright-core"
version = "0.1.0"
[[package]]
name = "heck"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
[[package]]
name = "libc"
version = "0.2.189"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2"
[[package]]
name = "nanoserde"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a36fb3a748a4c9736ed7aeb5f2dfc99665247f1ce306abbddb2bf0ba2ac530a4"
dependencies = [
"nanoserde-derive",
]
[[package]]
name = "nanoserde-derive"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a846cbc04412cf509efcd8f3694b114fc700a035fb5a37f21517f9fb019f1ebc"
[[package]]
name = "proc-macro2"
version = "1.0.107"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
dependencies = [
"unicode-ident",
]
[[package]]
name = "quote"
version = "1.0.47"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
dependencies = [
"proc-macro2",
]
[[package]]
name = "unicode-ident"
version = "1.0.26"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954"
[[package]]
name = "venial"
version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9a42528baceab6c7784446df2a10f4185078c39bf73dc614f154353f1a6b1229"
dependencies = [
"proc-macro2",
"quote",
]
+1 -1
View File
@@ -1,6 +1,6 @@
[workspace]
resolver = "2"
members = ["core"]
members = ["core", "bridge"]
# The shipped game and the tests must agree on what happens when a number
# overflows, so release builds keep the overflow checks that debug builds have.
+19
View File
@@ -0,0 +1,19 @@
[package]
name = "grovewright-bridge"
version = "0.1.0"
edition = "2021"
rust-version = "1.94"
publish = false
description = "GDExtension that makes the Grovewright core callable from Godot"
[lib]
crate-type = ["cdylib"]
[dependencies]
godot = { version = "=0.5.5", features = ["api-4-7"] }
grovewright-core = { path = "../core" }
[lints.rust]
# The MSVC linker reports the import library it writes on every build. That
# is expected and not worth a warning.
linker_messages = "allow"
+46
View File
@@ -0,0 +1,46 @@
//! Turns events of the core into dictionaries that GDScript can read.
use godot::prelude::*;
use grovewright_core::world::Event;
/// One event as a dictionary with a `type` entry and the event's fields.
pub fn to_dictionary(event: &Event) -> VarDictionary {
match event {
Event::PlayerJoined { player } => vdict! {
"type" => "player_joined",
"player" => i64::from(player.0),
},
Event::PlayerLeft { player } => vdict! {
"type" => "player_left",
"player" => i64::from(player.0),
},
Event::JoinRefused { .. } => vdict! {
"type" => "join_refused",
},
Event::TileHit { player, pos } => vdict! {
"type" => "tile_hit",
"player" => i64::from(player.0),
"x" => i64::from(pos.x),
"y" => i64::from(pos.y),
},
Event::TileChanged { pos } => vdict! {
"type" => "tile_changed",
"x" => i64::from(pos.x),
"y" => i64::from(pos.y),
},
Event::ItemGained {
player,
item,
count,
} => vdict! {
"type" => "item_gained",
"player" => i64::from(player.0),
"item" => i64::from(item.0),
"count" => i64::from(*count),
},
Event::InventoryFull { player } => vdict! {
"type" => "inventory_full",
"player" => i64::from(player.0),
},
}
}
+16
View File
@@ -0,0 +1,16 @@
//! GDExtension that makes the deterministic core callable from Godot.
//!
//! The bridge only translates. Every rule lives in `grovewright-core`, and
//! everything that is shown, heard or typed lives in the Godot project.
mod events;
mod session;
use godot::prelude::*;
struct GrovewrightExtension;
// SAFETY: godot-rust requires this marker to register the extension's entry
// point. The implementation keeps every default and adds no code.
#[gdextension]
unsafe impl ExtensionLibrary for GrovewrightExtension {}
+326
View File
@@ -0,0 +1,326 @@
//! 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};
use godot::prelude::*;
use grovewright_core::command::Command;
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::world::World;
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.
#[derive(GodotClass)]
#[class(base = RefCounted, init)]
pub struct GwSession {
game: Option<Game>,
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;
/// Starts a fresh world from `seed`, replacing any running game.
#[func]
fn start_new(&mut self, seed: i64) {
self.game = Some(Game::new(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.
#[func]
fn start_loaded(&mut self, save: PackedByteArray) -> bool {
match World::load(save.as_slice()) {
Ok(world) => {
self.game = Some(Game::new(world));
self.last_error = GString::new();
true
}
Err(error) => {
self.last_error = GString::from(error.to_string().as_str());
false
}
}
}
/// Why the last call failed, or an empty text.
#[func]
fn last_error(&self) -> GString {
self.last_error.clone()
}
#[func]
fn is_running(&self) -> bool {
self.game.is_some()
}
/// 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)
}
/// Queues the entry of the local player for the next tick. `key`
/// identifies the player across sessions and must have 16 bytes.
#[func]
fn join(&mut self, key: PackedByteArray, name: GString) -> bool {
let Some(game) = self.game.as_mut() else {
return false;
};
let Ok(key) = PlayerKey::try_from(key.as_slice()) else {
self.last_error = GString::from("a player key has 16 bytes");
return false;
};
game.local_key = Some(key);
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
/// next tick. Does nothing before the local player has joined.
#[func]
fn dig(&mut self, x: i64, y: i64) {
let Some(game) = self.game.as_mut() else {
return;
};
let (Some(player), Some(pos)) = (game.local, to_pos(x, y)) else {
return;
};
game.pending.push(Command::Dig { player, pos });
}
/// Runs one tick with the queued commands and returns what happened, as
/// dictionaries with a `type` entry.
#[func]
fn step(&mut self) -> Array<VarDictionary> {
let mut happened = Array::new();
let Some(game) = self.game.as_mut() else {
return happened;
};
if game.broken {
return happened;
}
let commands = std::mem::take(&mut game.pending);
let Game {
world, recording, ..
} = game;
match catch_unwind(AssertUnwindSafe(|| recording.step(world, &commands))) {
Ok(events) => {
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
}
/// 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)
}
#[func]
fn map_width(&self) -> i64 {
self.game
.as_ref()
.map_or(0, |game| i64::from(game.world.map().width()))
}
#[func]
fn map_height(&self) -> i64 {
self.game
.as_ref()
.map_or(0, |game| i64::from(game.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.game.as_ref().map_or(Vector2i::ZERO, |game| {
let spawn = spawn_pos(game.world.map());
Vector2i::new(spawn.x, spawn.y)
})
}
/// Index of the local player in the world, or -1 before joining.
#[func]
fn local_player(&self) -> i64 {
self.game
.as_ref()
.and_then(|game| game.local)
.map_or(-1, |id| i64::from(id.0))
}
/// 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 {
return 0;
};
game.local_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.
#[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(),
}
}
/// The recording of this session, or empty if there is no healthy
/// session.
#[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(),
}
}
/// 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())
})
}
}
impl GwSession {
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)?)?;
Some((tile.terrain, tile.damage))
}
fn tile_bytes(&self, byte: impl Fn(Terrain, u8) -> u8) -> PackedByteArray {
let Some(game) = self.game.as_ref() else {
return PackedByteArray::new();
};
let bytes: Vec<u8> = game
.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()?))
}
#[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);
}
}
+8
View File
@@ -0,0 +1,8 @@
[configuration]
entry_symbol = "gdext_rust_init"
compatibility_minimum = 4.7
reloadable = true
[libraries]
windows.debug.x86_64 = "res://../target/debug/grovewright_bridge.dll"
windows.release.x86_64 = "res://../target/release/grovewright_bridge.dll"
+1
View File
@@ -0,0 +1 @@
uid://cftoabe1brb4d
+20
View File
@@ -0,0 +1,20 @@
; Engine configuration file.
; Input actions are registered in code (scripts/input_setup.gd), so this file
; holds no serialized input events.
config_version=5
[application]
config/name="Grovewright"
config/features=PackedStringArray("4.7", "GL Compatibility")
[display]
window/size/viewport_width=1280
window/size/viewport_height=720
[rendering]
renderer/rendering_method="gl_compatibility"
renderer/rendering_method.mobile="gl_compatibility"
+81
View File
@@ -0,0 +1,81 @@
extends SceneTree
## Runs every test script in res://tests whose file name starts with "test_".
## Every method whose name starts with "test_" is one test. A test fails if a
## check fails or if an error is reported while it runs, for example a call
## on a null value. Exits with code 1 if any test failed.
##
## From the repository root:
## godot --headless --path game -s res://tests/run_tests.gd
const TEST_DIR := "res://tests"
## Counts the errors that are reported while tests run. Warnings do not count.
class ErrorCounter extends Logger:
var _mutex := Mutex.new()
var _errors := 0
func errors() -> int:
_mutex.lock()
var count := _errors
_mutex.unlock()
return count
func _log_error(
_function: String,
_file: String,
_line: int,
_code: String,
_rationale: String,
_editor_notify: bool,
error_type: int,
_script_backtraces: Array[ScriptBacktrace]
) -> void:
if error_type == ERROR_TYPE_WARNING:
return
_mutex.lock()
_errors += 1
_mutex.unlock()
func _initialize() -> void:
_run_all.call_deferred()
func _run_all() -> void:
var counter := ErrorCounter.new()
OS.add_logger(counter)
var passed := 0
var failed := 0
var files := DirAccess.get_files_at(TEST_DIR)
files.sort()
for file in files:
if not (file.begins_with("test_") and file.ends_with(".gd")):
continue
var script: GDScript = load(TEST_DIR.path_join(file))
if script == null or not script.can_instantiate():
failed += 1
print("FAIL %s the script does not load; see the error above" % file)
continue
for method in script.get_script_method_list():
var test_name: String = method["name"]
if not test_name.begins_with("test_"):
continue
var test: TestCase = script.new()
test.tree = self
var errors_before := counter.errors()
await test.call(test_name)
var errors := counter.errors() - errors_before
if errors > 0:
test.failures.append("%d error(s) reported while the test ran; see above" % errors)
if test.failures.is_empty():
passed += 1
print("ok %s %s" % [file, test_name])
else:
failed += 1
print("FAIL %s %s" % [file, test_name])
for failure in test.failures:
print(" " + failure)
OS.remove_logger(counter)
print("%d passed, %d failed" % [passed, failed])
quit(1 if failed > 0 else 0)
+1
View File
@@ -0,0 +1 @@
uid://b1bhpsppk18k2
+19
View File
@@ -0,0 +1,19 @@
class_name TestCase
extends RefCounted
## Base of every test script. Checks collect failures instead of stopping at
## the first one, so a run shows everything that is wrong.
var failures: PackedStringArray = []
## The running SceneTree, for tests that need frames or nodes.
var tree: SceneTree
func check(condition: bool, message: String) -> void:
if not condition:
failures.append(message)
## Fails unless `actual` and `expected` have the same type and value.
func check_eq(actual: Variant, expected: Variant, message: String) -> void:
if typeof(actual) != typeof(expected) or actual != expected:
failures.append("%s: expected %s, got %s" % [message, expected, actual])
+1
View File
@@ -0,0 +1 @@
uid://dlwjcshat6wd2
+140
View File
@@ -0,0 +1,140 @@
extends TestCase
## Tests of the bridge: GwSession is the Rust core, seen from GDScript.
var key := PackedByteArray([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16])
func _joined_session() -> GwSession:
var session := GwSession.new()
session.start_new(7)
session.join(key, "Ada")
session.step()
return session
func _first_soft_rock(session: GwSession) -> Vector2i:
var terrain := session.terrain_codes()
var width := session.map_width()
for index in terrain.size():
if terrain[index] == GwSession.TERRAIN_SOFT_ROCK:
@warning_ignore("integer_division")
return Vector2i(index % width, index / width)
return Vector2i(-1, -1)
## Hits `tile` three times, waiting out the cooldown, and returns all events.
func _dig_three_times(session: GwSession, tile: Vector2i) -> Array[Dictionary]:
var events: Array[Dictionary] = []
for hit in 3:
session.dig(tile.x, tile.y)
events.append_array(session.step())
for wait in GwSession.DIG_COOLDOWN_TICKS:
session.step()
return events
func test_a_session_without_a_world_is_harmless() -> void:
var session := GwSession.new()
check(not session.is_running(), "nothing runs yet")
check_eq(session.tick(), 0, "tick")
check_eq(session.step().size(), 0, "no events")
check_eq(session.save().size(), 0, "nothing to save")
check_eq(session.local_player(), -1, "no local player")
func test_joining_makes_the_player_local() -> void:
var session := GwSession.new()
session.start_new(7)
check(session.join(key, "Ada"), "join accepted")
check_eq(session.local_player(), -1, "not before the tick")
var events := session.step()
check_eq(events.size(), 1, "one event")
check_eq(events[0]["type"], "player_joined", "event type")
check_eq(session.local_player(), 0, "local player after the tick")
check_eq(session.tick(), 1, "one tick ran")
func test_a_key_needs_sixteen_bytes() -> void:
var session := GwSession.new()
session.start_new(7)
check(not session.join(PackedByteArray([1, 2, 3]), "Ada"), "short key refused")
check(not session.last_error().is_empty(), "the reason is given")
func test_the_map_can_be_read() -> void:
var session := _joined_session()
check_eq(session.map_width(), 64, "width")
check_eq(session.map_height(), 64, "height")
check_eq(session.terrain_codes().size(), 64 * 64, "one code per tile")
check_eq(session.damage_codes().size(), 64 * 64, "one damage per tile")
var spawn := session.spawn_point()
check_eq(spawn, Vector2i(32, 32), "spawn in the centre")
check(session.is_walkable(spawn.x, spawn.y), "the spawn can be walked on")
check_eq(session.terrain_at(0, 0), GwSession.TERRAIN_BEDROCK, "the border is bedrock")
check_eq(session.terrain_at(-1, 0), -1, "outside the map")
check(not session.is_walkable(500, 500), "outside the map is not walkable")
func test_three_hits_break_soft_rock_and_give_a_stone() -> void:
var session := _joined_session()
var rock := _first_soft_rock(session)
check(rock.x >= 0, "the map has soft rock")
var events := _dig_three_times(session, rock)
var types := events.map(func(event: Dictionary) -> String: return event["type"])
check_eq(types, ["tile_hit", "tile_hit", "tile_changed", "item_gained"], "event order")
check_eq(session.terrain_at(rock.x, rock.y), GwSession.TERRAIN_GROUND, "the rock is gone")
check_eq(session.item_count(GwSession.ITEM_STONE), 1, "one stone")
func test_digging_before_joining_does_nothing() -> void:
var session := GwSession.new()
session.start_new(7)
var rock := _first_soft_rock(session)
session.dig(rock.x, rock.y)
check_eq(session.step().size(), 0, "no events")
check_eq(session.damage_codes()[rock.y * session.map_width() + rock.x], 0, "no damage")
func test_a_save_keeps_the_world_and_the_player_comes_back() -> void:
var session := _joined_session()
_dig_three_times(session, _first_soft_rock(session))
var save := session.save()
check(save.size() > 0, "a save was made")
var loaded := GwSession.new()
check(loaded.start_loaded(save), "the save loads")
check_eq(loaded.tick(), session.tick(), "same tick")
check_eq(loaded.terrain_codes(), session.terrain_codes(), "same map")
check_eq(loaded.local_player(), -1, "nobody has joined the loaded world yet")
loaded.join(key, "Ada")
loaded.step()
check_eq(loaded.local_player(), 0, "the player is the same")
check_eq(loaded.item_count(GwSession.ITEM_STONE), 1, "the stone is still there")
func test_a_damaged_save_is_refused_and_the_running_world_stays() -> void:
var session := _joined_session()
var hash_before := session.state_hash_hex()
var save := session.save()
var middle := save.size() >> 1
save[middle] = save[middle] ^ 0xFF
check(not session.start_loaded(save), "a damaged save is refused")
check(session.last_error().contains("checksum"), "the reason names the checksum: %s" % session.last_error())
check(not session.start_loaded(PackedByteArray([1, 2, 3])), "random bytes are refused")
check_eq(session.state_hash_hex(), hash_before, "the running world is untouched")
func test_the_recording_of_a_session_is_available() -> void:
var session := _joined_session()
session.step()
var recording := session.recording_bytes()
check(recording.size() > 0, "a recording exists")
check_eq(recording.slice(0, 4), "GRWR".to_ascii_buffer(), "it starts with the recording marker")
func test_the_state_hash_changes_with_every_tick() -> void:
var session := _joined_session()
var first := session.state_hash_hex()
session.step()
check_eq(first.length(), 16, "sixteen hexadecimal digits")
check(session.state_hash_hex() != first, "the tick is part of the state")
+1
View File
@@ -0,0 +1 @@
uid://cm1pa0uh5tmn5