diff --git a/.gitignore b/.gitignore index 4b67358..3a9ec99 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,5 @@ # Rust /target/ + +# Godot +/game/.godot/ diff --git a/Cargo.lock b/Cargo.lock index f3da7f1..0245d3b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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", +] diff --git a/Cargo.toml b/Cargo.toml index 795adc1..844baec 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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. diff --git a/bridge/Cargo.toml b/bridge/Cargo.toml new file mode 100644 index 0000000..03e0397 --- /dev/null +++ b/bridge/Cargo.toml @@ -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" diff --git a/bridge/src/events.rs b/bridge/src/events.rs new file mode 100644 index 0000000..25c055a --- /dev/null +++ b/bridge/src/events.rs @@ -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), + }, + } +} diff --git a/bridge/src/lib.rs b/bridge/src/lib.rs new file mode 100644 index 0000000..9075ffb --- /dev/null +++ b/bridge/src/lib.rs @@ -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 {} diff --git a/bridge/src/session.rs b/bridge/src/session.rs new file mode 100644 index 0000000..908a68c --- /dev/null +++ b/bridge/src/session.rs @@ -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, + local_key: Option, + local: Option, + /// 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, + 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 { + 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 = 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 { + 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); + } +} diff --git a/game/grovewright.gdextension b/game/grovewright.gdextension new file mode 100644 index 0000000..a8974c7 --- /dev/null +++ b/game/grovewright.gdextension @@ -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" diff --git a/game/grovewright.gdextension.uid b/game/grovewright.gdextension.uid new file mode 100644 index 0000000..7fdaa7a --- /dev/null +++ b/game/grovewright.gdextension.uid @@ -0,0 +1 @@ +uid://cftoabe1brb4d diff --git a/game/project.godot b/game/project.godot new file mode 100644 index 0000000..ddf4ef3 --- /dev/null +++ b/game/project.godot @@ -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" diff --git a/game/tests/run_tests.gd b/game/tests/run_tests.gd new file mode 100644 index 0000000..8f305dd --- /dev/null +++ b/game/tests/run_tests.gd @@ -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) diff --git a/game/tests/run_tests.gd.uid b/game/tests/run_tests.gd.uid new file mode 100644 index 0000000..4e69ff4 --- /dev/null +++ b/game/tests/run_tests.gd.uid @@ -0,0 +1 @@ +uid://b1bhpsppk18k2 diff --git a/game/tests/support/test_case.gd b/game/tests/support/test_case.gd new file mode 100644 index 0000000..2cae27a --- /dev/null +++ b/game/tests/support/test_case.gd @@ -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]) diff --git a/game/tests/support/test_case.gd.uid b/game/tests/support/test_case.gd.uid new file mode 100644 index 0000000..fce11dd --- /dev/null +++ b/game/tests/support/test_case.gd.uid @@ -0,0 +1 @@ +uid://dlwjcshat6wd2 diff --git a/game/tests/test_session.gd b/game/tests/test_session.gd new file mode 100644 index 0000000..0f798e7 --- /dev/null +++ b/game/tests/test_session.gd @@ -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") diff --git a/game/tests/test_session.gd.uid b/game/tests/test_session.gd.uid new file mode 100644 index 0000000..2f84175 --- /dev/null +++ b/game/tests/test_session.gd.uid @@ -0,0 +1 @@ +uid://cm1pa0uh5tmn5