Compare commits

...

22 Commits

Author SHA1 Message Date
steven fc42cbb8c8 M1b: Brücke und Hülle (#6)
Rust-Brücke (GDExtension) und Godot-Hülle: Die Figur läuft über die
Testkarte und schlägt Stein, die Welt wird gespeichert, geladen und
aufgezeichnet. 110 Rust-Tests in Debug und Release, 44 GDScript-Tests,
jede Task und der ganze Branch geprüft.

Closes #4

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 11:46:01 +00:00
Steven Wroblewski bc34bc61f7 Tests: Speichern beim Schließen und automatisches Speichern
Beides setzt Konzept 9 um („automatisch in festen Abständen“, Speichern
beim Schließen), und beides ließ sich entfernen, ohne dass ein Test
rot wurde.

- Schließen: Die Hauptszene bekommt NOTIFICATION_WM_CLOSE_REQUEST. Danach
  meldet ein SaveStore im selben Ordner einen Spielstand und genau eine
  Aufzeichnung.
- Automatisch: Die Hauptszene läuft per _run_tick bis zum Takt vor
  AUTOSAVE_TICKS, ohne Bilder abzuwarten. Bis dahin gibt es keinen
  Spielstand, einen Takt später gibt es einen.

Entfernt man das Speichern beim Schließen, schlägt der erste Test fehl;
entfernt man das automatische Speichern in _run_tick, der zweite. Ohne
das Schreiben der Aufzeichnung beim Schließen schlägt der erste ebenfalls
fehl.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-01 13:26:30 +02:00
Steven Wroblewski 5b76f828e0 CLAUDE.md: neue Zustandsfelder an allen Kodier-Stellen ergänzen
Die Regel sagte, wer ein Feld ergänze, tue das in snapshot.rs und prüfe
es in check_restorable. Das gilt nur für Felder von World. Spielerfelder
kodiert und dekodiert core/src/player.rs, das Inventar inventory.rs, die
Karte map.rs; deren Prüfungen stehen im jeweiligen Dekodierer, nicht in
check_restorable. M2 ergänzt Welt-, Feld- und Spielerzustand und folgt
diesem Satz.

Die Regel nennt jetzt alle vier Stellen und sagt, dass ein neues Feld
beim Dekodieren oder in check_restorable geprüft wird. Die Namen wurden
gegen den Code geprüft: encode_state und from_snapshot, Player::encode
und Player::decode, Inventory::encode und Inventory::decode sowie
Map::encode und Map::decode.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-01 13:25:26 +02:00
Steven Wroblewski 0b20dd5459 Fragebogen M1b: harter Fels und Aufzeichnung nach einem Absturz
Frage 3 sagte, ein Fels brauche drei Schläge. Das gilt nur für weichen
Fels. Harter Fels (ab etwa 22 Feldern vom Start) lässt sich mit der
Startspitzhacke nicht abbauen; Schläge darauf bewirken nichts, und das
Feld bleibt grün umrandet. Ein Spieltester hielte das für einen Fehler.
Der Fragebogen sagt es jetzt vorher.

Frage 5 bat um die neueste Aufzeichnung. Eine Aufzeichnung entsteht aber
nur beim Schließen des Fensters oder beim Laden mit F9. Nach einem
Absturz oder nach der Meldung „Die Simulation wurde angehalten“ gibt es
keine Aufzeichnung dieser Sitzung, und die neueste Datei gehört zu einer
früheren. Dann soll der Tester stattdessen den Ordner logs schicken, und
zwar sofort, weil jeder Start ältere Logdateien verdrängt.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-01 13:24:55 +02:00
Steven Wroblewski bd8533e189 main.gd: Kommentar zu dig_at richtigstellen, Autosave-Takt ableiten
Der Kommentar an dig_at sagte, der Kern prüfe alles außer der
Abklingzeit. Das stimmt nicht: Der Kern prüft Abklingzeit, Feld,
Gelände, Werkzeugstufe und freien Platz, aber nicht die Reichweite. Die
prüft nur die Hülle (in _update_marker und _try_dig), weil die Figuren
außerhalb des Gleichschritts laufen und der Kern ihren Ort nicht kennt.
Der Plan für M1c darf sich nicht darauf verlassen. Der Kommentar nennt
jetzt, was der Aufrufer und was der Kern prüft.

AUTOSAVE_TICKS wiederholte mit 2400 die Taktrate von 20 pro Sekunde. Die
Konstante wird jetzt als zwei Minuten aus GwSession.TICKS_PER_SECOND
abgeleitet; GDScript nimmt das in einer const an und rechnet 2400.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-01 13:23:45 +02:00
Steven Wroblewski dff213cc61 Testdateien: Löschen nur unter user://tests, Altlasten räumen, Wortlaut
user://tests liegt im echten Benutzerordner des Spiels, neben Spielstand,
Schlüssel und Aufzeichnungen des Spielers. TestFiles.remove_dir löschte
jeden Pfad, den es bekam; ein falsches Argument hätte echte Spielerdateien
gelöscht. Tests, die früh mit einem Laufzeitfehler abbrachen, ließen ihren
Ordner zurück (es lagen 15 solche Ordner im Benutzerordner).

- remove_dir vereinfacht den Pfad (simplify_path) und verweigert alles,
  was nicht user://tests oder darunter liegt, mit push_error und ohne zu
  löschen. Die Wurzel steht in der Konstante TestFiles.ROOT, die auch
  temporary_dir benutzt.
- run_tests.gd entfernt user://tests zu Beginn von _run_all, vor dem
  Fehlerzähler, aber nur wenn der Ordner existiert, weil Godot beim
  Auflisten eines fehlenden Ordners einen Fehler meldet.
- CLAUDE.md sagt jetzt, dass die Tests unter user://tests schreiben und
  nie die echten Dateien des Spielers anfassen.

Das Verweigern wurde einmal von Hand geprüft, mit einem Wegwerfskript auf
selbst angelegten Ordnern: Ohne Schutz löschte remove_dir sie, mit Schutz
bleiben sie samt Datei stehen und es erscheint ein Fehler, auch für einen
Geschwisterordner user://tests_probe und für user://tests/../…. Danach
waren die 15 Altlasten verschwunden und die Ausgabe fehlerfrei, auch ohne
vorhandenen Ordner user://tests.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-01 13:23:01 +02:00
Steven Wroblewski 400556134f Testläufer meldet einen Fehler, wenn kein Test lief
quit(1 if failed > 0 else 0) beendete einen Lauf mit "0 passed, 0 failed"
mit Code 0, zum Beispiel nachdem der Testordner umbenannt wurde oder alle
Tests falsch benannt waren. Die Prüfliste sah dann grün aus, ohne etwas
geprüft zu haben.

Der Läufer endet jetzt auch mit Code 1, wenn kein Test bestanden wurde.
Der Doc-Kommentar sagt das. Einmal von Hand geprüft: Mit einem Dateifilter,
den keine Testdatei erfüllt, meldete der alte Läufer 0 passed, 0 failed
mit Code 0, der neue endet mit Code 1.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-01 13:21:31 +02:00
Steven Wroblewski d986f2a5db Test: Startzeitpunkt einer dekodierten Aufzeichnung ist festgeschrieben
Recording::from_bytes leitet start_tick aus der gespeicherten Startwelt
ab. Kein Test prüfte das: Setzte ein Fehler start_tick beim Dekodieren
auf 0, blieb er unbemerkt, und eine so dekodierte Aufzeichnung würde
beim erneuten Kodieren oder Verlängern abstürzen.

Der Test a_recording_can_start_in_the_middle_of_a_session kodiert die
Aufzeichnung jetzt, dekodiert sie wieder und prüft, dass sie beim
selben Takt endet und der ursprünglichen gleicht. Mit start_tick = 0
beim Dekodieren schlägt er fehl (Endtakt 70 statt 120).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-01 13:20:22 +02:00
Steven Wroblewski 8aaeae5cf7 Test: volle Spielerliste mit vielen Offline-Spielern wird geladen
Eine Welt kennt bis zu 16 Spieler, von denen höchstens vier online sind.
Kein Test lud eine Welt mit mehr als vier bekannten Spielern; ein Fehler,
der in check_restorable alle bekannten statt der Online-Spieler zählt,
hätte gültige Spielstände zurückgewiesen und blieb unbemerkt. Mit dem
Koop-Spiel in M1c sind mehr als vier bekannte Spieler der Normalfall.

Der neue Test baut die Welt über Befehle auf: 16 Schlüssel treten in
Vierergruppen bei, jede Gruppe außer der letzten geht wieder, bevor die
nächste kommt. from_snapshot muss dieselbe Welt liefern, load alle Spieler
offline. Mit dem Fehler (alle Spieler gezählt) schlägt der Test mit
Invalid("online players") fehl.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-01 13:19:23 +02:00
Steven Wroblewski fc8d5cb164 Textwächter erkennt gruppierte std-Importe und globale Sperren
Mit `use std::{fs, process};` und anschließendem `fs::remove_file(…)`
kam bisher beides durch: Der Textwächter suchte `std::fs` wörtlich, und
Clippy kennt nur die aufgelisteten Funktionen. Globaler veränderlicher
Zustand über `static` mit Mutex, RwLock, OnceLock oder LazyLock wurde
auch nicht erkannt.

Neu in FORBIDDEN: `std::{` (gruppierte Importe verbergen dem Text die
Modulnamen) sowie Mutex, RwLock, OnceLock und LazyLock. Der Kern in
core/src nutzt nichts davon. Der Kommentar in core/clippy.toml behauptet
jetzt nur noch, was Clippy leistet, und CLAUDE.md nennt die Regel, dass
jeder std-Pfad in einer eigenen use-Zeile steht.

Der bestehende Selbsttest every_forbidden_term_is_detected deckt die
neuen Einträge ab.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-01 13:18:26 +02:00
Steven Wroblewski 842213c574 Spielstand bleibt erhalten, wenn das letzte Umbenennen scheitert
SaveStore.write_save verschob die geprüfte Temporärdatei direkt auf
world.grw. Godot löscht unter Windows ein vorhandenes Ziel, bevor es die
Datei verschiebt. Scheiterte die Verschiebung, zum Beispiel weil ein
Virenscanner world.grw.tmp festhielt, war der vorige Spielstand schon weg.

Jetzt wird der vorige Stand zuerst nach world.grw.bak verschoben, dann
rückt die Temporärdatei nach. Scheitert das, kommt der Stand aus der
Sicherung zurück; nach Erfolg wird die Sicherung gelöscht. Bleibt nach
einem Absturz zwischen den beiden Verschiebungen nur world.grw.bak übrig,
holt SaveStore sie beim Öffnen des Ordners zurück.

Neue Tests: ein offenes Handle auf world.grw.tmp, eine blockierte
Sicherung, die Wiederherstellung aus der Sicherung und dass eine
Sicherung einen vorhandenen Stand nie ersetzt.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-01 13:16:02 +02:00
Steven Wroblewski 68d063209c Startskript, Regeln und Probelauf für M1b
play.bat baut die Brücke und startet das Spiel. CLAUDE.md beschreibt
jetzt Kern, Brücke und Hülle mit allen Prüfbefehlen, die Versionsregel
für festgeschriebene Werte und die Zuschreibung in Commits. Ein
Fragebogen begleitet den ersten Probelauf.

Der erste Godot-Import in einem frischen Checkout stürzt beim Beenden
ab; play.bat wiederholt ihn deshalb einmal.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-10-01 12:26:28 +02:00
Steven Wroblewski 754d7f1e92 Speichern meldet Fehler und schützt unbrauchbare Stände
Lässt sich ein unbrauchbarer Spielstand nicht beiseitelegen, bleibt das
Speichern gesperrt, bis ein Stand erfolgreich geladen wird; so wird er
nie überschrieben. Ein gescheitertes automatisches Speichern zeigt
SAVE_FAILED, und jedes gescheiterte Speichern wird protokolliert.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-10-01 12:15:05 +02:00
Steven Wroblewski 68483c648e Darstellung und Hauptszene
Die Hauptszene verbindet Kern, Karte, Figur, Anzeige und Dateien:
fester Takt, Laufen, Stein schlagen in Reichweite, Speichern mit F5,
Laden mit F9, Pause mit Esc, automatisches Speichern alle zwei Minuten
und beim Schließen. Die Karte erscheint in Platzhalterfarben, bis in
M1d ein Stil gewählt ist. Ein Werkzeug macht Bildschirmfotos ohne
Handarbeit.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-10-01 11:53:51 +02:00
Steven Wroblewski 3761966303 SaveStore prüft jeden Schreibvorgang
Ein Speicherstand ersetzt den vorigen nur noch, wenn er vollständig
geschrieben und zurückgelesen wurde. Ein nicht gespeicherter
Spielerschlüssel wird gemeldet, und set_aside_save meldet, wenn nichts
verschoben wurde.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-10-01 11:42:31 +02:00
Steven Wroblewski ccf9813c80 Texte, Tasten und Dateien
Alle Texte stehen in einer Tabelle mit Deutsch und Englisch, die das
Spiel zur Laufzeit liest. Die Tasten werden in Code angelegt, nach
physischer Position. SaveStore verwaltet den Spielerschlüssel dieser
Installation, schreibt den Speicherstand über eine temporäre Datei,
legt unbrauchbare Stände beiseite und behält die zehn neuesten
Aufzeichnungen.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-10-01 11:26:12 +02:00
Steven Wroblewski 2607df8a97 Takt und Bewegung der Figur
TickClock rechnet Bildzeit in Takte um und holt nach einem Ruckler
höchstens fünf Takte auf. GridMotion bewegt einen Körper über das
Kachelraster und lässt ihn an Wänden entlanggleiten. Die Figur läuft
fünf Felder pro Sekunde, außerhalb des Gleichschritts.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-10-01 11:11:52 +02:00
Steven Wroblewski bd0f588714 Brücke zu Godot und Godot-Projekt
Die GDExtension grovewright-bridge gibt den Kern als Klasse GwSession
an GDScript weiter: Befehle einreihen, einen Takt ausführen, Karte und
Inventar lesen, speichern, laden, aufzeichnen. Eine Panik im Kern macht
die Sitzung defekt, statt einen guten Speicherstand zu überschreiben.
Das Godot-Projekt bekommt einen Testläufer, der auch Laufzeitfehler als
Fehlschlag zählt.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-10-01 10:46:23 +02:00
Steven Wroblewski 5dacbf2222 Aufzeichnung an den Weltschritt koppeln
Recording::step führt einen Takt aus und zeichnet ihn auf. Andere
Befehle als die ausgeführten können so nicht in eine Aufzeichnung
geraten. Aufzeichnung und Welt prüfen gegenseitig ihren Takt, und ein
beschädigter Startzustand fällt schon beim Einlesen auf.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-10-01 10:34:30 +02:00
Steven Wroblewski ef728cd100 Speicherstand mit Prüfsumme und Prüfung beim Laden
Snapshots tragen jetzt eine Prüfsumme (Format 2). Beim Wiederherstellen
werden Zustände abgelehnt, die die Regeln nie erzeugen: zu große Takte,
mehr als vier Spieler online, doppelte Schlüssel, unmögliche
Wartezeiten. World::load ersetzt den öffentlichen Aufruf von
set_all_offline. Die festgeschriebenen Werte sind bewusst angepasst und
um einen Offline-Spieler und das Kartenformat ergänzt.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-10-01 10:17:03 +02:00
Steven Wroblewski 8573a68901 Wächter für den Kern schärfen
Clippy sperrt im Kern Hash-Tabellen, Uhr, Dateien, Threads und
Umgebung über aufgelöste Pfade, auch bei Aliasen und gruppierten
Importen. Der Textwächter kennt globalen veränderlichen Zustand und
Prozesse, prüft sich selbst, und ein neuer Test hält die Abhängigkeiten
des Kerns leer.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-10-01 10:01:11 +02:00
Steven Wroblewski 5be2764276 Umsetzungsplan M1b: Brücke und Hülle
Zweiter von vier Plänen für Meilenstein 1. Acht Tasks härten den Kern
(Wächter, Speicherstand mit Prüfsumme und Prüfung beim Laden,
Aufzeichnung am Weltschritt) und bauen die GDExtension-Brücke sowie die
Godot-Hülle mit Takt, Figur, Karte, Anzeige, Texten, Dateien und
Startskript. Der Code im Plan ist vorab geprüft, jede Task-Grenze
einzeln.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 09:55:14 +02:00
73 changed files with 6795 additions and 78 deletions
+2
View File
@@ -1,2 +1,4 @@
# Same line endings on every machine. # Same line endings on every machine.
* text=auto eol=lf * text=auto eol=lf
# Windows batch files need CRLF.
*.bat text eol=crlf
+3
View File
@@ -1,2 +1,5 @@
# Rust # Rust
/target/ /target/
# Godot
/game/.godot/
+40 -13
View File
@@ -9,48 +9,75 @@ Das verbindliche Konzept steht in [docs/KONZEPT.md](docs/KONZEPT.md). Lies es, b
| Ordner | Inhalt | | Ordner | Inhalt |
|---|---| |---|---|
| `core/` | Rust-Kern: die gesamte gemeinsame Welt. Ohne Zugriff auf die Engine, ohne fremde Crates. | | `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/KONZEPT.md` | Spielkonzept und Entscheidungen |
| `docs/superpowers/plans/` | Umsetzungspläne | | `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. Die Hülle darf Gleitkommazahlen nutzen, weil sie nichts an der gemeinsamen Welt ändert; sie schickt nur Befehle.
Faustregel: Was im Speicherstand steht oder alle Spieler betrifft, gehört in den Kern.
## Regeln für den Kern ## 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. - 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. - Zufall nur über `rng::Rng` mit fester Startzahl.
- Keine Sammlung mit wechselnder Reihenfolge. Statt einer Hash-Tabelle nimmst du `Vec`, `BTreeMap` oder `BTreeSet`. - 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. - 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.
- 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`. - Wer eine Sitzung aufzeichnet, ruft `Recording::step` statt `World::step` auf. So enthält die Aufzeichnung genau die ausgeführten Befehle.
- 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. - 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. - 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 ## Prüfen
Vor jedem Commit müssen alle drei Befehle fehlerfrei laufen: Vor jedem Commit müssen alle Befehle fehlerfrei laufen:
```bash ```bash
cargo fmt --all -- --check cargo fmt --all -- --check
cargo clippy --workspace --all-targets -- -D warnings cargo clippy --workspace --all-targets -- -D warnings
cargo test --workspace 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 ## Sprache
- Code, Bezeichner und Kommentare im Code: Englisch. - Code, Bezeichner und Kommentare im Code: Englisch.
- Dokumente, Pläne, Commit-Nachrichten, Issues und Pull Requests: Deutsch. - 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 ## 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`. - Gearbeitet wird auf einem Branch pro Plan, nicht direkt auf `main`.
- Nach jedem abgeschlossenen Arbeitsschritt wird committet und hochgeladen. Das ist freigegeben. - 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
View File
@@ -2,6 +2,161 @@
# It is not intended for manual editing. # It is not intended for manual editing.
version = 4 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]] [[package]]
name = "grovewright-core" name = "grovewright-core"
version = "0.1.0" version = "0.1.0"
[[package]]
name = "heck"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
[[package]]
name = "libc"
version = "0.2.189"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2"
[[package]]
name = "nanoserde"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a36fb3a748a4c9736ed7aeb5f2dfc99665247f1ce306abbddb2bf0ba2ac530a4"
dependencies = [
"nanoserde-derive",
]
[[package]]
name = "nanoserde-derive"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a846cbc04412cf509efcd8f3694b114fc700a035fb5a37f21517f9fb019f1ebc"
[[package]]
name = "proc-macro2"
version = "1.0.107"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
dependencies = [
"unicode-ident",
]
[[package]]
name = "quote"
version = "1.0.47"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
dependencies = [
"proc-macro2",
]
[[package]]
name = "unicode-ident"
version = "1.0.26"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954"
[[package]]
name = "venial"
version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9a42528baceab6c7784446df2a10f4185078c39bf73dc614f154353f1a6b1229"
dependencies = [
"proc-macro2",
"quote",
]
+1 -1
View File
@@ -1,6 +1,6 @@
[workspace] [workspace]
resolver = "2" resolver = "2"
members = ["core"] members = ["core", "bridge"]
# The shipped game and the tests must agree on what happens when a number # 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. # overflows, so release builds keep the overflow checks that debug builds have.
+19
View File
@@ -0,0 +1,19 @@
[package]
name = "grovewright-bridge"
version = "0.1.0"
edition = "2021"
rust-version = "1.94"
publish = false
description = "GDExtension that makes the Grovewright core callable from Godot"
[lib]
crate-type = ["cdylib"]
[dependencies]
godot = { version = "=0.5.5", features = ["api-4-7"] }
grovewright-core = { path = "../core" }
[lints.rust]
# The MSVC linker reports the import library it writes on every build. That
# is expected and not worth a warning.
linker_messages = "allow"
+46
View File
@@ -0,0 +1,46 @@
//! Turns events of the core into dictionaries that GDScript can read.
use godot::prelude::*;
use grovewright_core::world::Event;
/// One event as a dictionary with a `type` entry and the event's fields.
pub fn to_dictionary(event: &Event) -> VarDictionary {
match event {
Event::PlayerJoined { player } => vdict! {
"type" => "player_joined",
"player" => i64::from(player.0),
},
Event::PlayerLeft { player } => vdict! {
"type" => "player_left",
"player" => i64::from(player.0),
},
Event::JoinRefused { .. } => vdict! {
"type" => "join_refused",
},
Event::TileHit { player, pos } => vdict! {
"type" => "tile_hit",
"player" => i64::from(player.0),
"x" => i64::from(pos.x),
"y" => i64::from(pos.y),
},
Event::TileChanged { pos } => vdict! {
"type" => "tile_changed",
"x" => i64::from(pos.x),
"y" => i64::from(pos.y),
},
Event::ItemGained {
player,
item,
count,
} => vdict! {
"type" => "item_gained",
"player" => i64::from(player.0),
"item" => i64::from(item.0),
"count" => i64::from(*count),
},
Event::InventoryFull { player } => vdict! {
"type" => "inventory_full",
"player" => i64::from(player.0),
},
}
}
+16
View File
@@ -0,0 +1,16 @@
//! GDExtension that makes the deterministic core callable from Godot.
//!
//! The bridge only translates. Every rule lives in `grovewright-core`, and
//! everything that is shown, heard or typed lives in the Godot project.
mod events;
mod session;
use godot::prelude::*;
struct GrovewrightExtension;
// SAFETY: godot-rust requires this marker to register the extension's entry
// point. The implementation keeps every default and adds no code.
#[gdextension]
unsafe impl ExtensionLibrary for GrovewrightExtension {}
+326
View File
@@ -0,0 +1,326 @@
//! A solo game session for GDScript: the shared world, its recording and the
//! commands waiting for the next tick.
use std::panic::{catch_unwind, AssertUnwindSafe};
use godot::prelude::*;
use grovewright_core::command::Command;
use grovewright_core::inventory::ItemId;
use grovewright_core::map::{Pos, Terrain};
use grovewright_core::mapgen::spawn_pos;
use grovewright_core::player::{PlayerId, PlayerKey};
use grovewright_core::replay::Recording;
use grovewright_core::world::World;
use crate::events;
/// Everything a running session holds.
struct Game {
world: World,
recording: Recording,
/// Commands for the next tick, in the order they were queued.
pending: Vec<Command>,
local_key: Option<PlayerKey>,
local: Option<PlayerId>,
/// Set when the core panicked. The world may be half-updated, so the
/// session neither steps nor saves any more.
broken: bool,
}
impl Game {
fn new(world: World) -> Self {
let recording = Recording::start(&world);
Self {
world,
recording,
pending: Vec::new(),
local_key: None,
local: None,
broken: false,
}
}
fn local_player(&self) -> Option<&grovewright_core::player::Player> {
self.local.and_then(|id| self.world.player(id))
}
}
/// A solo session. GDScript queues commands, calls [`GwSession::step`] once
/// per tick and reads the world back for display.
#[derive(GodotClass)]
#[class(base = RefCounted, init)]
pub struct GwSession {
game: Option<Game>,
last_error: GString,
}
#[godot_api]
impl GwSession {
/// Simulation ticks per second.
#[constant]
const TICKS_PER_SECOND: i64 = grovewright_core::world::TICKS_PER_SECOND as i64;
/// Ticks a player waits between two pickaxe hits.
#[constant]
const DIG_COOLDOWN_TICKS: i64 = grovewright_core::world::DIG_COOLDOWN_TICKS as i64;
#[constant]
const TERRAIN_GROUND: i64 = Terrain::Ground as i64;
#[constant]
const TERRAIN_SOFT_ROCK: i64 = Terrain::SoftRock as i64;
#[constant]
const TERRAIN_HARD_ROCK: i64 = Terrain::HardRock as i64;
#[constant]
const TERRAIN_BEDROCK: i64 = Terrain::Bedrock as i64;
#[constant]
const ITEM_STONE: i64 = grovewright_core::inventory::ITEM_STONE.0 as i64;
/// Starts a fresh world from `seed`, replacing any running game.
#[func]
fn start_new(&mut self, seed: i64) {
self.game = Some(Game::new(World::new(seed as u64)));
self.last_error = GString::new();
}
/// Starts from a save. Returns false and keeps any running game if the
/// save cannot be used; [`GwSession::last_error`] then says why.
#[func]
fn start_loaded(&mut self, save: PackedByteArray) -> bool {
match World::load(save.as_slice()) {
Ok(world) => {
self.game = Some(Game::new(world));
self.last_error = GString::new();
true
}
Err(error) => {
self.last_error = GString::from(error.to_string().as_str());
false
}
}
}
/// Why the last call failed, or an empty text.
#[func]
fn last_error(&self) -> GString {
self.last_error.clone()
}
#[func]
fn is_running(&self) -> bool {
self.game.is_some()
}
/// True after the core stopped with an internal error.
#[func]
fn is_broken(&self) -> bool {
self.game.as_ref().is_some_and(|game| game.broken)
}
/// Queues the entry of the local player for the next tick. `key`
/// identifies the player across sessions and must have 16 bytes.
#[func]
fn join(&mut self, key: PackedByteArray, name: GString) -> bool {
let Some(game) = self.game.as_mut() else {
return false;
};
let Ok(key) = PlayerKey::try_from(key.as_slice()) else {
self.last_error = GString::from("a player key has 16 bytes");
return false;
};
game.local_key = Some(key);
game.local = None;
game.pending.push(Command::Join {
key,
name: name.to_string(),
});
true
}
/// Queues a pickaxe hit of the local player on tile (`x`, `y`) for the
/// next tick. Does nothing before the local player has joined.
#[func]
fn dig(&mut self, x: i64, y: i64) {
let Some(game) = self.game.as_mut() else {
return;
};
let (Some(player), Some(pos)) = (game.local, to_pos(x, y)) else {
return;
};
game.pending.push(Command::Dig { player, pos });
}
/// Runs one tick with the queued commands and returns what happened, as
/// dictionaries with a `type` entry.
#[func]
fn step(&mut self) -> Array<VarDictionary> {
let mut happened = Array::new();
let Some(game) = self.game.as_mut() else {
return happened;
};
if game.broken {
return happened;
}
let commands = std::mem::take(&mut game.pending);
let Game {
world, recording, ..
} = game;
match catch_unwind(AssertUnwindSafe(|| recording.step(world, &commands))) {
Ok(events) => {
for event in &events {
happened.push(&events::to_dictionary(event));
}
if game.local.is_none() {
game.local = game.local_key.and_then(|key| game.world.find_player(&key));
}
}
Err(_) => {
game.broken = true;
self.last_error = GString::from("the simulation stopped after an internal error");
}
}
happened
}
/// Number of ticks the world has run.
#[func]
fn tick(&self) -> i64 {
self.game
.as_ref()
.map_or(0, |game| game.world.tick() as i64)
}
#[func]
fn map_width(&self) -> i64 {
self.game
.as_ref()
.map_or(0, |game| i64::from(game.world.map().width()))
}
#[func]
fn map_height(&self) -> i64 {
self.game
.as_ref()
.map_or(0, |game| i64::from(game.world.map().height()))
}
/// The terrain of every tile, row by row (`TERRAIN_*` constants).
#[func]
fn terrain_codes(&self) -> PackedByteArray {
self.tile_bytes(|terrain, _| terrain as u8)
}
/// The hits every tile has taken, row by row.
#[func]
fn damage_codes(&self) -> PackedByteArray {
self.tile_bytes(|_, damage| damage)
}
/// The terrain of tile (`x`, `y`), or -1 outside the map.
#[func]
fn terrain_at(&self, x: i64, y: i64) -> i64 {
self.tile(x, y).map_or(-1, |(terrain, _)| terrain as i64)
}
/// Whether a figure can stand on tile (`x`, `y`).
#[func]
fn is_walkable(&self, x: i64, y: i64) -> bool {
self.tile(x, y)
.is_some_and(|(terrain, _)| terrain.is_walkable())
}
/// The tile where figures appear.
#[func]
fn spawn_point(&self) -> Vector2i {
self.game.as_ref().map_or(Vector2i::ZERO, |game| {
let spawn = spawn_pos(game.world.map());
Vector2i::new(spawn.x, spawn.y)
})
}
/// Index of the local player in the world, or -1 before joining.
#[func]
fn local_player(&self) -> i64 {
self.game
.as_ref()
.and_then(|game| game.local)
.map_or(-1, |id| i64::from(id.0))
}
/// How many of `item` the local player carries.
#[func]
fn item_count(&self, item: i64) -> i64 {
let (Some(game), Ok(item)) = (self.game.as_ref(), u16::try_from(item)) else {
return 0;
};
game.local_player()
.map_or(0, |player| i64::from(player.inventory.count(ItemId(item))))
}
/// The world as a save, or empty if there is no healthy session.
#[func]
fn save(&self) -> PackedByteArray {
match self.game.as_ref() {
Some(game) if !game.broken => {
PackedByteArray::from(game.world.to_snapshot().as_slice())
}
_ => PackedByteArray::new(),
}
}
/// The recording of this session, or empty if there is no healthy
/// session.
#[func]
fn recording_bytes(&self) -> PackedByteArray {
match self.game.as_ref() {
Some(game) if !game.broken => {
PackedByteArray::from(game.recording.to_bytes(&game.world).as_slice())
}
_ => PackedByteArray::new(),
}
}
/// The checksum of the world state, as 16 hexadecimal digits.
#[func]
fn state_hash_hex(&self) -> GString {
self.game.as_ref().map_or_else(GString::new, |game| {
GString::from(format!("{:016x}", game.world.state_hash()).as_str())
})
}
}
impl GwSession {
fn tile(&self, x: i64, y: i64) -> Option<(Terrain, u8)> {
let game = self.game.as_ref()?;
let tile = game.world.map().get(to_pos(x, y)?)?;
Some((tile.terrain, tile.damage))
}
fn tile_bytes(&self, byte: impl Fn(Terrain, u8) -> u8) -> PackedByteArray {
let Some(game) = self.game.as_ref() else {
return PackedByteArray::new();
};
let bytes: Vec<u8> = game
.world
.map()
.tiles()
.iter()
.map(|tile| byte(tile.terrain, tile.damage))
.collect();
PackedByteArray::from(bytes.as_slice())
}
}
/// A tile position from GDScript integers, or `None` if it cannot exist.
fn to_pos(x: i64, y: i64) -> Option<Pos> {
Some(Pos::new(i32::try_from(x).ok()?, i32::try_from(y).ok()?))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn positions_outside_the_i32_range_are_refused() {
assert_eq!(to_pos(3, -4), Some(Pos::new(3, -4)));
assert_eq!(to_pos(i64::from(i32::MAX) + 1, 0), None);
assert_eq!(to_pos(0, i64::from(i32::MIN) - 1), None);
}
}
+27
View File
@@ -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
View File
@@ -6,11 +6,12 @@ use std::fmt;
use crate::codec::{DecodeError, Reader, Writer}; use crate::codec::{DecodeError, Reader, Writer};
use crate::command::Command; use crate::command::Command;
use crate::snapshot::SnapshotError; use crate::snapshot::SnapshotError;
use crate::world::World; use crate::world::{Event, World};
const MAGIC: [u8; 4] = *b"GRWR"; const MAGIC: [u8; 4] = *b"GRWR";
/// Version of the recording format. /// Version of the recording format. Raise it whenever the encoded recording,
pub const RECORDING_VERSION: u32 = 1; /// including the snapshot inside it, changes.
pub const RECORDING_VERSION: u32 = 2;
/// Reason a recording could not be read or replayed. /// Reason a recording could not be read or replayed.
#[derive(Clone, Debug, PartialEq, Eq)] #[derive(Clone, Debug, PartialEq, Eq)]
@@ -56,6 +57,7 @@ impl From<DecodeError> for RecordingError {
#[derive(Clone, Debug, PartialEq, Eq)] #[derive(Clone, Debug, PartialEq, Eq)]
pub struct Recording { pub struct Recording {
start: Vec<u8>, start: Vec<u8>,
start_tick: u64,
tick_count: u32, tick_count: u32,
/// Ticks that had commands, as (tick index, commands), in rising order. /// Ticks that had commands, as (tick index, commands), in rising order.
busy_ticks: Vec<(u32, Vec<Command>)>, busy_ticks: Vec<(u32, Vec<Command>)>,
@@ -66,18 +68,33 @@ impl Recording {
pub fn start(world: &World) -> Self { pub fn start(world: &World) -> Self {
Self { Self {
start: world.to_snapshot(), start: world.to_snapshot(),
start_tick: world.tick(),
tick_count: 0, tick_count: 0,
busy_ticks: Vec::new(), busy_ticks: Vec::new(),
} }
} }
/// Records the commands of one tick. Call it once for every /// Advances `world` by one tick with `commands` and records them. This is
/// [`World::step`], with the same commands. /// the only way to add to a recording, so it can never hold other
pub fn push_tick(&mut self, commands: &[Command]) { /// 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() { if !commands.is_empty() {
self.busy_ticks.push((self.tick_count, commands.to_vec())); 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. /// Number of recorded ticks.
@@ -85,6 +102,11 @@ impl Recording {
self.tick_count 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. /// Restores the starting state and applies every recorded tick.
pub fn replay(&self) -> Result<World, RecordingError> { pub fn replay(&self) -> Result<World, RecordingError> {
let mut world = World::from_snapshot(&self.start).map_err(RecordingError::BadStart)?; 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 /// Encodes the recording together with the state that replaying it must
/// reach. `end` is the world after the last recorded tick. /// 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> { 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(); let mut w = Writer::new();
w.raw(&MAGIC); w.raw(&MAGIC);
w.u32(RECORDING_VERSION); w.u32(RECORDING_VERSION);
@@ -138,6 +168,9 @@ impl Recording {
} }
let expected_hash = r.u64()?; let expected_hash = r.u64()?;
let start = r.bytes()?.to_vec(); let start = r.bytes()?.to_vec();
let start_tick = World::from_snapshot(&start)
.map_err(RecordingError::BadStart)?
.tick();
let tick_count = r.u32()?; let tick_count = r.u32()?;
let busy_count = r.u32()?; let busy_count = r.u32()?;
let mut busy_ticks = Vec::new(); let mut busy_ticks = Vec::new();
@@ -162,6 +195,7 @@ impl Recording {
Ok(( Ok((
Self { Self {
start, start,
start_tick,
tick_count, tick_count,
busy_ticks, busy_ticks,
}, },
+74 -8
View File
@@ -1,18 +1,26 @@
//! Complete, versioned encoding of a world. Saves, joining players and state //! Complete, versioned encoding of a world. Saves, joining players and state
//! checksums all use it. //! 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 std::fmt;
use crate::codec::{DecodeError, Reader, Writer}; use crate::codec::{DecodeError, Reader, Writer};
use crate::hash::fnv1a64; use crate::hash::fnv1a64;
use crate::map::Map; use crate::map::Map;
use crate::player::{Player, MAX_KNOWN_PLAYERS}; use crate::player::{Player, MAX_KNOWN_PLAYERS, MAX_ONLINE_PLAYERS};
use crate::world::World; use crate::world::{World, DIG_COOLDOWN_TICKS};
const MAGIC: [u8; 4] = *b"GRWS"; const MAGIC: [u8; 4] = *b"GRWS";
/// Version of the snapshot format. Raise it whenever the encoded state /// Version of the snapshot format. Raise it whenever the encoded state
/// changes. /// 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. /// Reason a snapshot could not be restored.
#[derive(Clone, Debug, PartialEq, Eq)] #[derive(Clone, Debug, PartialEq, Eq)]
@@ -21,8 +29,13 @@ pub enum SnapshotError {
NotASnapshot, NotASnapshot,
/// The snapshot was written by an incompatible version of the game. /// The snapshot was written by an incompatible version of the game.
UnsupportedVersion(u32), UnsupportedVersion(u32),
/// The checksum does not match: the bytes changed after they were
/// written.
ChecksumMismatch,
/// The snapshot is damaged. /// The snapshot is damaged.
Corrupt(DecodeError), Corrupt(DecodeError),
/// The bytes decode, but describe a state the rules can never produce.
Invalid(&'static str),
} }
impl fmt::Display for SnapshotError { impl fmt::Display for SnapshotError {
@@ -32,7 +45,9 @@ impl fmt::Display for SnapshotError {
SnapshotError::UnsupportedVersion(version) => { SnapshotError::UnsupportedVersion(version) => {
write!(f, "unsupported snapshot version {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::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 /// Encodes the complete world behind a header that names format and
/// version. /// version, followed by a checksum.
pub fn to_snapshot(&self) -> Vec<u8> { pub fn to_snapshot(&self) -> Vec<u8> {
let mut w = Writer::new(); let mut w = Writer::new();
w.raw(&MAGIC); w.raw(&MAGIC);
w.u32(SNAPSHOT_VERSION); w.u32(SNAPSHOT_VERSION);
self.encode_state(&mut w); 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> { pub fn from_snapshot(bytes: &[u8]) -> Result<World, SnapshotError> {
let mut r = Reader::new(bytes); let mut r = Reader::new(bytes);
let magic = r let magic = r
@@ -87,6 +106,17 @@ impl World {
if version != SNAPSHOT_VERSION { if version != SNAPSHOT_VERSION {
return Err(SnapshotError::UnsupportedVersion(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 seed = r.u64()?;
let tick = r.u64()?; let tick = r.u64()?;
let map = Map::decode(&mut r)?; let map = Map::decode(&mut r)?;
@@ -99,11 +129,47 @@ impl World {
players.push(Player::decode(&mut r)?); players.push(Player::decode(&mut r)?);
} }
r.finish()?; r.finish()?;
Ok(World {
let world = World {
seed, seed,
tick, tick,
map, map,
players, 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
View File
@@ -95,9 +95,11 @@ impl World {
.map(|index| PlayerId(index as u8)) .map(|index| PlayerId(index as u8))
} }
/// Marks every player as offline. Used after loading a save from disk, /// Marks every player as offline. Only [`World::load`] uses it: a loaded
/// when nobody has joined yet. /// save starts a new session, which nobody has joined yet. Called during
pub fn set_all_offline(&mut self) { /// 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 { for player in &mut self.players {
player.online = false; player.online = false;
} }
+1 -2
View File
@@ -123,8 +123,7 @@ fn a_recorded_session_replays_to_the_same_hash() {
let mut world = World::new(8); let mut world = World::new(8);
let mut recording = Recording::start(&world); let mut recording = Recording::start(&world);
for commands in &script { for commands in &script {
recording.push_tick(commands); recording.step(&mut world, commands);
world.step(commands);
} }
let bytes = recording.to_bytes(&world); let bytes = recording.to_bytes(&world);
let replayed = Recording::verify(&bytes).expect("the recording replays cleanly"); let replayed = Recording::verify(&bytes).expect("the recording replays cleanly");
+62 -10
View File
@@ -1,5 +1,13 @@
//! Guards the lockstep rules: the core must not use anything whose result //! Guards the lockstep rules: the core must not use anything whose result
//! can differ between machines or between runs. //! 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::fs;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
@@ -29,9 +37,41 @@ const FORBIDDEN: &[(&str, &str)] = &[
"std::net", "std::net",
"the core must not open connections; the shell does that", "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"), ("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>) { fn rust_files(dir: &Path, out: &mut Vec<PathBuf>) {
for entry in fs::read_dir(dir).expect("source directory is readable") { for entry in fs::read_dir(dir).expect("source directory is readable") {
let path = entry.expect("directory entry is readable").path(); 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(); let mut problems = Vec::new();
for file in &files { for file in &files {
let text = fs::read_to_string(file).expect("source file is readable"); let text = fs::read_to_string(file).expect("source file is readable");
for (number, line) in text.lines().enumerate() { for (line, needle, reason) in violations(&text) {
for (needle, reason) in FORBIDDEN { problems.push(format!("{}:{line}: `{needle}`: {reason}", file.display()));
if line.contains(needle) {
problems.push(format!(
"{}:{}: `{needle}`: {reason}",
file.display(),
number + 1
));
}
}
} }
} }
assert!( assert!(
@@ -76,3 +108,23 @@ fn core_source_uses_no_forbidden_apis() {
problems.join("\n") 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
View File
@@ -1,15 +1,16 @@
//! Pinned values for everything that must not change unnoticed. //! 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 //! change that affects both sides alike stays green. This file compares
//! against values recorded once. If one of them fails, rules or encodings //! against values recorded once. If one of them fails, rules or encodings
//! changed, on purpose or by accident. If saves or recordings are affected, //! changed, on purpose or by accident. If saves or recordings are affected,
//! raise the version constant named in the failure message, then update the //! raise the version constant named in the failure message, then update the
//! value here deliberately. //! value here deliberately.
use grovewright_core::codec::Writer;
use grovewright_core::command::Command; use grovewright_core::command::Command;
use grovewright_core::hash::fnv1a64; 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::mapgen::outcrop_origin;
use grovewright_core::player::PlayerId; use grovewright_core::player::PlayerId;
use grovewright_core::replay::Recording; use grovewright_core::replay::Recording;
@@ -99,13 +100,55 @@ fn frozen_script(world: &World) -> Vec<Vec<Command>> {
ticks ticks
} }
/// Covers the map generator, the map encoding (terrain numbers, byte order of /// The commands of a second session of 30 ticks that ends with one player
/// width and height) and the snapshot header. /// 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] #[test]
fn the_starting_snapshot_is_unchanged() { fn the_starting_snapshot_is_unchanged() {
let actual = fnv1a64(&World::new(SEED).to_snapshot()); let actual = fnv1a64(&World::new(SEED).to_snapshot());
assert_eq!( assert_eq!(
actual, 0x1b78_d996_1586_fbf1, actual, 0x6812_937b_f2b2_19ae,
"the starting world or the snapshot format changed; if saves are \ "the starting world or the snapshot format changed; if saves are \
affected, raise SNAPSHOT_VERSION in core/src/snapshot.rs; then \ affected, raise SNAPSHOT_VERSION in core/src/snapshot.rs; then \
update this value" 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. /// Covers the recording header and layout and the wire format of commands.
#[test] #[test]
fn the_recording_format_is_unchanged() { fn the_recording_format_is_unchanged() {
let mut world = World::new(SEED); let mut world = World::new(SEED);
let mut recording = Recording::start(&world); let mut recording = Recording::start(&world);
for commands in frozen_script(&world) { for commands in frozen_script(&world) {
recording.push_tick(&commands); recording.step(&mut world, &commands);
world.step(&commands);
} }
assert_eq!( assert_eq!(
fnv1a64(&recording.to_bytes(&world)), fnv1a64(&recording.to_bytes(&world)),
0x2312_5a7d_d8ef_5ae0, 0x1c24_8cc7_6bc8_846a,
"the recording or command format changed; raise RECORDING_VERSION in \ "the recording or command format changed; if other tests in this \
core/src/replay.rs; then update this value" 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"
); );
} }
+67
View File
@@ -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
View File
@@ -4,6 +4,7 @@ use grovewright_core::inventory::ITEM_STONE;
use grovewright_core::mapgen::outcrop_origin; use grovewright_core::mapgen::outcrop_origin;
use grovewright_core::player::PlayerId; use grovewright_core::player::PlayerId;
use grovewright_core::replay::{Recording, RecordingError, RECORDING_VERSION}; use grovewright_core::replay::{Recording, RecordingError, RECORDING_VERSION};
use grovewright_core::snapshot::SnapshotError;
use grovewright_core::world::World; use grovewright_core::world::World;
const ADA: PlayerId = PlayerId(0); const ADA: PlayerId = PlayerId(0);
@@ -31,12 +32,28 @@ fn recorded_session() -> (Recording, World) {
let mut recording = Recording::start(&world); let mut recording = Recording::start(&world);
for tick in 0..200 { for tick in 0..200 {
let commands = commands_for(&world, tick); let commands = commands_for(&world, tick);
recording.push_tick(&commands); recording.step(&mut world, &commands);
world.step(&commands);
} }
(recording, world) (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] #[test]
fn replaying_reaches_the_same_state() { fn replaying_reaches_the_same_state() {
let (recording, world) = recorded_session(); let (recording, world) = recorded_session();
@@ -62,14 +79,14 @@ fn verify_accepts_a_faithful_recording() {
#[test] #[test]
fn verify_reports_a_replay_that_ends_in_another_state() { fn verify_reports_a_replay_that_ends_in_another_state() {
let (recording, mut world) = recorded_session(); let (recording, world) = recorded_session();
// One tick more than was recorded: the claimed end state is unreachable. let mut bytes = recording.to_bytes(&world);
world.step(&[]); // The expected state hash follows the marker and the version.
let bytes = recording.to_bytes(&world); bytes[8] ^= 0x01;
match Recording::verify(&bytes) { match Recording::verify(&bytes) {
Err(RecordingError::Diverged { expected, actual }) => { Err(RecordingError::Diverged { expected, actual }) => {
assert_eq!(expected, world.state_hash()); assert_eq!(actual, world.state_hash());
assert_ne!(actual, expected); assert_ne!(expected, actual);
} }
other => panic!("expected a divergence, got {other:?}"), 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); let mut recording = Recording::start(&world);
for tick in 50..120 { for tick in 50..120 {
let commands = commands_for(&world, tick); let commands = commands_for(&world, tick);
recording.push_tick(&commands); recording.step(&mut world, &commands);
world.step(&commands);
} }
assert_eq!(recording.tick_count(), 70); 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)); 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)); 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] #[test]
fn damaged_recordings_are_rejected() { fn damaged_recordings_are_rejected() {
let (recording, world) = recorded_session(); let (recording, world) = recorded_session();
@@ -133,3 +174,16 @@ fn damaged_recordings_are_rejected() {
Err(RecordingError::Corrupt(DecodeError::TrailingBytes)) 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
View File
@@ -1,10 +1,11 @@
use grovewright_core::codec::DecodeError; use grovewright_core::codec::{DecodeError, Writer};
use grovewright_core::command::Command; 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::mapgen::outcrop_origin;
use grovewright_core::player::PlayerId; use grovewright_core::player::{Player, PlayerId, MAX_KNOWN_PLAYERS, MAX_ONLINE_PLAYERS};
use grovewright_core::snapshot::{SnapshotError, SNAPSHOT_VERSION}; use grovewright_core::snapshot::{SnapshotError, MAX_RESTORABLE_TICK, SNAPSHOT_VERSION};
use grovewright_core::world::World; use grovewright_core::world::{Event, World, DIG_COOLDOWN_TICKS};
const ADA: PlayerId = PlayerId(0); const ADA: PlayerId = PlayerId(0);
@@ -27,6 +28,29 @@ fn played_world() -> 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] #[test]
fn a_snapshot_restores_an_identical_world() { fn a_snapshot_restores_an_identical_world() {
let world = played_world(); let world = played_world();
@@ -35,6 +59,13 @@ fn a_snapshot_restores_an_identical_world() {
assert_eq!(restored.state_hash(), world.state_hash()); 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] #[test]
fn the_state_hash_covers_every_part_of_the_state() { fn the_state_hash_covers_every_part_of_the_state() {
let base = played_world(); let base = played_world();
@@ -81,6 +112,35 @@ fn a_restored_world_continues_exactly_like_the_original() {
assert_eq!(original, restored); 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] #[test]
fn bytes_that_are_not_a_snapshot_are_rejected() { fn bytes_that_are_not_a_snapshot_are_rejected() {
assert_eq!(World::from_snapshot(b""), Err(SnapshotError::NotASnapshot)); 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] #[test]
fn cut_off_and_padded_snapshots_are_rejected() { fn cut_off_and_padded_snapshots_are_rejected() {
let bytes = played_world().to_snapshot(); let bytes = played_world().to_snapshot();
assert_eq!( assert_eq!(
World::from_snapshot(&bytes[..bytes.len() - 1]), World::from_snapshot(&bytes[..bytes.len() - 1]),
Err(SnapshotError::Corrupt(DecodeError::UnexpectedEnd)) Err(SnapshotError::ChecksumMismatch)
); );
let mut padded = bytes.clone(); let mut padded = bytes.clone();
padded.push(0); padded.push(0);
assert_eq!( assert_eq!(
World::from_snapshot(&padded), 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)) 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));
}
-8
View File
@@ -176,14 +176,6 @@ fn the_world_remembers_a_limited_number_of_players() {
assert_eq!(world.players().len(), MAX_KNOWN_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] #[test]
fn three_hits_break_soft_rock_and_give_one_stone() { fn three_hits_break_soft_rock_and_give_one_stone() {
let (mut world, id, rock) = world_with_player(); let (mut world, id, rock) = world_with_player();
+23
View File
@@ -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
+8
View File
@@ -0,0 +1,8 @@
[configuration]
entry_symbol = "gdext_rust_init"
compatibility_minimum = 4.7
reloadable = true
[libraries]
windows.debug.x86_64 = "res://../target/debug/grovewright_bridge.dll"
windows.release.x86_64 = "res://../target/release/grovewright_bridge.dll"
+1
View File
@@ -0,0 +1 @@
uid://cftoabe1brb4d
+10
View File
@@ -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
1 keys de en
2 STONE_COUNT Stein: %d Stone: %d
3 SAVED Gespeichert Saved
4 SAVE_FAILED Speichern fehlgeschlagen Saving failed
5 LOADED Spielstand geladen Save loaded
6 LOAD_FAILED Der Spielstand ließ sich nicht laden The save could not be loaded
7 INVENTORY_FULL Die Tasche ist voll Your bag is full
8 PAUSED Pause Paused
9 SIMULATION_STOPPED Die Simulation wurde angehalten The simulation stopped
10 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
+3
View File
@@ -0,0 +1,3 @@
[remap]
importer="keep"
+6
View File
@@ -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")
+21
View File
@@ -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"
+31
View File
@@ -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))
+1
View File
@@ -0,0 +1 @@
uid://bpltng2n5f61g
+44
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
uid://dn0m8pxkigqig
+58
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
uid://cosq3mrn0ve5v
+41
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
uid://dy10r7pfqy63v
+35
View File
@@ -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)
+1
View File
@@ -0,0 +1 @@
uid://dvckt6ss6k4xi
+177
View File
@@ -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())
+1
View File
@@ -0,0 +1 @@
uid://by56f6vi542a1
+48
View File
@@ -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)
+1
View File
@@ -0,0 +1 @@
uid://dasl7vpyifxob
+134
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
uid://cbp4jkbdfyawm
+29
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
uid://bmrv5nfb0wu6r
+25
View File
@@ -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)
+1
View File
@@ -0,0 +1 @@
uid://cjnucbgwr65ud
+87
View File
@@ -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)
+1
View File
@@ -0,0 +1 @@
uid://b1bhpsppk18k2
+19
View File
@@ -0,0 +1,19 @@
class_name TestCase
extends RefCounted
## Base of every test script. Checks collect failures instead of stopping at
## the first one, so a run shows everything that is wrong.
var failures: PackedStringArray = []
## The running SceneTree, for tests that need frames or nodes.
var tree: SceneTree
func check(condition: bool, message: String) -> void:
if not condition:
failures.append(message)
## Fails unless `actual` and `expected` have the same type and value.
func check_eq(actual: Variant, expected: Variant, message: String) -> void:
if typeof(actual) != typeof(expected) or actual != expected:
failures.append("%s: expected %s, got %s" % [message, expected, actual])
+1
View File
@@ -0,0 +1 @@
uid://dlwjcshat6wd2
+29
View File
@@ -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)
+1
View File
@@ -0,0 +1 @@
uid://cjpx0i0uknopn
+33
View File
@@ -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()
+1
View File
@@ -0,0 +1 @@
uid://b7gc0kkgk5xg0
+46
View File
@@ -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")
+1
View File
@@ -0,0 +1 @@
uid://bjgyllbxp5ty8
+35
View File
@@ -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])
+1
View File
@@ -0,0 +1 @@
uid://qmoy6gvht6ba
+15
View File
@@ -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")
+1
View File
@@ -0,0 +1 @@
uid://dxk7wymwduoy2
+103
View File
@@ -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)
+1
View File
@@ -0,0 +1 @@
uid://d0qbyofproc3j
+128
View File
@@ -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)
+1
View File
@@ -0,0 +1 @@
uid://cd4grmj44ifao
+140
View File
@@ -0,0 +1,140 @@
extends TestCase
## Tests of the bridge: GwSession is the Rust core, seen from GDScript.
var key := PackedByteArray([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16])
func _joined_session() -> GwSession:
var session := GwSession.new()
session.start_new(7)
session.join(key, "Ada")
session.step()
return session
func _first_soft_rock(session: GwSession) -> Vector2i:
var terrain := session.terrain_codes()
var width := session.map_width()
for index in terrain.size():
if terrain[index] == GwSession.TERRAIN_SOFT_ROCK:
@warning_ignore("integer_division")
return Vector2i(index % width, index / width)
return Vector2i(-1, -1)
## Hits `tile` three times, waiting out the cooldown, and returns all events.
func _dig_three_times(session: GwSession, tile: Vector2i) -> Array[Dictionary]:
var events: Array[Dictionary] = []
for hit in 3:
session.dig(tile.x, tile.y)
events.append_array(session.step())
for wait in GwSession.DIG_COOLDOWN_TICKS:
session.step()
return events
func test_a_session_without_a_world_is_harmless() -> void:
var session := GwSession.new()
check(not session.is_running(), "nothing runs yet")
check_eq(session.tick(), 0, "tick")
check_eq(session.step().size(), 0, "no events")
check_eq(session.save().size(), 0, "nothing to save")
check_eq(session.local_player(), -1, "no local player")
func test_joining_makes_the_player_local() -> void:
var session := GwSession.new()
session.start_new(7)
check(session.join(key, "Ada"), "join accepted")
check_eq(session.local_player(), -1, "not before the tick")
var events := session.step()
check_eq(events.size(), 1, "one event")
check_eq(events[0]["type"], "player_joined", "event type")
check_eq(session.local_player(), 0, "local player after the tick")
check_eq(session.tick(), 1, "one tick ran")
func test_a_key_needs_sixteen_bytes() -> void:
var session := GwSession.new()
session.start_new(7)
check(not session.join(PackedByteArray([1, 2, 3]), "Ada"), "short key refused")
check(not session.last_error().is_empty(), "the reason is given")
func test_the_map_can_be_read() -> void:
var session := _joined_session()
check_eq(session.map_width(), 64, "width")
check_eq(session.map_height(), 64, "height")
check_eq(session.terrain_codes().size(), 64 * 64, "one code per tile")
check_eq(session.damage_codes().size(), 64 * 64, "one damage per tile")
var spawn := session.spawn_point()
check_eq(spawn, Vector2i(32, 32), "spawn in the centre")
check(session.is_walkable(spawn.x, spawn.y), "the spawn can be walked on")
check_eq(session.terrain_at(0, 0), GwSession.TERRAIN_BEDROCK, "the border is bedrock")
check_eq(session.terrain_at(-1, 0), -1, "outside the map")
check(not session.is_walkable(500, 500), "outside the map is not walkable")
func test_three_hits_break_soft_rock_and_give_a_stone() -> void:
var session := _joined_session()
var rock := _first_soft_rock(session)
check(rock.x >= 0, "the map has soft rock")
var events := _dig_three_times(session, rock)
var types := events.map(func(event: Dictionary) -> String: return event["type"])
check_eq(types, ["tile_hit", "tile_hit", "tile_changed", "item_gained"], "event order")
check_eq(session.terrain_at(rock.x, rock.y), GwSession.TERRAIN_GROUND, "the rock is gone")
check_eq(session.item_count(GwSession.ITEM_STONE), 1, "one stone")
func test_digging_before_joining_does_nothing() -> void:
var session := GwSession.new()
session.start_new(7)
var rock := _first_soft_rock(session)
session.dig(rock.x, rock.y)
check_eq(session.step().size(), 0, "no events")
check_eq(session.damage_codes()[rock.y * session.map_width() + rock.x], 0, "no damage")
func test_a_save_keeps_the_world_and_the_player_comes_back() -> void:
var session := _joined_session()
_dig_three_times(session, _first_soft_rock(session))
var save := session.save()
check(save.size() > 0, "a save was made")
var loaded := GwSession.new()
check(loaded.start_loaded(save), "the save loads")
check_eq(loaded.tick(), session.tick(), "same tick")
check_eq(loaded.terrain_codes(), session.terrain_codes(), "same map")
check_eq(loaded.local_player(), -1, "nobody has joined the loaded world yet")
loaded.join(key, "Ada")
loaded.step()
check_eq(loaded.local_player(), 0, "the player is the same")
check_eq(loaded.item_count(GwSession.ITEM_STONE), 1, "the stone is still there")
func test_a_damaged_save_is_refused_and_the_running_world_stays() -> void:
var session := _joined_session()
var hash_before := session.state_hash_hex()
var save := session.save()
var middle := save.size() >> 1
save[middle] = save[middle] ^ 0xFF
check(not session.start_loaded(save), "a damaged save is refused")
check(session.last_error().contains("checksum"), "the reason names the checksum: %s" % session.last_error())
check(not session.start_loaded(PackedByteArray([1, 2, 3])), "random bytes are refused")
check_eq(session.state_hash_hex(), hash_before, "the running world is untouched")
func test_the_recording_of_a_session_is_available() -> void:
var session := _joined_session()
session.step()
var recording := session.recording_bytes()
check(recording.size() > 0, "a recording exists")
check_eq(recording.slice(0, 4), "GRWR".to_ascii_buffer(), "it starts with the recording marker")
func test_the_state_hash_changes_with_every_tick() -> void:
var session := _joined_session()
var first := session.state_hash_hex()
session.step()
check_eq(first.length(), 16, "sixteen hexadecimal digits")
check(session.state_hash_hex() != first, "the tick is part of the state")
+1
View File
@@ -0,0 +1 @@
uid://cm1pa0uh5tmn5
+31
View File
@@ -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")
+1
View File
@@ -0,0 +1 @@
uid://bhwedt1ik28sq
+50
View File
@@ -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)
+1
View File
@@ -0,0 +1 @@
uid://cltfxvihioc4v
+18
View File
@@ -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