Files
Grovewright/docs/superpowers/plans/2026-10-01-m1b-bruecke-und-huelle.md
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

4114 lines
141 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# M1b Brücke und Hülle Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Der erste sichtbare Stand von Grovewright: ein Godot-Fenster mit der Testkarte, eine Spielfigur, die läuft und Stein schlägt, Speichern und Laden, und ein Kern, der für das Laden fremder Dateien gehärtet ist.
**Architecture:** Der Rust-Kern bleibt die einzige Quelle aller Regeln. Eine GDExtension-Brücke (`bridge/`) gibt ihn als Klasse `GwSession` an GDScript weiter. Die Godot-Hülle (`game/`) treibt den festen Takt, bewegt die Figur außerhalb des Gleichschritts, schickt nur Befehle an den Kern und zeigt dessen Zustand an. Vorher wird der Kern gehärtet: Speicherstände tragen eine Prüfsumme und werden beim Laden auf unmögliche Zustände geprüft, Aufzeichnungen sind fest an den Weltschritt gekoppelt, und die Wächter gegen verbotene Funktionen werden schärfer.
**Tech Stack:** Rust 1.98.1 (Edition 2021), godot-rust `godot` 0.5.5 mit Feature `api-4-7`, Godot 4.7.2 (Standard-Ausgabe, Renderer „GL Compatibility"), GDScript mit Typangaben, Cargo, rustfmt, Clippy.
**Spec:** `docs/KONZEPT.md`, vor allem die Abschnitte 4 (Welt), 5 (Koop), 7 (Technik), 8 (Meilenstein 1) und 9 (Weitere Festlegungen). Dazu die M1b-Punkte aus Gitea-Issue #2 „Nacharbeiten aus M1a für die nächsten Pläne".
## Einordnung
| Plan | Inhalt | Stand |
|---|---|---|
| M1a Rust-Kern | Deterministischer Kern mit Karte, Spielern, Befehlen, Speicherstand, Aufzeichnung | fertig, in `main` |
| **M1b Brücke und Hülle** | Dieser Plan | offen |
| M1c Gleichschritt im Netz | Sitzungsprotokoll, direkte Verbindung, Beitritt, Prüfsummen-Vergleich, zwei Instanzen im Gleichschritt | nach M1b |
| M1d Stilproben | Dieselbe Szene in drei Grafikstilen zur Auswahl | nach M1b |
## Was dieser Plan aus Issue #2 erledigt
| Punkt aus Issue #2 | Task |
|---|---|
| `bridge/` wird Mitglied des Workspace (Überlaufprüfung im Release) | 4 |
| Speicherstände beim Laden prüfen | 2 |
| `World::set_all_offline` durch einen Lade-Konstruktor ersetzen | 2 |
| Aufzeichnung an den Weltschritt koppeln | 3 |
| Spielerschlüssel pro Installation dauerhaft speichern | 6 |
| Eine Panik aus dem Kern gilt als fatal für die Welt | 4 |
| Prüfsumme über Speicherdateien | 2 |
| Wächter schärfen: Clippy-Sperrliste, Abhängigkeitstest, Selbsttest | 1 |
| CLAUDE.md: Versionsregel, Satz zu `core/src/`, Zählerbreite | 8 |
| Zweites eingefrorenes Skript und nicht quadratischer Karten-Pin | 2 |
| Felsblock bei den Startzahlen 83, 145 und 221 nicht zu Fuß erreichbar | geklärt: Die Figur gräbt sich durch weichen Fels; keine Änderung nötig |
Die M1c-Punkte aus Issue #2 (Befehle an Verbindungen binden, Regelversion im Handshake, Verdrängungsregel, ausstehende Befehle beim Beitritt, Namensablehnung testen, Länge fremder Aufzeichnungen begrenzen) bleiben offen.
## Nicht Teil dieses Plans
- Netzwerk, Host und Gäste (M1c)
- Endgültige Grafik. Farben und Formen sind Platzhalter, bis du in M1d einen Stil wählst.
- Position der Figur im Speicherstand. Die Figur läuft außerhalb des Gleichschritts; nach dem Laden steht sie am Startpunkt.
- Ein exportiertes Spiel (`.exe`). Gestartet wird über `play.bat` mit dem installierten Godot.
- Namenseingabe. Der lokale Spieler heißt vorerst „Spieler".
## Technische Festlegungen
| Thema | Festlegung | Grund |
|---|---|---|
| Brücke | Eine Klasse `GwSession` (RefCounted) mit Methoden für Befehle, Takt und Lesezugriffe; Ereignisse kommen als Dictionaries mit einem Eintrag `type` | GDScript bekommt eine kleine, stabile Schnittstelle; die Regeln bleiben im Kern |
| Panik im Kern | `GwSession` fängt sie ab, markiert die Sitzung als defekt und speichert danach nicht mehr | Eine halb aktualisierte Welt darf keinen guten Speicherstand überschreiben |
| Speicherstand | Version 2 mit FNV-1a-Prüfsumme am Ende; Laden lehnt unmögliche Zustände ab | Beschädigte oder manipulierte Dateien scheitern beim Laden statt im Spiel |
| Takt | Die Hülle zählt Bildzeit in Takte um, höchstens 5 Takte pro Bild | Nach einem Ruckler holt das Spiel kurz auf, statt einzufrieren |
| Figur | Bewegung in GDScript mit Gleitkommazahlen, 5 Felder pro Sekunde, Reichweite der Spitzhacke 2,5 Felder | Die Figur ist nicht Teil des Gleichschritts (Konzept 7.2) |
| Darstellung | Karte als einfache Rechtecke in Platzhalterfarben, Kamera mit Zoom 3 | Grundlage zum Ausprobieren, bis M1d den Stil festlegt |
| Texte | `game/i18n/texts.csv` mit Deutsch und Englisch, zur Laufzeit gelesen; Import-Einstellung „keep" | Keine erzeugten Übersetzungsdateien im Archiv |
| Tasten | Aktionen in Code, physische Tastenpositionen | WASD liegt auch auf deutschen Tastaturen richtig |
| Dateien | `user://player.key`, `user://world.grw` (über eine temporäre Datei geschrieben), `user://recordings/` (die neuesten zehn) | Ein Absturz beim Speichern zerstört nie den alten Stand; Aufzeichnungen helfen bei Fehlerberichten |
| Unbrauchbarer Speicherstand | Wird als `world.grw.broken` beiseitegelegt, dann startet eine neue Welt | Konzept 9: kein Verlust von Fortschritt; die Datei bleibt für eine Untersuchung erhalten |
| Automatisches Speichern | Alle 2400 Takte (zwei Minuten) und beim Schließen des Fensters | Konzept 9: automatisch in festen Abständen |
| Tests der Hülle | Eigener kleiner Testläufer in GDScript. Ein Test scheitert an einer fehlgeschlagenen Prüfung oder an jedem gemeldeten Fehler (über Godots `Logger`). | Keine fremden Erweiterungen nötig; Laufzeitfehler bleiben nicht unbemerkt |
| Renderer | GL Compatibility | Läuft auch auf älteren Grafikkarten von Mitspielern |
## Global Constraints
- Arbeitsverzeichnis ist die Wurzel des Archivs: `F:/Development/_kiZeug/Grovewright`. Alle Befehle laufen dort in der Bash.
- Gearbeitet wird auf dem Branch `m1b-bruecke-und-huelle`, nicht auf `main`.
- Der Kern rechnet nur mit ganzen Zahlen.
- Der Kern läuft in einem festen Takt, unabhängig von der Bildrate.
- Zufall kommt nur aus einer kontrollierten Quelle mit fester Startzahl.
- Jede Änderung der Welt geschieht durch einen Befehl.
- Der Kern hat keinen Zugriff auf die Engine und verwendet keine fremden Crates.
- Im Quelltext des Kerns (`core/src/`) dürfen diese Begriffe nirgends vorkommen, auch nicht in Kommentaren: `f32`, `f64`, `HashMap`, `HashSet`, `std::time`, `Instant`, `SystemTime`, `std::thread`, `std::env`, `std::fs`, `std::net`, `std::io`, `std::process`, `static mut`, `thread_local`, `Atomic`, `unsafe`.
- Die Brücke übersetzt nur; Spielregeln gehören in den Kern, nie in `bridge/` oder `game/`.
- GDScript mit Typangaben. Texte, die Spieler sehen, kommen über `tr()` aus `game/i18n/texts.csv`.
- Tests, die Dateien schreiben, nutzen einen temporären Ordner, nie den echten Benutzerordner.
- Code, Bezeichner und Kommentare im Code sind Englisch. Dokumente und Commit-Nachrichten sind Deutsch.
- Jede Commit-Nachricht endet mit einer Zeile `Co-Authored-By: Claude <Modell> <noreply@anthropic.com>`, die das Claude-Modell nennt, das den Commit erstellt hat. Die Commit-Nachrichten in diesem Plan zeigen `Claude Opus 5.5`; wer auf einem anderen Modell läuft, setzt dessen Namen ein.
- Nach jedem Task wird committet und hochgeladen. Das Hochladen ist vom Nutzer freigegeben.
- Vor jedem Commit laufen fehlerfrei: `cargo fmt --all -- --check`, `cargo clippy --workspace --all-targets -- -D warnings`, `cargo test --workspace`. Ab Task 4 zusätzlich `cargo build -p grovewright-bridge`, `godot --headless --path game --import` und `godot --headless --path game -s res://tests/run_tests.gd`.
- Godot erzeugt beim Import zu jedem Skript eine `.uid`-Datei. Sie gehört in den Commit. Der Ordner `game/.godot/` gehört nicht hinein.
- Werkzeugstand: Rust 1.98.1, Godot 4.7.2 (Standard-Ausgabe, im Pfad als `godot`), godot-rust 0.5.5, Git 2.54.0.
## Hinweis zur Vorabprüfung
Der gesamte Code dieses Plans wurde am 1. Oktober 2026 vorab gebaut. Danach wurde jede der acht Task-Grenzen einzeln in einer Kopie von `main` nachgestellt, jeweils mit dem roten und dem grünen Lauf, die der Plan beschreibt:
- Rust-Tests nach den Tasks 1, 2 und 3: 95, 104, 108; ab Task 4: 109, auch im Release-Modus.
- GDScript-Tests nach den Tasks 4 bis 7: 10, 22, 32, 35.
- Clippy und rustfmt meldeten nichts.
- Das Bildschirmfoto-Werkzeug aus Task 7 lieferte ein Bild.
Weicht ein Schritt ab, ist zuerst ein Übertragungsfehler zu vermuten.
## Dateiübersicht
| Datei | Aufgabe |
|---|---|
| `core/clippy.toml` | Sperrliste für Clippy: Pfade, die der Kern nie nutzen darf |
| `core/tests/forbidden_apis.rs` | Textwächter mit Selbsttest |
| `core/tests/no_dependencies.rs` | Hält die Abhängigkeiten des Kerns leer |
| `core/src/snapshot.rs` | Speicherstand Version 2: Prüfsumme, Prüfung beim Laden, `World::load` |
| `core/src/replay.rs` | `Recording::step` koppelt Aufzeichnung und Weltschritt |
| `core/tests/golden.rs` | Festgeschriebene Werte, erweitert um Offline-Spieler und Kartenformat |
| `Cargo.toml` | Workspace mit `core` und `bridge` |
| `bridge/src/lib.rs` | Einstiegspunkt der GDExtension |
| `bridge/src/session.rs` | `GwSession`: Sitzung für GDScript |
| `bridge/src/events.rs` | Ereignisse des Kerns als Dictionaries |
| `game/project.godot`, `game/grovewright.gdextension` | Godot-Projekt und Anbindung der Brücke |
| `game/main.tscn`, `game/scripts/main.gd` | Hauptszene: verbindet Kern, Darstellung, Figur, Anzeige und Dateien |
| `game/scripts/tick_clock.gd` | Bildzeit in Takte umrechnen |
| `game/scripts/grid_motion.gd` | Bewegung über das Kachelraster mit Wänden |
| `game/scripts/avatar.gd` | Die Spielfigur |
| `game/scripts/map_view.gd`, `game/scripts/tile_marker.gd` | Karte zeichnen, Feld unter der Maus markieren |
| `game/scripts/hud.gd` | Steinanzeige, Meldungen, Hilfezeile |
| `game/scripts/save_store.gd` | Spielerschlüssel, Speicherstand, Aufzeichnungen |
| `game/scripts/i18n.gd`, `game/i18n/texts.csv` | Texte in Deutsch und Englisch |
| `game/scripts/input_setup.gd` | Tastenbelegung |
| `game/tests/run_tests.gd` | Testläufer für GDScript |
| `game/tests/support/` | Testhelfer: `TestCase`, `TestFiles` |
| `game/tests/test_*.gd` | Tests der Hülle und der Brücke |
| `game/tools/screenshot.gd` | Bildschirmfoto ohne Handarbeit, für Änderungen am Aussehen |
| `play.bat` | Baut die Brücke und startet das Spiel |
| `docs/playtests/m1b.md` | Fragen für deinen Probelauf |
---
### Task 1: Wächter schärfen
**Files:**
- Create: `core/clippy.toml`
- Modify: `core/tests/forbidden_apis.rs` (ganze Datei ersetzen)
- Create: `core/tests/no_dependencies.rs`
**Interfaces:**
- Consumes: nichts
- Produces: Clippy meldet im Kern jede Nutzung der gesperrten Pfade als Fehler (`clippy::disallowed_types`, `clippy::disallowed_methods`). Der Textwächter hat eine prüfbare Funktion `violations(text)` und kennt fünf neue Begriffe.
Diese Task hat keinen klassischen roten Schritt, weil sie Wächter baut. Stattdessen weist Step 6 nach, dass die Wächter anschlagen.
- [ ] **Step 1: Auf den Branch wechseln**
Der Branch existiert bereits. Er wurde zusammen mit diesem Plan angelegt und hochgeladen.
```bash
git switch m1b-bruecke-und-huelle
```
Expected: `Already on 'm1b-bruecke-und-huelle'` oder `Switched to branch 'm1b-bruecke-und-huelle'`
- [ ] **Step 2: Sperrliste für Clippy anlegen**
Create `core/clippy.toml`:
```toml
# Paths the deterministic core must never use. Clippy resolves imports, so
# aliases and grouped imports such as `use std::{fs, env}` are caught too.
# The text search in core/tests/forbidden_apis.rs covers the rest.
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" },
]
```
- [ ] **Step 3: Textwächter ersetzen**
Ersetze den gesamten Inhalt von `core/tests/forbidden_apis.rs` durch:
```rust
//! Guards the lockstep rules: the core must not use anything whose result
//! can differ between machines or between runs.
//!
//! Clippy checks the same rules on resolved paths (see `core/clippy.toml`),
//! which also catches aliases and grouped imports. This text search adds
//! what Clippy cannot express, such as floating point types and global
//! mutable state, and it runs even when Clippy does not.
// Tests may read files. The rules of core/clippy.toml apply to core/src.
#![allow(clippy::disallowed_methods)]
use std::fs;
use std::path::{Path, PathBuf};
/// Text that must not appear anywhere in the source of the core, and why.
const FORBIDDEN: &[(&str, &str)] = &[
("f32", "floating point results can differ between machines"),
("f64", "floating point results can differ between machines"),
(
"HashMap",
"iteration order differs between runs; use BTreeMap or Vec",
),
(
"HashSet",
"iteration order differs between runs; use BTreeSet or Vec",
),
("std::time", "the core must not read the clock"),
("Instant", "the core must not read the clock"),
("SystemTime", "the core must not read the clock"),
("std::thread", "the core must stay single-threaded"),
("std::env", "the core must not depend on the environment"),
(
"std::fs",
"the core must not touch files; the shell does that",
),
(
"std::net",
"the core must not open connections; the shell does that",
),
(
"std::io",
"the core must not read or write streams; the shell does that",
),
("std::process", "the core must not start or end processes"),
("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"),
("unsafe", "the core forbids unsafe code"),
];
/// Every forbidden term in `text`, as (line number, term, reason).
fn violations(text: &str) -> Vec<(usize, &'static str, &'static str)> {
let mut found = Vec::new();
for (index, line) in text.lines().enumerate() {
for &(needle, reason) in FORBIDDEN {
if line.contains(needle) {
found.push((index + 1, needle, reason));
}
}
}
found
}
fn rust_files(dir: &Path, out: &mut Vec<PathBuf>) {
for entry in fs::read_dir(dir).expect("source directory is readable") {
let path = entry.expect("directory entry is readable").path();
if path.is_dir() {
rust_files(&path, out);
} else if path.extension().is_some_and(|ext| ext == "rs") {
out.push(path);
}
}
}
#[test]
fn core_source_uses_no_forbidden_apis() {
let src = Path::new(env!("CARGO_MANIFEST_DIR")).join("src");
let mut files = Vec::new();
rust_files(&src, &mut files);
files.sort();
assert!(
!files.is_empty(),
"no source files found in {}",
src.display()
);
let mut problems = Vec::new();
for file in &files {
let text = fs::read_to_string(file).expect("source file is readable");
for (line, needle, reason) in violations(&text) {
problems.push(format!("{}:{line}: `{needle}`: {reason}", file.display()));
}
}
assert!(
problems.is_empty(),
"forbidden APIs in the core:\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());
}
```
- [ ] **Step 4: Abhängigkeitstest anlegen**
Create `core/tests/no_dependencies.rs`:
```rust
//! 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()]
),
]
);
}
```
- [ ] **Step 5: Wächter-Tests ausführen**
Run: `cargo test -p grovewright-core --test forbidden_apis --test no_dependencies`
Expected: PASS mit `test result: ok. 3 passed` für `forbidden_apis` und `test result: ok. 2 passed` für `no_dependencies`
- [ ] **Step 6: Nachweisen, dass die Wächter anschlagen**
Hänge vorübergehend an `core/src/hash.rs` an:
```rust
use std::{collections::HashMap as Lookup};
pub fn leak() -> usize {
Lookup::<u8, u8>::new().len()
}
```
Run: `cargo clippy -p grovewright-core --lib -- -D warnings`
Expected: FAIL mit ``error: use of a disallowed type `std::collections::HashMap` ``. Clippy findet die Hash-Tabelle trotz Alias und gruppiertem Import.
Run: `cargo test -p grovewright-core --test forbidden_apis`
Expected: FAIL mit einer Zeile der Form ``...core\src\hash.rs:19: `HashMap`: iteration order differs between runs; use BTreeMap or Vec``
Mache die Änderung rückgängig:
```bash
git checkout -- core/src/hash.rs
```
- [ ] **Step 7: Alles prüfen**
Run:
```bash
cargo fmt --all -- --check && cargo clippy --workspace --all-targets -- -D warnings && cargo test --workspace
```
Expected: keine Ausgabe von rustfmt, keine Warnung von Clippy, zusammen 95 bestandene Tests
- [ ] **Step 8: Commit und Hochladen**
```bash
git add core/clippy.toml core/tests/forbidden_apis.rs core/tests/no_dependencies.rs
git commit -F - <<'EOF'
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 Opus 5.5 <noreply@anthropic.com>
EOF
git push -u origin m1b-bruecke-und-huelle
```
---
### Task 2: Speicherstand mit Prüfsumme und Prüfung beim Laden
**Files:**
- Modify: `core/src/snapshot.rs` (ganze Datei ersetzen)
- Modify: `core/src/world.rs` (eine Stelle)
- Modify: `core/src/replay.rs` (eine Stelle: Versionsnummer)
- Modify: `core/tests/snapshot.rs` (ganze Datei ersetzen)
- Modify: `core/tests/world.rs` (einen Test entfernen)
- Modify: `core/tests/golden.rs` (ganze Datei ersetzen)
**Interfaces:**
- Consumes: `World` mit seinen `pub(crate)`-Feldern, `fnv1a64`, `Map`, `Player`, `MAX_KNOWN_PLAYERS`, `MAX_ONLINE_PLAYERS`, `DIG_COOLDOWN_TICKS`
- Produces:
- `snapshot::SNAPSHOT_VERSION: u32 = 2`
- `snapshot::MAX_RESTORABLE_TICK: u64 = u64::MAX / 2`
- `snapshot::SnapshotError` mit den neuen Varianten `ChecksumMismatch` und `Invalid(&'static str)`. Die Texte von `Invalid` sind `"tick"`, `"online players"`, `"duplicate player key"` und `"next action tick"`.
- `World::to_snapshot` hängt eine FNV-1a-Prüfsumme (8 Bytes, Little Endian) über alle Bytes davor an.
- `World::from_snapshot` prüft Version, Prüfsumme und Zustand und behält, wer online ist. So bekommen später Gäste die Welt des Hosts.
- `World::load(bytes: &[u8]) -> Result<World, SnapshotError>`: wie `from_snapshot`, aber alle Spieler sind danach offline.
- `World::set_all_offline` ist nur noch innerhalb des Kerns sichtbar.
- `replay::RECORDING_VERSION: u32 = 2`, weil eine Aufzeichnung einen Snapshot enthält.
- [ ] **Step 1: Fehlschlagende Tests schreiben**
Ersetze den gesamten Inhalt von `core/tests/snapshot.rs` durch:
```rust
use grovewright_core::codec::{DecodeError, Writer};
use grovewright_core::command::Command;
use grovewright_core::hash::fnv1a64;
use grovewright_core::map::{Map, Pos, Terrain};
use grovewright_core::mapgen::outcrop_origin;
use grovewright_core::player::{Player, PlayerId, MAX_ONLINE_PLAYERS};
use grovewright_core::snapshot::{SnapshotError, MAX_RESTORABLE_TICK, SNAPSHOT_VERSION};
use grovewright_core::world::{Event, World, DIG_COOLDOWN_TICKS};
const ADA: PlayerId = PlayerId(0);
fn rock(world: &World) -> Pos {
outcrop_origin(world.map())
}
/// A world at tick 12 with one player who has hit a rock once.
fn played_world() -> World {
let mut world = World::new(7);
world.step(&[Command::Join {
key: [3; 16],
name: "Ada".to_owned(),
}]);
let pos = rock(&world);
world.step(&[Command::Dig { player: ADA, pos }]);
for _ in 0..10 {
world.step(&[]);
}
world
}
/// Builds snapshot bytes by hand: header, state and checksum. This lets tests
/// describe states that the rules would never produce.
fn snapshot_of(seed: u64, tick: u64, map: &Map, players: &[Player]) -> Vec<u8> {
let mut w = Writer::new();
w.raw(b"GRWS");
w.u32(SNAPSHOT_VERSION);
w.u64(seed);
w.u64(tick);
map.encode(&mut w);
w.u8(players.len() as u8);
for player in players {
player.encode(&mut w);
}
let mut bytes = w.into_bytes();
let checksum = fnv1a64(&bytes);
bytes.extend_from_slice(&checksum.to_le_bytes());
bytes
}
fn player(key: u8) -> Player {
Player::new([key; 16], "P")
}
#[test]
fn a_snapshot_restores_an_identical_world() {
let world = played_world();
let restored = World::from_snapshot(&world.to_snapshot()).unwrap();
assert_eq!(restored, world);
assert_eq!(restored.state_hash(), world.state_hash());
}
#[test]
fn a_snapshot_is_header_then_state_then_checksum() {
let world = played_world();
let by_hand = snapshot_of(world.seed(), world.tick(), world.map(), world.players());
assert_eq!(world.to_snapshot(), by_hand);
}
#[test]
fn the_state_hash_covers_every_part_of_the_state() {
let base = played_world();
let pos = rock(&base);
let mut waited = base.clone();
waited.step(&[]);
assert_ne!(waited.state_hash(), base.state_hash(), "tick");
let mut dug = base.clone();
dug.step(&[Command::Dig { player: ADA, pos }]);
assert_ne!(dug.state_hash(), waited.state_hash(), "map and cooldown");
let mut left = base.clone();
left.step(&[Command::Leave { player: ADA }]);
assert_ne!(left.state_hash(), waited.state_hash(), "players");
assert_ne!(
World::new(1).state_hash(),
World::new(2).state_hash(),
"seed"
);
}
#[test]
fn equal_worlds_have_equal_hashes() {
assert_eq!(played_world().state_hash(), played_world().state_hash());
}
#[test]
fn a_restored_world_continues_exactly_like_the_original() {
let mut original = played_world();
let mut restored = World::from_snapshot(&original.to_snapshot()).unwrap();
let pos = rock(&original);
for tick in 0..40u32 {
let commands = if tick.is_multiple_of(6) {
vec![Command::Dig { player: ADA, pos }]
} else {
vec![]
};
assert_eq!(original.step(&commands), restored.step(&commands));
}
assert_eq!(original.state_hash(), restored.state_hash());
assert_eq!(original, restored);
}
#[test]
fn from_snapshot_keeps_online_players_online() {
let restored = World::from_snapshot(&played_world().to_snapshot()).unwrap();
assert!(restored.player(ADA).unwrap().online);
}
#[test]
fn loading_a_save_starts_with_every_player_offline() {
let world = played_world();
let loaded = World::load(&world.to_snapshot()).unwrap();
assert!(loaded.players().iter().all(|player| !player.online));
assert_eq!(loaded.tick(), world.tick());
assert_eq!(loaded.map(), world.map());
assert_eq!(
loaded.player(ADA).unwrap().inventory,
world.player(ADA).unwrap().inventory
);
}
#[test]
fn a_returning_player_can_join_a_loaded_world() {
let mut loaded = World::load(&played_world().to_snapshot()).unwrap();
let events = loaded.step(&[Command::Join {
key: [3; 16],
name: "Ada".to_owned(),
}]);
assert_eq!(events, vec![Event::PlayerJoined { player: ADA }]);
}
#[test]
fn bytes_that_are_not_a_snapshot_are_rejected() {
assert_eq!(World::from_snapshot(b""), Err(SnapshotError::NotASnapshot));
assert_eq!(
World::from_snapshot(b"GR"),
Err(SnapshotError::NotASnapshot)
);
assert_eq!(
World::from_snapshot(b"nope, not a save"),
Err(SnapshotError::NotASnapshot)
);
}
#[test]
fn snapshots_of_another_version_are_rejected() {
let mut bytes = played_world().to_snapshot();
bytes[4..8].copy_from_slice(&(SNAPSHOT_VERSION + 1).to_le_bytes());
assert_eq!(
World::from_snapshot(&bytes),
Err(SnapshotError::UnsupportedVersion(SNAPSHOT_VERSION + 1))
);
}
#[test]
fn any_changed_byte_is_detected() {
let bytes = played_world().to_snapshot();
for index in [8, bytes.len() / 2, bytes.len() - 9, bytes.len() - 1] {
let mut changed = bytes.clone();
changed[index] ^= 0x01;
assert_eq!(
World::from_snapshot(&changed),
Err(SnapshotError::ChecksumMismatch),
"byte {index}"
);
}
}
#[test]
fn cut_off_and_padded_snapshots_are_rejected() {
let bytes = played_world().to_snapshot();
assert_eq!(
World::from_snapshot(&bytes[..bytes.len() - 1]),
Err(SnapshotError::ChecksumMismatch)
);
let mut padded = bytes.clone();
padded.push(0);
assert_eq!(
World::from_snapshot(&padded),
Err(SnapshotError::ChecksumMismatch)
);
assert_eq!(
World::from_snapshot(&bytes[..10]),
Err(SnapshotError::Corrupt(DecodeError::UnexpectedEnd))
);
}
#[test]
fn damaged_content_behind_a_valid_checksum_is_rejected() {
let world = played_world();
let mut bytes = snapshot_of(world.seed(), world.tick(), world.map(), world.players());
bytes.truncate(bytes.len() - 8);
bytes.push(0);
let checksum = fnv1a64(&bytes);
bytes.extend_from_slice(&checksum.to_le_bytes());
assert_eq!(
World::from_snapshot(&bytes),
Err(SnapshotError::Corrupt(DecodeError::TrailingBytes))
);
}
#[test]
fn states_the_rules_never_produce_are_rejected() {
let map = Map::filled(4, 3, Terrain::Ground);
let too_many_online: Vec<Player> = (0..=MAX_ONLINE_PLAYERS as u8).map(player).collect();
let mut offline_twin = player(1);
offline_twin.online = false;
let mut waiting_too_long = player(1);
waiting_too_long.next_action_tick = 5 + DIG_COOLDOWN_TICKS + 1;
let cases = [
(snapshot_of(1, MAX_RESTORABLE_TICK + 1, &map, &[]), "tick"),
(snapshot_of(1, 5, &map, &too_many_online), "online players"),
(
snapshot_of(1, 5, &map, &[player(1), offline_twin]),
"duplicate player key",
),
(
snapshot_of(1, 5, &map, &[waiting_too_long]),
"next action tick",
),
];
for (bytes, what) in cases {
assert_eq!(
World::from_snapshot(&bytes),
Err(SnapshotError::Invalid(what)),
"{what}"
);
assert_eq!(
World::load(&bytes),
Err(SnapshotError::Invalid(what)),
"{what}"
);
}
}
#[test]
fn states_right_at_the_limits_are_accepted() {
let map = Map::filled(4, 3, Terrain::Ground);
let mut players: Vec<Player> = (0..MAX_ONLINE_PLAYERS as u8).map(player).collect();
players[0].next_action_tick = MAX_RESTORABLE_TICK + DIG_COOLDOWN_TICKS;
let bytes = snapshot_of(1, MAX_RESTORABLE_TICK, &map, &players);
assert!(World::from_snapshot(&bytes).is_ok());
}
```
- [ ] **Step 2: Tests ausführen und Fehlschlag sehen**
Run: `cargo test -p grovewright-core --test snapshot`
Expected: FAIL mit Kompilierfehlern, darunter ``error[E0432]: unresolved import `grovewright_core::snapshot::MAX_RESTORABLE_TICK` ``, ``no associated function or constant named `load` found for struct `World` `` und ``no variant, associated function, or constant named `ChecksumMismatch` found for enum `SnapshotError` ``
- [ ] **Step 3: Speicherstand Version 2 umsetzen**
Ersetze den gesamten Inhalt von `core/src/snapshot.rs` durch:
```rust
//! Complete, versioned encoding of a world. Saves, joining players and state
//! checksums all use it.
//!
//! Layout: the marker `GRWS`, the format version, the encoded state, and an
//! FNV-1a checksum over everything before it.
use std::fmt;
use crate::codec::{DecodeError, Reader, Writer};
use crate::hash::fnv1a64;
use crate::map::Map;
use crate::player::{Player, MAX_KNOWN_PLAYERS, MAX_ONLINE_PLAYERS};
use crate::world::{World, DIG_COOLDOWN_TICKS};
const MAGIC: [u8; 4] = *b"GRWS";
/// Version of the snapshot format. Raise it whenever the encoded state
/// changes.
pub const SNAPSHOT_VERSION: u32 = 2;
/// Length of the checksum at the end of a snapshot.
const CHECKSUM_BYTES: usize = 8;
/// Highest tick a restored world may have. No real session gets near it,
/// and adding to it can never overflow.
pub const MAX_RESTORABLE_TICK: u64 = u64::MAX / 2;
/// Reason a snapshot could not be restored.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum SnapshotError {
/// The bytes do not start with the snapshot marker.
NotASnapshot,
/// The snapshot was written by an incompatible version of the game.
UnsupportedVersion(u32),
/// The checksum does not match: the bytes changed after they were
/// written.
ChecksumMismatch,
/// The snapshot is damaged.
Corrupt(DecodeError),
/// The bytes decode, but describe a state the rules can never produce.
Invalid(&'static str),
}
impl fmt::Display for SnapshotError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
SnapshotError::NotASnapshot => write!(f, "not a snapshot"),
SnapshotError::UnsupportedVersion(version) => {
write!(f, "unsupported snapshot version {version}")
}
SnapshotError::ChecksumMismatch => write!(f, "snapshot checksum does not match"),
SnapshotError::Corrupt(error) => write!(f, "damaged snapshot: {error}"),
SnapshotError::Invalid(what) => write!(f, "impossible state in snapshot: {what}"),
}
}
}
impl std::error::Error for SnapshotError {}
impl From<DecodeError> for SnapshotError {
fn from(error: DecodeError) -> Self {
SnapshotError::Corrupt(error)
}
}
impl World {
fn encode_state(&self, w: &mut Writer) {
w.u64(self.seed);
w.u64(self.tick);
self.map.encode(w);
w.u8(self.players.len() as u8);
for player in &self.players {
player.encode(w);
}
}
/// Checksum of the complete world state. Two worlds with the same value
/// are in the same state, apart from hash collisions.
pub fn state_hash(&self) -> u64 {
let mut w = Writer::new();
self.encode_state(&mut w);
fnv1a64(&w.into_bytes())
}
/// Encodes the complete world behind a header that names format and
/// version, followed by a checksum.
pub fn to_snapshot(&self) -> Vec<u8> {
let mut w = Writer::new();
w.raw(&MAGIC);
w.u32(SNAPSHOT_VERSION);
self.encode_state(&mut w);
let mut bytes = w.into_bytes();
let checksum = fnv1a64(&bytes);
bytes.extend_from_slice(&checksum.to_le_bytes());
bytes
}
/// Restores a world exactly as it was encoded, including which players
/// were online. Joining players receive the host's world this way.
pub fn from_snapshot(bytes: &[u8]) -> Result<World, SnapshotError> {
let mut r = Reader::new(bytes);
let magic = r
.raw(MAGIC.len())
.map_err(|_| SnapshotError::NotASnapshot)?;
if magic != MAGIC.as_slice() {
return Err(SnapshotError::NotASnapshot);
}
let version = r.u32()?;
if version != SNAPSHOT_VERSION {
return Err(SnapshotError::UnsupportedVersion(version));
}
if r.remaining() < CHECKSUM_BYTES {
return Err(DecodeError::UnexpectedEnd.into());
}
let (body, trailer) = bytes.split_at(bytes.len() - CHECKSUM_BYTES);
let mut checksum = [0u8; CHECKSUM_BYTES];
checksum.copy_from_slice(trailer);
if fnv1a64(body) != u64::from_le_bytes(checksum) {
return Err(SnapshotError::ChecksumMismatch);
}
let mut r = Reader::new(&body[MAGIC.len() + 4..]);
let seed = r.u64()?;
let tick = r.u64()?;
let map = Map::decode(&mut r)?;
let count = r.u8()? as usize;
if count > MAX_KNOWN_PLAYERS {
return Err(DecodeError::InvalidValue("player count").into());
}
let mut players = Vec::with_capacity(count);
for _ in 0..count {
players.push(Player::decode(&mut r)?);
}
r.finish()?;
let world = World {
seed,
tick,
map,
players,
};
world.check_restorable()?;
Ok(world)
}
/// Restores a saved world for a new session. Like
/// [`World::from_snapshot`], but every player starts offline, because
/// nobody has joined the new session yet.
pub fn load(bytes: &[u8]) -> Result<World, SnapshotError> {
let mut world = World::from_snapshot(bytes)?;
world.set_all_offline();
Ok(world)
}
/// Rejects states that the rules can never produce, so that a damaged or
/// crafted snapshot fails here instead of breaking a later tick.
fn check_restorable(&self) -> Result<(), SnapshotError> {
if self.tick > MAX_RESTORABLE_TICK {
return Err(SnapshotError::Invalid("tick"));
}
let online = self.players.iter().filter(|player| player.online).count();
if online > MAX_ONLINE_PLAYERS {
return Err(SnapshotError::Invalid("online players"));
}
for (index, player) in self.players.iter().enumerate() {
if self.players[..index]
.iter()
.any(|other| other.key == player.key)
{
return Err(SnapshotError::Invalid("duplicate player key"));
}
if player.next_action_tick > self.tick + DIG_COOLDOWN_TICKS {
return Err(SnapshotError::Invalid("next action tick"));
}
}
Ok(())
}
}
```
- [ ] **Step 4: `set_all_offline` auf den Kern beschränken**
Ersetze in `core/src/world.rs`:
```rust
/// Marks every player as offline. Used after loading a save from disk,
/// when nobody has joined yet.
pub fn set_all_offline(&mut self) {
```
durch:
```rust
/// Marks every player as offline. Only [`World::load`] uses it: a loaded
/// save starts a new session, which nobody has joined yet. Called during
/// a running session on one machine, it would split the worlds of the
/// players apart.
pub(crate) fn set_all_offline(&mut self) {
```
Entferne in `core/tests/world.rs` den Test, der die Methode von außen aufruft. Der neue Test `loading_a_save_starts_with_every_player_offline` in `core/tests/snapshot.rs` ersetzt ihn. Zu löschen ist genau dieser Block samt der folgenden Leerzeile:
```rust
#[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));
}
```
- [ ] **Step 5: Tests ausführen und Erfolg sehen**
Run: `cargo test -p grovewright-core --test snapshot --test world`
Expected: PASS mit `test result: ok. 15 passed` für `snapshot` und `test result: ok. 15 passed` für `world`
- [ ] **Step 6: Die festgeschriebenen Werte schlagen an, wie beabsichtigt**
Run: `cargo test -p grovewright-core --test golden`
Expected: FAIL in `the_starting_snapshot_is_unchanged` und `the_recording_format_is_unchanged`. `the_frozen_session_ends_in_the_same_state` besteht weiter.
Das ist gewollt. Das Speicherformat hat sich geändert, die Spielregeln nicht. `SNAPSHOT_VERSION` ist im neuen `snapshot.rs` bereits 2. Weil jede Aufzeichnung einen Snapshot enthält, sind auch alte Aufzeichnungen betroffen. Ersetze deshalb in `core/src/replay.rs`:
```rust
/// Version of the recording format.
pub const RECORDING_VERSION: u32 = 1;
```
durch:
```rust
/// Version of the recording format. Raise it whenever the encoded recording,
/// including the snapshot inside it, changes.
pub const RECORDING_VERSION: u32 = 2;
```
Ersetze dann den gesamten Inhalt von `core/tests/golden.rs` durch die folgende Fassung. Sie enthält die neuen Werte für Snapshot und Aufzeichnung, einen besseren Kommentar und zwei neue Pins: einen Spieler, der offline ist, und das Byte-Format einer nicht quadratischen Karte.
```rust
//! Pinned values for everything that must not change unnoticed.
//!
//! Most other tests compare two computations inside the same build, so a
//! change that affects both sides alike stays green. This file compares
//! against values recorded once. If one of them fails, rules or encodings
//! changed, on purpose or by accident. If saves or recordings are affected,
//! raise the version constant named in the failure message, then update the
//! value here deliberately.
use grovewright_core::codec::Writer;
use grovewright_core::command::Command;
use grovewright_core::hash::fnv1a64;
use grovewright_core::map::{Map, Pos, Terrain, Tile};
use grovewright_core::mapgen::outcrop_origin;
use grovewright_core::player::PlayerId;
use grovewright_core::replay::Recording;
use grovewright_core::world::World;
const SEED: u64 = 2026;
/// The commands of a session of 60 ticks. It is built from literal values
/// and the outcrop origin of the map, not from constants such as
/// `DIG_COOLDOWN_TICKS`, so that changing a rule changes the result and not
/// the script.
///
/// Never change or extend this script. To pin more, add a new script and a
/// new pinned value instead.
fn frozen_script(world: &World) -> Vec<Vec<Command>> {
let origin = outcrop_origin(world.map());
let right_of_origin = Pos::new(origin.x + 1, origin.y);
// Pairs of (tick, command). Commands of the same tick are applied in the
// order listed here.
let scripted = [
// Two players join in the same tick.
(
0,
Command::Join {
key: [1; 16],
name: "Ada".to_owned(),
},
),
(
0,
Command::Join {
key: [2; 16],
name: "Grace".to_owned(),
},
),
// Player 0 hits the outcrop origin three times, five ticks apart.
// The third hit breaks the rock and gives a stone.
(
2,
Command::Dig {
player: PlayerId(0),
pos: origin,
},
),
(
7,
Command::Dig {
player: PlayerId(0),
pos: origin,
},
),
(
12,
Command::Dig {
player: PlayerId(0),
pos: origin,
},
),
// Player 1 hits the tile to the right of the origin once.
(
20,
Command::Dig {
player: PlayerId(1),
pos: right_of_origin,
},
),
// Player 1 leaves and returns with a name of 40 bytes, which the
// world shortens to 32.
(
30,
Command::Leave {
player: PlayerId(1),
},
),
(
40,
Command::Join {
key: [2; 16],
name: "ä".repeat(20),
},
),
];
let mut ticks = vec![Vec::new(); 60];
for (tick, command) in scripted {
ticks[tick].push(command);
}
ticks
}
/// The commands of a second session of 30 ticks that ends with one player
/// offline, so that the encoding of offline players is pinned as well.
///
/// Never change or extend this script either.
fn frozen_offline_script(world: &World) -> Vec<Vec<Command>> {
let origin = outcrop_origin(world.map());
let scripted = [
(
0,
Command::Join {
key: [7; 16],
name: "Linus".to_owned(),
},
),
(
1,
Command::Join {
key: [8; 16],
name: "Margaret".to_owned(),
},
),
(
3,
Command::Dig {
player: PlayerId(1),
pos: origin,
},
),
(
10,
Command::Leave {
player: PlayerId(0),
},
),
];
let mut ticks = vec![Vec::new(); 30];
for (tick, command) in scripted {
ticks[tick].push(command);
}
ticks
}
/// Covers the map generator, the map encoding and the snapshot header and
/// checksum.
#[test]
fn the_starting_snapshot_is_unchanged() {
let actual = fnv1a64(&World::new(SEED).to_snapshot());
assert_eq!(
actual, 0x6812_937b_f2b2_19ae,
"the starting world or the snapshot format changed; if saves are \
affected, raise SNAPSHOT_VERSION in core/src/snapshot.rs; then \
update this value"
);
}
/// Covers the rules of joining, leaving and digging, the shortening of names
/// and the encoding of players.
#[test]
fn the_frozen_session_ends_in_the_same_state() {
let mut world = World::new(SEED);
for commands in frozen_script(&world) {
world.step(&commands);
}
assert_eq!(
world.state_hash(),
0xc304_43df_fc74_dcfc,
"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 encoding of a player who is offline.
#[test]
fn the_frozen_offline_session_ends_in_the_same_state() {
let mut world = World::new(SEED);
for commands in frozen_offline_script(&world) {
world.step(&commands);
}
assert_eq!(
world.state_hash(),
0x904f_10e1_7c52_8be4,
"the rules or the snapshot format changed; old recordings no longer \
verify; if saves are affected, raise SNAPSHOT_VERSION in \
core/src/snapshot.rs; then update this value"
);
}
/// Covers the recording header and layout and the wire format of commands.
#[test]
fn the_recording_format_is_unchanged() {
let mut world = World::new(SEED);
let mut recording = Recording::start(&world);
for commands in frozen_script(&world) {
recording.push_tick(&commands);
world.step(&commands);
}
assert_eq!(
fnv1a64(&recording.to_bytes(&world)),
0x1c24_8cc7_6bc8_846a,
"the recording or command format changed; if other tests in this \
file fail as well, deal with those first, because a recording \
contains a snapshot and an end state; otherwise raise \
RECORDING_VERSION in core/src/replay.rs; then update this value"
);
}
/// Covers the byte layout of a map: width before height, little endian,
/// terrain numbers and damage. The map is not square, so a swap of width
/// and height shows.
#[test]
fn the_map_encoding_is_unchanged() {
let mut map = Map::filled(3, 2, Terrain::Ground);
map.set(Pos::new(1, 0), Tile::new(Terrain::Bedrock));
map.set(Pos::new(0, 1), Tile::new(Terrain::HardRock));
map.set(
Pos::new(2, 1),
Tile {
terrain: Terrain::SoftRock,
damage: 1,
},
);
let mut w = Writer::new();
map.encode(&mut w);
assert_eq!(
w.into_bytes(),
vec![
3, 0, 0, 0, // width
2, 0, 0, 0, // height
0, 0, 3, 0, 0, 0, // row 0: ground, bedrock, ground
2, 0, 0, 0, 1, 1, // row 1: hard rock, ground, soft rock hit once
],
"the map encoding changed; raise SNAPSHOT_VERSION in \
core/src/snapshot.rs; then update these bytes"
);
}
```
- [ ] **Step 7: Festgeschriebene Werte prüfen**
Run: `cargo test -p grovewright-core --test golden`
Expected: PASS mit `test result: ok. 5 passed`
- [ ] **Step 8: Alles prüfen**
Run:
```bash
cargo fmt --all -- --check && cargo clippy --workspace --all-targets -- -D warnings && cargo test --workspace
```
Expected: keine Ausgabe von rustfmt, keine Warnung von Clippy, zusammen 104 bestandene Tests
- [ ] **Step 9: Commit und Hochladen**
```bash
git add core/src/snapshot.rs core/src/world.rs core/src/replay.rs core/tests/snapshot.rs core/tests/world.rs core/tests/golden.rs
git commit -F - <<'EOF'
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 Opus 5.5 <noreply@anthropic.com>
EOF
git push
```
---
### Task 3: Aufzeichnung an den Weltschritt koppeln
**Files:**
- Modify: `core/src/replay.rs` (ganze Datei ersetzen)
- Modify: `core/tests/replay.rs` (ganze Datei ersetzen)
- Modify: `core/tests/determinism.rs` (eine Stelle)
- Modify: `core/tests/golden.rs` (eine Stelle)
**Interfaces:**
- Consumes: `World::to_snapshot`, `World::from_snapshot`, `World::state_hash`, `World::step`, `World::tick`, `snapshot::SnapshotError`, `world::Event`
- Produces:
- `Recording::step(&mut self, world: &mut World, commands: &[Command]) -> Vec<Event>`: führt den Takt aus und zeichnet ihn auf. Panik, wenn `world` nicht beim Takt `end_tick()` steht.
- `Recording::end_tick(&self) -> u64`: Takt der aufgezeichneten Welt nach dem letzten Takt.
- `Recording::push_tick` entfällt.
- `Recording::to_bytes(&self, end: &World)`: Panik, wenn `end` nicht bei `end_tick()` steht.
- `Recording::from_bytes` stellt den Startzustand sofort wieder her und meldet einen beschädigten Start als `BadStart`.
- [ ] **Step 1: Fehlschlagende Tests schreiben**
Ersetze den gesamten Inhalt von `core/tests/replay.rs` durch:
```rust
use grovewright_core::codec::DecodeError;
use grovewright_core::command::Command;
use grovewright_core::inventory::ITEM_STONE;
use grovewright_core::mapgen::outcrop_origin;
use grovewright_core::player::PlayerId;
use grovewright_core::replay::{Recording, RecordingError, RECORDING_VERSION};
use grovewright_core::snapshot::SnapshotError;
use grovewright_core::world::World;
const ADA: PlayerId = PlayerId(0);
/// Commands of tick `tick` in a short scripted session.
fn commands_for(world: &World, tick: u32) -> Vec<Command> {
if tick == 0 {
vec![Command::Join {
key: [1; 16],
name: "Ada".to_owned(),
}]
} else if tick.is_multiple_of(7) {
vec![Command::Dig {
player: ADA,
pos: outcrop_origin(world.map()),
}]
} else {
vec![]
}
}
/// Plays 200 ticks from a fresh world and records them.
fn recorded_session() -> (Recording, World) {
let mut world = World::new(11);
let mut recording = Recording::start(&world);
for tick in 0..200 {
let commands = commands_for(&world, tick);
recording.step(&mut world, &commands);
}
(recording, world)
}
#[test]
fn stepping_through_a_recording_gives_the_events_of_the_world() {
let mut plain = World::new(11);
let mut recorded = World::new(11);
let mut recording = Recording::start(&recorded);
for tick in 0..30 {
let commands = commands_for(&plain, tick);
assert_eq!(
recording.step(&mut recorded, &commands),
plain.step(&commands),
"tick {tick}"
);
}
assert_eq!(recorded, plain);
assert_eq!(recording.end_tick(), 30);
}
#[test]
fn replaying_reaches_the_same_state() {
let (recording, world) = recorded_session();
assert_eq!(recording.tick_count(), 200);
assert_eq!(world.player(ADA).unwrap().inventory.count(ITEM_STONE), 1);
assert_eq!(recording.replay(), Ok(world));
}
#[test]
fn a_recording_survives_encoding() {
let (recording, world) = recorded_session();
let bytes = recording.to_bytes(&world);
let (decoded, expected_hash) = Recording::from_bytes(&bytes).unwrap();
assert_eq!(decoded, recording);
assert_eq!(expected_hash, world.state_hash());
}
#[test]
fn verify_accepts_a_faithful_recording() {
let (recording, world) = recorded_session();
assert_eq!(Recording::verify(&recording.to_bytes(&world)), Ok(world));
}
#[test]
fn verify_reports_a_replay_that_ends_in_another_state() {
let (recording, world) = recorded_session();
let mut bytes = recording.to_bytes(&world);
// The expected state hash follows the marker and the version.
bytes[8] ^= 0x01;
match Recording::verify(&bytes) {
Err(RecordingError::Diverged { expected, actual }) => {
assert_eq!(actual, world.state_hash());
assert_ne!(expected, actual);
}
other => panic!("expected a divergence, got {other:?}"),
}
}
#[test]
fn a_recording_can_start_in_the_middle_of_a_session() {
let mut world = World::new(11);
for tick in 0..50 {
let commands = commands_for(&world, tick);
world.step(&commands);
}
let mut recording = Recording::start(&world);
for tick in 50..120 {
let commands = commands_for(&world, tick);
recording.step(&mut world, &commands);
}
assert_eq!(recording.tick_count(), 70);
assert_eq!(recording.end_tick(), 120);
assert_eq!(recording.replay(), Ok(world));
}
#[test]
fn an_empty_recording_replays_to_its_starting_state() {
let world = World::new(3);
let recording = Recording::start(&world);
assert_eq!(recording.tick_count(), 0);
assert_eq!(Recording::verify(&recording.to_bytes(&world)), Ok(world));
}
#[test]
#[should_panic(expected = "the world is not at the tick this recording has reached")]
fn a_recording_refuses_a_world_it_does_not_follow() {
let mut world = World::new(3);
let mut recording = Recording::start(&world);
world.step(&[]);
recording.step(&mut world, &[]);
}
#[test]
#[should_panic(expected = "the end state is not at the tick this recording has reached")]
fn a_recording_refuses_an_end_state_at_another_tick() {
let (recording, mut world) = recorded_session();
world.step(&[]);
recording.to_bytes(&world);
}
#[test]
fn damaged_recordings_are_rejected() {
let (recording, world) = recorded_session();
let bytes = recording.to_bytes(&world);
assert_eq!(
Recording::from_bytes(b""),
Err(RecordingError::NotARecording)
);
assert_eq!(
Recording::from_bytes(&world.to_snapshot()),
Err(RecordingError::NotARecording)
);
let mut other_version = bytes.clone();
other_version[4..8].copy_from_slice(&(RECORDING_VERSION + 1).to_le_bytes());
assert_eq!(
Recording::from_bytes(&other_version),
Err(RecordingError::UnsupportedVersion(RECORDING_VERSION + 1))
);
assert_eq!(
Recording::from_bytes(&bytes[..bytes.len() - 1]),
Err(RecordingError::Corrupt(DecodeError::UnexpectedEnd))
);
let mut padded = bytes.clone();
padded.push(0);
assert_eq!(
Recording::from_bytes(&padded),
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))
);
}
```
- [ ] **Step 2: Tests ausführen und Fehlschlag sehen**
Run: `cargo test -p grovewright-core --test replay`
Expected: FAIL mit Kompilierfehlern ``no method named `step` found for struct `Recording` `` und ``no method named `end_tick` found for struct `Recording` ``
- [ ] **Step 3: Aufzeichnung umbauen**
Ersetze den gesamten Inhalt von `core/src/replay.rs` durch:
```rust
//! Recording of a session: the starting state and every command, tick by
//! tick. Replaying a recording reproduces the session exactly.
use std::fmt;
use crate::codec::{DecodeError, Reader, Writer};
use crate::command::Command;
use crate::snapshot::SnapshotError;
use crate::world::{Event, World};
const MAGIC: [u8; 4] = *b"GRWR";
/// Version of the recording format. Raise it whenever the encoded recording,
/// including the snapshot inside it, changes.
pub const RECORDING_VERSION: u32 = 2;
/// Reason a recording could not be read or replayed.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum RecordingError {
/// The bytes do not start with the recording marker.
NotARecording,
/// The recording was written by an incompatible version of the game.
UnsupportedVersion(u32),
/// The recording is damaged.
Corrupt(DecodeError),
/// The starting state inside the recording could not be restored.
BadStart(SnapshotError),
/// Replaying did not end in the recorded state.
Diverged { expected: u64, actual: u64 },
}
impl fmt::Display for RecordingError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
RecordingError::NotARecording => write!(f, "not a recording"),
RecordingError::UnsupportedVersion(version) => {
write!(f, "unsupported recording version {version}")
}
RecordingError::Corrupt(error) => write!(f, "damaged recording: {error}"),
RecordingError::BadStart(error) => write!(f, "unusable starting state: {error}"),
RecordingError::Diverged { expected, actual } => write!(
f,
"replay ended in state {actual:016x} instead of {expected:016x}"
),
}
}
}
impl std::error::Error for RecordingError {}
impl From<DecodeError> for RecordingError {
fn from(error: DecodeError) -> Self {
RecordingError::Corrupt(error)
}
}
/// The starting state of a session and the commands of every tick after it.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Recording {
start: Vec<u8>,
start_tick: u64,
tick_count: u32,
/// Ticks that had commands, as (tick index, commands), in rising order.
busy_ticks: Vec<(u32, Vec<Command>)>,
}
impl Recording {
/// Starts a recording at the current state of `world`.
pub fn start(world: &World) -> Self {
Self {
start: world.to_snapshot(),
start_tick: world.tick(),
tick_count: 0,
busy_ticks: Vec::new(),
}
}
/// Advances `world` by one tick with `commands` and records them. This is
/// the only way to add to a recording, so it can never hold other
/// commands than the ones the world executed.
///
/// # Panics
/// Panics if `world` is not at [`Recording::end_tick`], which means it is
/// not the world this recording follows.
pub fn step(&mut self, world: &mut World, commands: &[Command]) -> Vec<Event> {
assert_eq!(
world.tick(),
self.end_tick(),
"the world is not at the tick this recording has reached"
);
if !commands.is_empty() {
self.busy_ticks.push((self.tick_count, commands.to_vec()));
}
self.tick_count = self
.tick_count
.checked_add(1)
.expect("a recording holds fewer than 2^32 ticks");
world.step(commands)
}
/// Number of recorded ticks.
pub fn tick_count(&self) -> u32 {
self.tick_count
}
/// Tick of the recorded world after the last recorded tick.
pub fn end_tick(&self) -> u64 {
self.start_tick + u64::from(self.tick_count)
}
/// Restores the starting state and applies every recorded tick.
pub fn replay(&self) -> Result<World, RecordingError> {
let mut world = World::from_snapshot(&self.start).map_err(RecordingError::BadStart)?;
let mut busy = self.busy_ticks.iter().peekable();
for index in 0..self.tick_count {
match busy.peek() {
Some((tick, commands)) if *tick == index => {
world.step(commands);
busy.next();
}
_ => {
world.step(&[]);
}
}
}
Ok(world)
}
/// Encodes the recording together with the state that replaying it must
/// reach. `end` is the world after the last recorded tick.
///
/// # Panics
/// Panics if `end` is not at [`Recording::end_tick`].
pub fn to_bytes(&self, end: &World) -> Vec<u8> {
assert_eq!(
end.tick(),
self.end_tick(),
"the end state is not at the tick this recording has reached"
);
let mut w = Writer::new();
w.raw(&MAGIC);
w.u32(RECORDING_VERSION);
w.u64(end.state_hash());
w.bytes(&self.start);
w.u32(self.tick_count);
w.u32(u32::try_from(self.busy_ticks.len()).expect("busy tick count fits in u32"));
for (tick, commands) in &self.busy_ticks {
w.u32(*tick);
w.u16(u16::try_from(commands.len()).expect("command count fits in u16"));
for command in commands {
command.encode(&mut w);
}
}
w.into_bytes()
}
/// Decodes a recording and the state hash that replaying it must reach.
pub fn from_bytes(bytes: &[u8]) -> Result<(Self, u64), RecordingError> {
let mut r = Reader::new(bytes);
let magic = r
.raw(MAGIC.len())
.map_err(|_| RecordingError::NotARecording)?;
if magic != MAGIC.as_slice() {
return Err(RecordingError::NotARecording);
}
let version = r.u32()?;
if version != RECORDING_VERSION {
return Err(RecordingError::UnsupportedVersion(version));
}
let expected_hash = r.u64()?;
let start = r.bytes()?.to_vec();
let start_tick = World::from_snapshot(&start)
.map_err(RecordingError::BadStart)?
.tick();
let tick_count = r.u32()?;
let busy_count = r.u32()?;
let mut busy_ticks = Vec::new();
let mut first_free_tick = 0;
for _ in 0..busy_count {
let tick = r.u32()?;
if tick < first_free_tick || tick >= tick_count {
return Err(DecodeError::InvalidValue("tick index").into());
}
first_free_tick = tick + 1;
let command_count = r.u16()?;
if command_count == 0 {
return Err(DecodeError::InvalidValue("command count").into());
}
let mut commands = Vec::new();
for _ in 0..command_count {
commands.push(Command::decode(&mut r)?);
}
busy_ticks.push((tick, commands));
}
r.finish()?;
Ok((
Self {
start,
start_tick,
tick_count,
busy_ticks,
},
expected_hash,
))
}
/// Replays `bytes` and checks that the recorded final state is reached.
pub fn verify(bytes: &[u8]) -> Result<World, RecordingError> {
let (recording, expected) = Self::from_bytes(bytes)?;
let world = recording.replay()?;
let actual = world.state_hash();
if actual == expected {
Ok(world)
} else {
Err(RecordingError::Diverged { expected, actual })
}
}
}
```
Ersetze in `core/tests/determinism.rs`:
```rust
for commands in &script {
recording.push_tick(commands);
world.step(commands);
}
```
durch:
```rust
for commands in &script {
recording.step(&mut world, commands);
}
```
Ersetze in `core/tests/golden.rs`:
```rust
for commands in frozen_script(&world) {
recording.push_tick(&commands);
world.step(&commands);
}
```
durch:
```rust
for commands in frozen_script(&world) {
recording.step(&mut world, &commands);
}
```
- [ ] **Step 4: Tests ausführen und Erfolg sehen**
Run: `cargo test -p grovewright-core --test replay --test determinism --test golden`
Expected: PASS mit `test result: ok. 11 passed` für `replay`, `test result: ok. 5 passed` für `determinism` und `test result: ok. 5 passed` für `golden`. Die festgeschriebenen Werte ändern sich nicht, denn das Format der Aufzeichnung bleibt gleich.
- [ ] **Step 5: Alles prüfen**
Run:
```bash
cargo fmt --all -- --check && cargo clippy --workspace --all-targets -- -D warnings && cargo test --workspace
```
Expected: keine Ausgabe von rustfmt, keine Warnung von Clippy, zusammen 108 bestandene Tests
- [ ] **Step 6: Commit und Hochladen**
```bash
git add core/src/replay.rs core/tests/replay.rs core/tests/determinism.rs core/tests/golden.rs
git commit -F - <<'EOF'
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 Opus 5.5 <noreply@anthropic.com>
EOF
git push
```
---
### Task 4: Brücke und Godot-Projekt
**Files:**
- Modify: `Cargo.toml` (Workspace um `bridge` erweitern)
- Modify: `.gitignore`
- Create: `bridge/Cargo.toml`, `bridge/src/lib.rs`, `bridge/src/events.rs`, `bridge/src/session.rs`
- Create: `game/project.godot`, `game/grovewright.gdextension`
- Create: `game/tests/run_tests.gd`, `game/tests/support/test_case.gd`
- Test: `game/tests/test_session.gd`
- Generated: `Cargo.lock` (godot-rust kommt dazu), `.uid`-Dateien von Godot
**Interfaces:**
- Consumes: aus dem Kern `World::new`, `World::load`, `World::step` über `Recording::step`, `World::map`, `World::player`, `World::find_player`, `World::to_snapshot`, `World::state_hash`, `Recording::start`, `Recording::to_bytes`, `Command`, `Event`, `Terrain`, `Pos`, `PlayerKey`, `PlayerId`, `ItemId`, `ITEM_STONE`, `spawn_pos`, `TICKS_PER_SECOND`, `DIG_COOLDOWN_TICKS`
- Produces, für GDScript, die Klasse `GwSession`:
- Konstanten `TICKS_PER_SECOND` (20), `DIG_COOLDOWN_TICKS` (5), `TERRAIN_GROUND` (0), `TERRAIN_SOFT_ROCK` (1), `TERRAIN_HARD_ROCK` (2), `TERRAIN_BEDROCK` (3), `ITEM_STONE` (1)
- `start_new(seed: int)`, `start_loaded(save: PackedByteArray) -> bool`, `last_error() -> String`, `is_running() -> bool`, `is_broken() -> bool`
- `join(key: PackedByteArray, name: String) -> bool` (Schlüssel mit 16 Bytes), `dig(x: int, y: int)`, `step() -> Array[Dictionary]`
- `tick() -> int`, `map_width() -> int`, `map_height() -> int`, `terrain_codes() -> PackedByteArray`, `damage_codes() -> PackedByteArray`, `terrain_at(x, y) -> int` (-1 außerhalb), `is_walkable(x, y) -> bool`, `spawn_point() -> Vector2i`, `local_player() -> int` (-1 vor dem Beitritt), `item_count(item: int) -> int`
- `save() -> PackedByteArray`, `recording_bytes() -> PackedByteArray` (beide leer, wenn die Sitzung defekt ist), `state_hash_hex() -> String`
- Ereignisse aus `step()` als Dictionaries mit `type` gleich `player_joined`, `player_left`, `join_refused`, `tile_hit`, `tile_changed`, `item_gained` oder `inventory_full`, dazu `player`, `x`, `y`, `item`, `count`, wo vorhanden
- Produces, für alle weiteren Tests: den Testläufer `res://tests/run_tests.gd` und die Basisklasse `TestCase` mit `check(condition, message)`, `check_eq(actual, expected, message)` und `tree: SceneTree`
- [ ] **Step 1: Godot-Projekt und Testläufer anlegen**
Ersetze den gesamten Inhalt von `.gitignore` durch:
```text
# Rust
/target/
# Godot
/game/.godot/
```
Create `game/project.godot`:
```ini
; Engine configuration file.
; Input actions are registered in code (scripts/input_setup.gd), so this file
; holds no serialized input events.
config_version=5
[application]
config/name="Grovewright"
config/features=PackedStringArray("4.7", "GL Compatibility")
[display]
window/size/viewport_width=1280
window/size/viewport_height=720
[rendering]
renderer/rendering_method="gl_compatibility"
renderer/rendering_method.mobile="gl_compatibility"
```
Create `game/grovewright.gdextension`:
```ini
[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"
```
Create `game/tests/run_tests.gd`:
```gdscript
extends SceneTree
## Runs every test script in res://tests whose file name starts with "test_".
## Every method whose name starts with "test_" is one test. A test fails if a
## check fails or if an error is reported while it runs, for example a call
## on a null value. Exits with code 1 if any test failed.
##
## From the repository root:
## godot --headless --path game -s res://tests/run_tests.gd
const TEST_DIR := "res://tests"
## Counts the errors that are reported while tests run. Warnings do not count.
class ErrorCounter extends Logger:
var _mutex := Mutex.new()
var _errors := 0
func errors() -> int:
_mutex.lock()
var count := _errors
_mutex.unlock()
return count
func _log_error(
_function: String,
_file: String,
_line: int,
_code: String,
_rationale: String,
_editor_notify: bool,
error_type: int,
_script_backtraces: Array[ScriptBacktrace]
) -> void:
if error_type == ERROR_TYPE_WARNING:
return
_mutex.lock()
_errors += 1
_mutex.unlock()
func _initialize() -> void:
_run_all.call_deferred()
func _run_all() -> void:
var counter := ErrorCounter.new()
OS.add_logger(counter)
var passed := 0
var failed := 0
var files := DirAccess.get_files_at(TEST_DIR)
files.sort()
for file in files:
if not (file.begins_with("test_") and file.ends_with(".gd")):
continue
var script: GDScript = load(TEST_DIR.path_join(file))
if script == null or not script.can_instantiate():
failed += 1
print("FAIL %s the script does not load; see the error above" % file)
continue
for method in script.get_script_method_list():
var test_name: String = method["name"]
if not test_name.begins_with("test_"):
continue
var test: TestCase = script.new()
test.tree = self
var errors_before := counter.errors()
await test.call(test_name)
var errors := counter.errors() - errors_before
if errors > 0:
test.failures.append("%d error(s) reported while the test ran; see above" % errors)
if test.failures.is_empty():
passed += 1
print("ok %s %s" % [file, test_name])
else:
failed += 1
print("FAIL %s %s" % [file, test_name])
for failure in test.failures:
print(" " + failure)
OS.remove_logger(counter)
print("%d passed, %d failed" % [passed, failed])
quit(1 if failed > 0 else 0)
```
Create `game/tests/support/test_case.gd`:
```gdscript
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])
```
- [ ] **Step 2: Fehlschlagenden Test der Brücke schreiben**
Create `game/tests/test_session.gd`:
```gdscript
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")
```
- [ ] **Step 3: Test ausführen und Fehlschlag sehen**
Run:
```bash
godot --headless --path game --import
godot --headless --path game -s res://tests/run_tests.gd
```
Expected: Der Import endet ohne Abbruch. Der Testlauf endet mit Exit-Code 1 und `0 passed, 1 failed`. Davor stehen `ERROR: GDExtension dynamic library not found: 'res://grovewright.gdextension'.` und `FAIL test_session.gd the script does not load; see the error above`.
- [ ] **Step 4: Brücke anlegen**
Ersetze den gesamten Inhalt von `Cargo.toml` (Workspace-Wurzel) durch:
```toml
[workspace]
resolver = "2"
members = ["core", "bridge"]
# The shipped game and the tests must agree on what happens when a number
# overflows, so release builds keep the overflow checks that debug builds have.
[profile.release]
overflow-checks = true
```
Create `bridge/Cargo.toml`:
```toml
[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"
```
Create `bridge/src/lib.rs`:
```rust
//! 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 {}
```
Create `bridge/src/events.rs`:
```rust
//! 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),
},
}
}
```
Create `bridge/src/session.rs`:
```rust
//! 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);
}
}
```
- [ ] **Step 5: Brücke bauen**
Run: `cargo build -p grovewright-bridge`
Expected: `Finished` ohne Warnung. Der erste Bau lädt und übersetzt godot-rust und dauert einige Minuten. Danach liegt `target/debug/grovewright_bridge.dll` vor.
- [ ] **Step 6: Test ausführen und Erfolg sehen**
Run:
```bash
godot --headless --path game --import
godot --headless --path game -s res://tests/run_tests.gd
```
Expected: Exit-Code 0 und als letzte Zeile `10 passed, 0 failed`. Beim Import darf eine Warnung über eine gekürzte `.pdb`-Datei erscheinen; Godot legt damit eine Kopie für das Neuladen der Erweiterung an.
- [ ] **Step 7: Alles prüfen**
Run:
```bash
cargo fmt --all -- --check && cargo clippy --workspace --all-targets -- -D warnings && cargo test --workspace
```
Expected: keine Ausgabe von rustfmt, keine Warnung von Clippy, zusammen 109 bestandene Tests (108 im Kern, 1 in der Brücke)
- [ ] **Step 8: Commit und Hochladen**
`git status --short` zeigt jetzt auch die von Godot erzeugten `.uid`-Dateien neben den Skripten und neben `grovewright.gdextension`. Sie gehören in den Commit. `game/.godot/` erscheint nicht, weil `.gitignore` es ausschließt.
```bash
git add .gitignore Cargo.toml Cargo.lock bridge game
git commit -F - <<'EOF'
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 Opus 5.5 <noreply@anthropic.com>
EOF
git push
```
---
### Task 5: Takt und Bewegung
**Files:**
- Create: `game/scripts/tick_clock.gd`, `game/scripts/grid_motion.gd`, `game/scripts/avatar.gd`
- Test: `game/tests/test_tick_clock.gd`, `game/tests/test_grid_motion.gd`, `game/tests/test_avatar.gd`
**Interfaces:**
- Consumes: `TestCase` aus Task 4
- Produces:
- `TickClock.new(ticks_per_second: int)`, `advance(delta: float) -> int`, `paused: bool`, `MAX_TICKS_PER_FRAME` (5)
- `GridMotion.TILE_SIZE` (16.0), `tile_of(point: Vector2) -> Vector2i`, `tile_center(tile: Vector2i) -> Vector2`, `fits(center, radius, is_walkable: Callable) -> bool`, `move(center, motion, radius, is_walkable: Callable) -> Vector2`
- `Avatar` (Node2D): `is_walkable: Callable`, `walk(direction: Vector2, delta: float)`, `SPEED`, `RADIUS`, `MAX_STEP_SECONDS`. Liest im `_process` die Aktionen `move_left`, `move_right`, `move_up` und `move_down`, die Task 6 anlegt.
- [ ] **Step 1: Fehlschlagende Tests schreiben**
Create `game/tests/test_tick_clock.gd`:
```gdscript
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")
```
Create `game/tests/test_grid_motion.gd`:
```gdscript
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")
```
Create `game/tests/test_avatar.gd`:
```gdscript
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()
```
- [ ] **Step 2: Tests ausführen und Fehlschlag sehen**
Run:
```bash
cargo build -p grovewright-bridge
godot --headless --path game --import
godot --headless --path game -s res://tests/run_tests.gd
```
Expected: Exit-Code 1 und `10 passed, 3 failed`. Die drei neuen Skripte laden nicht (`Identifier "TickClock" not declared`, entsprechend für `GridMotion` und `Avatar`).
- [ ] **Step 3: Takt, Raster und Figur umsetzen**
Create `game/scripts/tick_clock.gd`:
```gdscript
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
```
Create `game/scripts/grid_motion.gd`:
```gdscript
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
```
Create `game/scripts/avatar.gd`:
```gdscript
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))
```
- [ ] **Step 4: Tests ausführen und Erfolg sehen**
Run:
```bash
godot --headless --path game --import
godot --headless --path game -s res://tests/run_tests.gd
```
Expected: Exit-Code 0 und `22 passed, 0 failed`
- [ ] **Step 5: Alles prüfen**
Run:
```bash
cargo fmt --all -- --check && cargo clippy --workspace --all-targets -- -D warnings && cargo test --workspace
```
Expected: keine Ausgabe von rustfmt, keine Warnung von Clippy, 109 bestandene Rust-Tests
- [ ] **Step 6: Commit und Hochladen**
```bash
git add game
git commit -F - <<'EOF'
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 Opus 5.5 <noreply@anthropic.com>
EOF
git push
```
---
### Task 6: Texte, Tasten und Dateien
**Files:**
- Create: `game/i18n/texts.csv`, `game/i18n/texts.csv.import`
- Create: `game/scripts/i18n.gd`, `game/scripts/input_setup.gd`, `game/scripts/save_store.gd`
- Create: `game/tests/support/test_files.gd`
- Test: `game/tests/test_i18n.gd`, `game/tests/test_input_setup.gd`, `game/tests/test_save_store.gd`
**Interfaces:**
- Consumes: `TestCase` aus Task 4
- Produces:
- `I18n.install()` (einmalig), `I18n.load_translations(path) -> Array[Translation]`, `I18n.TEXTS_FILE`; Textschlüssel `STONE_COUNT` (mit `%d`), `SAVED`, `SAVE_FAILED`, `LOADED`, `LOAD_FAILED`, `INVENTORY_FULL`, `PAUSED`, `SIMULATION_STOPPED`, `HELP`
- `InputSetup.install()`: Aktionen `move_up`, `move_down`, `move_left`, `move_right`, `dig`, `save_game`, `load_game`, `toggle_pause`
- `SaveStore.new(directory: String = "user://")`, `player_key() -> PackedByteArray`, `has_save() -> bool`, `read_save() -> PackedByteArray`, `write_save(bytes) -> bool`, `set_aside_save() -> String`, `write_recording(bytes) -> String`, `recordings() -> PackedStringArray`, Konstanten `KEY_FILE`, `SAVE_FILE`, `RECORDINGS_DIR`, `KEEP_RECORDINGS` (10), `KEY_BYTES` (16)
- `TestFiles.temporary_dir(label) -> String`, `TestFiles.remove_dir(path)`
- [ ] **Step 1: Fehlschlagende Tests schreiben**
Create `game/tests/support/test_files.gd`:
```gdscript
class_name TestFiles
extends RefCounted
## Temporary folders below user://tests for tests that write files, so that
## tests never touch the player's real key, save or recordings.
static func temporary_dir(label: String) -> String:
var path := "user://tests".path_join("%s-%d" % [label, Time.get_ticks_usec()])
DirAccess.make_dir_recursive_absolute(path)
return path
static func remove_dir(path: String) -> void:
for file in DirAccess.get_files_at(path):
DirAccess.remove_absolute(path.path_join(file))
for directory in DirAccess.get_directories_at(path):
remove_dir(path.path_join(directory))
DirAccess.remove_absolute(path)
```
Create `game/tests/test_i18n.gd`:
```gdscript
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])
```
Create `game/tests/test_input_setup.gd`:
```gdscript
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")
```
Create `game/tests/test_save_store.gd`:
```gdscript
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")
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_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)
```
- [ ] **Step 2: Tests ausführen und Fehlschlag sehen**
Run:
```bash
cargo build -p grovewright-bridge
godot --headless --path game --import
godot --headless --path game -s res://tests/run_tests.gd
```
Expected: Exit-Code 1 und `22 passed, 3 failed`. Die drei neuen Testskripte laden nicht (`Identifier "I18n" not declared`, entsprechend für `InputSetup` und `SaveStore`).
- [ ] **Step 3: Texte, Tasten und Dateien umsetzen**
Create `game/i18n/texts.csv`:
```csv
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
```
Create `game/i18n/texts.csv.import`. Die Einstellung „keep" verhindert, dass Godot aus der Tabelle Übersetzungsdateien erzeugt; das Spiel liest die Tabelle selbst.
```ini
[remap]
importer="keep"
```
Create `game/scripts/i18n.gd`:
```gdscript
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
```
Create `game/scripts/input_setup.gd`:
```gdscript
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)
```
Create `game/scripts/save_store.gd`:
```gdscript
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"
const RECORDINGS_DIR := "recordings"
## Recordings beyond this number are deleted, oldest first.
const KEEP_RECORDINGS := 10
const KEY_BYTES := 16
var base_dir: String
func _init(directory: String = "user://") -> void:
base_dir = directory
DirAccess.make_dir_recursive_absolute(base_dir)
## 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)
_write(path, key)
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 while writing
## never destroys the previous save. Returns false if nothing was written.
func write_save(bytes: PackedByteArray) -> bool:
if bytes.is_empty():
return false
var path := base_dir.path_join(SAVE_FILE)
var temporary := path + ".tmp"
if not _write(temporary, bytes):
return false
return DirAccess.rename_absolute(temporary, path) == OK
## 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.
func set_aside_save() -> String:
var path := base_dir.path_join(SAVE_FILE)
var aside := path + ".broken"
DirAccess.rename_absolute(path, aside)
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
func _write(path: String, bytes: PackedByteArray) -> bool:
var file := FileAccess.open(path, FileAccess.WRITE)
if file == null:
return false
file.store_buffer(bytes)
file.close()
return true
```
- [ ] **Step 4: Tests ausführen und Erfolg sehen**
Run:
```bash
godot --headless --path game --import
godot --headless --path game -s res://tests/run_tests.gd
ls game/i18n
```
Expected: Exit-Code 0 und `32 passed, 0 failed`. In `game/i18n` liegen nur `texts.csv` und `texts.csv.import`, keine `.translation`-Dateien.
- [ ] **Step 5: Alles prüfen**
Run:
```bash
cargo fmt --all -- --check && cargo clippy --workspace --all-targets -- -D warnings && cargo test --workspace
```
Expected: keine Ausgabe von rustfmt, keine Warnung von Clippy, 109 bestandene Rust-Tests
- [ ] **Step 6: Commit und Hochladen**
```bash
git add game
git commit -F - <<'EOF'
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 Opus 5.5 <noreply@anthropic.com>
EOF
git push
```
---
### Task 7: Darstellung und Zusammenbau
**Files:**
- Create: `game/scripts/map_view.gd`, `game/scripts/tile_marker.gd`, `game/scripts/hud.gd`, `game/scripts/main.gd`
- Create: `game/main.tscn`, `game/tools/screenshot.gd`
- Modify: `game/project.godot` (Hauptszene eintragen; ganze Datei ersetzen)
- Test: `game/tests/test_main.gd`
**Interfaces:**
- Consumes: `GwSession` (Task 4), `TickClock`, `GridMotion`, `Avatar` (Task 5), `I18n`, `InputSetup`, `SaveStore`, `TestFiles` (Task 6)
- Produces:
- `MapView` (Node2D) mit `session: GwSession` und `static color_of(terrain: int) -> Color`
- `TileMarker` (Node2D) mit `tile: Vector2i`, `in_reach: bool`, `show_tile(tile, reachable)`
- `Hud` (CanvasLayer) mit `show_stone(count)`, `flash(key)`, `show_paused(paused)`, `message() -> String`, `stone_text() -> String`
- Hauptszene `res://main.tscn` mit dem Skript `main.gd`: `storage_dir` (Export, Standard `user://`), `session`, `store`, `clock`, `map_view`, `marker`, `avatar`, `hud`, `dig_at(tile: Vector2i)`, `save_game() -> bool`; Konstanten `NEW_WORLD_SEED` (2026), `PLAYER_NAME`, `REACH_TILES` (2.5), `AUTOSAVE_TICKS` (2400), `CAMERA_ZOOM`
- `res://tools/screenshot.gd`: Bildschirmfoto in einem temporären Speicherordner
- [ ] **Step 1: Fehlschlagenden Test schreiben**
Create `game/tests/test_main.gd`:
```gdscript
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_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)
```
- [ ] **Step 2: Test ausführen und Fehlschlag sehen**
Run:
```bash
cargo build -p grovewright-bridge
godot --headless --path game --import
godot --headless --path game -s res://tests/run_tests.gd
```
Expected: Exit-Code 1 und `32 passed, 3 failed`. Alle drei Tests aus `test_main.gd` scheitern mit `ERROR: Cannot open file 'res://main.tscn'.` und dem Hinweis `error(s) reported while the test ran`.
- [ ] **Step 3: Darstellung, Anzeige und Hauptszene umsetzen**
Create `game/scripts/map_view.gd`:
```gdscript
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)
```
Create `game/scripts/tile_marker.gd`:
```gdscript
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)
```
Create `game/scripts/hud.gd`:
```gdscript
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
```
Create `game/scripts/main.gd`:
```gdscript
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 := 2400
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
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:
save_game()
_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()
push_warning("save not usable (%s), kept as %s" % [loaded.last_error(), aside])
hud.flash("LOAD_FAILED")
return 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:
save_game()
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 core checks
## everything else.
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.
func save_game() -> bool:
return store.write_save(session.save())
func _write_recording() -> void:
store.write_recording(session.recording_bytes())
```
Create `game/main.tscn`:
```text
[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")
```
Ersetze den gesamten Inhalt von `game/project.godot` durch die Fassung mit eingetragener Hauptszene:
```ini
; 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"
```
Create `game/tools/screenshot.gd`:
```gdscript
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)
```
- [ ] **Step 4: Tests ausführen und Erfolg sehen**
Run:
```bash
godot --headless --path game --import
godot --headless --path game -s res://tests/run_tests.gd
```
Expected: Exit-Code 0 und `35 passed, 0 failed`. Eine Zeile `WARNING: save not usable (not a snapshot), kept as …` ist erwartet: Sie stammt aus dem Test, der einen unbrauchbaren Speicherstand absichtlich anbietet. Warnungen zählen nicht als Fehler.
- [ ] **Step 5: Aussehen prüfen**
Run (öffnet kurz ein Fenster):
```bash
godot --path game -s res://tools/screenshot.gd -- "$(pwd)/screenshot-m1b.png"
```
Expected: `screenshot saved: …screenshot-m1b.png`. Öffne das Bild und prüfe:
- Brauner Boden und graue Felsflächen in zwei Grautönen; der Kartenrand ist auf dem Bild nicht zu sehen.
- Die Figur, ein heller Kreis mit dunklem Rand, steht in der Bildmitte.
- Rechts neben der Figur liegt ein Fels mit einer kurzen dunklen Risslinie, das ist der eine Schlag.
- Oben links steht `Stein: 0`, unten links die Hilfezeile; in der Sprache des Systems, also Deutsch oder Englisch.
Lösche das Bild danach (`rm screenshot-m1b.png`). Es gehört nicht ins Archiv.
- [ ] **Step 6: Alles prüfen**
Run:
```bash
cargo fmt --all -- --check && cargo clippy --workspace --all-targets -- -D warnings && cargo test --workspace
```
Expected: keine Ausgabe von rustfmt, keine Warnung von Clippy, 109 bestandene Rust-Tests
- [ ] **Step 7: Commit und Hochladen**
```bash
git add game
git commit -F - <<'EOF'
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 Opus 5.5 <noreply@anthropic.com>
EOF
git push
```
---
### Task 8: Startskript, Regeln und Probelauf
**Files:**
- Modify: `.gitattributes`
- Create: `play.bat`
- Modify: `CLAUDE.md` (ganze Datei ersetzen)
- Create: `docs/playtests/m1b.md`
**Interfaces:**
- Consumes: alles aus den Tasks 1 bis 7
- Produces: `play.bat` als Startweg für dich; `CLAUDE.md` mit den Regeln für Kern, Brücke und Hülle; den Fragebogen für deinen Probelauf
- [ ] **Step 1: Zeilenenden für Windows-Skripte festlegen**
Ersetze den gesamten Inhalt von `.gitattributes` durch:
```text
# Same line endings on every machine.
* text=auto eol=lf
# Windows batch files need CRLF.
*.bat text eol=crlf
```
- [ ] **Step 2: Startskript anlegen**
Create `play.bat`:
```bat
@echo off
rem Builds the bridge and starts Grovewright from this folder.
cd /d "%~dp0"
cargo build -p grovewright-bridge || goto failed
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
```
Batch-Dateien brauchen Windows-Zeilenenden. Lass Git die Datei einmal mit der neuen Regel auschecken:
```bash
git add .gitattributes play.bat
rm play.bat
git checkout -- play.bat
od -c play.bat | head -1
```
Expected: In der Ausgabe von `od` stehen `\r \n` am Zeilenende.
- [ ] **Step 3: Regeln für künftige Sitzungen erneuern**
Ersetze den gesamten Inhalt von `CLAUDE.md` durch:
````markdown
# Grovewright
Aufbauspiel für ein bis vier Spieler, in dem die Fabrik lebt. Gebaut wird zuerst eine Vertical Slice mit 2–3 Stunden Spielzeit.
Das verbindliche Konzept steht in [docs/KONZEPT.md](docs/KONZEPT.md). Lies es, bevor du Spielregeln, Architektur oder Umfang änderst. Was dort nicht steht, ist nicht beschlossen.
## Aufbau
| Ordner | Inhalt |
|---|---|
| `core/` | Rust-Kern: die gesamte gemeinsame Welt. Ohne Zugriff auf die Engine, ohne fremde Crates. |
| `bridge/` | Rust-Brücke (GDExtension): macht den Kern für Godot aufrufbar und enthält keine Spielregeln. |
| `game/` | Godot-Projekt mit der GDScript-Hülle: Darstellung, Eingabe, Oberfläche, Dateien. |
| `docs/KONZEPT.md` | Spielkonzept und Entscheidungen |
| `docs/superpowers/plans/` | Umsetzungspläne |
| `docs/playtests/` | Fragebögen für Playtests |
Faustregel: Was im Speicherstand steht oder alle Spieler betrifft, gehört in den Kern. Die Hülle darf Gleitkommazahlen nutzen, weil sie nichts an der gemeinsamen Welt ändert; sie schickt nur Befehle.
## Regeln für den Kern
Alle Spieler simulieren die Welt selbst und tauschen nur Befehle aus (Gleichschritt). Das funktioniert nur, wenn der Kern auf jedem Rechner exakt gleich rechnet. Deshalb gelten für den Quelltext des Kerns in `core/src/` diese Regeln (die Tests in `core/tests/` dürfen zum Beispiel Dateien lesen):
- Nur ganze Zahlen, keine Gleitkommazahlen.
- Keine Uhr, keine Dateien, kein Netzwerk, keine Threads, keine Umgebungsvariablen, kein globaler veränderlicher Zustand.
- Zufall nur über `rng::Rng` mit fester Startzahl.
- Keine Sammlung mit wechselnder Reihenfolge. Statt einer Hash-Tabelle nimmst du `Vec`, `BTreeMap` oder `BTreeSet`.
- Die Welt ändert sich nur durch einen `Command` in `World::step`. Einzige Ausnahme ist das Laden eines Speicherstands mit `World::load`, das alle Spieler abmeldet.
- Wer eine Sitzung aufzeichnet, ruft `Recording::step` statt `World::step` auf. So enthält die Aufzeichnung genau die ausgeführten Befehle.
- Alles, was zum Zustand der Welt gehört, steht im Snapshot. Wer ein Feld ergänzt, ergänzt es in `snapshot.rs`, prüft es 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.
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. `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()`, nie den echten Benutzerordner.
- Godot erzeugt beim Import zu jedem Skript eine `.uid`-Datei. Sie gehört ins Archiv. Der Ordner `game/.godot/` gehört nicht hinein.
## Prüfen
Vor jedem Commit müssen alle Befehle fehlerfrei laufen:
```bash
cargo fmt --all -- --check
cargo clippy --workspace --all-targets -- -D warnings
cargo test --workspace
cargo build -p grovewright-bridge
godot --headless --path game --import
godot --headless --path game -s res://tests/run_tests.gd
```
Schlägt `core/tests/golden.rs` fehl, haben sich Regeln oder ein Format geändert. Sind Speicherstände oder Aufzeichnungen betroffen, erhöhst du die Versionskonstante, die die Fehlermeldung nennt. Erst danach passt du den festgeschriebenen Wert bewusst an.
Für Änderungen am Aussehen gibt es ein Werkzeug, das kurz ein Fenster öffnet und ein Bildschirmfoto speichert:
```bash
godot --path game -s res://tools/screenshot.gd -- <Pfad der PNG-Datei>
```
## Spielen
`play.bat` im Hauptordner baut die Brücke und startet das Spiel. Spielstand, Spielerschlüssel und Aufzeichnungen liegen unter `%APPDATA%\Godot\app_userdata\Grovewright`.
## Sprache
- Code, Bezeichner und Kommentare im Code: Englisch.
- Dokumente, Pläne, Commit-Nachrichten, Issues und Pull Requests: Deutsch.
- Texte im Spiel: Deutsch und Englisch über `game/i18n/texts.csv`.
## Git
- Archiv: `https://git.race-cave.cloud/steven/Grovewright.git` (Gitea). Issues und Pull Requests sind freigegeben.
- Gearbeitet wird auf einem Branch pro Plan, nicht direkt auf `main`.
- Nach jedem abgeschlossenen Arbeitsschritt wird committet und hochgeladen. Das ist freigegeben.
- Commit-Nachrichten enden mit einer Zeile `Co-Authored-By: Claude <Modell> <noreply@anthropic.com>`, die das Claude-Modell nennt, das den Commit erstellt hat.
````
- [ ] **Step 4: Fragebogen für den Probelauf anlegen**
Create `docs/playtests/m1b.md`:
```markdown
# 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? Ein Fels braucht drei Schläge, höchstens vier Schläge gehen pro Sekunde.
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`. Schick mir die neueste Datei, dann spiele ich die Sitzung exakt nach.
Antworte formlos im Chat oder als Kommentar in Gitea.
```
- [ ] **Step 5: Alles prüfen, auch im Release-Modus**
Run:
```bash
cargo fmt --all -- --check && cargo clippy --workspace --all-targets -- -D warnings && cargo test --workspace && cargo test --workspace --release
cargo build -p grovewright-bridge
godot --headless --path game --import
godot --headless --path game -s res://tests/run_tests.gd
```
Expected:
- rustfmt gibt nichts aus, Clippy warnt nicht.
- Beide Rust-Testläufe haben je 109 bestandene Tests.
- Die GDScript-Tests enden mit `35 passed, 0 failed` und Exit-Code 0.
- [ ] **Step 6: Commit und Hochladen**
```bash
git add .gitattributes play.bat CLAUDE.md docs/playtests/m1b.md
git commit -F - <<'EOF'
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.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
EOF
git push
```
---
## Abschluss
Nach Task 8 ist M1b fertig. Der Branch `m1b-bruecke-und-huelle` wird über den Skill `superpowers:finishing-a-development-branch` in `main` übernommen. Danach kannst du mit `play.bat` den ersten Probelauf machen.
Die nächsten Pläne bauen auf diesen Schnittstellen auf:
- **M1c:**
- `World::from_snapshot` (behält, wer online ist) für den Beitritt eines Gastes.
- `Recording::step` für die Aufzeichnung.
- `GwSession` für die Hülle. Sie bekommt dort Host- und Gast-Betrieb.
- **M1d:**
- `MapView.color_of` und `MapView._draw` sind die Stellen, an denen die Stilproben ansetzen.
- Das Bildschirmfoto-Werkzeug liefert die Vergleichsbilder.