M1b: Brücke und Hülle #6
@@ -1,2 +1,4 @@
|
||||
# Same line endings on every machine.
|
||||
* text=auto eol=lf
|
||||
# Windows batch files need CRLF.
|
||||
*.bat text eol=crlf
|
||||
|
||||
@@ -1,2 +1,5 @@
|
||||
# Rust
|
||||
/target/
|
||||
|
||||
# Godot
|
||||
/game/.godot/
|
||||
|
||||
@@ -9,48 +9,75 @@ Das verbindliche Konzept steht in [docs/KONZEPT.md](docs/KONZEPT.md). Lies es, b
|
||||
| Ordner | Inhalt |
|
||||
|---|---|
|
||||
| `core/` | Rust-Kern: die gesamte gemeinsame Welt. Ohne Zugriff auf die Engine, ohne fremde Crates. |
|
||||
| `bridge/` | Rust-Brücke (GDExtension): macht den Kern für Godot aufrufbar und enthält keine Spielregeln. |
|
||||
| `game/` | Godot-Projekt mit der GDScript-Hülle: Darstellung, Eingabe, Oberfläche, Dateien. |
|
||||
| `docs/KONZEPT.md` | Spielkonzept und Entscheidungen |
|
||||
| `docs/superpowers/plans/` | Umsetzungspläne |
|
||||
| `docs/playtests/` | Fragebögen für Playtests |
|
||||
|
||||
Die Brücke zu Godot (`bridge/`), das Godot-Projekt (`game/`) und die Inhaltsdateien (`content/`) kommen mit späteren Plänen dazu.
|
||||
|
||||
Faustregel: Was im Speicherstand steht oder alle Spieler betrifft, gehört in den Kern.
|
||||
Faustregel: Was im Speicherstand steht oder alle Spieler betrifft, gehört in den Kern. Die Hülle darf Gleitkommazahlen nutzen, weil sie nichts an der gemeinsamen Welt ändert; sie schickt nur Befehle.
|
||||
|
||||
## Regeln für den Kern
|
||||
|
||||
Alle Spieler simulieren die Welt selbst und tauschen nur Befehle aus (Gleichschritt). Das funktioniert nur, wenn der Kern auf jedem Rechner exakt gleich rechnet. Deshalb gilt für den Quelltext des Kerns in `core/src/`. Die Tests in `core/tests/` dürfen zum Beispiel Dateien lesen.
|
||||
Alle Spieler simulieren die Welt selbst und tauschen nur Befehle aus (Gleichschritt). Das funktioniert nur, wenn der Kern auf jedem Rechner exakt gleich rechnet. Deshalb gelten für den Quelltext des Kerns in `core/src/` diese Regeln (die Tests in `core/tests/` dürfen zum Beispiel Dateien lesen):
|
||||
|
||||
- Nur ganze Zahlen, keine Gleitkommazahlen.
|
||||
- Keine Uhr, keine Dateien, kein Netzwerk, keine Threads, keine Umgebungsvariablen.
|
||||
- Keine Uhr, keine Dateien, kein Netzwerk, keine Threads, keine Umgebungsvariablen, kein globaler veränderlicher Zustand.
|
||||
- Zufall nur über `rng::Rng` mit fester Startzahl.
|
||||
- Keine Sammlung mit wechselnder Reihenfolge. Statt einer Hash-Tabelle nimmst du `Vec`, `BTreeMap` oder `BTreeSet`.
|
||||
- Die Welt ändert sich nur durch einen `Command` in `World::step`. Einzige Ausnahme ist `World::set_all_offline`: Es darf nur direkt nach dem Laden eines Speicherstands aufgerufen werden, bevor jemand beigetreten ist.
|
||||
- Alles, was zum Zustand der Welt gehört, steht im Snapshot. Wer ein Feld ergänzt, ergänzt es in `snapshot.rs` und erhöht `SNAPSHOT_VERSION`.
|
||||
- Wo eine Rechnung im Spiel überlaufen kann, steht eine ausdrückliche Wahl: `saturating_`, `wrapping_` oder `checked_`. Taktzähler (`u64` bei 20 Takten pro Sekunde) laufen praktisch nie über und werden normal addiert. Werte aus geladenen Dateien müssen deshalb beim Laden geprüft werden.
|
||||
- Die Welt ändert sich nur durch einen `Command` in `World::step`. Einzige Ausnahme ist das Laden eines Speicherstands mit `World::load`, das alle Spieler abmeldet.
|
||||
- Wer eine Sitzung aufzeichnet, ruft `Recording::step` statt `World::step` auf. So enthält die Aufzeichnung genau die ausgeführten Befehle.
|
||||
- Alles, was zum Zustand der Welt gehört, steht im Snapshot. Wer ein Feld ergänzt, ergänzt es im Kodierer und im Dekodierer seiner Struktur (`snapshot.rs` für die Welt, `player.rs` für Spieler, `inventory.rs` für das Inventar, `map.rs` für die Karte), prüft es beim Dekodieren oder in `check_restorable` und erhöht `SNAPSHOT_VERSION`.
|
||||
- Wo eine Rechnung im Spiel überlaufen kann, steht eine ausdrückliche Wahl: `saturating_`, `wrapping_` oder `checked_`. Taktzähler laufen praktisch nie über und werden normal addiert: Die Welt zählt in `u64`, eine Aufzeichnung in `u32`, was bei 20 Takten pro Sekunde für über sechs Jahre reicht. Takte aus geladenen Dateien begrenzt `MAX_RESTORABLE_TICK`.
|
||||
- Keine fremden Crates im Kern.
|
||||
|
||||
Der Test `core/tests/forbidden_apis.rs` durchsucht den Quelltext des Kerns nach verbotenen Begriffen. Sie dürfen dort auch in Kommentaren nicht vorkommen.
|
||||
Drei Wächter prüfen das. `core/clippy.toml` sperrt Pfade wie `std::collections::HashMap` oder `std::fs::read`, auch über Aliase und gruppierte Importe. Der Test `core/tests/forbidden_apis.rs` durchsucht zusätzlich den Text nach verbotenen Begriffen; sie dürfen dort auch in Kommentaren nicht vorkommen. Gruppierte Importe aus `std` (`use std::{…}`) sind verboten, weil sie dem Text die Modulnamen verbergen: Jeder Pfad steht in einer eigenen `use`-Zeile. `core/tests/no_dependencies.rs` hält die Abhängigkeiten leer.
|
||||
|
||||
## Regeln für Brücke und Hülle
|
||||
|
||||
- Die Brücke übersetzt nur. Neue Spielregeln gehören in den Kern, nie in `bridge/` oder `game/`.
|
||||
- GDScript mit Typangaben schreiben (`var x := …`, Rückgabetypen an jeder Funktion).
|
||||
- Eingabeaktionen legt `game/scripts/input_setup.gd` an, nicht `project.godot`.
|
||||
- Alle Texte, die Spieler sehen, stehen in `game/i18n/texts.csv` (Deutsch und Englisch) und werden über `tr()` geholt.
|
||||
- Tests, die Dateien schreiben, nutzen `TestFiles.temporary_dir()` (Ordner unter `user://tests`), nie die echten Dateien des Spielers.
|
||||
- Godot erzeugt beim Import zu jedem Skript eine `.uid`-Datei. Sie gehört ins Archiv. Der Ordner `game/.godot/` gehört nicht hinein.
|
||||
|
||||
## Prüfen
|
||||
|
||||
Vor jedem Commit müssen alle drei Befehle fehlerfrei laufen:
|
||||
Vor jedem Commit müssen alle Befehle fehlerfrei laufen:
|
||||
|
||||
```bash
|
||||
cargo fmt --all -- --check
|
||||
cargo clippy --workspace --all-targets -- -D warnings
|
||||
cargo test --workspace
|
||||
cargo build -p grovewright-bridge
|
||||
godot --headless --path game --import
|
||||
godot --headless --path game -s res://tests/run_tests.gd
|
||||
```
|
||||
|
||||
Schlägt `core/tests/golden.rs` fehl, haben sich Regeln oder ein Format geändert. Die Fehlermeldung nennt die Versionskonstante, die dann erhöht werden muss. Erst danach wird der festgeschriebene Wert bewusst angepasst.
|
||||
Der erste Import in einem frischen Checkout stürzt mit Godot 4.7.2 und der Rust-Erweiterung beim Beenden ab (Exit-Code 139 in der Bash). Der Import ist dann trotzdem erfolgt; ein zweiter Aufruf läuft fehlerfrei durch. `play.bat` wiederholt den Import deshalb einmal.
|
||||
|
||||
Schlägt `core/tests/golden.rs` fehl, haben sich Regeln oder ein Format geändert. Sind Speicherstände oder Aufzeichnungen betroffen, erhöhst du die Versionskonstante, die die Fehlermeldung nennt. Erst danach passt du den festgeschriebenen Wert bewusst an.
|
||||
|
||||
Für Änderungen am Aussehen gibt es ein Werkzeug, das kurz ein Fenster öffnet und ein Bildschirmfoto speichert:
|
||||
|
||||
```bash
|
||||
godot --path game -s res://tools/screenshot.gd -- <Pfad der PNG-Datei>
|
||||
```
|
||||
|
||||
## Spielen
|
||||
|
||||
`play.bat` im Hauptordner baut die Brücke und startet das Spiel. Spielstand, Spielerschlüssel und Aufzeichnungen liegen unter `%APPDATA%\Godot\app_userdata\Grovewright`.
|
||||
|
||||
## Sprache
|
||||
|
||||
- Code, Bezeichner und Kommentare im Code: Englisch.
|
||||
- Dokumente, Pläne, Commit-Nachrichten, Issues und Pull Requests: Deutsch.
|
||||
- Texte im Spiel: Deutsch und Englisch über Übersetzungsdateien.
|
||||
- Texte im Spiel: Deutsch und Englisch über `game/i18n/texts.csv`.
|
||||
|
||||
## Git
|
||||
|
||||
- Archiv: `https://git.race-cave.cloud/steven/Grovewright.git` (Gitea).
|
||||
- Archiv: `https://git.race-cave.cloud/steven/Grovewright.git` (Gitea). Issues und Pull Requests sind freigegeben.
|
||||
- Gearbeitet wird auf einem Branch pro Plan, nicht direkt auf `main`.
|
||||
- Nach jedem abgeschlossenen Arbeitsschritt wird committet und hochgeladen. Das ist freigegeben.
|
||||
- Commit-Nachrichten enden mit einer Zeile `Co-Authored-By: Claude <Modell> <noreply@anthropic.com>`, die das Claude-Modell nennt, das den Commit erstellt hat.
|
||||
|
||||
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,27 @@
|
||||
# Paths the deterministic core must never use. Clippy resolves paths, so a
|
||||
# listed path is caught under an alias or in a grouped import as well, for
|
||||
# example `fs::read` after `use std::{fs, env}`. Only the paths listed below
|
||||
# are checked. Everything else is the job of the text search in
|
||||
# core/tests/forbidden_apis.rs, which forbids grouped imports from `std`
|
||||
# because they hide module names from it.
|
||||
|
||||
disallowed-types = [
|
||||
{ path = "std::collections::HashMap", reason = "iteration order differs between runs; use BTreeMap or Vec" },
|
||||
{ path = "std::collections::HashSet", reason = "iteration order differs between runs; use BTreeSet or Vec" },
|
||||
{ path = "std::hash::RandomState", reason = "seeded differently in every run" },
|
||||
{ path = "std::hash::DefaultHasher", reason = "seeded differently in every run" },
|
||||
{ path = "std::time::Instant", reason = "the core must not read the clock" },
|
||||
{ path = "std::time::SystemTime", reason = "the core must not read the clock" },
|
||||
]
|
||||
|
||||
disallowed-methods = [
|
||||
{ path = "std::env::var", reason = "the core must not depend on the environment" },
|
||||
{ path = "std::env::args", reason = "the core must not depend on the environment" },
|
||||
{ path = "std::thread::spawn", reason = "the core must stay single-threaded" },
|
||||
{ path = "std::fs::read", reason = "the core must not touch files" },
|
||||
{ path = "std::fs::read_to_string", reason = "the core must not touch files" },
|
||||
{ path = "std::fs::write", reason = "the core must not touch files" },
|
||||
{ path = "std::fs::File::open", reason = "the core must not touch files" },
|
||||
{ path = "std::fs::File::create", reason = "the core must not touch files" },
|
||||
{ path = "std::process::exit", reason = "the core must not end the process" },
|
||||
]
|
||||
+41
-7
@@ -6,11 +6,12 @@ use std::fmt;
|
||||
use crate::codec::{DecodeError, Reader, Writer};
|
||||
use crate::command::Command;
|
||||
use crate::snapshot::SnapshotError;
|
||||
use crate::world::World;
|
||||
use crate::world::{Event, World};
|
||||
|
||||
const MAGIC: [u8; 4] = *b"GRWR";
|
||||
/// Version of the recording format.
|
||||
pub const RECORDING_VERSION: u32 = 1;
|
||||
/// Version of the recording format. Raise it whenever the encoded recording,
|
||||
/// including the snapshot inside it, changes.
|
||||
pub const RECORDING_VERSION: u32 = 2;
|
||||
|
||||
/// Reason a recording could not be read or replayed.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
@@ -56,6 +57,7 @@ impl From<DecodeError> for RecordingError {
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct Recording {
|
||||
start: Vec<u8>,
|
||||
start_tick: u64,
|
||||
tick_count: u32,
|
||||
/// Ticks that had commands, as (tick index, commands), in rising order.
|
||||
busy_ticks: Vec<(u32, Vec<Command>)>,
|
||||
@@ -66,18 +68,33 @@ impl Recording {
|
||||
pub fn start(world: &World) -> Self {
|
||||
Self {
|
||||
start: world.to_snapshot(),
|
||||
start_tick: world.tick(),
|
||||
tick_count: 0,
|
||||
busy_ticks: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Records the commands of one tick. Call it once for every
|
||||
/// [`World::step`], with the same commands.
|
||||
pub fn push_tick(&mut self, commands: &[Command]) {
|
||||
/// Advances `world` by one tick with `commands` and records them. This is
|
||||
/// the only way to add to a recording, so it can never hold other
|
||||
/// commands than the ones the world executed.
|
||||
///
|
||||
/// # Panics
|
||||
/// Panics if `world` is not at [`Recording::end_tick`], which means it is
|
||||
/// not the world this recording follows.
|
||||
pub fn step(&mut self, world: &mut World, commands: &[Command]) -> Vec<Event> {
|
||||
assert_eq!(
|
||||
world.tick(),
|
||||
self.end_tick(),
|
||||
"the world is not at the tick this recording has reached"
|
||||
);
|
||||
if !commands.is_empty() {
|
||||
self.busy_ticks.push((self.tick_count, commands.to_vec()));
|
||||
}
|
||||
self.tick_count += 1;
|
||||
self.tick_count = self
|
||||
.tick_count
|
||||
.checked_add(1)
|
||||
.expect("a recording holds fewer than 2^32 ticks");
|
||||
world.step(commands)
|
||||
}
|
||||
|
||||
/// Number of recorded ticks.
|
||||
@@ -85,6 +102,11 @@ impl Recording {
|
||||
self.tick_count
|
||||
}
|
||||
|
||||
/// Tick of the recorded world after the last recorded tick.
|
||||
pub fn end_tick(&self) -> u64 {
|
||||
self.start_tick + u64::from(self.tick_count)
|
||||
}
|
||||
|
||||
/// Restores the starting state and applies every recorded tick.
|
||||
pub fn replay(&self) -> Result<World, RecordingError> {
|
||||
let mut world = World::from_snapshot(&self.start).map_err(RecordingError::BadStart)?;
|
||||
@@ -105,7 +127,15 @@ impl Recording {
|
||||
|
||||
/// Encodes the recording together with the state that replaying it must
|
||||
/// reach. `end` is the world after the last recorded tick.
|
||||
///
|
||||
/// # Panics
|
||||
/// Panics if `end` is not at [`Recording::end_tick`].
|
||||
pub fn to_bytes(&self, end: &World) -> Vec<u8> {
|
||||
assert_eq!(
|
||||
end.tick(),
|
||||
self.end_tick(),
|
||||
"the end state is not at the tick this recording has reached"
|
||||
);
|
||||
let mut w = Writer::new();
|
||||
w.raw(&MAGIC);
|
||||
w.u32(RECORDING_VERSION);
|
||||
@@ -138,6 +168,9 @@ impl Recording {
|
||||
}
|
||||
let expected_hash = r.u64()?;
|
||||
let start = r.bytes()?.to_vec();
|
||||
let start_tick = World::from_snapshot(&start)
|
||||
.map_err(RecordingError::BadStart)?
|
||||
.tick();
|
||||
let tick_count = r.u32()?;
|
||||
let busy_count = r.u32()?;
|
||||
let mut busy_ticks = Vec::new();
|
||||
@@ -162,6 +195,7 @@ impl Recording {
|
||||
Ok((
|
||||
Self {
|
||||
start,
|
||||
start_tick,
|
||||
tick_count,
|
||||
busy_ticks,
|
||||
},
|
||||
|
||||
+74
-8
@@ -1,18 +1,26 @@
|
||||
//! Complete, versioned encoding of a world. Saves, joining players and state
|
||||
//! checksums all use it.
|
||||
//!
|
||||
//! Layout: the marker `GRWS`, the format version, the encoded state, and an
|
||||
//! FNV-1a checksum over everything before it.
|
||||
|
||||
use std::fmt;
|
||||
|
||||
use crate::codec::{DecodeError, Reader, Writer};
|
||||
use crate::hash::fnv1a64;
|
||||
use crate::map::Map;
|
||||
use crate::player::{Player, MAX_KNOWN_PLAYERS};
|
||||
use crate::world::World;
|
||||
use crate::player::{Player, MAX_KNOWN_PLAYERS, MAX_ONLINE_PLAYERS};
|
||||
use crate::world::{World, DIG_COOLDOWN_TICKS};
|
||||
|
||||
const MAGIC: [u8; 4] = *b"GRWS";
|
||||
/// Version of the snapshot format. Raise it whenever the encoded state
|
||||
/// changes.
|
||||
pub const SNAPSHOT_VERSION: u32 = 1;
|
||||
pub const SNAPSHOT_VERSION: u32 = 2;
|
||||
/// Length of the checksum at the end of a snapshot.
|
||||
const CHECKSUM_BYTES: usize = 8;
|
||||
/// Highest tick a restored world may have. No real session gets near it,
|
||||
/// and adding to it can never overflow.
|
||||
pub const MAX_RESTORABLE_TICK: u64 = u64::MAX / 2;
|
||||
|
||||
/// Reason a snapshot could not be restored.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
@@ -21,8 +29,13 @@ pub enum SnapshotError {
|
||||
NotASnapshot,
|
||||
/// The snapshot was written by an incompatible version of the game.
|
||||
UnsupportedVersion(u32),
|
||||
/// The checksum does not match: the bytes changed after they were
|
||||
/// written.
|
||||
ChecksumMismatch,
|
||||
/// The snapshot is damaged.
|
||||
Corrupt(DecodeError),
|
||||
/// The bytes decode, but describe a state the rules can never produce.
|
||||
Invalid(&'static str),
|
||||
}
|
||||
|
||||
impl fmt::Display for SnapshotError {
|
||||
@@ -32,7 +45,9 @@ impl fmt::Display for SnapshotError {
|
||||
SnapshotError::UnsupportedVersion(version) => {
|
||||
write!(f, "unsupported snapshot version {version}")
|
||||
}
|
||||
SnapshotError::ChecksumMismatch => write!(f, "snapshot checksum does not match"),
|
||||
SnapshotError::Corrupt(error) => write!(f, "damaged snapshot: {error}"),
|
||||
SnapshotError::Invalid(what) => write!(f, "impossible state in snapshot: {what}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -65,16 +80,20 @@ impl World {
|
||||
}
|
||||
|
||||
/// Encodes the complete world behind a header that names format and
|
||||
/// version.
|
||||
/// version, followed by a checksum.
|
||||
pub fn to_snapshot(&self) -> Vec<u8> {
|
||||
let mut w = Writer::new();
|
||||
w.raw(&MAGIC);
|
||||
w.u32(SNAPSHOT_VERSION);
|
||||
self.encode_state(&mut w);
|
||||
w.into_bytes()
|
||||
let mut bytes = w.into_bytes();
|
||||
let checksum = fnv1a64(&bytes);
|
||||
bytes.extend_from_slice(&checksum.to_le_bytes());
|
||||
bytes
|
||||
}
|
||||
|
||||
/// Restores a world from bytes written by [`World::to_snapshot`].
|
||||
/// Restores a world exactly as it was encoded, including which players
|
||||
/// were online. Joining players receive the host's world this way.
|
||||
pub fn from_snapshot(bytes: &[u8]) -> Result<World, SnapshotError> {
|
||||
let mut r = Reader::new(bytes);
|
||||
let magic = r
|
||||
@@ -87,6 +106,17 @@ impl World {
|
||||
if version != SNAPSHOT_VERSION {
|
||||
return Err(SnapshotError::UnsupportedVersion(version));
|
||||
}
|
||||
if r.remaining() < CHECKSUM_BYTES {
|
||||
return Err(DecodeError::UnexpectedEnd.into());
|
||||
}
|
||||
let (body, trailer) = bytes.split_at(bytes.len() - CHECKSUM_BYTES);
|
||||
let mut checksum = [0u8; CHECKSUM_BYTES];
|
||||
checksum.copy_from_slice(trailer);
|
||||
if fnv1a64(body) != u64::from_le_bytes(checksum) {
|
||||
return Err(SnapshotError::ChecksumMismatch);
|
||||
}
|
||||
|
||||
let mut r = Reader::new(&body[MAGIC.len() + 4..]);
|
||||
let seed = r.u64()?;
|
||||
let tick = r.u64()?;
|
||||
let map = Map::decode(&mut r)?;
|
||||
@@ -99,11 +129,47 @@ impl World {
|
||||
players.push(Player::decode(&mut r)?);
|
||||
}
|
||||
r.finish()?;
|
||||
Ok(World {
|
||||
|
||||
let world = World {
|
||||
seed,
|
||||
tick,
|
||||
map,
|
||||
players,
|
||||
})
|
||||
};
|
||||
world.check_restorable()?;
|
||||
Ok(world)
|
||||
}
|
||||
|
||||
/// Restores a saved world for a new session. Like
|
||||
/// [`World::from_snapshot`], but every player starts offline, because
|
||||
/// nobody has joined the new session yet.
|
||||
pub fn load(bytes: &[u8]) -> Result<World, SnapshotError> {
|
||||
let mut world = World::from_snapshot(bytes)?;
|
||||
world.set_all_offline();
|
||||
Ok(world)
|
||||
}
|
||||
|
||||
/// Rejects states that the rules can never produce, so that a damaged or
|
||||
/// crafted snapshot fails here instead of breaking a later tick.
|
||||
fn check_restorable(&self) -> Result<(), SnapshotError> {
|
||||
if self.tick > MAX_RESTORABLE_TICK {
|
||||
return Err(SnapshotError::Invalid("tick"));
|
||||
}
|
||||
let online = self.players.iter().filter(|player| player.online).count();
|
||||
if online > MAX_ONLINE_PLAYERS {
|
||||
return Err(SnapshotError::Invalid("online players"));
|
||||
}
|
||||
for (index, player) in self.players.iter().enumerate() {
|
||||
if self.players[..index]
|
||||
.iter()
|
||||
.any(|other| other.key == player.key)
|
||||
{
|
||||
return Err(SnapshotError::Invalid("duplicate player key"));
|
||||
}
|
||||
if player.next_action_tick > self.tick + DIG_COOLDOWN_TICKS {
|
||||
return Err(SnapshotError::Invalid("next action tick"));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
+5
-3
@@ -95,9 +95,11 @@ impl World {
|
||||
.map(|index| PlayerId(index as u8))
|
||||
}
|
||||
|
||||
/// Marks every player as offline. Used after loading a save from disk,
|
||||
/// when nobody has joined yet.
|
||||
pub fn set_all_offline(&mut self) {
|
||||
/// Marks every player as offline. Only [`World::load`] uses it: a loaded
|
||||
/// save starts a new session, which nobody has joined yet. Called during
|
||||
/// a running session on one machine, it would split the worlds of the
|
||||
/// players apart.
|
||||
pub(crate) fn set_all_offline(&mut self) {
|
||||
for player in &mut self.players {
|
||||
player.online = false;
|
||||
}
|
||||
|
||||
@@ -123,8 +123,7 @@ fn a_recorded_session_replays_to_the_same_hash() {
|
||||
let mut world = World::new(8);
|
||||
let mut recording = Recording::start(&world);
|
||||
for commands in &script {
|
||||
recording.push_tick(commands);
|
||||
world.step(commands);
|
||||
recording.step(&mut world, commands);
|
||||
}
|
||||
let bytes = recording.to_bytes(&world);
|
||||
let replayed = Recording::verify(&bytes).expect("the recording replays cleanly");
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
//! Guards the lockstep rules: the core must not use anything whose result
|
||||
//! can differ between machines or between runs.
|
||||
//!
|
||||
//! Clippy checks the same rules on resolved paths (see `core/clippy.toml`),
|
||||
//! which also catches aliases and grouped imports. This text search adds
|
||||
//! what Clippy cannot express, such as floating point types and global
|
||||
//! mutable state, and it runs even when Clippy does not.
|
||||
|
||||
// Tests may read files. The rules of core/clippy.toml apply to core/src.
|
||||
#![allow(clippy::disallowed_methods)]
|
||||
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
@@ -29,9 +37,41 @@ const FORBIDDEN: &[(&str, &str)] = &[
|
||||
"std::net",
|
||||
"the core must not open connections; the shell does that",
|
||||
),
|
||||
(
|
||||
"std::io",
|
||||
"the core must not read or write streams; the shell does that",
|
||||
),
|
||||
("std::process", "the core must not start or end processes"),
|
||||
(
|
||||
"std::{",
|
||||
"grouped std imports hide forbidden modules from this search; import each path on its own line",
|
||||
),
|
||||
("static mut", "global mutable state can differ between runs"),
|
||||
(
|
||||
"thread_local",
|
||||
"global mutable state can differ between runs",
|
||||
),
|
||||
("Atomic", "global mutable state can differ between runs"),
|
||||
("Mutex", "global mutable state can differ between runs"),
|
||||
("RwLock", "global mutable state can differ between runs"),
|
||||
("OnceLock", "global mutable state can differ between runs"),
|
||||
("LazyLock", "global mutable state can differ between runs"),
|
||||
("unsafe", "the core forbids unsafe code"),
|
||||
];
|
||||
|
||||
/// Every forbidden term in `text`, as (line number, term, reason).
|
||||
fn violations(text: &str) -> Vec<(usize, &'static str, &'static str)> {
|
||||
let mut found = Vec::new();
|
||||
for (index, line) in text.lines().enumerate() {
|
||||
for &(needle, reason) in FORBIDDEN {
|
||||
if line.contains(needle) {
|
||||
found.push((index + 1, needle, reason));
|
||||
}
|
||||
}
|
||||
}
|
||||
found
|
||||
}
|
||||
|
||||
fn rust_files(dir: &Path, out: &mut Vec<PathBuf>) {
|
||||
for entry in fs::read_dir(dir).expect("source directory is readable") {
|
||||
let path = entry.expect("directory entry is readable").path();
|
||||
@@ -58,16 +98,8 @@ fn core_source_uses_no_forbidden_apis() {
|
||||
let mut problems = Vec::new();
|
||||
for file in &files {
|
||||
let text = fs::read_to_string(file).expect("source file is readable");
|
||||
for (number, line) in text.lines().enumerate() {
|
||||
for (needle, reason) in FORBIDDEN {
|
||||
if line.contains(needle) {
|
||||
problems.push(format!(
|
||||
"{}:{}: `{needle}`: {reason}",
|
||||
file.display(),
|
||||
number + 1
|
||||
));
|
||||
}
|
||||
}
|
||||
for (line, needle, reason) in violations(&text) {
|
||||
problems.push(format!("{}:{line}: `{needle}`: {reason}", file.display()));
|
||||
}
|
||||
}
|
||||
assert!(
|
||||
@@ -76,3 +108,23 @@ fn core_source_uses_no_forbidden_apis() {
|
||||
problems.join("\n")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_forbidden_term_is_detected() {
|
||||
for &(needle, _) in FORBIDDEN {
|
||||
let text = format!("pub fn fine() {{}}\nlet x = {needle};\n");
|
||||
let found = violations(&text);
|
||||
assert!(
|
||||
found
|
||||
.iter()
|
||||
.any(|&(line, term, _)| line == 2 && term == needle),
|
||||
"`{needle}` was not detected: {found:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clean_code_has_no_violations() {
|
||||
let text = "pub fn add(a: u32, b: u32) -> u32 {\n a.wrapping_add(b)\n}\n";
|
||||
assert!(violations(text).is_empty());
|
||||
}
|
||||
|
||||
+100
-10
@@ -1,15 +1,16 @@
|
||||
//! Pinned values for everything that must not change unnoticed.
|
||||
//!
|
||||
//! Every other test compares two computations inside the same build, so a
|
||||
//! Most other tests compare two computations inside the same build, so a
|
||||
//! change that affects both sides alike stays green. This file compares
|
||||
//! against values recorded once. If one of them fails, rules or encodings
|
||||
//! changed, on purpose or by accident. If saves or recordings are affected,
|
||||
//! raise the version constant named in the failure message, then update the
|
||||
//! value here deliberately.
|
||||
|
||||
use grovewright_core::codec::Writer;
|
||||
use grovewright_core::command::Command;
|
||||
use grovewright_core::hash::fnv1a64;
|
||||
use grovewright_core::map::Pos;
|
||||
use grovewright_core::map::{Map, Pos, Terrain, Tile};
|
||||
use grovewright_core::mapgen::outcrop_origin;
|
||||
use grovewright_core::player::PlayerId;
|
||||
use grovewright_core::replay::Recording;
|
||||
@@ -99,13 +100,55 @@ fn frozen_script(world: &World) -> Vec<Vec<Command>> {
|
||||
ticks
|
||||
}
|
||||
|
||||
/// Covers the map generator, the map encoding (terrain numbers, byte order of
|
||||
/// width and height) and the snapshot header.
|
||||
/// The commands of a second session of 30 ticks that ends with one player
|
||||
/// offline, so that the encoding of offline players is pinned as well.
|
||||
///
|
||||
/// Never change or extend this script either.
|
||||
fn frozen_offline_script(world: &World) -> Vec<Vec<Command>> {
|
||||
let origin = outcrop_origin(world.map());
|
||||
let scripted = [
|
||||
(
|
||||
0,
|
||||
Command::Join {
|
||||
key: [7; 16],
|
||||
name: "Linus".to_owned(),
|
||||
},
|
||||
),
|
||||
(
|
||||
1,
|
||||
Command::Join {
|
||||
key: [8; 16],
|
||||
name: "Margaret".to_owned(),
|
||||
},
|
||||
),
|
||||
(
|
||||
3,
|
||||
Command::Dig {
|
||||
player: PlayerId(1),
|
||||
pos: origin,
|
||||
},
|
||||
),
|
||||
(
|
||||
10,
|
||||
Command::Leave {
|
||||
player: PlayerId(0),
|
||||
},
|
||||
),
|
||||
];
|
||||
let mut ticks = vec![Vec::new(); 30];
|
||||
for (tick, command) in scripted {
|
||||
ticks[tick].push(command);
|
||||
}
|
||||
ticks
|
||||
}
|
||||
|
||||
/// Covers the map generator, the map encoding and the snapshot header and
|
||||
/// checksum.
|
||||
#[test]
|
||||
fn the_starting_snapshot_is_unchanged() {
|
||||
let actual = fnv1a64(&World::new(SEED).to_snapshot());
|
||||
assert_eq!(
|
||||
actual, 0x1b78_d996_1586_fbf1,
|
||||
actual, 0x6812_937b_f2b2_19ae,
|
||||
"the starting world or the snapshot format changed; if saves are \
|
||||
affected, raise SNAPSHOT_VERSION in core/src/snapshot.rs; then \
|
||||
update this value"
|
||||
@@ -129,19 +172,66 @@ fn the_frozen_session_ends_in_the_same_state() {
|
||||
);
|
||||
}
|
||||
|
||||
/// Covers the encoding of a player who is offline.
|
||||
#[test]
|
||||
fn the_frozen_offline_session_ends_in_the_same_state() {
|
||||
let mut world = World::new(SEED);
|
||||
for commands in frozen_offline_script(&world) {
|
||||
world.step(&commands);
|
||||
}
|
||||
assert_eq!(
|
||||
world.state_hash(),
|
||||
0x904f_10e1_7c52_8be4,
|
||||
"the rules or the snapshot format changed; old recordings no longer \
|
||||
verify; if saves are affected, raise SNAPSHOT_VERSION in \
|
||||
core/src/snapshot.rs; then update this value"
|
||||
);
|
||||
}
|
||||
|
||||
/// Covers the recording header and layout and the wire format of commands.
|
||||
#[test]
|
||||
fn the_recording_format_is_unchanged() {
|
||||
let mut world = World::new(SEED);
|
||||
let mut recording = Recording::start(&world);
|
||||
for commands in frozen_script(&world) {
|
||||
recording.push_tick(&commands);
|
||||
world.step(&commands);
|
||||
recording.step(&mut world, &commands);
|
||||
}
|
||||
assert_eq!(
|
||||
fnv1a64(&recording.to_bytes(&world)),
|
||||
0x2312_5a7d_d8ef_5ae0,
|
||||
"the recording or command format changed; raise RECORDING_VERSION in \
|
||||
core/src/replay.rs; then update this value"
|
||||
0x1c24_8cc7_6bc8_846a,
|
||||
"the recording or command format changed; if other tests in this \
|
||||
file fail as well, deal with those first, because a recording \
|
||||
contains a snapshot and an end state; otherwise raise \
|
||||
RECORDING_VERSION in core/src/replay.rs; then update this value"
|
||||
);
|
||||
}
|
||||
|
||||
/// Covers the byte layout of a map: width before height, little endian,
|
||||
/// terrain numbers and damage. The map is not square, so a swap of width
|
||||
/// and height shows.
|
||||
#[test]
|
||||
fn the_map_encoding_is_unchanged() {
|
||||
let mut map = Map::filled(3, 2, Terrain::Ground);
|
||||
map.set(Pos::new(1, 0), Tile::new(Terrain::Bedrock));
|
||||
map.set(Pos::new(0, 1), Tile::new(Terrain::HardRock));
|
||||
map.set(
|
||||
Pos::new(2, 1),
|
||||
Tile {
|
||||
terrain: Terrain::SoftRock,
|
||||
damage: 1,
|
||||
},
|
||||
);
|
||||
let mut w = Writer::new();
|
||||
map.encode(&mut w);
|
||||
assert_eq!(
|
||||
w.into_bytes(),
|
||||
vec![
|
||||
3, 0, 0, 0, // width
|
||||
2, 0, 0, 0, // height
|
||||
0, 0, 3, 0, 0, 0, // row 0: ground, bedrock, ground
|
||||
2, 0, 0, 0, 1, 1, // row 1: hard rock, ground, soft rock hit once
|
||||
],
|
||||
"the map encoding changed; raise SNAPSHOT_VERSION in \
|
||||
core/src/snapshot.rs; then update these bytes"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
//! The core builds from the standard library alone. A foreign crate could
|
||||
//! bring floating point, hash maps or threads in through the back door.
|
||||
|
||||
// Tests may read files. The rules of core/clippy.toml apply to core/src.
|
||||
#![allow(clippy::disallowed_methods)]
|
||||
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
|
||||
/// Every manifest section that may add a dependency, with the lines it
|
||||
/// declares. Development dependencies are allowed, because they never reach
|
||||
/// the game.
|
||||
fn dependency_sections(manifest: &str) -> Vec<(String, Vec<String>)> {
|
||||
let mut sections: Vec<(String, Vec<String>)> = Vec::new();
|
||||
let mut inside = false;
|
||||
for line in manifest.lines() {
|
||||
let line = line.trim();
|
||||
if line.starts_with('[') {
|
||||
inside = line.contains("dependencies") && !line.contains("dev-dependencies");
|
||||
if inside {
|
||||
sections.push((line.to_owned(), Vec::new()));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if inside && !line.is_empty() && !line.starts_with('#') {
|
||||
if let Some((_, entries)) = sections.last_mut() {
|
||||
entries.push(line.to_owned());
|
||||
}
|
||||
}
|
||||
}
|
||||
sections
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_core_has_no_dependencies() {
|
||||
let path = Path::new(env!("CARGO_MANIFEST_DIR")).join("Cargo.toml");
|
||||
let manifest = fs::read_to_string(&path).expect("the manifest is readable");
|
||||
for (header, entries) in dependency_sections(&manifest) {
|
||||
assert!(
|
||||
!header.contains("dependencies."),
|
||||
"the core declares a dependency table: {header}"
|
||||
);
|
||||
assert!(
|
||||
entries.is_empty(),
|
||||
"{header} of the core must stay empty, found: {entries:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dependency_sections_are_found_with_their_entries() {
|
||||
let manifest = "[package]\nname = \"x\"\n\n[dependencies]\n# a comment\nserde = \"1\"\n\n\
|
||||
[dev-dependencies]\nproptest = \"1\"\n\n[dependencies.rand]\nversion = \"0.8\"\n";
|
||||
assert_eq!(
|
||||
dependency_sections(manifest),
|
||||
vec![
|
||||
(
|
||||
"[dependencies]".to_owned(),
|
||||
vec!["serde = \"1\"".to_owned()]
|
||||
),
|
||||
(
|
||||
"[dependencies.rand]".to_owned(),
|
||||
vec!["version = \"0.8\"".to_owned()]
|
||||
),
|
||||
]
|
||||
);
|
||||
}
|
||||
+64
-10
@@ -4,6 +4,7 @@ use grovewright_core::inventory::ITEM_STONE;
|
||||
use grovewright_core::mapgen::outcrop_origin;
|
||||
use grovewright_core::player::PlayerId;
|
||||
use grovewright_core::replay::{Recording, RecordingError, RECORDING_VERSION};
|
||||
use grovewright_core::snapshot::SnapshotError;
|
||||
use grovewright_core::world::World;
|
||||
|
||||
const ADA: PlayerId = PlayerId(0);
|
||||
@@ -31,12 +32,28 @@ fn recorded_session() -> (Recording, World) {
|
||||
let mut recording = Recording::start(&world);
|
||||
for tick in 0..200 {
|
||||
let commands = commands_for(&world, tick);
|
||||
recording.push_tick(&commands);
|
||||
world.step(&commands);
|
||||
recording.step(&mut world, &commands);
|
||||
}
|
||||
(recording, world)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stepping_through_a_recording_gives_the_events_of_the_world() {
|
||||
let mut plain = World::new(11);
|
||||
let mut recorded = World::new(11);
|
||||
let mut recording = Recording::start(&recorded);
|
||||
for tick in 0..30 {
|
||||
let commands = commands_for(&plain, tick);
|
||||
assert_eq!(
|
||||
recording.step(&mut recorded, &commands),
|
||||
plain.step(&commands),
|
||||
"tick {tick}"
|
||||
);
|
||||
}
|
||||
assert_eq!(recorded, plain);
|
||||
assert_eq!(recording.end_tick(), 30);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replaying_reaches_the_same_state() {
|
||||
let (recording, world) = recorded_session();
|
||||
@@ -62,14 +79,14 @@ fn verify_accepts_a_faithful_recording() {
|
||||
|
||||
#[test]
|
||||
fn verify_reports_a_replay_that_ends_in_another_state() {
|
||||
let (recording, mut world) = recorded_session();
|
||||
// One tick more than was recorded: the claimed end state is unreachable.
|
||||
world.step(&[]);
|
||||
let bytes = recording.to_bytes(&world);
|
||||
let (recording, world) = recorded_session();
|
||||
let mut bytes = recording.to_bytes(&world);
|
||||
// The expected state hash follows the marker and the version.
|
||||
bytes[8] ^= 0x01;
|
||||
match Recording::verify(&bytes) {
|
||||
Err(RecordingError::Diverged { expected, actual }) => {
|
||||
assert_eq!(expected, world.state_hash());
|
||||
assert_ne!(actual, expected);
|
||||
assert_eq!(actual, world.state_hash());
|
||||
assert_ne!(expected, actual);
|
||||
}
|
||||
other => panic!("expected a divergence, got {other:?}"),
|
||||
}
|
||||
@@ -85,10 +102,17 @@ fn a_recording_can_start_in_the_middle_of_a_session() {
|
||||
let mut recording = Recording::start(&world);
|
||||
for tick in 50..120 {
|
||||
let commands = commands_for(&world, tick);
|
||||
recording.push_tick(&commands);
|
||||
world.step(&commands);
|
||||
recording.step(&mut world, &commands);
|
||||
}
|
||||
assert_eq!(recording.tick_count(), 70);
|
||||
assert_eq!(recording.end_tick(), 120);
|
||||
|
||||
// Decoding derives the start tick from the stored start world. The
|
||||
// decoded recording must end at the same tick and equal the original.
|
||||
let (decoded, _) = Recording::from_bytes(&recording.to_bytes(&world)).unwrap();
|
||||
assert_eq!(decoded.end_tick(), 120);
|
||||
assert_eq!(decoded, recording);
|
||||
|
||||
assert_eq!(recording.replay(), Ok(world));
|
||||
}
|
||||
|
||||
@@ -100,6 +124,23 @@ fn an_empty_recording_replays_to_its_starting_state() {
|
||||
assert_eq!(Recording::verify(&recording.to_bytes(&world)), Ok(world));
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[should_panic(expected = "the world is not at the tick this recording has reached")]
|
||||
fn a_recording_refuses_a_world_it_does_not_follow() {
|
||||
let mut world = World::new(3);
|
||||
let mut recording = Recording::start(&world);
|
||||
world.step(&[]);
|
||||
recording.step(&mut world, &[]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[should_panic(expected = "the end state is not at the tick this recording has reached")]
|
||||
fn a_recording_refuses_an_end_state_at_another_tick() {
|
||||
let (recording, mut world) = recorded_session();
|
||||
world.step(&[]);
|
||||
recording.to_bytes(&world);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn damaged_recordings_are_rejected() {
|
||||
let (recording, world) = recorded_session();
|
||||
@@ -133,3 +174,16 @@ fn damaged_recordings_are_rejected() {
|
||||
Err(RecordingError::Corrupt(DecodeError::TrailingBytes))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_recording_with_a_damaged_start_is_rejected() {
|
||||
let (recording, world) = recorded_session();
|
||||
let mut bytes = recording.to_bytes(&world);
|
||||
// The snapshot starts after marker, version, expected hash and its own
|
||||
// length: 4 + 4 + 8 + 4 bytes.
|
||||
bytes[20 + 10] ^= 0x01;
|
||||
assert_eq!(
|
||||
Recording::from_bytes(&bytes),
|
||||
Err(RecordingError::BadStart(SnapshotError::ChecksumMismatch))
|
||||
);
|
||||
}
|
||||
|
||||
+183
-6
@@ -1,10 +1,11 @@
|
||||
use grovewright_core::codec::DecodeError;
|
||||
use grovewright_core::codec::{DecodeError, Writer};
|
||||
use grovewright_core::command::Command;
|
||||
use grovewright_core::map::Pos;
|
||||
use grovewright_core::hash::fnv1a64;
|
||||
use grovewright_core::map::{Map, Pos, Terrain};
|
||||
use grovewright_core::mapgen::outcrop_origin;
|
||||
use grovewright_core::player::PlayerId;
|
||||
use grovewright_core::snapshot::{SnapshotError, SNAPSHOT_VERSION};
|
||||
use grovewright_core::world::World;
|
||||
use grovewright_core::player::{Player, PlayerId, MAX_KNOWN_PLAYERS, MAX_ONLINE_PLAYERS};
|
||||
use grovewright_core::snapshot::{SnapshotError, MAX_RESTORABLE_TICK, SNAPSHOT_VERSION};
|
||||
use grovewright_core::world::{Event, World, DIG_COOLDOWN_TICKS};
|
||||
|
||||
const ADA: PlayerId = PlayerId(0);
|
||||
|
||||
@@ -27,6 +28,29 @@ fn played_world() -> World {
|
||||
world
|
||||
}
|
||||
|
||||
/// Builds snapshot bytes by hand: header, state and checksum. This lets tests
|
||||
/// describe states that the rules would never produce.
|
||||
fn snapshot_of(seed: u64, tick: u64, map: &Map, players: &[Player]) -> Vec<u8> {
|
||||
let mut w = Writer::new();
|
||||
w.raw(b"GRWS");
|
||||
w.u32(SNAPSHOT_VERSION);
|
||||
w.u64(seed);
|
||||
w.u64(tick);
|
||||
map.encode(&mut w);
|
||||
w.u8(players.len() as u8);
|
||||
for player in players {
|
||||
player.encode(&mut w);
|
||||
}
|
||||
let mut bytes = w.into_bytes();
|
||||
let checksum = fnv1a64(&bytes);
|
||||
bytes.extend_from_slice(&checksum.to_le_bytes());
|
||||
bytes
|
||||
}
|
||||
|
||||
fn player(key: u8) -> Player {
|
||||
Player::new([key; 16], "P")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_snapshot_restores_an_identical_world() {
|
||||
let world = played_world();
|
||||
@@ -35,6 +59,13 @@ fn a_snapshot_restores_an_identical_world() {
|
||||
assert_eq!(restored.state_hash(), world.state_hash());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_snapshot_is_header_then_state_then_checksum() {
|
||||
let world = played_world();
|
||||
let by_hand = snapshot_of(world.seed(), world.tick(), world.map(), world.players());
|
||||
assert_eq!(world.to_snapshot(), by_hand);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_state_hash_covers_every_part_of_the_state() {
|
||||
let base = played_world();
|
||||
@@ -81,6 +112,35 @@ fn a_restored_world_continues_exactly_like_the_original() {
|
||||
assert_eq!(original, restored);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn from_snapshot_keeps_online_players_online() {
|
||||
let restored = World::from_snapshot(&played_world().to_snapshot()).unwrap();
|
||||
assert!(restored.player(ADA).unwrap().online);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn loading_a_save_starts_with_every_player_offline() {
|
||||
let world = played_world();
|
||||
let loaded = World::load(&world.to_snapshot()).unwrap();
|
||||
assert!(loaded.players().iter().all(|player| !player.online));
|
||||
assert_eq!(loaded.tick(), world.tick());
|
||||
assert_eq!(loaded.map(), world.map());
|
||||
assert_eq!(
|
||||
loaded.player(ADA).unwrap().inventory,
|
||||
world.player(ADA).unwrap().inventory
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_returning_player_can_join_a_loaded_world() {
|
||||
let mut loaded = World::load(&played_world().to_snapshot()).unwrap();
|
||||
let events = loaded.step(&[Command::Join {
|
||||
key: [3; 16],
|
||||
name: "Ada".to_owned(),
|
||||
}]);
|
||||
assert_eq!(events, vec![Event::PlayerJoined { player: ADA }]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bytes_that_are_not_a_snapshot_are_rejected() {
|
||||
assert_eq!(World::from_snapshot(b""), Err(SnapshotError::NotASnapshot));
|
||||
@@ -104,18 +164,135 @@ fn snapshots_of_another_version_are_rejected() {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn any_changed_byte_is_detected() {
|
||||
let bytes = played_world().to_snapshot();
|
||||
for index in [8, bytes.len() / 2, bytes.len() - 9, bytes.len() - 1] {
|
||||
let mut changed = bytes.clone();
|
||||
changed[index] ^= 0x01;
|
||||
assert_eq!(
|
||||
World::from_snapshot(&changed),
|
||||
Err(SnapshotError::ChecksumMismatch),
|
||||
"byte {index}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cut_off_and_padded_snapshots_are_rejected() {
|
||||
let bytes = played_world().to_snapshot();
|
||||
assert_eq!(
|
||||
World::from_snapshot(&bytes[..bytes.len() - 1]),
|
||||
Err(SnapshotError::Corrupt(DecodeError::UnexpectedEnd))
|
||||
Err(SnapshotError::ChecksumMismatch)
|
||||
);
|
||||
|
||||
let mut padded = bytes.clone();
|
||||
padded.push(0);
|
||||
assert_eq!(
|
||||
World::from_snapshot(&padded),
|
||||
Err(SnapshotError::ChecksumMismatch)
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
World::from_snapshot(&bytes[..10]),
|
||||
Err(SnapshotError::Corrupt(DecodeError::UnexpectedEnd))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn damaged_content_behind_a_valid_checksum_is_rejected() {
|
||||
let world = played_world();
|
||||
let mut bytes = snapshot_of(world.seed(), world.tick(), world.map(), world.players());
|
||||
bytes.truncate(bytes.len() - 8);
|
||||
bytes.push(0);
|
||||
let checksum = fnv1a64(&bytes);
|
||||
bytes.extend_from_slice(&checksum.to_le_bytes());
|
||||
assert_eq!(
|
||||
World::from_snapshot(&bytes),
|
||||
Err(SnapshotError::Corrupt(DecodeError::TrailingBytes))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn states_the_rules_never_produce_are_rejected() {
|
||||
let map = Map::filled(4, 3, Terrain::Ground);
|
||||
let too_many_online: Vec<Player> = (0..=MAX_ONLINE_PLAYERS as u8).map(player).collect();
|
||||
let mut offline_twin = player(1);
|
||||
offline_twin.online = false;
|
||||
let mut waiting_too_long = player(1);
|
||||
waiting_too_long.next_action_tick = 5 + DIG_COOLDOWN_TICKS + 1;
|
||||
|
||||
let cases = [
|
||||
(snapshot_of(1, MAX_RESTORABLE_TICK + 1, &map, &[]), "tick"),
|
||||
(snapshot_of(1, 5, &map, &too_many_online), "online players"),
|
||||
(
|
||||
snapshot_of(1, 5, &map, &[player(1), offline_twin]),
|
||||
"duplicate player key",
|
||||
),
|
||||
(
|
||||
snapshot_of(1, 5, &map, &[waiting_too_long]),
|
||||
"next action tick",
|
||||
),
|
||||
];
|
||||
for (bytes, what) in cases {
|
||||
assert_eq!(
|
||||
World::from_snapshot(&bytes),
|
||||
Err(SnapshotError::Invalid(what)),
|
||||
"{what}"
|
||||
);
|
||||
assert_eq!(
|
||||
World::load(&bytes),
|
||||
Err(SnapshotError::Invalid(what)),
|
||||
"{what}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn states_right_at_the_limits_are_accepted() {
|
||||
let map = Map::filled(4, 3, Terrain::Ground);
|
||||
let mut players: Vec<Player> = (0..MAX_ONLINE_PLAYERS as u8).map(player).collect();
|
||||
players[0].next_action_tick = MAX_RESTORABLE_TICK + DIG_COOLDOWN_TICKS;
|
||||
let bytes = snapshot_of(1, MAX_RESTORABLE_TICK, &map, &players);
|
||||
assert!(World::from_snapshot(&bytes).is_ok());
|
||||
}
|
||||
|
||||
/// A world that knows `MAX_KNOWN_PLAYERS` players, built through commands.
|
||||
/// They join in groups of `MAX_ONLINE_PLAYERS`, and each group but the last
|
||||
/// leaves before the next one joins, so only the last group is online.
|
||||
fn world_with_a_full_roster() -> World {
|
||||
let mut world = World::new(7);
|
||||
let groups = MAX_KNOWN_PLAYERS / MAX_ONLINE_PLAYERS;
|
||||
for group in 0..groups {
|
||||
let first = group * MAX_ONLINE_PLAYERS;
|
||||
for index in first..first + MAX_ONLINE_PLAYERS {
|
||||
world.step(&[Command::Join {
|
||||
key: [index as u8; 16],
|
||||
name: format!("P{index}"),
|
||||
}]);
|
||||
}
|
||||
if group + 1 < groups {
|
||||
for index in first..first + MAX_ONLINE_PLAYERS {
|
||||
world.step(&[Command::Leave {
|
||||
player: PlayerId(index as u8),
|
||||
}]);
|
||||
}
|
||||
}
|
||||
}
|
||||
world
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_full_roster_with_most_players_offline_is_accepted() {
|
||||
let world = world_with_a_full_roster();
|
||||
assert_eq!(world.players().len(), MAX_KNOWN_PLAYERS);
|
||||
let online = world.players().iter().filter(|player| player.online);
|
||||
assert_eq!(online.count(), MAX_ONLINE_PLAYERS);
|
||||
|
||||
let restored = World::from_snapshot(&world.to_snapshot()).unwrap();
|
||||
assert_eq!(restored, world);
|
||||
|
||||
let loaded = World::load(&world.to_snapshot()).unwrap();
|
||||
assert_eq!(loaded.players().len(), MAX_KNOWN_PLAYERS);
|
||||
assert!(loaded.players().iter().all(|player| !player.online));
|
||||
}
|
||||
|
||||
@@ -176,14 +176,6 @@ fn the_world_remembers_a_limited_number_of_players() {
|
||||
assert_eq!(world.players().len(), MAX_KNOWN_PLAYERS);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_all_offline_marks_every_player_offline() {
|
||||
let mut world = World::new(1);
|
||||
world.step(&[join(1), join(2)]);
|
||||
world.set_all_offline();
|
||||
assert!(world.players().iter().all(|player| !player.online));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn three_hits_break_soft_rock_and_give_one_stone() {
|
||||
let (mut world, id, rock) = world_with_player();
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
# Probelauf M1b: Brücke und Hülle
|
||||
|
||||
M1b ist noch kein Spiel, sondern das Fundament zum Anfassen: Du läufst über die Testkarte und schlägst Stein. Farben und Formen sind Platzhalter; den Grafikstil wählst du in M1d aus drei Stilproben.
|
||||
|
||||
## So startest du
|
||||
|
||||
1. Doppelklick auf `play.bat` im Projektordner. Beim ersten Start baut Rust die Brücke, das dauert einige Minuten.
|
||||
2. Steuerung:
|
||||
- WASD: laufen
|
||||
- Linke Maustaste: Stein schlagen. Ein grün umrandetes Feld ist in Reichweite, ein rot umrandetes nicht.
|
||||
- F5: speichern, F9: gespeicherten Stand laden
|
||||
- Esc: Pause
|
||||
3. Beim Schließen des Fensters wird gespeichert. Beim nächsten Start geht es dort weiter, und die Figur steht wieder am Startpunkt.
|
||||
|
||||
## Fragen
|
||||
|
||||
1. Fühlt sich das Laufen gut an: Tempo, Halten an Wänden, Gleiten an Wänden entlang?
|
||||
2. Passt die Reichweite der Spitzhacke: zu kurz, zu weit oder genau richtig?
|
||||
3. Fühlt sich der Rhythmus des Schlagens gut an? Weicher Fels braucht drei Schläge, höchstens vier Schläge gehen pro Sekunde. Harter Fels lässt sich noch nicht abbauen: Schläge darauf bewirken nichts, auch wenn das Feld grün umrandet ist.
|
||||
4. Ist auf einen Blick klar, was Boden, weicher Fels, harter Fels und der Rand der Karte ist?
|
||||
5. Ist dir etwas Merkwürdiges passiert? Die Aufzeichnungen deiner Sitzungen liegen unter `%APPDATA%\Godot\app_userdata\Grovewright\recordings`. Eine Aufzeichnung entsteht nur, wenn du das Fenster schließt oder mit F9 einen Stand lädst. Schick mir die neueste Datei, dann spiele ich die Sitzung exakt nach. Nach einem Absturz oder nach der Meldung „Die Simulation wurde angehalten“ gibt es von dieser Sitzung keine Aufzeichnung. Schick mir dann stattdessen den Ordner `%APPDATA%\Godot\app_userdata\Grovewright\logs`, und zwar sofort, bevor du das Spiel erneut startest, denn jeder Start verdrängt ältere Logdateien.
|
||||
|
||||
Antworte formlos im Chat oder als Kommentar in Gitea.
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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,10 @@
|
||||
keys,de,en
|
||||
STONE_COUNT,Stein: %d,Stone: %d
|
||||
SAVED,Gespeichert,Saved
|
||||
SAVE_FAILED,Speichern fehlgeschlagen,Saving failed
|
||||
LOADED,Spielstand geladen,Save loaded
|
||||
LOAD_FAILED,Der Spielstand ließ sich nicht laden,The save could not be loaded
|
||||
INVENTORY_FULL,Die Tasche ist voll,Your bag is full
|
||||
PAUSED,Pause,Paused
|
||||
SIMULATION_STOPPED,Die Simulation wurde angehalten,The simulation stopped
|
||||
HELP,WASD: laufen · Linke Maustaste: Stein schlagen · F5: speichern · F9: laden · Esc: Pause,WASD: walk · Left mouse button: break rock · F5: save · F9: load · Esc: pause
|
||||
|
@@ -0,0 +1,3 @@
|
||||
[remap]
|
||||
|
||||
importer="keep"
|
||||
@@ -0,0 +1,6 @@
|
||||
[gd_scene format=3]
|
||||
|
||||
[ext_resource type="Script" path="res://scripts/main.gd" id="1_main"]
|
||||
|
||||
[node name="Main" type="Node2D"]
|
||||
script = ExtResource("1_main")
|
||||
@@ -0,0 +1,21 @@
|
||||
; 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"
|
||||
run/main_scene="res://main.tscn"
|
||||
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,31 @@
|
||||
class_name Avatar
|
||||
extends Node2D
|
||||
## The local player's figure. It moves outside the lockstep simulation; only
|
||||
## its pickaxe hits become commands.
|
||||
|
||||
## Walking speed: five tiles per second.
|
||||
const SPEED := 5.0 * GridMotion.TILE_SIZE
|
||||
const RADIUS := 5.0
|
||||
## Longest frame that moves the figure in one go. Longer frames are cut, so
|
||||
## that the figure cannot pass through a wall after a stall.
|
||||
const MAX_STEP_SECONDS := 0.1
|
||||
|
||||
## Callable(x: int, y: int) -> bool that says whether a tile can be walked on.
|
||||
var is_walkable: Callable
|
||||
|
||||
|
||||
func _process(delta: float) -> void:
|
||||
walk(Input.get_vector("move_left", "move_right", "move_up", "move_down"), delta)
|
||||
|
||||
|
||||
## Moves the figure in `direction` for `delta` seconds, sliding along walls.
|
||||
func walk(direction: Vector2, delta: float) -> void:
|
||||
if direction == Vector2.ZERO or not is_walkable.is_valid():
|
||||
return
|
||||
var motion := direction * SPEED * minf(delta, MAX_STEP_SECONDS)
|
||||
position = GridMotion.move(position, motion, RADIUS, is_walkable)
|
||||
|
||||
|
||||
func _draw() -> void:
|
||||
draw_circle(Vector2.ZERO, RADIUS + 1.0, Color(0.15, 0.1, 0.05))
|
||||
draw_circle(Vector2.ZERO, RADIUS, Color(0.95, 0.85, 0.6))
|
||||
@@ -0,0 +1 @@
|
||||
uid://bpltng2n5f61g
|
||||
@@ -0,0 +1,44 @@
|
||||
class_name GridMotion
|
||||
extends RefCounted
|
||||
## Movement of a body over the tile grid. Figures move outside the lockstep
|
||||
## simulation, so this code may use floating point.
|
||||
|
||||
## Edge length of a tile, in pixels.
|
||||
const TILE_SIZE := 16.0
|
||||
|
||||
|
||||
## The tile that contains `point`.
|
||||
static func tile_of(point: Vector2) -> Vector2i:
|
||||
return Vector2i(floori(point.x / TILE_SIZE), floori(point.y / TILE_SIZE))
|
||||
|
||||
|
||||
## The centre of `tile`, in pixels.
|
||||
static func tile_center(tile: Vector2i) -> Vector2:
|
||||
return (Vector2(tile) + Vector2(0.5, 0.5)) * TILE_SIZE
|
||||
|
||||
|
||||
## True if a square body of half width `radius` around `center` touches
|
||||
## only walkable tiles. `is_walkable` is a Callable(x: int, y: int) -> bool.
|
||||
static func fits(center: Vector2, radius: float, is_walkable: Callable) -> bool:
|
||||
var first := tile_of(center - Vector2(radius, radius))
|
||||
# The far edge lies just inside its tile, so a body that ends exactly on
|
||||
# a tile border does not count the next tile.
|
||||
var last := tile_of(center + Vector2(radius, radius) - Vector2(0.001, 0.001))
|
||||
for y in range(first.y, last.y + 1):
|
||||
for x in range(first.x, last.x + 1):
|
||||
if not is_walkable.call(x, y):
|
||||
return false
|
||||
return true
|
||||
|
||||
|
||||
## Moves a body by `motion`, one axis after the other. An axis that would
|
||||
## end in a wall is skipped, so a body pushed into a wall slides along it.
|
||||
static func move(center: Vector2, motion: Vector2, radius: float, is_walkable: Callable) -> Vector2:
|
||||
var result := center
|
||||
var along_x := result + Vector2(motion.x, 0.0)
|
||||
if fits(along_x, radius, is_walkable):
|
||||
result = along_x
|
||||
var along_y := result + Vector2(0.0, motion.y)
|
||||
if fits(along_y, radius, is_walkable):
|
||||
result = along_y
|
||||
return result
|
||||
@@ -0,0 +1 @@
|
||||
uid://dn0m8pxkigqig
|
||||
@@ -0,0 +1,58 @@
|
||||
class_name Hud
|
||||
extends CanvasLayer
|
||||
## Text over the game: the stone count, short messages and a help line.
|
||||
|
||||
const MESSAGE_SECONDS := 2.5
|
||||
|
||||
var _stone := Label.new()
|
||||
var _message := Label.new()
|
||||
var _help := Label.new()
|
||||
var _message_left := 0.0
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
_stone.position = Vector2(16, 12)
|
||||
_stone.add_theme_font_size_override("font_size", 22)
|
||||
_message.position = Vector2(16, 48)
|
||||
_message.add_theme_font_size_override("font_size", 18)
|
||||
_help.text = tr("HELP")
|
||||
_help.add_theme_font_size_override("font_size", 14)
|
||||
for label: Label in [_stone, _message, _help]:
|
||||
label.add_theme_color_override("font_outline_color", Color.BLACK)
|
||||
label.add_theme_constant_override("outline_size", 4)
|
||||
add_child(label)
|
||||
_help.set_anchors_and_offsets_preset(Control.PRESET_BOTTOM_LEFT, Control.PRESET_MODE_MINSIZE, 16)
|
||||
|
||||
|
||||
func _process(delta: float) -> void:
|
||||
if _message_left <= 0.0:
|
||||
return
|
||||
_message_left -= delta
|
||||
if _message_left <= 0.0:
|
||||
_message.text = ""
|
||||
|
||||
|
||||
func show_stone(count: int) -> void:
|
||||
_stone.text = tr("STONE_COUNT") % count
|
||||
|
||||
|
||||
## Shows the text for `key` for a few seconds.
|
||||
func flash(key: String) -> void:
|
||||
_message.text = tr(key)
|
||||
_message_left = MESSAGE_SECONDS
|
||||
|
||||
|
||||
## Shows or hides the pause notice. It stays until the game continues.
|
||||
func show_paused(paused: bool) -> void:
|
||||
_message.text = tr("PAUSED") if paused else ""
|
||||
_message_left = 0.0
|
||||
|
||||
|
||||
## The text currently shown as a message. Tests read it.
|
||||
func message() -> String:
|
||||
return _message.text
|
||||
|
||||
|
||||
## The text currently shown as the stone count. Tests read it.
|
||||
func stone_text() -> String:
|
||||
return _stone.text
|
||||
@@ -0,0 +1 @@
|
||||
uid://cosq3mrn0ve5v
|
||||
@@ -0,0 +1,41 @@
|
||||
class_name I18n
|
||||
extends RefCounted
|
||||
## Loads the game's texts from a CSV file: the first column holds the key,
|
||||
## every further column one language, named in the first row.
|
||||
##
|
||||
## The file is read at run time. Its import setting is "keep", so Godot
|
||||
## generates no translation files from it.
|
||||
|
||||
const TEXTS_FILE := "res://i18n/texts.csv"
|
||||
|
||||
static var _installed := false
|
||||
|
||||
|
||||
## Adds the translations of TEXTS_FILE to the TranslationServer, once.
|
||||
static func install() -> void:
|
||||
if _installed:
|
||||
return
|
||||
for translation in load_translations(TEXTS_FILE):
|
||||
TranslationServer.add_translation(translation)
|
||||
_installed = true
|
||||
|
||||
|
||||
## One Translation per language column of the CSV file at `path`.
|
||||
static func load_translations(path: String) -> Array[Translation]:
|
||||
var translations: Array[Translation] = []
|
||||
var file := FileAccess.open(path, FileAccess.READ)
|
||||
if file == null:
|
||||
push_error("texts not found: %s" % path)
|
||||
return translations
|
||||
var header := file.get_csv_line()
|
||||
for column in range(1, header.size()):
|
||||
var translation := Translation.new()
|
||||
translation.locale = header[column]
|
||||
translations.append(translation)
|
||||
while not file.eof_reached():
|
||||
var row := file.get_csv_line()
|
||||
if row.size() < header.size() or row[0].is_empty():
|
||||
continue
|
||||
for column in range(1, header.size()):
|
||||
translations[column - 1].add_message(row[0], row[column])
|
||||
return translations
|
||||
@@ -0,0 +1 @@
|
||||
uid://dy10r7pfqy63v
|
||||
@@ -0,0 +1,35 @@
|
||||
class_name InputSetup
|
||||
extends RefCounted
|
||||
## Registers the game's input actions in code, so that project.godot holds no
|
||||
## serialized input events. Keys are physical positions, so WASD stays in
|
||||
## place on German keyboards too.
|
||||
|
||||
|
||||
static func install() -> void:
|
||||
_add_keys("move_up", [KEY_W, KEY_UP])
|
||||
_add_keys("move_down", [KEY_S, KEY_DOWN])
|
||||
_add_keys("move_left", [KEY_A, KEY_LEFT])
|
||||
_add_keys("move_right", [KEY_D, KEY_RIGHT])
|
||||
_add_keys("save_game", [KEY_F5])
|
||||
_add_keys("load_game", [KEY_F9])
|
||||
_add_keys("toggle_pause", [KEY_ESCAPE])
|
||||
_add_mouse_button("dig", MOUSE_BUTTON_LEFT)
|
||||
|
||||
|
||||
static func _add_keys(action: StringName, keys: Array[Key]) -> void:
|
||||
if InputMap.has_action(action):
|
||||
return
|
||||
InputMap.add_action(action)
|
||||
for key in keys:
|
||||
var event := InputEventKey.new()
|
||||
event.physical_keycode = key
|
||||
InputMap.action_add_event(action, event)
|
||||
|
||||
|
||||
static func _add_mouse_button(action: StringName, button: MouseButton) -> void:
|
||||
if InputMap.has_action(action):
|
||||
return
|
||||
InputMap.add_action(action)
|
||||
var event := InputEventMouseButton.new()
|
||||
event.button_index = button
|
||||
InputMap.action_add_event(action, event)
|
||||
@@ -0,0 +1 @@
|
||||
uid://dvckt6ss6k4xi
|
||||
@@ -0,0 +1,177 @@
|
||||
extends Node2D
|
||||
## Composition root of a solo session: connects the simulation core with the
|
||||
## map view, the figure, the HUD and the files on disk.
|
||||
|
||||
## Seed of a new world until the slice ships its chosen seed.
|
||||
const NEW_WORLD_SEED := 2026
|
||||
const PLAYER_NAME := "Spieler"
|
||||
## How far the pickaxe reaches, in tiles from the figure to the tile centre.
|
||||
const REACH_TILES := 2.5
|
||||
## Ticks between two automatic saves: two minutes.
|
||||
const AUTOSAVE_TICKS := 2 * 60 * GwSession.TICKS_PER_SECOND
|
||||
const CAMERA_ZOOM := Vector2(3.0, 3.0)
|
||||
|
||||
## Folder for the player key, the save and the recordings. Tests use their
|
||||
## own folder.
|
||||
@export var storage_dir := "user://"
|
||||
|
||||
var session: GwSession
|
||||
var store: SaveStore
|
||||
var clock: TickClock
|
||||
var map_view: MapView
|
||||
var marker: TileMarker
|
||||
var avatar: Avatar
|
||||
var hud: Hud
|
||||
|
||||
var _player_key: PackedByteArray
|
||||
var _next_dig_tick := 0
|
||||
## True while an unusable save could not be set aside. Saving is refused
|
||||
## then, so that the unusable save is never overwritten.
|
||||
var _save_blocked := false
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
I18n.install()
|
||||
InputSetup.install()
|
||||
store = SaveStore.new(storage_dir)
|
||||
_player_key = store.player_key()
|
||||
clock = TickClock.new(GwSession.TICKS_PER_SECOND)
|
||||
map_view = MapView.new()
|
||||
add_child(map_view)
|
||||
marker = TileMarker.new()
|
||||
add_child(marker)
|
||||
avatar = Avatar.new()
|
||||
add_child(avatar)
|
||||
var camera := Camera2D.new()
|
||||
camera.zoom = CAMERA_ZOOM
|
||||
avatar.add_child(camera)
|
||||
hud = Hud.new()
|
||||
add_child(hud)
|
||||
if not _start_from_save():
|
||||
_start_new_world()
|
||||
|
||||
|
||||
func _process(delta: float) -> void:
|
||||
if session == null or session.is_broken():
|
||||
return
|
||||
_update_marker()
|
||||
if Input.is_action_pressed("dig") and not clock.paused:
|
||||
_try_dig()
|
||||
for i in clock.advance(delta):
|
||||
_run_tick()
|
||||
|
||||
|
||||
func _unhandled_input(event: InputEvent) -> void:
|
||||
if event.is_action_pressed("save_game"):
|
||||
hud.flash("SAVED" if save_game() else "SAVE_FAILED")
|
||||
elif event.is_action_pressed("load_game"):
|
||||
_write_recording()
|
||||
hud.flash("LOADED" if _start_from_save() else "LOAD_FAILED")
|
||||
elif event.is_action_pressed("toggle_pause"):
|
||||
clock.paused = not clock.paused
|
||||
avatar.set_process(not clock.paused)
|
||||
hud.show_paused(clock.paused)
|
||||
|
||||
|
||||
func _notification(what: int) -> void:
|
||||
if what == NOTIFICATION_WM_CLOSE_REQUEST and session != null:
|
||||
if not save_game():
|
||||
push_warning("the game could not be saved on close")
|
||||
_write_recording()
|
||||
|
||||
|
||||
func _start_new_world() -> void:
|
||||
var fresh := GwSession.new()
|
||||
fresh.start_new(NEW_WORLD_SEED)
|
||||
_enter(fresh)
|
||||
|
||||
|
||||
## Continues the saved world. Returns false if there is no usable save. An
|
||||
## unusable save is set aside, so that the next save does not overwrite it.
|
||||
func _start_from_save() -> bool:
|
||||
if not store.has_save():
|
||||
return false
|
||||
var loaded := GwSession.new()
|
||||
if not loaded.start_loaded(store.read_save()):
|
||||
var aside := store.set_aside_save()
|
||||
if aside.is_empty():
|
||||
_save_blocked = true
|
||||
push_warning("save not usable (%s) and could not be set aside; saving is off" % loaded.last_error())
|
||||
else:
|
||||
push_warning("save not usable (%s), kept as %s" % [loaded.last_error(), aside])
|
||||
hud.flash("LOAD_FAILED")
|
||||
return false
|
||||
_save_blocked = false
|
||||
_enter(loaded)
|
||||
return true
|
||||
|
||||
|
||||
## Makes `next` the running session and lets the local player join it.
|
||||
func _enter(next: GwSession) -> void:
|
||||
session = next
|
||||
session.join(_player_key, PLAYER_NAME)
|
||||
_run_tick()
|
||||
_next_dig_tick = 0
|
||||
map_view.session = session
|
||||
map_view.queue_redraw()
|
||||
avatar.is_walkable = session.is_walkable
|
||||
avatar.position = GridMotion.tile_center(session.spawn_point())
|
||||
hud.show_stone(session.item_count(GwSession.ITEM_STONE))
|
||||
|
||||
|
||||
func _run_tick() -> void:
|
||||
_apply(session.step())
|
||||
if session.tick() % AUTOSAVE_TICKS == 0 and not save_game():
|
||||
hud.flash("SAVE_FAILED")
|
||||
push_warning("the automatic save failed")
|
||||
|
||||
|
||||
func _apply(events: Array[Dictionary]) -> void:
|
||||
var local := session.local_player()
|
||||
for event in events:
|
||||
match event["type"]:
|
||||
"tile_hit", "tile_changed":
|
||||
map_view.queue_redraw()
|
||||
"item_gained":
|
||||
if event["player"] == local:
|
||||
hud.show_stone(session.item_count(GwSession.ITEM_STONE))
|
||||
"inventory_full":
|
||||
if event["player"] == local:
|
||||
hud.flash("INVENTORY_FULL")
|
||||
if session.is_broken():
|
||||
hud.flash("SIMULATION_STOPPED")
|
||||
push_error(session.last_error())
|
||||
|
||||
|
||||
func _update_marker() -> void:
|
||||
var tile := GridMotion.tile_of(get_global_mouse_position())
|
||||
var distance := avatar.position.distance_to(GridMotion.tile_center(tile))
|
||||
marker.show_tile(tile, distance <= REACH_TILES * GridMotion.TILE_SIZE)
|
||||
|
||||
|
||||
func _try_dig() -> void:
|
||||
if marker.in_reach:
|
||||
dig_at(marker.tile)
|
||||
|
||||
|
||||
## Queues a pickaxe hit on `tile`, at most once per cooldown. The caller must
|
||||
## check the reach: figures move outside the lockstep, so the core does not
|
||||
## know where the figure is. The core checks the cooldown, the tile, its
|
||||
## terrain, the tool tier and the free space in the inventory.
|
||||
func dig_at(tile: Vector2i) -> void:
|
||||
if session.tick() < _next_dig_tick:
|
||||
return
|
||||
session.dig(tile.x, tile.y)
|
||||
_next_dig_tick = session.tick() + GwSession.DIG_COOLDOWN_TICKS
|
||||
|
||||
|
||||
## Writes the running world as the save. Returns false if nothing was
|
||||
## written, also while an unusable save could not be set aside.
|
||||
func save_game() -> bool:
|
||||
if _save_blocked:
|
||||
return false
|
||||
return store.write_save(session.save())
|
||||
|
||||
|
||||
func _write_recording() -> void:
|
||||
store.write_recording(session.recording_bytes())
|
||||
@@ -0,0 +1 @@
|
||||
uid://by56f6vi542a1
|
||||
@@ -0,0 +1,48 @@
|
||||
class_name MapView
|
||||
extends Node2D
|
||||
## Draws the terrain of the shared world. The colours are placeholders until
|
||||
## a style is chosen from the samples of plan M1d.
|
||||
|
||||
const CRACK_COLOR := Color(0.1, 0.08, 0.06, 0.8)
|
||||
|
||||
var session: GwSession
|
||||
|
||||
|
||||
func _draw() -> void:
|
||||
if session == null or not session.is_running():
|
||||
return
|
||||
var width := session.map_width()
|
||||
var height := session.map_height()
|
||||
var terrain := session.terrain_codes()
|
||||
var damage := session.damage_codes()
|
||||
var size := GridMotion.TILE_SIZE
|
||||
for y in height:
|
||||
for x in width:
|
||||
var index := y * width + x
|
||||
var rect := Rect2(x * size, y * size, size, size)
|
||||
draw_rect(rect, color_of(terrain[index]))
|
||||
if damage[index] > 0:
|
||||
_draw_cracks(rect, damage[index])
|
||||
|
||||
|
||||
## The placeholder colour of a terrain code.
|
||||
static func color_of(terrain: int) -> Color:
|
||||
if terrain == GwSession.TERRAIN_GROUND:
|
||||
return Color(0.42, 0.36, 0.28)
|
||||
if terrain == GwSession.TERRAIN_SOFT_ROCK:
|
||||
return Color(0.56, 0.55, 0.52)
|
||||
if terrain == GwSession.TERRAIN_HARD_ROCK:
|
||||
return Color(0.33, 0.33, 0.37)
|
||||
if terrain == GwSession.TERRAIN_BEDROCK:
|
||||
return Color(0.12, 0.12, 0.14)
|
||||
return Color.MAGENTA
|
||||
|
||||
|
||||
## One crack line for every hit the tile has taken.
|
||||
func _draw_cracks(rect: Rect2, hits: int) -> void:
|
||||
var inset := rect.grow(-3.0)
|
||||
for line in hits:
|
||||
var share := (line + 1.0) / (hits + 1.0)
|
||||
var from := Vector2(inset.position.x, inset.position.y + inset.size.y * share)
|
||||
var to := Vector2(inset.end.x, inset.position.y + inset.size.y * (1.0 - share))
|
||||
draw_line(from, to, CRACK_COLOR, 1.0)
|
||||
@@ -0,0 +1 @@
|
||||
uid://dasl7vpyifxob
|
||||
@@ -0,0 +1,134 @@
|
||||
class_name SaveStore
|
||||
extends RefCounted
|
||||
## The game's files on the player's machine: the player key, the save and
|
||||
## the session recordings for bug reports.
|
||||
|
||||
const KEY_FILE := "player.key"
|
||||
const SAVE_FILE := "world.grw"
|
||||
## Holds the previous save while write_save replaces it.
|
||||
const BACKUP_FILE := SAVE_FILE + ".bak"
|
||||
const RECORDINGS_DIR := "recordings"
|
||||
## Recordings beyond this number are deleted, oldest first.
|
||||
const KEEP_RECORDINGS := 10
|
||||
const KEY_BYTES := 16
|
||||
|
||||
var base_dir: String
|
||||
|
||||
|
||||
## Opens the folder and creates it if it is missing. If a crash interrupted a
|
||||
## save and left only the backup of the previous one (see write_save), the
|
||||
## backup becomes the save again.
|
||||
func _init(directory: String = "user://") -> void:
|
||||
base_dir = directory
|
||||
DirAccess.make_dir_recursive_absolute(base_dir)
|
||||
_restore_backup()
|
||||
|
||||
|
||||
## The key that identifies this installation in every world. It is created
|
||||
## on first use and kept from then on.
|
||||
func player_key() -> PackedByteArray:
|
||||
var path := base_dir.path_join(KEY_FILE)
|
||||
var key := FileAccess.get_file_as_bytes(path)
|
||||
if key.size() != KEY_BYTES:
|
||||
key = Crypto.new().generate_random_bytes(KEY_BYTES)
|
||||
if not _write(path, key):
|
||||
push_error("player key could not be stored at %s" % path)
|
||||
return key
|
||||
|
||||
|
||||
func has_save() -> bool:
|
||||
return FileAccess.file_exists(base_dir.path_join(SAVE_FILE))
|
||||
|
||||
|
||||
func read_save() -> PackedByteArray:
|
||||
return FileAccess.get_file_as_bytes(base_dir.path_join(SAVE_FILE))
|
||||
|
||||
|
||||
## Writes the save through a temporary file, so that a crash or a failed
|
||||
## write never destroys the previous save. Returns false if the new save was
|
||||
## not stored completely; the previous save then stays as it was.
|
||||
##
|
||||
## The temporary file replaces the previous save in two moves: the previous
|
||||
## save becomes the backup, then the temporary file becomes the save. A single
|
||||
## move onto the previous save would not be safe, because on Windows Godot
|
||||
## deletes the target first and only then moves the file. If that move failed,
|
||||
## for example because a virus scanner holds the temporary file, the previous
|
||||
## save would already be gone. If the second move fails, the backup is moved
|
||||
## back. A crash between the two moves leaves only the backup; the next
|
||||
## SaveStore on the folder restores it.
|
||||
func write_save(bytes: PackedByteArray) -> bool:
|
||||
if bytes.is_empty():
|
||||
return false
|
||||
var path := base_dir.path_join(SAVE_FILE)
|
||||
var temporary := path + ".tmp"
|
||||
var backup := base_dir.path_join(BACKUP_FILE)
|
||||
if not _write(temporary, bytes) or FileAccess.get_file_as_bytes(temporary) != bytes:
|
||||
DirAccess.remove_absolute(temporary)
|
||||
return false
|
||||
var has_previous := FileAccess.file_exists(path)
|
||||
if has_previous and DirAccess.rename_absolute(path, backup) != OK:
|
||||
DirAccess.remove_absolute(temporary)
|
||||
return false
|
||||
if DirAccess.rename_absolute(temporary, path) != OK:
|
||||
if has_previous:
|
||||
DirAccess.rename_absolute(backup, path)
|
||||
return false
|
||||
if has_previous:
|
||||
DirAccess.remove_absolute(backup)
|
||||
return true
|
||||
|
||||
|
||||
## Moves an unusable save out of the way, so that the next save does not
|
||||
## overwrite it. An older set-aside save is replaced. Returns the new path,
|
||||
## or an empty text if there was no save or it could not be moved.
|
||||
func set_aside_save() -> String:
|
||||
var path := base_dir.path_join(SAVE_FILE)
|
||||
var aside := path + ".broken"
|
||||
if DirAccess.rename_absolute(path, aside) != OK:
|
||||
return ""
|
||||
return aside
|
||||
|
||||
|
||||
## Stores a session recording and deletes the oldest ones beyond
|
||||
## KEEP_RECORDINGS. Returns the path, or an empty text if nothing was written.
|
||||
func write_recording(bytes: PackedByteArray) -> String:
|
||||
if bytes.is_empty():
|
||||
return ""
|
||||
var directory := base_dir.path_join(RECORDINGS_DIR)
|
||||
DirAccess.make_dir_recursive_absolute(directory)
|
||||
# Microseconds since 1970, padded so that names sort by age.
|
||||
var stamp := int(Time.get_unix_time_from_system() * 1_000_000.0)
|
||||
var path := directory.path_join("session-%016d.grwr" % stamp)
|
||||
if not _write(path, bytes):
|
||||
return ""
|
||||
var names := recordings()
|
||||
while names.size() > KEEP_RECORDINGS:
|
||||
DirAccess.remove_absolute(directory.path_join(names[0]))
|
||||
names.remove_at(0)
|
||||
return path
|
||||
|
||||
|
||||
## File names of the stored recordings, oldest first.
|
||||
func recordings() -> PackedStringArray:
|
||||
var names := DirAccess.get_files_at(base_dir.path_join(RECORDINGS_DIR))
|
||||
names.sort()
|
||||
return names
|
||||
|
||||
|
||||
## Moves the backup back to the save if there is no save, see write_save.
|
||||
func _restore_backup() -> void:
|
||||
var path := base_dir.path_join(SAVE_FILE)
|
||||
var backup := base_dir.path_join(BACKUP_FILE)
|
||||
if FileAccess.file_exists(path) or not FileAccess.file_exists(backup):
|
||||
return
|
||||
if DirAccess.rename_absolute(backup, path) != OK:
|
||||
push_warning("the previous save could not be restored from %s" % backup)
|
||||
|
||||
|
||||
func _write(path: String, bytes: PackedByteArray) -> bool:
|
||||
var file := FileAccess.open(path, FileAccess.WRITE)
|
||||
if file == null:
|
||||
return false
|
||||
var stored := file.store_buffer(bytes)
|
||||
file.close()
|
||||
return stored
|
||||
@@ -0,0 +1 @@
|
||||
uid://cbp4jkbdfyawm
|
||||
@@ -0,0 +1,29 @@
|
||||
class_name TickClock
|
||||
extends RefCounted
|
||||
## Turns frame time into a whole number of simulation ticks.
|
||||
|
||||
## Most ticks one frame may run. After a long stall the simulation catches
|
||||
## up only this far and drops the rest, instead of freezing to catch up.
|
||||
const MAX_TICKS_PER_FRAME := 5
|
||||
|
||||
var seconds_per_tick: float
|
||||
var paused := false
|
||||
|
||||
var _accumulated := 0.0
|
||||
|
||||
|
||||
func _init(ticks_per_second: int) -> void:
|
||||
seconds_per_tick = 1.0 / ticks_per_second
|
||||
|
||||
|
||||
## Returns how many ticks to run for a frame that took `delta` seconds.
|
||||
func advance(delta: float) -> int:
|
||||
if paused:
|
||||
return 0
|
||||
_accumulated += delta
|
||||
var ticks := int(_accumulated / seconds_per_tick)
|
||||
if ticks > MAX_TICKS_PER_FRAME:
|
||||
_accumulated = 0.0
|
||||
return MAX_TICKS_PER_FRAME
|
||||
_accumulated -= ticks * seconds_per_tick
|
||||
return ticks
|
||||
@@ -0,0 +1 @@
|
||||
uid://bmrv5nfb0wu6r
|
||||
@@ -0,0 +1,25 @@
|
||||
class_name TileMarker
|
||||
extends Node2D
|
||||
## Outlines the tile under the mouse: green when the figure can reach it,
|
||||
## red when it is too far away.
|
||||
|
||||
const REACHABLE_COLOR := Color(0.4, 1.0, 0.4)
|
||||
const UNREACHABLE_COLOR := Color(1.0, 0.4, 0.4)
|
||||
|
||||
var tile := Vector2i(-1, -1)
|
||||
var in_reach := false
|
||||
|
||||
|
||||
## Moves the outline to `new_tile`. Redraws only when something changed.
|
||||
func show_tile(new_tile: Vector2i, reachable: bool) -> void:
|
||||
if new_tile == tile and reachable == in_reach:
|
||||
return
|
||||
tile = new_tile
|
||||
in_reach = reachable
|
||||
queue_redraw()
|
||||
|
||||
|
||||
func _draw() -> void:
|
||||
var size := GridMotion.TILE_SIZE
|
||||
var color := REACHABLE_COLOR if in_reach else UNREACHABLE_COLOR
|
||||
draw_rect(Rect2(Vector2(tile) * size, Vector2(size, size)), color, false, 1.0)
|
||||
@@ -0,0 +1 @@
|
||||
uid://cjnucbgwr65ud
|
||||
@@ -0,0 +1,87 @@
|
||||
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 or if no test ran,
|
||||
## so that a renamed test folder or misnamed tests cannot pass as a clean run.
|
||||
##
|
||||
## 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:
|
||||
# Remove what runs that stopped early left behind, before the error
|
||||
# counter starts. Only if the folder exists, because Godot reports an
|
||||
# error when it lists a missing one.
|
||||
if DirAccess.dir_exists_absolute(TestFiles.ROOT):
|
||||
TestFiles.remove_dir(TestFiles.ROOT)
|
||||
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 or passed == 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,29 @@
|
||||
class_name TestFiles
|
||||
extends RefCounted
|
||||
## Temporary folders below user://tests for tests that write files. That
|
||||
## folder lies inside the game's real user folder, next to the player's own
|
||||
## key, save and recordings, so nothing outside it may be deleted here.
|
||||
|
||||
## The only folder the tests write to and remove_dir deletes from.
|
||||
const ROOT := "user://tests"
|
||||
|
||||
|
||||
static func temporary_dir(label: String) -> String:
|
||||
var path := ROOT.path_join("%s-%d" % [label, Time.get_ticks_usec()])
|
||||
DirAccess.make_dir_recursive_absolute(path)
|
||||
return path
|
||||
|
||||
|
||||
## Deletes the folder `path` with everything in it. Refuses every path that is
|
||||
## not ROOT or below it, with an error and without deleting anything: a wrong
|
||||
## argument must never delete the player's real files.
|
||||
static func remove_dir(path: String) -> void:
|
||||
var clean := path.simplify_path()
|
||||
if clean != ROOT and not clean.begins_with(ROOT + "/"):
|
||||
push_error("TestFiles.remove_dir refuses %s: it is not %s or below it" % [path, ROOT])
|
||||
return
|
||||
for file in DirAccess.get_files_at(clean):
|
||||
DirAccess.remove_absolute(clean.path_join(file))
|
||||
for directory in DirAccess.get_directories_at(clean):
|
||||
remove_dir(clean.path_join(directory))
|
||||
DirAccess.remove_absolute(clean)
|
||||
@@ -0,0 +1 @@
|
||||
uid://cjpx0i0uknopn
|
||||
@@ -0,0 +1,33 @@
|
||||
extends TestCase
|
||||
|
||||
|
||||
func _open_field(x: int, y: int) -> bool:
|
||||
return x >= 0 and y >= 0 and x < 100 and y < 100
|
||||
|
||||
|
||||
func test_the_figure_walks_five_tiles_per_second() -> void:
|
||||
var avatar := Avatar.new()
|
||||
avatar.is_walkable = _open_field
|
||||
avatar.position = GridMotion.tile_center(Vector2i(50, 50))
|
||||
var start := avatar.position
|
||||
for frame in 10:
|
||||
avatar.walk(Vector2.RIGHT, 0.1)
|
||||
check(is_equal_approx(avatar.position.x - start.x, 5.0 * GridMotion.TILE_SIZE), "five tiles in one second, moved %s" % (avatar.position.x - start.x))
|
||||
avatar.free()
|
||||
|
||||
|
||||
func test_a_long_frame_moves_the_figure_no_further_than_a_tenth_of_a_second() -> void:
|
||||
var avatar := Avatar.new()
|
||||
avatar.is_walkable = _open_field
|
||||
avatar.position = GridMotion.tile_center(Vector2i(50, 50))
|
||||
var start := avatar.position
|
||||
avatar.walk(Vector2.DOWN, 3.0)
|
||||
check(is_equal_approx(avatar.position.y - start.y, Avatar.SPEED * Avatar.MAX_STEP_SECONDS), "cut to a tenth of a second")
|
||||
avatar.free()
|
||||
|
||||
|
||||
func test_without_a_map_the_figure_stays() -> void:
|
||||
var avatar := Avatar.new()
|
||||
avatar.walk(Vector2.LEFT, 0.1)
|
||||
check_eq(avatar.position, Vector2.ZERO, "no walkability callable")
|
||||
avatar.free()
|
||||
@@ -0,0 +1 @@
|
||||
uid://b7gc0kkgk5xg0
|
||||
@@ -0,0 +1,46 @@
|
||||
extends TestCase
|
||||
## A 10 x 10 test grid in which tile (3, 1) and every tile outside are walls.
|
||||
|
||||
const SIZE := GridMotion.TILE_SIZE
|
||||
const RADIUS := 5.0
|
||||
|
||||
|
||||
func _walkable(x: int, y: int) -> bool:
|
||||
if x < 0 or y < 0 or x >= 10 or y >= 10:
|
||||
return false
|
||||
return Vector2i(x, y) != Vector2i(3, 1)
|
||||
|
||||
|
||||
func test_tile_of_and_tile_center_agree() -> void:
|
||||
check_eq(GridMotion.tile_of(Vector2(0.0, 0.0)), Vector2i(0, 0), "origin")
|
||||
check_eq(GridMotion.tile_of(Vector2(15.9, 16.0)), Vector2i(0, 1), "tile borders")
|
||||
check_eq(GridMotion.tile_of(Vector2(-0.1, 3.0)), Vector2i(-1, 0), "left of the map")
|
||||
check_eq(GridMotion.tile_center(Vector2i(2, 3)), Vector2(40.0, 56.0), "centre")
|
||||
check_eq(GridMotion.tile_of(GridMotion.tile_center(Vector2i(7, 4))), Vector2i(7, 4), "round trip")
|
||||
|
||||
|
||||
func test_free_movement_covers_the_full_distance() -> void:
|
||||
var start := GridMotion.tile_center(Vector2i(5, 5))
|
||||
var moved := GridMotion.move(start, Vector2(3.0, -2.0), RADIUS, _walkable)
|
||||
check_eq(moved, start + Vector2(3.0, -2.0), "unblocked")
|
||||
|
||||
|
||||
func test_a_wall_stops_the_body_before_it() -> void:
|
||||
var position := GridMotion.tile_center(Vector2i(1, 1))
|
||||
for step in 40:
|
||||
position = GridMotion.move(position, Vector2(1.0, 0.0), RADIUS, _walkable)
|
||||
check(position.x + RADIUS <= 3.0 * SIZE, "the body stays left of the wall at x = 48, ends at %s" % position.x)
|
||||
check(position.x + RADIUS > 3.0 * SIZE - 1.0, "and gets close to it, ends at %s" % position.x)
|
||||
|
||||
|
||||
func test_a_body_slides_along_a_wall() -> void:
|
||||
var start := GridMotion.tile_center(Vector2i(2, 1))
|
||||
var moved := GridMotion.move(start, Vector2(4.0, 2.0), RADIUS, _walkable)
|
||||
check_eq(moved.x, start.x, "the blocked axis stays")
|
||||
check_eq(moved.y, start.y + 2.0, "the free axis moves")
|
||||
|
||||
|
||||
func test_the_edge_of_the_map_is_a_wall() -> void:
|
||||
var start := Vector2(RADIUS, RADIUS)
|
||||
var moved := GridMotion.move(start, Vector2(-1.0, -1.0), RADIUS, _walkable)
|
||||
check_eq(moved, start, "no step off the map")
|
||||
@@ -0,0 +1 @@
|
||||
uid://bjgyllbxp5ty8
|
||||
@@ -0,0 +1,35 @@
|
||||
extends TestCase
|
||||
|
||||
|
||||
func _by_locale() -> Dictionary:
|
||||
var result := {}
|
||||
for translation in I18n.load_translations(I18n.TEXTS_FILE):
|
||||
result[translation.locale] = translation
|
||||
return result
|
||||
|
||||
|
||||
func test_german_and_english_exist() -> void:
|
||||
var translations := _by_locale()
|
||||
check(translations.has("de"), "German")
|
||||
check(translations.has("en"), "English")
|
||||
|
||||
|
||||
func test_every_text_exists_in_every_language() -> void:
|
||||
var translations := _by_locale()
|
||||
var german: Translation = translations["de"]
|
||||
var english: Translation = translations["en"]
|
||||
var keys := german.get_message_list()
|
||||
keys.sort()
|
||||
var english_keys := english.get_message_list()
|
||||
english_keys.sort()
|
||||
check_eq(english_keys, keys, "the same keys")
|
||||
for key in keys:
|
||||
check(not String(german.get_message(key)).is_empty(), "German text for %s" % key)
|
||||
check(not String(english.get_message(key)).is_empty(), "English text for %s" % key)
|
||||
|
||||
|
||||
func test_the_stone_count_has_a_slot_for_the_number() -> void:
|
||||
var translations := _by_locale()
|
||||
for locale: String in translations:
|
||||
var text := String(translations[locale].get_message("STONE_COUNT"))
|
||||
check(text.contains("%d"), "%s: %s" % [locale, text])
|
||||
@@ -0,0 +1 @@
|
||||
uid://qmoy6gvht6ba
|
||||
@@ -0,0 +1,15 @@
|
||||
extends TestCase
|
||||
|
||||
|
||||
func test_every_action_of_the_game_exists() -> void:
|
||||
InputSetup.install()
|
||||
for action in ["move_up", "move_down", "move_left", "move_right", "dig", "save_game", "load_game", "toggle_pause"]:
|
||||
check(InputMap.has_action(action), "action %s" % action)
|
||||
|
||||
|
||||
func test_installing_twice_adds_no_second_set_of_keys() -> void:
|
||||
InputSetup.install()
|
||||
var events := InputMap.action_get_events("move_up").size()
|
||||
InputSetup.install()
|
||||
check_eq(InputMap.action_get_events("move_up").size(), events, "same number of keys")
|
||||
check_eq(events, 2, "W and the arrow key")
|
||||
@@ -0,0 +1 @@
|
||||
uid://dxk7wymwduoy2
|
||||
@@ -0,0 +1,103 @@
|
||||
extends TestCase
|
||||
## Starts the real main scene with its own storage folder and runs a few
|
||||
## frames.
|
||||
|
||||
|
||||
func _start_main(directory: String) -> Node:
|
||||
var main: Node = load("res://main.tscn").instantiate()
|
||||
main.storage_dir = directory
|
||||
tree.root.add_child(main)
|
||||
return main
|
||||
|
||||
|
||||
func _stop_main(main: Node) -> void:
|
||||
tree.root.remove_child(main)
|
||||
main.free()
|
||||
|
||||
|
||||
func test_a_new_game_starts_and_the_player_joins() -> void:
|
||||
var directory := TestFiles.temporary_dir("main")
|
||||
var main := _start_main(directory)
|
||||
for frame in 5:
|
||||
await tree.process_frame
|
||||
var session: GwSession = main.session
|
||||
check(session != null and session.is_running(), "a session runs")
|
||||
check(not session.is_broken(), "the simulation is healthy")
|
||||
check_eq(session.local_player(), 0, "the local player has joined")
|
||||
check_eq(main.avatar.position, GridMotion.tile_center(session.spawn_point()), "the figure stands on the spawn")
|
||||
check_eq(main.hud.stone_text(), tr("STONE_COUNT") % 0, "the HUD shows the stones")
|
||||
_stop_main(main)
|
||||
TestFiles.remove_dir(directory)
|
||||
|
||||
|
||||
func test_a_saved_game_is_continued_on_the_next_start() -> void:
|
||||
var directory := TestFiles.temporary_dir("continue")
|
||||
var first := _start_main(directory)
|
||||
await tree.process_frame
|
||||
var tick_at_save: int = first.session.tick()
|
||||
check(first.save_game(), "the game was saved")
|
||||
_stop_main(first)
|
||||
|
||||
var second := _start_main(directory)
|
||||
await tree.process_frame
|
||||
check(second.session.tick() > tick_at_save, "the saved world continues")
|
||||
check_eq(second.session.local_player(), 0, "the same player rejoins")
|
||||
_stop_main(second)
|
||||
TestFiles.remove_dir(directory)
|
||||
|
||||
|
||||
func test_closing_the_window_saves_the_game_and_records_the_session() -> void:
|
||||
var directory := TestFiles.temporary_dir("close")
|
||||
var main := _start_main(directory)
|
||||
await tree.process_frame
|
||||
main.notification(Node.NOTIFICATION_WM_CLOSE_REQUEST)
|
||||
var store := SaveStore.new(directory)
|
||||
check(store.has_save(), "the game was saved")
|
||||
check_eq(store.recordings().size(), 1, "the session was recorded")
|
||||
_stop_main(main)
|
||||
TestFiles.remove_dir(directory)
|
||||
|
||||
|
||||
func test_the_game_is_saved_automatically_every_two_minutes() -> void:
|
||||
var directory := TestFiles.temporary_dir("autosave")
|
||||
var main := _start_main(directory)
|
||||
var store := SaveStore.new(directory)
|
||||
check(not store.has_save(), "there is no save at the start")
|
||||
# No frames are awaited, so only these ticks run.
|
||||
while main.session.tick() < main.AUTOSAVE_TICKS - 1:
|
||||
main._run_tick()
|
||||
check(not store.has_save(), "still no save one tick before the interval ends")
|
||||
main._run_tick()
|
||||
check(store.has_save(), "saved when the interval ends")
|
||||
_stop_main(main)
|
||||
TestFiles.remove_dir(directory)
|
||||
|
||||
|
||||
func test_an_unusable_save_is_set_aside_and_a_new_world_starts() -> void:
|
||||
var directory := TestFiles.temporary_dir("broken")
|
||||
var store := SaveStore.new(directory)
|
||||
store.write_save(PackedByteArray([1, 2, 3]))
|
||||
var main := _start_main(directory)
|
||||
await tree.process_frame
|
||||
check(main.session.is_running(), "a new world runs")
|
||||
check(main.session.tick() <= 5, "it is new, tick %d" % main.session.tick())
|
||||
check(FileAccess.file_exists(directory.path_join(SaveStore.SAVE_FILE + ".broken")), "the unusable save is kept aside")
|
||||
check_eq(main.hud.message(), tr("LOAD_FAILED"), "the player is told")
|
||||
_stop_main(main)
|
||||
TestFiles.remove_dir(directory)
|
||||
|
||||
|
||||
func test_an_unusable_save_that_cannot_be_set_aside_is_never_overwritten() -> void:
|
||||
var directory := TestFiles.temporary_dir("stuck")
|
||||
var store := SaveStore.new(directory)
|
||||
store.write_save(PackedByteArray([1, 2, 3]))
|
||||
# A folder under the set-aside name keeps the unusable save from moving.
|
||||
DirAccess.make_dir_absolute(directory.path_join(SaveStore.SAVE_FILE + ".broken"))
|
||||
var main := _start_main(directory)
|
||||
await tree.process_frame
|
||||
check(main.session.is_running(), "a new world runs")
|
||||
check(not main.save_game(), "saving is refused")
|
||||
check_eq(store.read_save(), PackedByteArray([1, 2, 3]), "the unusable save is untouched")
|
||||
check_eq(main.hud.message(), tr("LOAD_FAILED"), "the player is told")
|
||||
_stop_main(main)
|
||||
TestFiles.remove_dir(directory)
|
||||
@@ -0,0 +1 @@
|
||||
uid://d0qbyofproc3j
|
||||
@@ -0,0 +1,128 @@
|
||||
extends TestCase
|
||||
|
||||
|
||||
func test_the_player_key_is_created_once_and_kept() -> void:
|
||||
var directory := TestFiles.temporary_dir("key")
|
||||
var first := SaveStore.new(directory).player_key()
|
||||
var second := SaveStore.new(directory).player_key()
|
||||
check_eq(first.size(), SaveStore.KEY_BYTES, "sixteen bytes")
|
||||
check_eq(second, first, "the same key on the next start")
|
||||
TestFiles.remove_dir(directory)
|
||||
|
||||
|
||||
func test_a_save_is_written_and_read_back() -> void:
|
||||
var directory := TestFiles.temporary_dir("save")
|
||||
var store := SaveStore.new(directory)
|
||||
check(not store.has_save(), "no save at first")
|
||||
check(store.write_save(PackedByteArray([1, 2, 3])), "written")
|
||||
check(store.write_save(PackedByteArray([4, 5])), "overwritten")
|
||||
check(store.has_save(), "a save exists")
|
||||
check_eq(store.read_save(), PackedByteArray([4, 5]), "the newest save is read")
|
||||
check(not FileAccess.file_exists(directory.path_join(SaveStore.SAVE_FILE + ".tmp")), "no temporary file is left")
|
||||
check(not FileAccess.file_exists(directory.path_join(SaveStore.BACKUP_FILE)), "no backup is left")
|
||||
TestFiles.remove_dir(directory)
|
||||
|
||||
|
||||
func test_an_empty_save_is_not_written() -> void:
|
||||
var directory := TestFiles.temporary_dir("empty")
|
||||
var store := SaveStore.new(directory)
|
||||
check(not store.write_save(PackedByteArray()), "refused")
|
||||
check(not store.has_save(), "no file")
|
||||
TestFiles.remove_dir(directory)
|
||||
|
||||
|
||||
func test_a_save_that_cannot_be_written_keeps_the_previous_one() -> void:
|
||||
var directory := TestFiles.temporary_dir("blocked")
|
||||
var store := SaveStore.new(directory)
|
||||
store.write_save(PackedByteArray([1, 2, 3]))
|
||||
# A folder in place of the temporary file makes the write fail.
|
||||
DirAccess.make_dir_absolute(directory.path_join(SaveStore.SAVE_FILE + ".tmp"))
|
||||
check(not store.write_save(PackedByteArray([4, 5])), "the failed save is reported")
|
||||
check_eq(store.read_save(), PackedByteArray([1, 2, 3]), "the previous save is untouched")
|
||||
check(not store.write_save(PackedByteArray()), "an empty save is refused")
|
||||
check_eq(store.read_save(), PackedByteArray([1, 2, 3]), "still untouched")
|
||||
TestFiles.remove_dir(directory)
|
||||
|
||||
|
||||
func test_a_save_that_cannot_be_moved_into_place_keeps_the_previous_one() -> void:
|
||||
var directory := TestFiles.temporary_dir("held")
|
||||
var store := SaveStore.new(directory)
|
||||
store.write_save(PackedByteArray([1, 2, 3]))
|
||||
# The open handle stands for an outside program, for example a virus
|
||||
# scanner. The new save can be written, but Windows refuses to move a file
|
||||
# that is held open.
|
||||
var holder := FileAccess.open(directory.path_join(SaveStore.SAVE_FILE + ".tmp"), FileAccess.WRITE)
|
||||
check(not store.write_save(PackedByteArray([4, 5])), "the failed save is reported")
|
||||
check_eq(store.read_save(), PackedByteArray([1, 2, 3]), "the previous save is untouched")
|
||||
check(not FileAccess.file_exists(directory.path_join(SaveStore.BACKUP_FILE)), "no backup is left")
|
||||
holder.close()
|
||||
TestFiles.remove_dir(directory)
|
||||
|
||||
|
||||
func test_a_save_that_cannot_move_the_previous_one_aside_keeps_it() -> void:
|
||||
var directory := TestFiles.temporary_dir("aside-blocked")
|
||||
var store := SaveStore.new(directory)
|
||||
store.write_save(PackedByteArray([1, 2, 3]))
|
||||
# A folder under the backup name keeps the previous save from moving.
|
||||
DirAccess.make_dir_absolute(directory.path_join(SaveStore.BACKUP_FILE))
|
||||
check(not store.write_save(PackedByteArray([4, 5])), "the failed save is reported")
|
||||
check_eq(store.read_save(), PackedByteArray([1, 2, 3]), "the previous save is untouched")
|
||||
check(not FileAccess.file_exists(directory.path_join(SaveStore.SAVE_FILE + ".tmp")), "no temporary file is left")
|
||||
TestFiles.remove_dir(directory)
|
||||
|
||||
|
||||
func test_an_interrupted_save_is_recovered_from_the_backup() -> void:
|
||||
var directory := TestFiles.temporary_dir("recover")
|
||||
# A crash between the two moves of write_save leaves only the backup.
|
||||
var backup := directory.path_join(SaveStore.BACKUP_FILE)
|
||||
var file := FileAccess.open(backup, FileAccess.WRITE)
|
||||
file.store_buffer(PackedByteArray([7, 8, 9]))
|
||||
file.close()
|
||||
var store := SaveStore.new(directory)
|
||||
check(store.has_save(), "the previous save is back")
|
||||
check_eq(store.read_save(), PackedByteArray([7, 8, 9]), "with its content")
|
||||
check(not FileAccess.file_exists(backup), "the backup is moved, not copied")
|
||||
TestFiles.remove_dir(directory)
|
||||
|
||||
|
||||
func test_a_backup_never_replaces_an_existing_save() -> void:
|
||||
var directory := TestFiles.temporary_dir("keep")
|
||||
SaveStore.new(directory).write_save(PackedByteArray([1, 2, 3]))
|
||||
var file := FileAccess.open(directory.path_join(SaveStore.BACKUP_FILE), FileAccess.WRITE)
|
||||
file.store_buffer(PackedByteArray([9, 9]))
|
||||
file.close()
|
||||
var store := SaveStore.new(directory)
|
||||
check_eq(store.read_save(), PackedByteArray([1, 2, 3]), "the save is not replaced by the older backup")
|
||||
TestFiles.remove_dir(directory)
|
||||
|
||||
|
||||
func test_setting_aside_without_a_save_reports_nothing_moved() -> void:
|
||||
var directory := TestFiles.temporary_dir("nothing")
|
||||
check_eq(SaveStore.new(directory).set_aside_save(), "", "no save, nothing moved")
|
||||
TestFiles.remove_dir(directory)
|
||||
|
||||
|
||||
func test_an_unusable_save_can_be_set_aside() -> void:
|
||||
var directory := TestFiles.temporary_dir("aside")
|
||||
var store := SaveStore.new(directory)
|
||||
store.write_save(PackedByteArray([9, 9]))
|
||||
var aside := store.set_aside_save()
|
||||
check(not store.has_save(), "the save is out of the way")
|
||||
check_eq(FileAccess.get_file_as_bytes(aside), PackedByteArray([9, 9]), "its content is kept")
|
||||
TestFiles.remove_dir(directory)
|
||||
|
||||
|
||||
func test_only_the_newest_recordings_are_kept() -> void:
|
||||
var directory := TestFiles.temporary_dir("recordings")
|
||||
var store := SaveStore.new(directory)
|
||||
var paths: PackedStringArray = []
|
||||
for number in SaveStore.KEEP_RECORDINGS + 2:
|
||||
paths.append(store.write_recording(PackedByteArray([number])))
|
||||
OS.delay_usec(10)
|
||||
var names := store.recordings()
|
||||
check_eq(names.size(), SaveStore.KEEP_RECORDINGS, "only the newest are kept")
|
||||
check(not FileAccess.file_exists(paths[0]), "the oldest is gone")
|
||||
check(not FileAccess.file_exists(paths[1]), "the second oldest is gone")
|
||||
check(FileAccess.file_exists(paths[paths.size() - 1]), "the newest is there")
|
||||
check_eq(store.write_recording(PackedByteArray()), "", "an empty recording is not written")
|
||||
TestFiles.remove_dir(directory)
|
||||
@@ -0,0 +1 @@
|
||||
uid://cd4grmj44ifao
|
||||
@@ -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
|
||||
@@ -0,0 +1,31 @@
|
||||
extends TestCase
|
||||
|
||||
|
||||
func test_one_tick_lasts_a_twentieth_of_a_second() -> void:
|
||||
var clock := TickClock.new(20)
|
||||
check_eq(clock.advance(0.05), 1, "a full tick")
|
||||
check_eq(clock.advance(0.025), 0, "half a tick")
|
||||
check_eq(clock.advance(0.025), 1, "the second half completes the tick")
|
||||
|
||||
|
||||
func test_a_second_of_short_frames_runs_about_twenty_ticks() -> void:
|
||||
var clock := TickClock.new(20)
|
||||
var ticks := 0
|
||||
for frame in 60:
|
||||
ticks += clock.advance(1.0 / 60.0)
|
||||
# Rounding in floating point may leave the last tick for the next frame.
|
||||
check(ticks == 19 or ticks == 20, "19 or 20 ticks, got %d" % ticks)
|
||||
|
||||
|
||||
func test_a_long_stall_runs_at_most_five_ticks_and_forgets_the_rest() -> void:
|
||||
var clock := TickClock.new(20)
|
||||
check_eq(clock.advance(10.0), TickClock.MAX_TICKS_PER_FRAME, "capped")
|
||||
check_eq(clock.advance(0.0), 0, "the rest of the stall is dropped")
|
||||
|
||||
|
||||
func test_a_paused_clock_runs_no_ticks() -> void:
|
||||
var clock := TickClock.new(20)
|
||||
clock.paused = true
|
||||
check_eq(clock.advance(1.0), 0, "paused")
|
||||
clock.paused = false
|
||||
check_eq(clock.advance(0.05), 1, "running again")
|
||||
@@ -0,0 +1 @@
|
||||
uid://bhwedt1ik28sq
|
||||
@@ -0,0 +1,50 @@
|
||||
extends SceneTree
|
||||
## Starts the game in a temporary storage folder, walks the figure toward the
|
||||
## rock beside the clearing, hits it once and saves a screenshot. Lets a
|
||||
## change to the look be checked without playing by hand. The player's real
|
||||
## key and save stay untouched.
|
||||
##
|
||||
## From the repository root (opens a window for a moment):
|
||||
## godot --path game -s res://tools/screenshot.gd -- <path of the PNG>
|
||||
|
||||
const STORAGE := "user://screenshot-run"
|
||||
const FRAMES_AFTER_HIT := 30
|
||||
|
||||
|
||||
func _initialize() -> void:
|
||||
_run.call_deferred()
|
||||
|
||||
|
||||
func _run() -> void:
|
||||
var arguments := OS.get_cmdline_user_args()
|
||||
var output := arguments[0] if arguments.size() > 0 else "user://screenshot.png"
|
||||
DirAccess.make_dir_recursive_absolute(STORAGE)
|
||||
var main: Node = load("res://main.tscn").instantiate()
|
||||
main.storage_dir = STORAGE
|
||||
root.add_child(main)
|
||||
await process_frame
|
||||
|
||||
# Four tiles to the right, toward the rock outcrop beside the clearing.
|
||||
for frame in 48:
|
||||
main.avatar.walk(Vector2.RIGHT, 1.0 / 60.0)
|
||||
await process_frame
|
||||
var session: GwSession = main.session
|
||||
var rock := session.spawn_point() + Vector2i(7, 0)
|
||||
main.dig_at(rock)
|
||||
for frame in FRAMES_AFTER_HIT:
|
||||
await process_frame
|
||||
|
||||
var image := root.get_texture().get_image()
|
||||
var error := image.save_png(output)
|
||||
print("screenshot %s: %s" % ["saved" if error == OK else "failed", ProjectSettings.globalize_path(output)])
|
||||
root.remove_child(main)
|
||||
main.free()
|
||||
for file in DirAccess.get_files_at(STORAGE):
|
||||
DirAccess.remove_absolute(STORAGE.path_join(file))
|
||||
for directory in DirAccess.get_directories_at(STORAGE):
|
||||
var path := STORAGE.path_join(directory)
|
||||
for file in DirAccess.get_files_at(path):
|
||||
DirAccess.remove_absolute(path.path_join(file))
|
||||
DirAccess.remove_absolute(path)
|
||||
DirAccess.remove_absolute(STORAGE)
|
||||
quit(0 if error == OK else 1)
|
||||
@@ -0,0 +1 @@
|
||||
uid://cltfxvihioc4v
|
||||
@@ -0,0 +1,18 @@
|
||||
@echo off
|
||||
rem Builds the bridge and starts Grovewright from this folder.
|
||||
cd /d "%~dp0"
|
||||
cargo build -p grovewright-bridge || goto failed
|
||||
rem The first import of a fresh checkout crashes while Godot shuts down
|
||||
rem (Godot 4.7.2 with a Rust extension). The import has happened by then,
|
||||
rem and a second run succeeds. Batch reports the crash as a negative exit
|
||||
rem code, so the check compares with zero instead of using errorlevel 1.
|
||||
godot --headless --path game --import
|
||||
if %errorlevel% neq 0 godot --headless --path game --import || goto failed
|
||||
godot --path game
|
||||
goto :eof
|
||||
|
||||
:failed
|
||||
echo.
|
||||
echo Grovewright konnte nicht gestartet werden. Die Meldungen oben nennen den Grund.
|
||||
pause
|
||||
exit /b 1
|
||||
Reference in New Issue
Block a user