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:
@@ -1,2 +1,5 @@
|
||||
# Rust
|
||||
/target/
|
||||
|
||||
# Godot
|
||||
/game/.godot/
|
||||
|
||||
Generated
+155
@@ -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
@@ -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.
|
||||
|
||||
@@ -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"
|
||||
@@ -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),
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
@@ -0,0 +1 @@
|
||||
uid://cftoabe1brb4d
|
||||
@@ -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"
|
||||
@@ -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)
|
||||
@@ -0,0 +1 @@
|
||||
uid://b1bhpsppk18k2
|
||||
@@ -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])
|
||||
@@ -0,0 +1 @@
|
||||
uid://dlwjcshat6wd2
|
||||
@@ -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")
|
||||
@@ -0,0 +1 @@
|
||||
uid://cm1pa0uh5tmn5
|
||||
Reference in New Issue
Block a user