From fc909f963d4b41e226e1901ca1902414a6c61362 Mon Sep 17 00:00:00 2001 From: Steven Wroblewski Date: Wed, 30 Sep 2026 16:10:40 +0200 Subject: [PATCH 01/15] Umsetzungsplan M1a: Rust-Kern MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Erster von vier Plänen für Meilenstein 1. Elf Tasks bauen den deterministischen Kern: Kodierung, Prüfsumme, Zufall, Karte, Generator, Inventar, Spieler, Befehle, Welt, Snapshot, Aufzeichnung und die Abnahmetests für den Gleichschritt. Der Code im Plan ist vorab geprüft. Co-Authored-By: Claude Opus 5.5 --- .../plans/2026-09-30-m1a-rust-kern.md | 4016 +++++++++++++++++ 1 file changed, 4016 insertions(+) create mode 100644 docs/superpowers/plans/2026-09-30-m1a-rust-kern.md diff --git a/docs/superpowers/plans/2026-09-30-m1a-rust-kern.md b/docs/superpowers/plans/2026-09-30-m1a-rust-kern.md new file mode 100644 index 0000000..7e67a25 --- /dev/null +++ b/docs/superpowers/plans/2026-09-30-m1a-rust-kern.md @@ -0,0 +1,4016 @@ +# M1a Rust-Kern 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 deterministische Rust-Kern von Grovewright: eine Welt mit Testkarte, Spielern und Inventaren, die sich nur durch Befehle ändert und sich vollständig speichern, prüfen, aufzeichnen und exakt wiederholen lässt. + +**Architecture:** Ein Cargo-Workspace mit dem Crate `grovewright-core`, ohne fremde Abhängigkeiten und ohne Zugriff auf die Engine. `World::step` wendet die Befehle eines Takts der Reihe nach an. Der Snapshot ist zugleich Speicherstand, Beitrittspaket und Grundlage der Prüfsumme. Die Tests liegen als Integrationstests in `core/tests/` und prüfen nur die öffentliche Schnittstelle. + +**Tech Stack:** Rust 1.98.1 (Edition 2021, `rust-version = "1.94"`), Cargo, rustfmt, Clippy. Keine fremden Crates. + +**Spec:** `docs/KONZEPT.md`, vor allem die Abschnitte 5 (Koop), 7.1 (Aufbau), 7.2 (Gleichschritt), 7.3 (Absicherung) und 8 (Meilenstein 1). + +## Einordnung + +Meilenstein 1 (Fundament) ist in vier Pläne geteilt. Jeder liefert für sich lauffähige, geprüfte Software. + +| Plan | Inhalt | Stand | +|---|---|---| +| **M1a Rust-Kern** | Dieser Plan | offen | +| M1b Brücke und Hülle | GDExtension-Brücke, Godot-Projekt, Figur läuft über die Karte und schlägt Stein, Speichern und Laden | wird nach M1a geschrieben | +| M1c Gleichschritt im Netz | Sitzungsprotokoll im Kern, direkte Verbindung, Beitritt, Prüfsummen-Vergleich, zwei Instanzen im Gleichschritt | wird nach M1b geschrieben | +| M1d Stilproben | Dieselbe Szene in drei Grafikstilen zur Auswahl | wird nach M1b geschrieben | + +Die späteren Pläne entstehen erst, wenn ihr Vorgänger steht, weil sie dessen tatsächliche Schnittstellen brauchen. + +## Nicht Teil dieses Plans + +- Brücke zu Godot, Godot-Projekt, Darstellung, Eingabe +- Netzwerk und Sitzungsprotokoll (Host, Gast, Beitritt, Prüfsummen-Vergleich zwischen Rechnern) +- Positionen der Spielfiguren. Die Figur läuft außerhalb des Gleichschritts in der Hülle. Ob sie in Reichweite steht, prüft die Hülle, bevor sie einen Befehl schickt. +- Inhaltsdateien. In Meilenstein 1 gibt es nur Gelände und den Gegenstand Stein; beides steht fest im Code. Das Dateiformat für Inhalte entsteht in Meilenstein 2, wenn es echte Inhalte gibt. +- Lebendiger Boden, Pflanzen, Ranken, Werkstätten, Tiere, Ruinen + +## Technische Festlegungen + +Das Konzept überlässt rein technische Details der Umsetzung. Dieser Plan legt fest: + +| Thema | Festlegung | Grund | +|---|---|---| +| Takt | 20 Takte pro Sekunde | Flüssig genug für Fabrikabläufe, sparsam genug für vier mitrechnende Rechner | +| Speicherstand | Vollständiger Zustand der Welt, nicht Startzahl plus Änderungen | Bleibt gültig, wenn sich der Kartengenerator zwischen Versionen ändert. Das Konzept wird in Task 9 entsprechend korrigiert. | +| Prüfsumme | FNV-1a (64 Bit) über den kodierten Zustand | Soll nur versehentliches Auseinanderlaufen zeigen, keine Manipulation abwehren | +| Zufall | SplitMix64 | Einfach, schnell, mit veröffentlichten Referenzwerten prüfbar | +| Kodierung | Eigenes, schlankes Binärformat (Little Endian) | Keine fremden Crates im Kern, volle Kontrolle über jedes Byte | +| Überlauf | `overflow-checks = true` auch im Release-Profil | Tests und ausgeliefertes Spiel verhalten sich bei Überlauf gleich | +| Testkarte | 64 × 64 Felder | Reicht für Meilenstein 1; der Generator für drei Regionen kommt später | +| Spieler | Höchstens 4 gleichzeitig, höchstens 16 bekannte pro Welt | Inventare warten auf Rückkehrer, ohne dass die Liste unbegrenzt wächst | + +## Global Constraints + +- Arbeitsverzeichnis ist die Wurzel des Archivs: `F:/Development/_kiZeug/Grovewright`. Alle Befehle laufen dort in der Bash. +- Gearbeitet wird auf dem Branch `m1a-rust-kern`, 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`, `unsafe`. +- Code, Bezeichner und Kommentare im Code sind Englisch. Dokumente und Commit-Nachrichten sind Deutsch. +- Jede Commit-Nachricht endet mit der Zeile `Co-Authored-By: Claude Opus 5.5 `. +- 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`. +- Werkzeugstand: Rust 1.98.1, Git 2.54.0. + +## Hinweis zur Vorabprüfung + +Der gesamte Code dieses Plans wurde am 30. September 2026 vorab in einem Wegwerf-Verzeichnis gebaut: 87 Tests bestanden im Debug- und im Release-Modus, Clippy und rustfmt meldeten nichts. Weicht ein Schritt beim Ausführen ab, ist zuerst ein Übertragungsfehler zu vermuten. Ändere den Code erst, wenn der Vergleich mit dem Plan keinen Unterschied zeigt. + +## Dateiübersicht + +| Datei | Aufgabe | +|---|---| +| `Cargo.toml` | Workspace; hält die Überlaufprüfung im Release-Profil an | +| `.gitignore`, `.gitattributes` | Build-Ordner ausschließen, einheitliche Zeilenenden | +| `CLAUDE.md` | Regeln für künftige Sitzungen: Aufbau, Kernregeln, Prüfbefehle, Sprache, Git | +| `core/Cargo.toml` | Crate `grovewright-core`, keine Abhängigkeiten, Lints | +| `core/src/lib.rs` | Modulliste und Beschreibung des Kerns | +| `core/src/codec.rs` | Binäres Schreiben und Lesen (`Writer`, `Reader`, `DecodeError`) | +| `core/src/hash.rs` | Prüfsumme `fnv1a64` | +| `core/src/rng.rs` | Wiederholbarer Zufall `Rng` | +| `core/src/map.rs` | `Pos`, `Terrain`, `Tile`, `Map` | +| `core/src/mapgen.rs` | Generator der Testkarte | +| `core/src/inventory.rs` | `ItemId`, `Stack`, `Inventory` | +| `core/src/player.rs` | `Player`, `PlayerId`, `PlayerKey`, `clamp_name` | +| `core/src/command.rs` | `Command` mit Kodierung | +| `core/src/world.rs` | `World`, `Event`, die Regeln in `World::step` | +| `core/src/snapshot.rs` | Snapshot, Prüfsumme des Zustands, `SnapshotError` | +| `core/src/replay.rs` | `Recording`: Aufzeichnung und Wiederholung einer Sitzung | +| `core/tests/forbidden_apis.rs` | Wächter über die Gleichschritt-Regeln | +| `core/tests/.rs` | Je ein Integrationstest pro Modul | +| `core/tests/determinism.rs` | Abnahmetests: gleiche Befehle ergeben dieselbe Welt | + +--- + +### Task 1: Gerüst und Wächter + +**Files:** +- Create: `.gitattributes` +- Create: `.gitignore` +- Create: `Cargo.toml` +- Create: `core/Cargo.toml` +- Create: `core/src/lib.rs` +- Create: `CLAUDE.md` +- Test: `core/tests/forbidden_apis.rs` + +**Interfaces:** +- Consumes: nichts +- Produces: das Crate `grovewright_core` (noch ohne Module) und den Test `core_source_uses_no_forbidden_apis`, der ab jetzt jede Quelldatei unter `core/src/` prüft + +- [ ] **Step 1: Auf den Branch wechseln** + +Der Branch existiert bereits. Er wurde zusammen mit diesem Plan angelegt und hochgeladen. + +```bash +git switch m1a-rust-kern +``` + +Expected: `Already on 'm1a-rust-kern'` oder `Switched to branch 'm1a-rust-kern'` + +- [ ] **Step 2: Git-Dateien anlegen** + +Create `.gitattributes`: + +```text +# Same line endings on every machine. +* text=auto eol=lf +``` + +Create `.gitignore`: + +```text +# Rust +/target/ +``` + +- [ ] **Step 3: Workspace und Crate anlegen** + +Create `Cargo.toml`: + +```toml +[workspace] +resolver = "2" +members = ["core"] + +# 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 `core/Cargo.toml`: + +```toml +[package] +name = "grovewright-core" +version = "0.1.0" +edition = "2021" +rust-version = "1.94" +publish = false +description = "Deterministic simulation core of Grovewright" + +[lints.rust] +unsafe_code = "forbid" + +[lints.clippy] +float_arithmetic = "deny" +``` + +Create `core/src/lib.rs`: + +```rust +//! Deterministic core of Grovewright. +//! +//! Everything that belongs to the shared world lives here. The core never +//! touches the engine, the clock, files or the network, and it calculates +//! only with whole numbers. Given the same commands it produces the same +//! world on every machine. That is what lets every player simulate the whole +//! world and exchange nothing but commands. +``` + +- [ ] **Step 4: Wächter-Test schreiben** + +Create `core/tests/forbidden_apis.rs`: + +```rust +//! Guards the lockstep rules: the core must not use anything whose result +//! can differ between machines or between runs. + +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", + ), + ("unsafe", "the core forbids unsafe code"), +]; + +fn rust_files(dir: &Path, out: &mut Vec) { + 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 (number, line) in text.lines().enumerate() { + for (needle, reason) in FORBIDDEN { + if line.contains(needle) { + problems.push(format!( + "{}:{}: `{needle}`: {reason}", + file.display(), + number + 1 + )); + } + } + } + } + assert!( + problems.is_empty(), + "forbidden APIs in the core:\n{}", + problems.join("\n") + ); +} +``` + +- [ ] **Step 5: Test ausführen** + +Run: `cargo test --workspace` +Expected: PASS mit `test core_source_uses_no_forbidden_apis ... ok` + +- [ ] **Step 6: Nachweisen, dass der Wächter anschlägt** + +Hänge vorübergehend diese Zeilen an `core/src/lib.rs` an: + +```rust + +pub fn broken() -> f32 { + 0.5 +} +``` + +Run: `cargo test -p grovewright-core --test forbidden_apis` +Expected: FAIL mit einer Zeile der Form ``...core\src\lib.rs:9: `f32`: floating point results can differ between machines`` + +Entferne die angehängten Zeilen wieder, sodass `core/src/lib.rs` exakt dem Stand aus Step 3 entspricht. + +Run: `cargo test -p grovewright-core --test forbidden_apis` +Expected: PASS + +- [ ] **Step 7: Regeln für künftige Sitzungen festhalten** + +Create `CLAUDE.md`: + +````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. | +| `docs/KONZEPT.md` | Spielkonzept und Entscheidungen | +| `docs/superpowers/plans/` | Umsetzungspläne | + +Die Brücke zu Godot (`bridge/`), das Godot-Projekt (`game/`) und die Inhaltsdateien (`content/`) kommen mit späteren Plänen dazu. + +Faustregel: Was im Speicherstand steht oder alle Spieler betrifft, gehört in den Kern. + +## Regeln für den Kern + +Alle Spieler simulieren die Welt selbst und tauschen nur Befehle aus (Gleichschritt). Das funktioniert nur, wenn der Kern auf jedem Rechner exakt gleich rechnet. Deshalb gilt in `core/`: + +- Nur ganze Zahlen, keine Gleitkommazahlen. +- Keine Uhr, keine Dateien, kein Netzwerk, keine Threads, keine Umgebungsvariablen. +- 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`. +- Alles, was zum Zustand der Welt gehört, steht im Snapshot. Wer ein Feld ergänzt, ergänzt es in `snapshot.rs` und erhöht `SNAPSHOT_VERSION`. +- Wo eine Rechnung überlaufen kann, steht eine ausdrückliche Wahl: `saturating_`, `wrapping_` oder `checked_`. +- Keine fremden Crates im Kern. + +Der Test `core/tests/forbidden_apis.rs` durchsucht den Quelltext des Kerns nach verbotenen Begriffen. Sie dürfen dort auch in Kommentaren nicht vorkommen. + +## Prüfen + +Vor jedem Commit müssen alle drei Befehle fehlerfrei laufen: + +```bash +cargo fmt --all -- --check +cargo clippy --workspace --all-targets -- -D warnings +cargo test --workspace +``` + +## Sprache + +- Code, Bezeichner und Kommentare im Code: Englisch. +- Dokumente, Pläne, Commit-Nachrichten, Issues und Pull Requests: Deutsch. +- Texte im Spiel: Deutsch und Englisch über Übersetzungsdateien. + +## Git + +- Archiv: `https://git.race-cave.cloud/steven/Grovewright.git` (Gitea). +- Gearbeitet wird auf einem Branch pro Plan, nicht direkt auf `main`. +- Nach jedem abgeschlossenen Arbeitsschritt wird committet und hochgeladen. Das ist freigegeben. +```` + +- [ ] **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, `test result: ok. 1 passed` + +- [ ] **Step 9: Commit und Hochladen** + +```bash +git add .gitattributes .gitignore Cargo.toml Cargo.lock core/Cargo.toml core/src/lib.rs core/tests/forbidden_apis.rs CLAUDE.md +git commit -F - <<'EOF' +Gerüst für den Rust-Kern anlegen + +Workspace, leeres Crate grovewright-core und ein Wächter-Test, der den +Quelltext des Kerns auf Verstöße gegen die Gleichschritt-Regeln prüft. + +Co-Authored-By: Claude Opus 5.5 +EOF +git push -u origin m1a-rust-kern +``` + +--- + +### Task 2: Binäre Kodierung + +**Files:** +- Create: `core/src/codec.rs` +- Modify: `core/src/lib.rs` +- Test: `core/tests/codec.rs` + +**Interfaces:** +- Consumes: nichts +- Produces: + - `codec::DecodeError` mit den Varianten `UnexpectedEnd`, `InvalidValue(&'static str)`, `TrailingBytes` + - `codec::Writer`: `new()`, `u8(u8)`, `u16(u16)`, `u32(u32)`, `u64(u64)`, `i32(i32)`, `bool(bool)`, `raw(&[u8])` ohne Länge, `bytes(&[u8])` mit `u32`-Länge, `str(&str)` mit `u32`-Länge, `into_bytes(self) -> Vec` + - `codec::Reader<'a>`: `new(&'a [u8])`, `remaining() -> usize`, `raw(usize) -> Result<&'a [u8], DecodeError>`, `u8()`, `u16()`, `u32()`, `u64()`, `i32()`, `bool()`, `bytes() -> Result<&'a [u8], DecodeError>`, `string() -> Result`, `finish(self) -> Result<(), DecodeError>` + +- [ ] **Step 1: Fehlschlagenden Test schreiben** + +Create `core/tests/codec.rs`: + +```rust +use grovewright_core::codec::{DecodeError, Reader, Writer}; + +#[test] +fn every_kind_of_value_survives_a_round_trip() { + let mut w = Writer::new(); + w.u8(0xAB); + w.u16(0xBEEF); + w.u32(0xDEAD_BEEF); + w.u64(0x0123_4567_89AB_CDEF); + w.i32(-42); + w.bool(true); + w.bool(false); + w.bytes(&[1, 2, 3]); + w.str("Grüner Hain"); + w.raw(&[9, 9]); + let bytes = w.into_bytes(); + + let mut r = Reader::new(&bytes); + assert_eq!(r.u8(), Ok(0xAB)); + assert_eq!(r.u16(), Ok(0xBEEF)); + assert_eq!(r.u32(), Ok(0xDEAD_BEEF)); + assert_eq!(r.u64(), Ok(0x0123_4567_89AB_CDEF)); + assert_eq!(r.i32(), Ok(-42)); + assert_eq!(r.bool(), Ok(true)); + assert_eq!(r.bool(), Ok(false)); + assert_eq!(r.bytes(), Ok(&[1u8, 2, 3][..])); + assert_eq!(r.string(), Ok("Grüner Hain".to_owned())); + assert_eq!(r.raw(2), Ok(&[9u8, 9][..])); + assert_eq!(r.finish(), Ok(())); +} + +#[test] +fn integers_are_little_endian() { + let mut w = Writer::new(); + w.u32(0x0102_0304); + assert_eq!(w.into_bytes(), vec![4, 3, 2, 1]); +} + +#[test] +fn reading_past_the_end_fails() { + let mut r = Reader::new(&[1, 2]); + assert_eq!(r.u32(), Err(DecodeError::UnexpectedEnd)); +} + +#[test] +fn an_oversized_length_fails_without_panicking() { + let mut w = Writer::new(); + w.u32(u32::MAX); + let bytes = w.into_bytes(); + let mut r = Reader::new(&bytes); + assert_eq!(r.bytes(), Err(DecodeError::UnexpectedEnd)); +} + +#[test] +fn invalid_bool_and_invalid_utf8_are_rejected() { + let mut r = Reader::new(&[2]); + assert_eq!(r.bool(), Err(DecodeError::InvalidValue("bool"))); + + let mut w = Writer::new(); + w.bytes(&[0xFF, 0xFE]); + let bytes = w.into_bytes(); + let mut r = Reader::new(&bytes); + assert_eq!(r.string(), Err(DecodeError::InvalidValue("utf-8 string"))); +} + +#[test] +fn finish_rejects_bytes_that_were_not_read() { + let r = Reader::new(&[0]); + assert_eq!(r.finish(), Err(DecodeError::TrailingBytes)); +} + +#[test] +fn remaining_counts_down_as_values_are_read() { + let mut r = Reader::new(&[1, 2, 3]); + assert_eq!(r.remaining(), 3); + r.u8().unwrap(); + assert_eq!(r.remaining(), 2); +} +``` + +- [ ] **Step 2: Test ausführen und Fehlschlag sehen** + +Run: `cargo test -p grovewright-core --test codec` +Expected: FAIL mit ``error[E0432]: unresolved import `grovewright_core::codec` `` + +- [ ] **Step 3: Kodierung umsetzen** + +Create `core/src/codec.rs`: + +```rust +//! Minimal little-endian binary encoding for commands, snapshots and +//! recordings. + +use std::fmt; + +/// Error returned when bytes cannot be decoded. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum DecodeError { + /// The input ended before the value was complete. + UnexpectedEnd, + /// The input contained a value that is not allowed at this position. + InvalidValue(&'static str), + /// The input contained bytes after the last expected value. + TrailingBytes, +} + +impl fmt::Display for DecodeError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + DecodeError::UnexpectedEnd => write!(f, "unexpected end of input"), + DecodeError::InvalidValue(what) => write!(f, "invalid value: {what}"), + DecodeError::TrailingBytes => write!(f, "trailing bytes after the last value"), + } + } +} + +impl std::error::Error for DecodeError {} + +/// Appends values to a growing byte buffer. +#[derive(Debug, Default)] +pub struct Writer { + buf: Vec, +} + +impl Writer { + pub fn new() -> Self { + Self::default() + } + + pub fn u8(&mut self, value: u8) { + self.buf.push(value); + } + + pub fn u16(&mut self, value: u16) { + self.buf.extend_from_slice(&value.to_le_bytes()); + } + + pub fn u32(&mut self, value: u32) { + self.buf.extend_from_slice(&value.to_le_bytes()); + } + + pub fn u64(&mut self, value: u64) { + self.buf.extend_from_slice(&value.to_le_bytes()); + } + + pub fn i32(&mut self, value: i32) { + self.buf.extend_from_slice(&value.to_le_bytes()); + } + + pub fn bool(&mut self, value: bool) { + self.buf.push(u8::from(value)); + } + + /// Writes bytes as they are, without a length. + pub fn raw(&mut self, value: &[u8]) { + self.buf.extend_from_slice(value); + } + + /// Writes a `u32` length followed by the bytes. + pub fn bytes(&mut self, value: &[u8]) { + let len = u32::try_from(value.len()).expect("length fits in u32"); + self.u32(len); + self.buf.extend_from_slice(value); + } + + /// Writes a string as UTF-8 with a `u32` length in front. + pub fn str(&mut self, value: &str) { + self.bytes(value.as_bytes()); + } + + pub fn into_bytes(self) -> Vec { + self.buf + } +} + +/// Reads values from a byte slice in the order they were written. +#[derive(Debug)] +pub struct Reader<'a> { + buf: &'a [u8], + pos: usize, +} + +impl<'a> Reader<'a> { + pub fn new(buf: &'a [u8]) -> Self { + Self { buf, pos: 0 } + } + + /// Number of bytes that have not been read yet. + pub fn remaining(&self) -> usize { + self.buf.len() - self.pos + } + + /// Reads exactly `len` bytes. + pub fn raw(&mut self, len: usize) -> Result<&'a [u8], DecodeError> { + if len > self.remaining() { + return Err(DecodeError::UnexpectedEnd); + } + let slice = &self.buf[self.pos..self.pos + len]; + self.pos += len; + Ok(slice) + } + + fn array(&mut self) -> Result<[u8; N], DecodeError> { + let mut out = [0u8; N]; + out.copy_from_slice(self.raw(N)?); + Ok(out) + } + + pub fn u8(&mut self) -> Result { + Ok(self.array::<1>()?[0]) + } + + pub fn u16(&mut self) -> Result { + Ok(u16::from_le_bytes(self.array()?)) + } + + pub fn u32(&mut self) -> Result { + Ok(u32::from_le_bytes(self.array()?)) + } + + pub fn u64(&mut self) -> Result { + Ok(u64::from_le_bytes(self.array()?)) + } + + pub fn i32(&mut self) -> Result { + Ok(i32::from_le_bytes(self.array()?)) + } + + pub fn bool(&mut self) -> Result { + match self.u8()? { + 0 => Ok(false), + 1 => Ok(true), + _ => Err(DecodeError::InvalidValue("bool")), + } + } + + /// Reads a `u32` length followed by that many bytes. + pub fn bytes(&mut self) -> Result<&'a [u8], DecodeError> { + let len = self.u32()? as usize; + self.raw(len) + } + + /// Reads a UTF-8 string with a `u32` length in front. + pub fn string(&mut self) -> Result { + let bytes = self.bytes()?; + std::str::from_utf8(bytes) + .map(str::to_owned) + .map_err(|_| DecodeError::InvalidValue("utf-8 string")) + } + + /// Succeeds only if every byte has been read. + pub fn finish(self) -> Result<(), DecodeError> { + if self.remaining() == 0 { + Ok(()) + } else { + Err(DecodeError::TrailingBytes) + } + } +} +``` + +Hänge an `core/src/lib.rs` nach dem Kommentarblock eine Leerzeile und diese Zeile an: + +```rust +pub mod codec; +``` + +- [ ] **Step 4: Test ausführen und Erfolg sehen** + +Run: `cargo test -p grovewright-core --test codec` +Expected: PASS mit `test result: ok. 7 passed` + +- [ ] **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, alle Tests bestanden + +- [ ] **Step 6: Commit und Hochladen** + +```bash +git add core/src/codec.rs core/src/lib.rs core/tests/codec.rs +git commit -F - <<'EOF' +Binäre Kodierung für den Kern + +Writer und Reader für Befehle, Snapshots und Aufzeichnungen. Beschädigte +Eingaben führen zu einem Fehler statt zu einem Absturz. + +Co-Authored-By: Claude Opus 5.5 +EOF +git push +``` + +--- + +### Task 3: Prüfsumme und Zufall + +**Files:** +- Create: `core/src/hash.rs` +- Create: `core/src/rng.rs` +- Modify: `core/src/lib.rs` +- Test: `core/tests/hash.rs` +- Test: `core/tests/rng.rs` + +**Interfaces:** +- Consumes: nichts +- Produces: + - `hash::fnv1a64(bytes: &[u8]) -> u64` + - `rng::Rng`: `new(seed: u64) -> Rng`, `next_u64(&mut self) -> u64`, `below(&mut self, bound: u32) -> u32` (Wert in `0..bound`, Panik bei `bound == 0`). `Rng` ist `Clone`, `Debug`, `PartialEq`, `Eq`. + +- [ ] **Step 1: Fehlschlagende Tests schreiben** + +Create `core/tests/hash.rs`: + +```rust +use grovewright_core::hash::fnv1a64; + +#[test] +fn matches_the_published_fnv1a_test_vectors() { + assert_eq!(fnv1a64(b""), 0xcbf2_9ce4_8422_2325); + assert_eq!(fnv1a64(b"a"), 0xaf63_dc4c_8601_ec8c); + assert_eq!(fnv1a64(b"foobar"), 0x8594_4171_f739_67e8); +} + +#[test] +fn the_order_of_bytes_matters() { + assert_ne!(fnv1a64(&[0, 1]), fnv1a64(&[1, 0])); +} +``` + +Create `core/tests/rng.rs`: + +```rust +use grovewright_core::rng::Rng; + +#[test] +fn matches_the_splitmix64_reference_values() { + let mut rng = Rng::new(0); + assert_eq!(rng.next_u64(), 0xE220_A839_7B1D_CDAF); + assert_eq!(rng.next_u64(), 0x6E78_9E6A_A1B9_65F4); + assert_eq!(rng.next_u64(), 0x06C4_5D18_8009_454F); +} + +#[test] +fn the_same_seed_gives_the_same_sequence() { + let mut a = Rng::new(2026); + let mut b = Rng::new(2026); + for _ in 0..1_000 { + assert_eq!(a.next_u64(), b.next_u64()); + } +} + +#[test] +fn different_seeds_give_different_sequences() { + assert_ne!(Rng::new(1).next_u64(), Rng::new(2).next_u64()); +} + +#[test] +fn below_stays_inside_the_bound() { + let mut rng = Rng::new(7); + for bound in [1u32, 2, 3, 10, 100, 1_000_000, u32::MAX] { + for _ in 0..1_000 { + assert!(rng.below(bound) < bound); + } + } +} + +#[test] +fn below_reaches_every_value_of_a_small_bound() { + let mut rng = Rng::new(99); + let mut seen = [false; 6]; + for _ in 0..1_000 { + seen[rng.below(6) as usize] = true; + } + assert_eq!(seen, [true; 6]); +} + +#[test] +#[should_panic(expected = "bound must be greater than zero")] +fn below_refuses_a_bound_of_zero() { + Rng::new(1).below(0); +} +``` + +- [ ] **Step 2: Tests ausführen und Fehlschlag sehen** + +Run: `cargo test -p grovewright-core --test hash --test rng` +Expected: FAIL mit ``error[E0432]: unresolved import `grovewright_core::hash` `` und ``unresolved import `grovewright_core::rng` `` + +- [ ] **Step 3: Prüfsumme und Zufall umsetzen** + +Create `core/src/hash.rs`: + +```rust +//! FNV-1a hashing for state checksums. +//! +//! The hash only has to reveal accidental divergence between peers. It is +//! not meant to resist tampering. + +const FNV_OFFSET_BASIS: u64 = 0xcbf2_9ce4_8422_2325; +const FNV_PRIME: u64 = 0x0000_0100_0000_01b3; + +/// Returns the 64-bit FNV-1a hash of `bytes`. +pub fn fnv1a64(bytes: &[u8]) -> u64 { + let mut hash = FNV_OFFSET_BASIS; + for &byte in bytes { + hash ^= u64::from(byte); + hash = hash.wrapping_mul(FNV_PRIME); + } + hash +} +``` + +Create `core/src/rng.rs`: + +```rust +//! Repeatable pseudo random numbers (SplitMix64). +//! +//! The core never uses any other source of randomness. The same seed always +//! gives the same sequence, on every machine. + +/// Pseudo random number generator with 64 bits of state. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Rng { + state: u64, +} + +impl Rng { + pub fn new(seed: u64) -> Self { + Self { state: seed } + } + + /// Returns the next value of the sequence. + pub fn next_u64(&mut self) -> u64 { + self.state = self.state.wrapping_add(0x9E37_79B9_7F4A_7C15); + let mut z = self.state; + z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9); + z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB); + z ^ (z >> 31) + } + + /// Returns a value in `0..bound`. + /// + /// # Panics + /// Panics if `bound` is zero. + pub fn below(&mut self, bound: u32) -> u32 { + assert!(bound > 0, "bound must be greater than zero"); + ((u128::from(self.next_u64()) * u128::from(bound)) >> 64) as u32 + } +} +``` + +Ergänze in `core/src/lib.rs` die Zeilen `pub mod hash;` und `pub mod rng;`. Die `pub mod`-Zeilen bleiben alphabetisch sortiert: + +```rust +pub mod codec; +pub mod hash; +pub mod rng; +``` + +- [ ] **Step 4: Tests ausführen und Erfolg sehen** + +Run: `cargo test -p grovewright-core --test hash --test rng` +Expected: PASS mit `test result: ok. 2 passed` für `hash` und `test result: ok. 6 passed` für `rng` + +- [ ] **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, alle Tests bestanden + +- [ ] **Step 6: Commit und Hochladen** + +```bash +git add core/src/hash.rs core/src/rng.rs core/src/lib.rs core/tests/hash.rs core/tests/rng.rs +git commit -F - <<'EOF' +Prüfsumme und wiederholbarer Zufall + +FNV-1a für Zustandsprüfsummen und SplitMix64 als einzige Zufallsquelle +des Kerns. Beide sind gegen veröffentlichte Referenzwerte geprüft. + +Co-Authored-By: Claude Opus 5.5 +EOF +git push +``` + +--- + +### Task 4: Karte + +**Files:** +- Create: `core/src/map.rs` +- Modify: `core/src/lib.rs` +- Test: `core/tests/map.rs` + +**Interfaces:** +- Consumes: `codec::{DecodeError, Reader, Writer}` aus Task 2 +- Produces: + - `map::MAX_MAP_SIZE: u32 = 1024` + - `map::Pos { pub x: i32, pub y: i32 }` mit `Pos::new(x, y)`; `Copy`, `Ord` + - `map::Terrain` mit `Ground = 0`, `SoftRock = 1`, `HardRock = 2`, `Bedrock = 3` und den Methoden `from_u8(u8) -> Option`, `is_walkable(self) -> bool`, `hits_to_break(self) -> Option`, `required_tool_tier(self) -> Option` + - `map::Tile { pub terrain: Terrain, pub damage: u8 }` mit `Tile::new(terrain)` (Schaden 0) + - `map::Map`: `filled(width: u32, height: u32, terrain: Terrain) -> Map`, `width()`, `height()`, `in_bounds(Pos) -> bool`, `get(Pos) -> Option`, `set(&mut self, Pos, Tile) -> bool`, `tiles() -> &[Tile]` (Index `y * width + x`), `encode(&self, &mut Writer)`, `decode(&mut Reader) -> Result` + +- [ ] **Step 1: Fehlschlagenden Test schreiben** + +Create `core/tests/map.rs`: + +```rust +use grovewright_core::codec::{DecodeError, Reader, Writer}; +use grovewright_core::map::{Map, Pos, Terrain, Tile}; + +#[test] +fn a_filled_map_has_the_requested_size_and_terrain() { + let map = Map::filled(4, 3, Terrain::SoftRock); + assert_eq!(map.width(), 4); + assert_eq!(map.height(), 3); + assert_eq!(map.tiles().len(), 12); + assert_eq!(map.get(Pos::new(3, 2)), Some(Tile::new(Terrain::SoftRock))); +} + +#[test] +fn positions_outside_the_map_are_rejected() { + let mut map = Map::filled(4, 3, Terrain::Ground); + for pos in [ + Pos::new(-1, 0), + Pos::new(0, -1), + Pos::new(4, 0), + Pos::new(0, 3), + ] { + assert!(!map.in_bounds(pos), "{pos:?}"); + assert_eq!(map.get(pos), None, "{pos:?}"); + assert!(!map.set(pos, Tile::new(Terrain::Bedrock)), "{pos:?}"); + } + assert!(map.in_bounds(Pos::new(3, 2))); +} + +#[test] +fn set_changes_exactly_one_tile() { + let mut map = Map::filled(4, 3, Terrain::Ground); + assert!(map.set(Pos::new(1, 2), Tile::new(Terrain::HardRock))); + let hard_rock = map + .tiles() + .iter() + .filter(|tile| tile.terrain == Terrain::HardRock) + .count(); + assert_eq!(hard_rock, 1); + assert_eq!(map.tiles()[2 * 4 + 1].terrain, Terrain::HardRock); +} + +#[test] +fn only_rock_can_be_dug() { + assert_eq!(Terrain::Ground.hits_to_break(), None); + assert_eq!(Terrain::Bedrock.hits_to_break(), None); + assert_eq!(Terrain::SoftRock.hits_to_break(), Some(3)); + assert_eq!(Terrain::HardRock.hits_to_break(), Some(6)); + assert_eq!(Terrain::Ground.required_tool_tier(), None); + assert_eq!(Terrain::Bedrock.required_tool_tier(), None); + assert_eq!(Terrain::SoftRock.required_tool_tier(), Some(1)); + assert_eq!(Terrain::HardRock.required_tool_tier(), Some(2)); +} + +#[test] +fn only_ground_can_be_walked_on() { + assert!(Terrain::Ground.is_walkable()); + assert!(!Terrain::SoftRock.is_walkable()); + assert!(!Terrain::HardRock.is_walkable()); + assert!(!Terrain::Bedrock.is_walkable()); +} + +#[test] +fn terrain_numbers_are_stable() { + for terrain in [ + Terrain::Ground, + Terrain::SoftRock, + Terrain::HardRock, + Terrain::Bedrock, + ] { + assert_eq!(Terrain::from_u8(terrain as u8), Some(terrain)); + } + assert_eq!(Terrain::from_u8(4), None); +} + +#[test] +fn a_map_survives_encoding() { + let mut map = Map::filled(5, 4, Terrain::Ground); + map.set( + Pos::new(2, 1), + Tile { + terrain: Terrain::SoftRock, + damage: 2, + }, + ); + let mut w = Writer::new(); + map.encode(&mut w); + let bytes = w.into_bytes(); + let mut r = Reader::new(&bytes); + assert_eq!(Map::decode(&mut r), Ok(map)); + assert_eq!(r.finish(), Ok(())); +} + +fn encoded_map(width: u32, height: u32, tiles: &[(u8, u8)]) -> Vec { + let mut w = Writer::new(); + w.u32(width); + w.u32(height); + for (terrain, damage) in tiles { + w.u8(*terrain); + w.u8(*damage); + } + w.into_bytes() +} + +#[test] +fn decoding_rejects_impossible_maps() { + let cases: [(Vec, DecodeError); 6] = [ + ( + encoded_map(0, 4, &[]), + DecodeError::InvalidValue("map size"), + ), + ( + encoded_map(2_000, 4, &[]), + DecodeError::InvalidValue("map size"), + ), + ( + encoded_map(1, 1, &[(9, 0)]), + DecodeError::InvalidValue("terrain"), + ), + ( + encoded_map(1, 1, &[(0, 1)]), + DecodeError::InvalidValue("tile damage"), + ), + ( + encoded_map(1, 1, &[(1, 3)]), + DecodeError::InvalidValue("tile damage"), + ), + (encoded_map(2, 2, &[(0, 0)]), DecodeError::UnexpectedEnd), + ]; + for (bytes, expected) in cases { + assert_eq!(Map::decode(&mut Reader::new(&bytes)), Err(expected)); + } +} +``` + +- [ ] **Step 2: Test ausführen und Fehlschlag sehen** + +Run: `cargo test -p grovewright-core --test map` +Expected: FAIL mit ``error[E0432]: unresolved import `grovewright_core::map` `` + +- [ ] **Step 3: Karte umsetzen** + +Create `core/src/map.rs`: + +```rust +//! Tile map of the shared world. + +use crate::codec::{DecodeError, Reader, Writer}; + +/// Longest accepted map edge, in tiles. +pub const MAX_MAP_SIZE: u32 = 1024; + +/// Tile coordinate. `x` grows to the right, `y` grows downwards. +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)] +pub struct Pos { + pub x: i32, + pub y: i32, +} + +impl Pos { + pub const fn new(x: i32, y: i32) -> Self { + Self { x, y } + } +} + +/// What a tile is made of. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +#[repr(u8)] +pub enum Terrain { + /// Open ground that can be walked on. + Ground = 0, + /// Rock that a stone pickaxe can break. + SoftRock = 1, + /// Rock that needs a copper pickaxe. + HardRock = 2, + /// Indestructible rock at the edge of the map. + Bedrock = 3, +} + +impl Terrain { + pub fn from_u8(value: u8) -> Option { + match value { + 0 => Some(Terrain::Ground), + 1 => Some(Terrain::SoftRock), + 2 => Some(Terrain::HardRock), + 3 => Some(Terrain::Bedrock), + _ => None, + } + } + + pub fn is_walkable(self) -> bool { + matches!(self, Terrain::Ground) + } + + /// Number of hits that break this terrain, or `None` if it cannot be dug. + pub fn hits_to_break(self) -> Option { + match self { + Terrain::SoftRock => Some(3), + Terrain::HardRock => Some(6), + Terrain::Ground | Terrain::Bedrock => None, + } + } + + /// Lowest pickaxe tier that can dig this terrain, or `None` if it cannot + /// be dug. + pub fn required_tool_tier(self) -> Option { + match self { + Terrain::SoftRock => Some(1), + Terrain::HardRock => Some(2), + Terrain::Ground | Terrain::Bedrock => None, + } + } +} + +/// One cell of the map. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct Tile { + pub terrain: Terrain, + /// Hits this tile has taken so far. Always below the number of hits that + /// break its terrain, and zero for terrain that cannot be dug. + pub damage: u8, +} + +impl Tile { + pub const fn new(terrain: Terrain) -> Self { + Self { terrain, damage: 0 } + } +} + +/// Rectangular grid of tiles. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Map { + width: u32, + height: u32, + tiles: Vec, +} + +impl Map { + /// Creates a map in which every tile has the given terrain. + /// + /// # Panics + /// Panics if an edge is zero or longer than [`MAX_MAP_SIZE`]. + pub fn filled(width: u32, height: u32, terrain: Terrain) -> Self { + assert!(width > 0 && height > 0, "map must not be empty"); + assert!( + width <= MAX_MAP_SIZE && height <= MAX_MAP_SIZE, + "map is too large" + ); + let count = (width as usize) * (height as usize); + Self { + width, + height, + tiles: vec![Tile::new(terrain); count], + } + } + + pub fn width(&self) -> u32 { + self.width + } + + pub fn height(&self) -> u32 { + self.height + } + + fn index(&self, pos: Pos) -> Option { + if pos.x < 0 || pos.y < 0 { + return None; + } + let (x, y) = (pos.x as u32, pos.y as u32); + if x >= self.width || y >= self.height { + return None; + } + Some((y as usize) * (self.width as usize) + (x as usize)) + } + + pub fn in_bounds(&self, pos: Pos) -> bool { + self.index(pos).is_some() + } + + pub fn get(&self, pos: Pos) -> Option { + self.index(pos).map(|index| self.tiles[index]) + } + + /// Replaces the tile at `pos`. Returns `false` if `pos` is outside the + /// map. + pub fn set(&mut self, pos: Pos, tile: Tile) -> bool { + match self.index(pos) { + Some(index) => { + self.tiles[index] = tile; + true + } + None => false, + } + } + + /// All tiles, row by row: the tile at `pos` has index `y * width + x`. + pub fn tiles(&self) -> &[Tile] { + &self.tiles + } + + pub fn encode(&self, w: &mut Writer) { + w.u32(self.width); + w.u32(self.height); + for tile in &self.tiles { + w.u8(tile.terrain as u8); + w.u8(tile.damage); + } + } + + pub fn decode(r: &mut Reader<'_>) -> Result { + let width = r.u32()?; + let height = r.u32()?; + if width == 0 || height == 0 || width > MAX_MAP_SIZE || height > MAX_MAP_SIZE { + return Err(DecodeError::InvalidValue("map size")); + } + let count = (width as usize) * (height as usize); + if r.remaining() < count * 2 { + return Err(DecodeError::UnexpectedEnd); + } + let mut tiles = Vec::with_capacity(count); + for _ in 0..count { + let terrain = Terrain::from_u8(r.u8()?).ok_or(DecodeError::InvalidValue("terrain"))?; + let damage = r.u8()?; + if damage > 0 && damage >= terrain.hits_to_break().unwrap_or(0) { + return Err(DecodeError::InvalidValue("tile damage")); + } + tiles.push(Tile { terrain, damage }); + } + Ok(Self { + width, + height, + tiles, + }) + } +} +``` + +Ergänze in `core/src/lib.rs` die Zeile `pub mod map;`, alphabetisch einsortiert: + +```rust +pub mod codec; +pub mod hash; +pub mod map; +pub mod rng; +``` + +- [ ] **Step 4: Test ausführen und Erfolg sehen** + +Run: `cargo test -p grovewright-core --test map` +Expected: PASS mit `test result: ok. 8 passed` + +- [ ] **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, alle Tests bestanden + +- [ ] **Step 6: Commit und Hochladen** + +```bash +git add core/src/map.rs core/src/lib.rs core/tests/map.rs +git commit -F - <<'EOF' +Karte aus Kacheln + +Gelände mit Grabregeln, Kacheln mit Schaden und eine rechteckige Karte, +die sich kodieren lässt und unmögliche Eingaben ablehnt. + +Co-Authored-By: Claude Opus 5.5 +EOF +git push +``` + +--- + +### Task 5: Generator der Testkarte + +**Files:** +- Create: `core/src/mapgen.rs` +- Modify: `core/src/lib.rs` +- Test: `core/tests/mapgen.rs` + +**Interfaces:** +- Consumes: `map::{Map, Pos, Terrain, Tile}` aus Task 4, `rng::Rng` aus Task 3 +- Produces: + - `mapgen::TEST_MAP_SIZE: u32 = 64` + - `mapgen::SPAWN_CLEARING_RADIUS: i32 = 5` + - `mapgen::OUTCROP_SIZE: i32 = 4` + - `mapgen::HARD_ROCK_DISTANCE: i32 = 22` + - `mapgen::spawn_pos(map: &Map) -> Pos` (Mittelpunkt der Karte) + - `mapgen::outcrop_origin(map: &Map) -> Pos` (linke obere Ecke eines 4 × 4 großen Blocks aus weichem Fels direkt neben der Lichtung; spätere Tests graben dort) + - `mapgen::generate_test_map(seed: u64) -> Map` + +Regeln des Generators, die der Test für 300 Startzahlen prüft: + +- Der Rand ist Grundgestein, und nur der Rand. +- Im Umkreis von 5 Feldern um den Startpunkt ist offener Boden. +- Der Block aus weichem Fels liegt immer an derselben Stelle neben der Lichtung. +- Harter Fels kommt nur ab 22 Feldern Abstand vor, weicher nur darunter. +- Zwischen 20 und 80 Prozent der Karte sind Fels. + +- [ ] **Step 1: Fehlschlagenden Test schreiben** + +Create `core/tests/mapgen.rs`: + +```rust +//! Rules the map generator has to keep for every seed. + +use grovewright_core::map::{Map, Pos, Terrain}; +use grovewright_core::mapgen::{ + generate_test_map, outcrop_origin, spawn_pos, HARD_ROCK_DISTANCE, OUTCROP_SIZE, + SPAWN_CLEARING_RADIUS, TEST_MAP_SIZE, +}; + +/// Number of seeds every rule is checked against. +const SEEDS: u64 = 300; + +fn terrain_at(map: &Map, x: i32, y: i32) -> Terrain { + map.get(Pos::new(x, y)) + .expect("position is on the map") + .terrain +} + +#[test] +fn the_same_seed_gives_the_same_map() { + assert_eq!(generate_test_map(42), generate_test_map(42)); +} + +#[test] +fn different_seeds_give_different_maps() { + assert_ne!(generate_test_map(1), generate_test_map(2)); +} + +#[test] +fn the_map_has_the_test_size() { + let map = generate_test_map(0); + assert_eq!((map.width(), map.height()), (TEST_MAP_SIZE, TEST_MAP_SIZE)); +} + +#[test] +fn bedrock_forms_the_border_and_nothing_else() { + let last = TEST_MAP_SIZE as i32 - 1; + for seed in 0..SEEDS { + let map = generate_test_map(seed); + for y in 0..=last { + for x in 0..=last { + let on_border = x == 0 || y == 0 || x == last || y == last; + let is_bedrock = terrain_at(&map, x, y) == Terrain::Bedrock; + assert_eq!(is_bedrock, on_border, "seed {seed}, tile {x}/{y}"); + } + } + } +} + +#[test] +fn the_spawn_clearing_is_open_ground() { + for seed in 0..SEEDS { + let map = generate_test_map(seed); + let spawn = spawn_pos(&map); + for dy in -SPAWN_CLEARING_RADIUS..=SPAWN_CLEARING_RADIUS { + for dx in -SPAWN_CLEARING_RADIUS..=SPAWN_CLEARING_RADIUS { + if dx * dx + dy * dy > SPAWN_CLEARING_RADIUS * SPAWN_CLEARING_RADIUS { + continue; + } + let terrain = terrain_at(&map, spawn.x + dx, spawn.y + dy); + assert_eq!(terrain, Terrain::Ground, "seed {seed}, offset {dx}/{dy}"); + } + } + } +} + +#[test] +fn a_soft_rock_outcrop_lies_next_to_the_clearing() { + for seed in 0..SEEDS { + let map = generate_test_map(seed); + let spawn = spawn_pos(&map); + let origin = outcrop_origin(&map); + assert_eq!(origin.x - spawn.x, SPAWN_CLEARING_RADIUS + 2, "seed {seed}"); + for y in origin.y..origin.y + OUTCROP_SIZE { + for x in origin.x..origin.x + OUTCROP_SIZE { + assert_eq!( + terrain_at(&map, x, y), + Terrain::SoftRock, + "seed {seed}, tile {x}/{y}" + ); + } + } + } +} + +#[test] +fn hard_rock_appears_only_far_from_the_spawn_point() { + let last = TEST_MAP_SIZE as i32 - 1; + for seed in 0..SEEDS { + let map = generate_test_map(seed); + let spawn = spawn_pos(&map); + for y in 1..last { + for x in 1..last { + let far = (x - spawn.x).abs().max((y - spawn.y).abs()) >= HARD_ROCK_DISTANCE; + match terrain_at(&map, x, y) { + Terrain::HardRock => assert!(far, "seed {seed}: hard rock near spawn"), + Terrain::SoftRock => assert!(!far, "seed {seed}: soft rock far out"), + Terrain::Ground | Terrain::Bedrock => {} + } + } + } + } +} + +#[test] +fn every_map_mixes_rock_and_open_ground() { + for seed in 0..SEEDS { + let map = generate_test_map(seed); + let total = map.tiles().len(); + let rock = map + .tiles() + .iter() + .filter(|tile| !tile.terrain.is_walkable()) + .count(); + let percent = rock * 100 / total; + assert!( + (20..=80).contains(&percent), + "seed {seed}: {percent} percent rock" + ); + let hard = map + .tiles() + .iter() + .filter(|tile| tile.terrain == Terrain::HardRock) + .count(); + assert!(hard > 0, "seed {seed}: no hard rock"); + } +} + +#[test] +fn no_generated_tile_starts_damaged() { + let map = generate_test_map(5); + assert!(map.tiles().iter().all(|tile| tile.damage == 0)); +} +``` + +- [ ] **Step 2: Test ausführen und Fehlschlag sehen** + +Run: `cargo test -p grovewright-core --test mapgen` +Expected: FAIL mit ``error[E0432]: unresolved import `grovewright_core::mapgen` `` + +- [ ] **Step 3: Generator umsetzen** + +Create `core/src/mapgen.rs`: + +```rust +//! Generator for the milestone 1 test map. +//! +//! The broad layout is fixed and the details are random: a bedrock border, a +//! clearing around the spawn point, a rock outcrop right next to the +//! clearing, cave-like soft rock in the inner area and hard rock further out. + +use crate::map::{Map, Pos, Terrain, Tile}; +use crate::rng::Rng; + +/// Edge length of the test map, in tiles. +pub const TEST_MAP_SIZE: u32 = 64; +/// Tiles within this distance of the spawn point are always open ground. +pub const SPAWN_CLEARING_RADIUS: i32 = 5; +/// Edge length of the soft rock outcrop that every test map has. +pub const OUTCROP_SIZE: i32 = 4; +/// Rock at least this many tiles from the spawn point, counted along the +/// longer axis, is hard rock. +pub const HARD_ROCK_DISTANCE: i32 = 22; + +const INITIAL_ROCK_PERCENT: u32 = 46; +const SMOOTHING_PASSES: u32 = 4; +/// Keeps the map sequence apart from other uses of the same seed. +const SEED_SALT: u64 = 0x6D61_7067_656E_0001; + +/// Spawn point of a map: its centre tile. +pub fn spawn_pos(map: &Map) -> Pos { + Pos::new((map.width() / 2) as i32, (map.height() / 2) as i32) +} + +/// Top left tile of the soft rock outcrop next to the spawn clearing. +pub fn outcrop_origin(map: &Map) -> Pos { + let spawn = spawn_pos(map); + Pos::new(spawn.x + SPAWN_CLEARING_RADIUS + 2, spawn.y - 2) +} + +/// Builds the test map for `seed`. The same seed always gives the same map. +pub fn generate_test_map(seed: u64) -> Map { + let size = TEST_MAP_SIZE; + let mut rng = Rng::new(seed ^ SEED_SALT); + + let mut rock = vec![false; (size as usize) * (size as usize)]; + for cell in rock.iter_mut() { + *cell = rng.below(100) < INITIAL_ROCK_PERCENT; + } + for _ in 0..SMOOTHING_PASSES { + rock = smooth(&rock, size); + } + + let mut map = Map::filled(size, size, Terrain::Ground); + let spawn = spawn_pos(&map); + let outcrop = outcrop_origin(&map); + let last = size as i32 - 1; + + for y in 0..=last { + for x in 0..=last { + let dx = x - spawn.x; + let dy = y - spawn.y; + let on_border = x == 0 || y == 0 || x == last || y == last; + let in_clearing = dx * dx + dy * dy <= SPAWN_CLEARING_RADIUS * SPAWN_CLEARING_RADIUS; + let in_outcrop = (outcrop.x..outcrop.x + OUTCROP_SIZE).contains(&x) + && (outcrop.y..outcrop.y + OUTCROP_SIZE).contains(&y); + let is_rock = rock[(y as usize) * (size as usize) + (x as usize)]; + + let terrain = if on_border { + Terrain::Bedrock + } else if in_clearing { + Terrain::Ground + } else if in_outcrop { + Terrain::SoftRock + } else if !is_rock { + Terrain::Ground + } else if dx.abs().max(dy.abs()) >= HARD_ROCK_DISTANCE { + Terrain::HardRock + } else { + Terrain::SoftRock + }; + map.set(Pos::new(x, y), Tile::new(terrain)); + } + } + map +} + +/// One pass of cave smoothing: a cell becomes rock when at least five of the +/// nine cells around it, itself included, are rock. Cells outside the grid +/// count as rock. +fn smooth(rock: &[bool], size: u32) -> Vec { + let size = size as i32; + let mut out = vec![false; rock.len()]; + for y in 0..size { + for x in 0..size { + let mut count = 0; + for ny in y - 1..=y + 1 { + for nx in x - 1..=x + 1 { + let outside = nx < 0 || ny < 0 || nx >= size || ny >= size; + if outside || rock[(ny * size + nx) as usize] { + count += 1; + } + } + } + out[(y * size + x) as usize] = count >= 5; + } + } + out +} +``` + +Ergänze in `core/src/lib.rs` die Zeile `pub mod mapgen;`, alphabetisch einsortiert: + +```rust +pub mod codec; +pub mod hash; +pub mod map; +pub mod mapgen; +pub mod rng; +``` + +- [ ] **Step 4: Test ausführen und Erfolg sehen** + +Run: `cargo test -p grovewright-core --test mapgen` +Expected: PASS mit `test result: ok. 9 passed` (dauert etwa eine Sekunde) + +- [ ] **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, alle Tests bestanden + +- [ ] **Step 6: Commit und Hochladen** + +```bash +git add core/src/mapgen.rs core/src/lib.rs core/tests/mapgen.rs +git commit -F - <<'EOF' +Generator für die Testkarte + +Baut aus einer Startzahl eine 64 × 64 Felder große Karte mit Rand aus +Grundgestein, Lichtung am Startpunkt, Felsblock daneben, weichem Fels +innen und hartem Fels außen. Die Regeln sind für 300 Startzahlen geprüft. + +Co-Authored-By: Claude Opus 5.5 +EOF +git push +``` + +--- + +### Task 6: Inventar + +**Files:** +- Create: `core/src/inventory.rs` +- Modify: `core/src/lib.rs` +- Test: `core/tests/inventory.rs` + +**Interfaces:** +- Consumes: `codec::{DecodeError, Reader, Writer}` aus Task 2 +- Produces: + - `inventory::ItemId(pub u16)`; der Wert 0 ist für leere Plätze reserviert + - `inventory::ITEM_STONE: ItemId = ItemId(1)` + - `inventory::INVENTORY_SLOTS: usize = 24`, `inventory::STACK_LIMIT: u32 = 99` + - `inventory::Stack { pub item: ItemId, pub count: u32 }` + - `inventory::Inventory`: `new()`, `slots() -> &[Option]`, `count(ItemId) -> u32`, `free_space(ItemId) -> u32`, `add(&mut self, ItemId, count: u32) -> u32` (gibt zurück, was nicht passte), `remove(&mut self, ItemId, count: u32) -> bool` (alles oder nichts), `encode(&self, &mut Writer)`, `decode(&mut Reader) -> Result`; außerdem `Default`, `Clone`, `PartialEq` + +- [ ] **Step 1: Fehlschlagenden Test schreiben** + +Create `core/tests/inventory.rs`: + +```rust +use grovewright_core::codec::{DecodeError, Reader, Writer}; +use grovewright_core::inventory::{ + Inventory, ItemId, Stack, INVENTORY_SLOTS, ITEM_STONE, STACK_LIMIT, +}; + +const OTHER: ItemId = ItemId(2); +const CAPACITY: u32 = STACK_LIMIT * INVENTORY_SLOTS as u32; + +#[test] +fn a_new_inventory_is_empty() { + let inventory = Inventory::new(); + assert_eq!(inventory.slots().len(), INVENTORY_SLOTS); + assert!(inventory.slots().iter().all(Option::is_none)); + assert_eq!(inventory.count(ITEM_STONE), 0); + assert_eq!(inventory.free_space(ITEM_STONE), CAPACITY); + assert_eq!(Inventory::default(), inventory); +} + +#[test] +fn add_tops_up_a_stack_before_using_an_empty_slot() { + let mut inventory = Inventory::new(); + assert_eq!(inventory.add(ITEM_STONE, 90), 0); + assert_eq!(inventory.add(OTHER, 5), 0); + assert_eq!(inventory.add(ITEM_STONE, 20), 0); + + let stone = |count| { + Some(Stack { + item: ITEM_STONE, + count, + }) + }; + assert_eq!(inventory.slots()[0], stone(99)); + assert_eq!( + inventory.slots()[1], + Some(Stack { + item: OTHER, + count: 5 + }) + ); + assert_eq!(inventory.slots()[2], stone(11)); + assert_eq!(inventory.count(ITEM_STONE), 110); + assert_eq!(inventory.count(OTHER), 5); +} + +#[test] +fn add_reports_what_does_not_fit() { + let mut inventory = Inventory::new(); + assert_eq!(inventory.add(ITEM_STONE, CAPACITY + 7), 7); + assert_eq!(inventory.count(ITEM_STONE), CAPACITY); + assert_eq!(inventory.free_space(ITEM_STONE), 0); + assert_eq!(inventory.free_space(OTHER), 0); + assert_eq!(inventory.add(OTHER, 1), 1); +} + +#[test] +fn free_space_counts_empty_slots_and_matching_stacks() { + let mut inventory = Inventory::new(); + inventory.add(ITEM_STONE, 10); + inventory.add(OTHER, 1); + assert_eq!( + inventory.free_space(ITEM_STONE), + CAPACITY - STACK_LIMIT - 10 + ); + assert_eq!(inventory.free_space(OTHER), CAPACITY - STACK_LIMIT - 1); +} + +#[test] +fn remove_takes_exactly_the_requested_amount() { + let mut inventory = Inventory::new(); + inventory.add(ITEM_STONE, 150); + assert!(inventory.remove(ITEM_STONE, 60)); + assert_eq!(inventory.count(ITEM_STONE), 90); + assert_eq!( + inventory.slots()[0], + Some(Stack { + item: ITEM_STONE, + count: 90 + }) + ); + assert_eq!(inventory.slots()[1], None); +} + +#[test] +fn remove_changes_nothing_when_too_few_are_present() { + let mut inventory = Inventory::new(); + inventory.add(ITEM_STONE, 10); + let before = inventory.clone(); + assert!(!inventory.remove(ITEM_STONE, 11)); + assert!(!inventory.remove(OTHER, 1)); + assert_eq!(inventory, before); +} + +#[test] +#[should_panic(expected = "item id zero is reserved")] +fn the_reserved_item_id_cannot_be_added() { + Inventory::new().add(ItemId(0), 1); +} + +#[test] +fn an_inventory_survives_encoding() { + let mut inventory = Inventory::new(); + inventory.add(ITEM_STONE, 120); + inventory.add(OTHER, 3); + let mut w = Writer::new(); + inventory.encode(&mut w); + let bytes = w.into_bytes(); + let mut r = Reader::new(&bytes); + assert_eq!(Inventory::decode(&mut r), Ok(inventory)); + assert_eq!(r.finish(), Ok(())); +} + +#[test] +fn decoding_rejects_impossible_stack_counts() { + for count in [0, STACK_LIMIT + 1] { + let mut w = Writer::new(); + w.u16(ITEM_STONE.0); + w.u32(count); + let bytes = w.into_bytes(); + assert_eq!( + Inventory::decode(&mut Reader::new(&bytes)), + Err(DecodeError::InvalidValue("stack count")), + "count {count}" + ); + } +} +``` + +- [ ] **Step 2: Test ausführen und Fehlschlag sehen** + +Run: `cargo test -p grovewright-core --test inventory` +Expected: FAIL mit ``error[E0432]: unresolved import `grovewright_core::inventory` `` + +- [ ] **Step 3: Inventar umsetzen** + +Create `core/src/inventory.rs`: + +```rust +//! Item stacks carried by a player. + +use crate::codec::{DecodeError, Reader, Writer}; + +/// Identifies a kind of item. The value zero is reserved for empty slots. +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)] +pub struct ItemId(pub u16); + +/// Stone, gained by breaking rock. +pub const ITEM_STONE: ItemId = ItemId(1); + +/// Number of slots in an inventory. +pub const INVENTORY_SLOTS: usize = 24; +/// Largest number of items in one slot. +pub const STACK_LIMIT: u32 = 99; + +/// Items of one kind in one slot. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct Stack { + pub item: ItemId, + pub count: u32, +} + +/// Fixed number of slots, each empty or holding one stack. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Inventory { + slots: [Option; INVENTORY_SLOTS], +} + +impl Default for Inventory { + fn default() -> Self { + Self::new() + } +} + +impl Inventory { + pub fn new() -> Self { + Self { + slots: [None; INVENTORY_SLOTS], + } + } + + pub fn slots(&self) -> &[Option] { + &self.slots + } + + /// Total number of `item` across all slots. + pub fn count(&self, item: ItemId) -> u32 { + self.slots + .iter() + .flatten() + .filter(|stack| stack.item == item) + .map(|stack| stack.count) + .sum() + } + + /// How many more of `item` fit. + pub fn free_space(&self, item: ItemId) -> u32 { + self.slots + .iter() + .map(|slot| match slot { + None => STACK_LIMIT, + Some(stack) if stack.item == item => STACK_LIMIT - stack.count, + Some(_) => 0, + }) + .sum() + } + + /// Adds up to `count` of `item`. Existing stacks are topped up before + /// empty slots are used. Returns how many did not fit. + /// + /// # Panics + /// Panics if `item` is the reserved id zero. + pub fn add(&mut self, item: ItemId, count: u32) -> u32 { + assert!(item.0 != 0, "item id zero is reserved for empty slots"); + let mut left = count; + for slot in self.slots.iter_mut() { + if left == 0 { + break; + } + let Some(stack) = slot else { continue }; + if stack.item != item { + continue; + } + let moved = left.min(STACK_LIMIT - stack.count); + stack.count += moved; + left -= moved; + } + for slot in self.slots.iter_mut() { + if left == 0 { + break; + } + if slot.is_none() { + let moved = left.min(STACK_LIMIT); + *slot = Some(Stack { item, count: moved }); + left -= moved; + } + } + left + } + + /// Removes exactly `count` of `item`, emptying the last slots first. + /// Returns `false` and changes nothing if fewer are present. + pub fn remove(&mut self, item: ItemId, count: u32) -> bool { + if self.count(item) < count { + return false; + } + let mut left = count; + for slot in self.slots.iter_mut().rev() { + if left == 0 { + break; + } + let Some(stack) = slot else { continue }; + if stack.item != item { + continue; + } + let moved = left.min(stack.count); + stack.count -= moved; + left -= moved; + if stack.count == 0 { + *slot = None; + } + } + true + } + + pub fn encode(&self, w: &mut Writer) { + for slot in &self.slots { + match slot { + None => w.u16(0), + Some(stack) => { + w.u16(stack.item.0); + w.u32(stack.count); + } + } + } + } + + pub fn decode(r: &mut Reader<'_>) -> Result { + let mut slots = [None; INVENTORY_SLOTS]; + for slot in slots.iter_mut() { + let item = r.u16()?; + if item == 0 { + continue; + } + let count = r.u32()?; + if count == 0 || count > STACK_LIMIT { + return Err(DecodeError::InvalidValue("stack count")); + } + *slot = Some(Stack { + item: ItemId(item), + count, + }); + } + Ok(Self { slots }) + } +} +``` + +Ergänze in `core/src/lib.rs` die Zeile `pub mod inventory;`, alphabetisch einsortiert: + +```rust +pub mod codec; +pub mod hash; +pub mod inventory; +pub mod map; +pub mod mapgen; +pub mod rng; +``` + +- [ ] **Step 4: Test ausführen und Erfolg sehen** + +Run: `cargo test -p grovewright-core --test inventory` +Expected: PASS mit `test result: ok. 9 passed` + +- [ ] **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, alle Tests bestanden + +- [ ] **Step 6: Commit und Hochladen** + +```bash +git add core/src/inventory.rs core/src/lib.rs core/tests/inventory.rs +git commit -F - <<'EOF' +Inventar mit Stapeln + +24 Plätze mit je bis zu 99 Gegenständen. Hinzufügen füllt zuerst +vorhandene Stapel und meldet, was nicht passt; Entfernen nimmt alles +oder nichts. + +Co-Authored-By: Claude Opus 5.5 +EOF +git push +``` + +--- + +### Task 7: Spieler und Befehle + +**Files:** +- Create: `core/src/player.rs` +- Create: `core/src/command.rs` +- Modify: `core/src/lib.rs` +- Test: `core/tests/player.rs` +- Test: `core/tests/command.rs` + +**Interfaces:** +- Consumes: `codec::{DecodeError, Reader, Writer}` aus Task 2, `map::Pos` aus Task 4, `inventory::Inventory` aus Task 6 +- Produces: + - `player::PlayerKey = [u8; 16]` (dauerhafte Kennung, die das Spiel des Spielers wählt) + - `player::PlayerId(pub u8)` (Index in der Spielerliste der Welt) + - `player::MAX_ONLINE_PLAYERS: usize = 4`, `player::MAX_KNOWN_PLAYERS: usize = 16`, `player::MAX_NAME_BYTES: usize = 32`, `player::STARTING_TOOL_TIER: u8 = 1` + - `player::Player { pub key: PlayerKey, pub name: String, pub online: bool, pub tool_tier: u8, pub next_action_tick: u64, pub inventory: Inventory }` mit `Player::new(key, name: &str)`, `encode(&self, &mut Writer)`, `decode(&mut Reader) -> Result` + - `player::clamp_name(name: &str) -> String` (höchstens 32 Bytes, ohne ein Zeichen zu zerteilen) + - `command::Command` mit den Varianten `Join { key: PlayerKey, name: String }`, `Leave { player: PlayerId }`, `Dig { player: PlayerId, pos: Pos }` und den Methoden `encode(&self, &mut Writer)`, `decode(&mut Reader) -> Result`; `Clone`, `Debug`, `PartialEq`, `Eq` + +- [ ] **Step 1: Fehlschlagende Tests schreiben** + +Create `core/tests/player.rs`: + +```rust +use grovewright_core::codec::{DecodeError, Reader, Writer}; +use grovewright_core::inventory::ITEM_STONE; +use grovewright_core::player::{clamp_name, Player, MAX_NAME_BYTES, STARTING_TOOL_TIER}; + +#[test] +fn a_new_player_is_online_with_a_stone_pickaxe_and_empty_pockets() { + let player = Player::new([5; 16], "Ada"); + assert_eq!(player.key, [5; 16]); + assert_eq!(player.name, "Ada"); + assert!(player.online); + assert_eq!(player.tool_tier, STARTING_TOOL_TIER); + assert_eq!(player.next_action_tick, 0); + assert!(player.inventory.slots().iter().all(Option::is_none)); +} + +#[test] +fn long_names_are_cut_at_a_character_boundary() { + // "ä" takes two bytes, so twenty of them are forty bytes. + let long = "ä".repeat(20); + let clamped = clamp_name(&long); + assert_eq!(clamped, "ä".repeat(16)); + assert_eq!(clamped.len(), MAX_NAME_BYTES); + + // With one byte in front, the limit falls into the middle of a character. + let odd = format!("a{}", "ä".repeat(20)); + assert_eq!(clamp_name(&odd), format!("a{}", "ä".repeat(15))); + + assert_eq!(clamp_name("Ada"), "Ada"); + assert_eq!(Player::new([0; 16], &long).name, clamped); +} + +#[test] +fn a_player_survives_encoding() { + let mut player = Player::new([9; 16], "Grace"); + player.online = false; + player.next_action_tick = 1234; + player.inventory.add(ITEM_STONE, 17); + + let mut w = Writer::new(); + player.encode(&mut w); + let bytes = w.into_bytes(); + let mut r = Reader::new(&bytes); + assert_eq!(Player::decode(&mut r), Ok(player)); + assert_eq!(r.finish(), Ok(())); +} + +#[test] +fn decoding_rejects_an_overlong_name() { + let mut w = Writer::new(); + w.raw(&[0; 16]); + w.str(&"x".repeat(MAX_NAME_BYTES + 1)); + let bytes = w.into_bytes(); + assert_eq!( + Player::decode(&mut Reader::new(&bytes)), + Err(DecodeError::InvalidValue("player name")) + ); +} +``` + +Create `core/tests/command.rs`: + +```rust +use grovewright_core::codec::{DecodeError, Reader, Writer}; +use grovewright_core::command::Command; +use grovewright_core::map::Pos; +use grovewright_core::player::{PlayerId, MAX_NAME_BYTES}; + +fn round_trip(command: &Command) -> Command { + let mut w = Writer::new(); + command.encode(&mut w); + let bytes = w.into_bytes(); + let mut r = Reader::new(&bytes); + let decoded = Command::decode(&mut r).expect("command decodes"); + r.finish().expect("no bytes are left over"); + decoded +} + +#[test] +fn every_command_survives_encoding() { + let commands = [ + Command::Join { + key: [7; 16], + name: "Ada".to_owned(), + }, + Command::Leave { + player: PlayerId(3), + }, + Command::Dig { + player: PlayerId(1), + pos: Pos::new(-4, 1_000), + }, + ]; + for command in &commands { + assert_eq!(&round_trip(command), command); + } +} + +#[test] +fn an_overlong_name_is_shortened_when_encoded() { + let command = Command::Join { + key: [1; 16], + name: "x".repeat(MAX_NAME_BYTES + 10), + }; + assert_eq!( + round_trip(&command), + Command::Join { + key: [1; 16], + name: "x".repeat(MAX_NAME_BYTES), + } + ); +} + +#[test] +fn an_unknown_command_is_rejected() { + assert_eq!( + Command::decode(&mut Reader::new(&[0xEE])), + Err(DecodeError::InvalidValue("command tag")) + ); +} + +#[test] +fn a_cut_off_command_is_rejected() { + let mut w = Writer::new(); + Command::Dig { + player: PlayerId(0), + pos: Pos::new(1, 2), + } + .encode(&mut w); + let bytes = w.into_bytes(); + assert_eq!( + Command::decode(&mut Reader::new(&bytes[..bytes.len() - 1])), + Err(DecodeError::UnexpectedEnd) + ); +} + +#[test] +fn several_commands_can_follow_each_other_in_one_buffer() { + let commands = vec![ + Command::Leave { + player: PlayerId(2), + }, + Command::Dig { + player: PlayerId(0), + pos: Pos::new(5, 6), + }, + ]; + let mut w = Writer::new(); + for command in &commands { + command.encode(&mut w); + } + let bytes = w.into_bytes(); + let mut r = Reader::new(&bytes); + let mut decoded = Vec::new(); + while r.remaining() > 0 { + decoded.push(Command::decode(&mut r).unwrap()); + } + assert_eq!(decoded, commands); +} +``` + +- [ ] **Step 2: Tests ausführen und Fehlschlag sehen** + +Run: `cargo test -p grovewright-core --test player --test command` +Expected: FAIL mit ``error[E0432]: unresolved import `grovewright_core::player` `` und ``unresolved import `grovewright_core::command` `` + +- [ ] **Step 3: Spieler und Befehle umsetzen** + +Create `core/src/player.rs`: + +```rust +//! Players known to a world. + +use crate::codec::{DecodeError, Reader, Writer}; +use crate::inventory::Inventory; + +/// Stable identity of a player across sessions, chosen by the player's game. +pub type PlayerKey = [u8; 16]; + +/// Index of a player in the world's player list. +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)] +pub struct PlayerId(pub u8); + +/// Most players that can be online at the same time. +pub const MAX_ONLINE_PLAYERS: usize = 4; +/// Most players a world remembers. +pub const MAX_KNOWN_PLAYERS: usize = 16; +/// Longest stored player name, in bytes of UTF-8. +pub const MAX_NAME_BYTES: usize = 32; +/// Pickaxe tier every player starts with: stone. +pub const STARTING_TOOL_TIER: u8 = 1; + +/// A player and everything that belongs to them personally. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Player { + pub key: PlayerKey, + pub name: String, + pub online: bool, + pub tool_tier: u8, + /// First tick at which the player may act again. + pub next_action_tick: u64, + pub inventory: Inventory, +} + +impl Player { + /// Creates an online player with a stone pickaxe and an empty inventory. + pub fn new(key: PlayerKey, name: &str) -> Self { + Self { + key, + name: clamp_name(name), + online: true, + tool_tier: STARTING_TOOL_TIER, + next_action_tick: 0, + inventory: Inventory::new(), + } + } + + pub fn encode(&self, w: &mut Writer) { + w.raw(&self.key); + w.str(&self.name); + w.bool(self.online); + w.u8(self.tool_tier); + w.u64(self.next_action_tick); + self.inventory.encode(w); + } + + pub fn decode(r: &mut Reader<'_>) -> Result { + let mut key = [0u8; 16]; + key.copy_from_slice(r.raw(16)?); + let name = r.string()?; + if name.len() > MAX_NAME_BYTES { + return Err(DecodeError::InvalidValue("player name")); + } + let online = r.bool()?; + let tool_tier = r.u8()?; + let next_action_tick = r.u64()?; + let inventory = Inventory::decode(r)?; + Ok(Self { + key, + name, + online, + tool_tier, + next_action_tick, + inventory, + }) + } +} + +/// Shortens `name` to at most [`MAX_NAME_BYTES`] bytes without splitting a +/// character. +pub fn clamp_name(name: &str) -> String { + let mut end = name.len().min(MAX_NAME_BYTES); + while !name.is_char_boundary(end) { + end -= 1; + } + name[..end].to_owned() +} +``` + +Create `core/src/command.rs`: + +```rust +//! Everything a player can ask the world to do. + +use crate::codec::{DecodeError, Reader, Writer}; +use crate::map::Pos; +use crate::player::{clamp_name, PlayerId, PlayerKey, MAX_NAME_BYTES}; + +/// A request that changes the world. Commands are the only way to change it. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum Command { + /// A player enters the world, new or returning. + Join { key: PlayerKey, name: String }, + /// A player leaves the world. Their inventory stays. + Leave { player: PlayerId }, + /// A player hits the tile at `pos` with their pickaxe. + Dig { player: PlayerId, pos: Pos }, +} + +const TAG_JOIN: u8 = 1; +const TAG_LEAVE: u8 = 2; +const TAG_DIG: u8 = 3; + +impl Command { + pub fn encode(&self, w: &mut Writer) { + match self { + Command::Join { key, name } => { + w.u8(TAG_JOIN); + w.raw(key); + w.str(&clamp_name(name)); + } + Command::Leave { player } => { + w.u8(TAG_LEAVE); + w.u8(player.0); + } + Command::Dig { player, pos } => { + w.u8(TAG_DIG); + w.u8(player.0); + w.i32(pos.x); + w.i32(pos.y); + } + } + } + + pub fn decode(r: &mut Reader<'_>) -> Result { + match r.u8()? { + TAG_JOIN => { + let mut key = [0u8; 16]; + key.copy_from_slice(r.raw(16)?); + let name = r.string()?; + if name.len() > MAX_NAME_BYTES { + return Err(DecodeError::InvalidValue("player name")); + } + Ok(Command::Join { key, name }) + } + TAG_LEAVE => Ok(Command::Leave { + player: PlayerId(r.u8()?), + }), + TAG_DIG => { + let player = PlayerId(r.u8()?); + let x = r.i32()?; + let y = r.i32()?; + Ok(Command::Dig { + player, + pos: Pos::new(x, y), + }) + } + _ => Err(DecodeError::InvalidValue("command tag")), + } + } +} +``` + +Ergänze in `core/src/lib.rs` die Zeilen `pub mod command;` und `pub mod player;`, alphabetisch einsortiert: + +```rust +pub mod codec; +pub mod command; +pub mod hash; +pub mod inventory; +pub mod map; +pub mod mapgen; +pub mod player; +pub mod rng; +``` + +- [ ] **Step 4: Tests ausführen und Erfolg sehen** + +Run: `cargo test -p grovewright-core --test player --test command` +Expected: PASS mit `test result: ok. 4 passed` für `player` und `test result: ok. 5 passed` für `command` + +- [ ] **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, alle Tests bestanden + +- [ ] **Step 6: Commit und Hochladen** + +```bash +git add core/src/player.rs core/src/command.rs core/src/lib.rs core/tests/player.rs core/tests/command.rs +git commit -F - <<'EOF' +Spieler und Befehle + +Ein Spieler hat eine dauerhafte Kennung, einen Namen, eine +Werkzeugstufe und ein Inventar. Befehle (Beitreten, Verlassen, Graben) +sind der einzige Weg, die Welt zu ändern, und lassen sich kodieren. + +Co-Authored-By: Claude Opus 5.5 +EOF +git push +``` + +--- + +### Task 8: Welt und Regeln + +**Files:** +- Create: `core/src/world.rs` +- Modify: `core/src/lib.rs` +- Test: `core/tests/world.rs` +- Test: ein Modultest am Ende von `core/src/world.rs` + +**Interfaces:** +- Consumes: `command::Command` und `player::{clamp_name, Player, PlayerId, PlayerKey, MAX_KNOWN_PLAYERS, MAX_ONLINE_PLAYERS}` aus Task 7, `inventory::{ItemId, ITEM_STONE}` aus Task 6, `map::{Map, Pos, Terrain, Tile}` aus Task 4, `mapgen::generate_test_map` aus Task 5 +- Produces: + - `world::TICKS_PER_SECOND: u32 = 20`, `world::DIG_COOLDOWN_TICKS: u64 = 5` + - `world::Event` mit den Varianten `PlayerJoined { player }`, `PlayerLeft { player }`, `JoinRefused { key }`, `TileHit { player, pos }`, `TileChanged { pos }`, `ItemGained { player, item, count }`, `InventoryFull { player }` + - `world::World` mit den Feldern `pub(crate) seed: u64`, `pub(crate) tick: u64`, `pub(crate) map: Map`, `pub(crate) players: Vec` und den Methoden `new(seed: u64) -> World`, `seed() -> u64`, `tick() -> u64`, `map() -> &Map`, `players() -> &[Player]`, `player(PlayerId) -> Option<&Player>`, `find_player(&PlayerKey) -> Option`, `set_all_offline(&mut self)`, `step(&mut self, commands: &[Command]) -> Vec`; `Clone`, `Debug`, `PartialEq`, `Eq` + +Die Regeln in `World::step`: + +- Die Befehle eines Takts werden in der gegebenen Reihenfolge angewandt, danach steigt der Takt um eins. +- Beitreten: Ein unbekannter Spieler wird angelegt, ein bekannter kommt mit seinem Inventar zurück. Bei vier Spielern online oder 16 bekannten Spielern wird der Beitritt abgelehnt. +- Graben: Ein Schlag zählt nur, wenn der Spieler online ist, seine Wartezeit abgelaufen ist, das Feld grabbar ist und seine Werkzeugstufe reicht. Der dritte Schlag bricht weichen Fels und bringt einen Stein. +- Ist das Inventar voll, wird der brechende Schlag abgelehnt. Das Feld bleibt stehen, und es beginnt keine Wartezeit. +- Ein abgelehnter Befehl ändert nichts an der Welt. + +- [ ] **Step 1: Fehlschlagenden Test schreiben** + +Create `core/tests/world.rs`: + +```rust +use grovewright_core::command::Command; +use grovewright_core::inventory::ITEM_STONE; +use grovewright_core::map::{Pos, Terrain}; +use grovewright_core::mapgen::{outcrop_origin, spawn_pos}; +use grovewright_core::player::{PlayerId, PlayerKey, MAX_KNOWN_PLAYERS, MAX_ONLINE_PLAYERS}; +use grovewright_core::world::{Event, World, DIG_COOLDOWN_TICKS, TICKS_PER_SECOND}; + +fn key(n: u8) -> PlayerKey { + [n; 16] +} + +fn join(n: u8) -> Command { + Command::Join { + key: key(n), + name: format!("Spieler {n}"), + } +} + +/// A world with one online player, and a soft rock tile to dig at. +fn world_with_player() -> (World, PlayerId, Pos) { + let mut world = World::new(42); + world.step(&[join(1)]); + let rock = outcrop_origin(world.map()); + (world, PlayerId(0), rock) +} + +/// Steps without commands until a player who just hit may hit again. +fn wait_for_cooldown(world: &mut World) { + for _ in 0..DIG_COOLDOWN_TICKS { + world.step(&[]); + } +} + +#[test] +fn a_new_world_starts_at_tick_zero_without_players() { + let world = World::new(42); + assert_eq!(world.seed(), 42); + assert_eq!(world.tick(), 0); + assert!(world.players().is_empty()); + assert_eq!(TICKS_PER_SECOND, 20); +} + +#[test] +fn step_advances_the_tick() { + let mut world = World::new(1); + assert!(world.step(&[]).is_empty()); + assert_eq!(world.tick(), 1); +} + +#[test] +fn joining_adds_an_online_player() { + let mut world = World::new(1); + let events = world.step(&[join(7)]); + assert_eq!( + events, + vec![Event::PlayerJoined { + player: PlayerId(0) + }] + ); + let player = world.player(PlayerId(0)).unwrap(); + assert!(player.online); + assert_eq!(player.name, "Spieler 7"); + assert_eq!(world.find_player(&key(7)), Some(PlayerId(0))); + assert_eq!(world.find_player(&key(8)), None); + assert!(world.player(PlayerId(1)).is_none()); +} + +#[test] +fn joining_twice_changes_nothing() { + let mut world = World::new(1); + world.step(&[join(7)]); + assert!(world.step(&[join(7)]).is_empty()); + assert_eq!(world.players().len(), 1); +} + +#[test] +fn leaving_and_returning_keeps_the_inventory() { + let (mut world, id, rock) = world_with_player(); + for _ in 0..3 { + world.step(&[Command::Dig { + player: id, + pos: rock, + }]); + wait_for_cooldown(&mut world); + } + assert_eq!(world.player(id).unwrap().inventory.count(ITEM_STONE), 1); + + assert_eq!( + world.step(&[Command::Leave { player: id }]), + vec![Event::PlayerLeft { player: id }] + ); + assert!(!world.player(id).unwrap().online); + assert!(world.step(&[Command::Leave { player: id }]).is_empty()); + + assert_eq!( + world.step(&[join(1)]), + vec![Event::PlayerJoined { player: id }] + ); + assert_eq!(world.players().len(), 1); + assert!(world.player(id).unwrap().online); + assert_eq!(world.player(id).unwrap().inventory.count(ITEM_STONE), 1); +} + +#[test] +fn a_returning_player_can_change_their_name() { + let (mut world, id, _) = world_with_player(); + world.step(&[Command::Leave { player: id }]); + world.step(&[Command::Join { + key: key(1), + name: "Neuer Name".to_owned(), + }]); + assert_eq!(world.player(id).unwrap().name, "Neuer Name"); +} + +#[test] +fn a_fifth_online_player_is_refused() { + let mut world = World::new(1); + for n in 0..MAX_ONLINE_PLAYERS as u8 { + world.step(&[join(n)]); + } + assert_eq!( + world.step(&[join(99)]), + vec![Event::JoinRefused { key: key(99) }] + ); + assert_eq!(world.players().len(), MAX_ONLINE_PLAYERS); + + // A known player who is offline is refused as well while the world is full. + world.step(&[Command::Leave { + player: PlayerId(0), + }]); + world.step(&[join(50)]); + assert_eq!( + world.step(&[join(0)]), + vec![Event::JoinRefused { key: key(0) }] + ); +} + +#[test] +fn the_world_remembers_a_limited_number_of_players() { + let mut world = World::new(1); + for n in 0..MAX_KNOWN_PLAYERS as u8 { + world.step(&[join(n)]); + world.step(&[Command::Leave { + player: PlayerId(n), + }]); + } + assert_eq!(world.players().len(), MAX_KNOWN_PLAYERS); + assert_eq!( + world.step(&[join(200)]), + vec![Event::JoinRefused { key: key(200) }] + ); + assert_eq!(world.players().len(), MAX_KNOWN_PLAYERS); +} + +#[test] +fn set_all_offline_marks_every_player_offline() { + let mut world = World::new(1); + world.step(&[join(1), join(2)]); + world.set_all_offline(); + assert!(world.players().iter().all(|player| !player.online)); +} + +#[test] +fn three_hits_break_soft_rock_and_give_one_stone() { + let (mut world, id, rock) = world_with_player(); + let dig = [Command::Dig { + player: id, + pos: rock, + }]; + let hit = vec![Event::TileHit { + player: id, + pos: rock, + }]; + + assert_eq!(world.step(&dig), hit); + assert_eq!(world.map().get(rock).unwrap().damage, 1); + wait_for_cooldown(&mut world); + + assert_eq!(world.step(&dig), hit); + assert_eq!(world.map().get(rock).unwrap().damage, 2); + wait_for_cooldown(&mut world); + + assert_eq!( + world.step(&dig), + vec![ + Event::TileChanged { pos: rock }, + Event::ItemGained { + player: id, + item: ITEM_STONE, + count: 1 + } + ] + ); + let tile = world.map().get(rock).unwrap(); + assert_eq!((tile.terrain, tile.damage), (Terrain::Ground, 0)); + assert_eq!(world.player(id).unwrap().inventory.count(ITEM_STONE), 1); +} + +#[test] +fn hits_during_the_cooldown_are_ignored() { + let (mut world, id, rock) = world_with_player(); + let dig = [Command::Dig { + player: id, + pos: rock, + }]; + world.step(&dig); + for _ in 1..DIG_COOLDOWN_TICKS { + assert!(world.step(&dig).is_empty()); + } + assert_eq!(world.map().get(rock).unwrap().damage, 1); + assert_eq!(world.step(&dig).len(), 1); + assert_eq!(world.map().get(rock).unwrap().damage, 2); +} + +#[test] +fn two_hits_in_one_tick_count_once() { + let (mut world, id, rock) = world_with_player(); + let dig = Command::Dig { + player: id, + pos: rock, + }; + let twice = [dig.clone(), dig]; + assert_eq!(world.step(&twice).len(), 1); + assert_eq!(world.map().get(rock).unwrap().damage, 1); +} + +#[test] +fn ground_bedrock_and_positions_off_the_map_cannot_be_dug() { + let (mut world, id, _) = world_with_player(); + let spawn = spawn_pos(world.map()); + for pos in [ + spawn, + Pos::new(0, 0), + Pos::new(-5, 3), + Pos::new(10_000, 10_000), + ] { + let before = world.map().clone(); + let events = world.step(&[Command::Dig { player: id, pos }]); + assert!(events.is_empty(), "{pos:?}"); + assert_eq!(world.map(), &before, "{pos:?}"); + } +} + +#[test] +fn hard_rock_resists_the_stone_pickaxe() { + let (mut world, id, _) = world_with_player(); + let width = world.map().width() as usize; + let index = world + .map() + .tiles() + .iter() + .position(|tile| tile.terrain == Terrain::HardRock) + .expect("the test map has hard rock"); + let pos = Pos::new((index % width) as i32, (index / width) as i32); + + assert!(world.step(&[Command::Dig { player: id, pos }]).is_empty()); + assert_eq!(world.map().get(pos).unwrap().damage, 0); +} + +#[test] +fn unknown_and_offline_players_cannot_dig() { + let (mut world, id, rock) = world_with_player(); + assert!(world + .step(&[Command::Dig { + player: PlayerId(9), + pos: rock + }]) + .is_empty()); + world.step(&[Command::Leave { player: id }]); + assert!(world + .step(&[Command::Dig { + player: id, + pos: rock + }]) + .is_empty()); + assert_eq!(world.map().get(rock).unwrap().damage, 0); +} + +#[test] +fn commands_of_one_tick_are_applied_in_order() { + let (mut world, id, rock) = world_with_player(); + // Leaving first makes the hit in the same tick invalid. + let events = world.step(&[ + Command::Leave { player: id }, + Command::Dig { + player: id, + pos: rock, + }, + ]); + assert_eq!(events, vec![Event::PlayerLeft { player: id }]); + assert_eq!(world.map().get(rock).unwrap().damage, 0); +} +``` + +- [ ] **Step 2: Test ausführen und Fehlschlag sehen** + +Run: `cargo test -p grovewright-core --test world` +Expected: FAIL mit ``error[E0432]: unresolved import `grovewright_core::world` `` + +- [ ] **Step 3: Welt umsetzen** + +Create `core/src/world.rs`. Die Datei endet mit einem Modultest. Er steht hier und nicht in `core/tests/world.rs`, weil er das Inventar eines Spielers direkt füllen muss, was die öffentliche Schnittstelle nicht erlaubt. + +```rust +//! The shared world and the rules that change it. + +use crate::command::Command; +use crate::inventory::{ItemId, ITEM_STONE}; +use crate::map::{Map, Pos, Terrain, Tile}; +use crate::mapgen::generate_test_map; +use crate::player::{ + clamp_name, Player, PlayerId, PlayerKey, MAX_KNOWN_PLAYERS, MAX_ONLINE_PLAYERS, +}; + +/// Simulation steps per second of play. +pub const TICKS_PER_SECOND: u32 = 20; +/// Ticks a player has to wait between two pickaxe hits. +pub const DIG_COOLDOWN_TICKS: u64 = 5; + +/// Something that happened during a tick. Events tell the presentation what +/// to show. They are not part of the world state. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum Event { + PlayerJoined { + player: PlayerId, + }, + PlayerLeft { + player: PlayerId, + }, + /// A player could not join because the world is full. + JoinRefused { + key: PlayerKey, + }, + /// A pickaxe hit landed without breaking the tile. + TileHit { + player: PlayerId, + pos: Pos, + }, + /// A tile changed its terrain. + TileChanged { + pos: Pos, + }, + ItemGained { + player: PlayerId, + item: ItemId, + count: u32, + }, + /// A hit was refused because the inventory has no room for the result. + InventoryFull { + player: PlayerId, + }, +} + +/// Complete state of the shared world. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct World { + pub(crate) seed: u64, + pub(crate) tick: u64, + pub(crate) map: Map, + pub(crate) players: Vec, +} + +impl World { + /// Creates a fresh world with the test map for `seed` and no players. + pub fn new(seed: u64) -> Self { + Self { + seed, + tick: 0, + map: generate_test_map(seed), + players: Vec::new(), + } + } + + pub fn seed(&self) -> u64 { + self.seed + } + + /// Number of ticks simulated so far. + pub fn tick(&self) -> u64 { + self.tick + } + + pub fn map(&self) -> &Map { + &self.map + } + + pub fn players(&self) -> &[Player] { + &self.players + } + + pub fn player(&self, id: PlayerId) -> Option<&Player> { + self.players.get(id.0 as usize) + } + + pub fn find_player(&self, key: &PlayerKey) -> Option { + self.players + .iter() + .position(|player| &player.key == key) + .map(|index| PlayerId(index as u8)) + } + + /// Marks every player as offline. Used after loading a save from disk, + /// when nobody has joined yet. + pub fn set_all_offline(&mut self) { + for player in &mut self.players { + player.online = false; + } + } + + /// Applies `commands` in order and advances the world by one tick. + pub fn step(&mut self, commands: &[Command]) -> Vec { + let mut events = Vec::new(); + for command in commands { + match command { + Command::Join { key, name } => self.join(key, name, &mut events), + Command::Leave { player } => self.leave(*player, &mut events), + Command::Dig { player, pos } => self.dig(*player, *pos, &mut events), + } + } + self.tick += 1; + events + } + + fn join(&mut self, key: &PlayerKey, name: &str, events: &mut Vec) { + let online = self.players.iter().filter(|player| player.online).count(); + match self.find_player(key) { + Some(id) => { + let player = &mut self.players[id.0 as usize]; + if player.online { + return; + } + if online >= MAX_ONLINE_PLAYERS { + events.push(Event::JoinRefused { key: *key }); + return; + } + player.online = true; + player.name = clamp_name(name); + events.push(Event::PlayerJoined { player: id }); + } + None => { + if online >= MAX_ONLINE_PLAYERS || self.players.len() >= MAX_KNOWN_PLAYERS { + events.push(Event::JoinRefused { key: *key }); + return; + } + self.players.push(Player::new(*key, name)); + let id = PlayerId((self.players.len() - 1) as u8); + events.push(Event::PlayerJoined { player: id }); + } + } + } + + fn leave(&mut self, id: PlayerId, events: &mut Vec) { + let Some(player) = self.players.get_mut(id.0 as usize) else { + return; + }; + if !player.online { + return; + } + player.online = false; + events.push(Event::PlayerLeft { player: id }); + } + + fn dig(&mut self, id: PlayerId, pos: Pos, events: &mut Vec) { + let tick = self.tick; + let Some(player) = self.players.get_mut(id.0 as usize) else { + return; + }; + if !player.online || tick < player.next_action_tick { + return; + } + let Some(tile) = self.map.get(pos) else { + return; + }; + let (Some(hits), Some(tier)) = ( + tile.terrain.hits_to_break(), + tile.terrain.required_tool_tier(), + ) else { + return; + }; + if player.tool_tier < tier { + return; + } + + let damage = tile.damage.saturating_add(1); + let breaks = damage >= hits; + if breaks && player.inventory.free_space(ITEM_STONE) == 0 { + events.push(Event::InventoryFull { player: id }); + return; + } + + player.next_action_tick = tick + DIG_COOLDOWN_TICKS; + if breaks { + self.map.set(pos, Tile::new(Terrain::Ground)); + player.inventory.add(ITEM_STONE, 1); + events.push(Event::TileChanged { pos }); + events.push(Event::ItemGained { + player: id, + item: ITEM_STONE, + count: 1, + }); + } else { + self.map.set( + pos, + Tile { + terrain: tile.terrain, + damage, + }, + ); + events.push(Event::TileHit { player: id, pos }); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::inventory::{INVENTORY_SLOTS, STACK_LIMIT}; + use crate::mapgen::outcrop_origin; + + // Lives here instead of in `tests/world.rs` because filling an inventory + // needs direct access to the player, which the public API does not give. + #[test] + fn a_full_inventory_refuses_the_breaking_hit() { + let mut world = World::new(42); + world.step(&[Command::Join { + key: [1; 16], + name: "Ada".to_owned(), + }]); + let id = PlayerId(0); + let rock = outcrop_origin(world.map()); + let dig = [Command::Dig { + player: id, + pos: rock, + }]; + let capacity = STACK_LIMIT * INVENTORY_SLOTS as u32; + assert_eq!(world.players[0].inventory.add(ITEM_STONE, capacity), 0); + + for _ in 0..2 { + assert_eq!( + world.step(&dig), + vec![Event::TileHit { + player: id, + pos: rock + }] + ); + for _ in 0..DIG_COOLDOWN_TICKS { + world.step(&[]); + } + } + + assert_eq!(world.step(&dig), vec![Event::InventoryFull { player: id }]); + let tile = world.map().get(rock).unwrap(); + assert_eq!((tile.terrain, tile.damage), (Terrain::SoftRock, 2)); + + // The refused hit starts no cooldown: once there is room, the very + // next tick can break the tile. + assert!(world.players[0].inventory.remove(ITEM_STONE, 1)); + assert_eq!( + world.step(&dig), + vec![ + Event::TileChanged { pos: rock }, + Event::ItemGained { + player: id, + item: ITEM_STONE, + count: 1 + } + ] + ); + } +} +``` + +Ergänze in `core/src/lib.rs` die Zeile `pub mod world;`, alphabetisch einsortiert: + +```rust +pub mod codec; +pub mod command; +pub mod hash; +pub mod inventory; +pub mod map; +pub mod mapgen; +pub mod player; +pub mod rng; +pub mod world; +``` + +- [ ] **Step 4: Tests ausführen und Erfolg sehen** + +Run: `cargo test -p grovewright-core --test world` +Expected: PASS mit `test result: ok. 16 passed` + +Run: `cargo test -p grovewright-core --lib` +Expected: PASS mit `test world::tests::a_full_inventory_refuses_the_breaking_hit ... ok` + +- [ ] **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, alle Tests bestanden + +- [ ] **Step 6: Commit und Hochladen** + +```bash +git add core/src/world.rs core/src/lib.rs core/tests/world.rs +git commit -F - <<'EOF' +Welt und ihre Regeln + +World::step wendet die Befehle eines Takts der Reihe nach an: Spieler +treten bei und verlassen die Welt, drei Schläge brechen weichen Fels +und bringen einen Stein. Abgelehnte Befehle ändern nichts. + +Co-Authored-By: Claude Opus 5.5 +EOF +git push +``` + +--- + +### Task 9: Snapshot und Prüfsumme + +**Files:** +- Create: `core/src/snapshot.rs` +- Modify: `core/src/lib.rs` +- Modify: `docs/KONZEPT.md` (Abschnitt 7.3, Punkt „Speicherstand") +- Test: `core/tests/snapshot.rs` + +**Interfaces:** +- Consumes: `world::World` mit seinen `pub(crate)`-Feldern aus Task 8, `codec::{DecodeError, Reader, Writer}` aus Task 2, `hash::fnv1a64` aus Task 3, `map::Map` aus Task 4, `player::{Player, MAX_KNOWN_PLAYERS}` aus Task 7 +- Produces: + - `snapshot::SNAPSHOT_VERSION: u32 = 1` + - `snapshot::SnapshotError` mit den Varianten `NotASnapshot`, `UnsupportedVersion(u32)`, `Corrupt(DecodeError)`; `From` + - `World::state_hash(&self) -> u64` + - `World::to_snapshot(&self) -> Vec` (beginnt mit den vier Bytes `GRWS`, dann die Version als `u32`) + - `World::from_snapshot(bytes: &[u8]) -> Result` + +Der Snapshot enthält den vollständigen Zustand: Startzahl, Takt, Karte und alle Spieler. Die Prüfsumme wird über genau diese Bytes ohne Kopf gebildet. Was im Snapshot fehlt, fehlt damit auch in der Prüfsumme; deshalb gehört jedes neue Zustandsfeld hierher. + +- [ ] **Step 1: Fehlschlagenden Test schreiben** + +Create `core/tests/snapshot.rs`: + +```rust +use grovewright_core::codec::DecodeError; +use grovewright_core::command::Command; +use grovewright_core::map::Pos; +use grovewright_core::mapgen::outcrop_origin; +use grovewright_core::player::PlayerId; +use grovewright_core::snapshot::{SnapshotError, SNAPSHOT_VERSION}; +use grovewright_core::world::World; + +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 +} + +#[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 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 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 cut_off_and_padded_snapshots_are_rejected() { + let bytes = played_world().to_snapshot(); + assert_eq!( + World::from_snapshot(&bytes[..bytes.len() - 1]), + Err(SnapshotError::Corrupt(DecodeError::UnexpectedEnd)) + ); + + let mut padded = bytes.clone(); + padded.push(0); + assert_eq!( + World::from_snapshot(&padded), + Err(SnapshotError::Corrupt(DecodeError::TrailingBytes)) + ); +} +``` + +- [ ] **Step 2: Test ausführen und Fehlschlag sehen** + +Run: `cargo test -p grovewright-core --test snapshot` +Expected: FAIL mit ``error[E0432]: unresolved import `grovewright_core::snapshot` `` + +- [ ] **Step 3: Snapshot umsetzen** + +Create `core/src/snapshot.rs`: + +```rust +//! Complete, versioned encoding of a world. Saves, joining players and state +//! checksums all use it. + +use std::fmt; + +use crate::codec::{DecodeError, Reader, Writer}; +use crate::hash::fnv1a64; +use crate::map::Map; +use crate::player::{Player, MAX_KNOWN_PLAYERS}; +use crate::world::World; + +const MAGIC: [u8; 4] = *b"GRWS"; +/// Version of the snapshot format. Raise it whenever the encoded state +/// changes. +pub const SNAPSHOT_VERSION: u32 = 1; + +/// 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 snapshot is damaged. + Corrupt(DecodeError), +} + +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::Corrupt(error) => write!(f, "damaged snapshot: {error}"), + } + } +} + +impl std::error::Error for SnapshotError {} + +impl From 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. + pub fn to_snapshot(&self) -> Vec { + let mut w = Writer::new(); + w.raw(&MAGIC); + w.u32(SNAPSHOT_VERSION); + self.encode_state(&mut w); + w.into_bytes() + } + + /// Restores a world from bytes written by [`World::to_snapshot`]. + pub fn from_snapshot(bytes: &[u8]) -> Result { + 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)); + } + 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()?; + Ok(World { + seed, + tick, + map, + players, + }) + } +} +``` + +Ergänze in `core/src/lib.rs` die Zeile `pub mod snapshot;`, alphabetisch einsortiert: + +```rust +pub mod codec; +pub mod command; +pub mod hash; +pub mod inventory; +pub mod map; +pub mod mapgen; +pub mod player; +pub mod rng; +pub mod snapshot; +pub mod world; +``` + +- [ ] **Step 4: Test ausführen und Erfolg sehen** + +Run: `cargo test -p grovewright-core --test snapshot` +Expected: PASS mit `test result: ok. 7 passed` + +- [ ] **Step 5: Konzept korrigieren** + +Das Konzept beschreibt den Speicherstand als Startzahl plus Änderungen. Umgesetzt ist der vollständige Zustand, weil ein solcher Speicherstand gültig bleibt, wenn sich der Kartengenerator zwischen zwei Versionen ändert. + +Ersetze in `docs/KONZEPT.md` im Abschnitt 7.3 diese Zeile: + +```markdown +- **Speicherstand.** Er hält Startzahl und Änderungen fest. Ein Gast bekommt ihn beim Beitritt geschickt. +``` + +durch: + +```markdown +- **Speicherstand.** Er enthält den vollständigen Zustand der Welt samt Startzahl und bleibt deshalb gültig, wenn sich der Kartengenerator später ändert. Ein Gast bekommt ihn beim Beitritt geschickt. +``` + +- [ ] **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, alle Tests bestanden + +- [ ] **Step 7: Commit und Hochladen** + +```bash +git add core/src/snapshot.rs core/src/lib.rs core/tests/snapshot.rs docs/KONZEPT.md +git commit -F - <<'EOF' +Snapshot und Zustandsprüfsumme + +Der Snapshot enthält den vollständigen Zustand der Welt hinter einem +Kopf mit Format und Version. Er dient als Speicherstand und als +Grundlage der Prüfsumme. Das Konzept ist entsprechend korrigiert: Der +Speicherstand ist der vollständige Zustand, nicht Startzahl plus +Änderungen. + +Co-Authored-By: Claude Opus 5.5 +EOF +git push +``` + +--- + +### Task 10: Aufzeichnung und Wiederholung + +**Files:** +- Create: `core/src/replay.rs` +- Modify: `core/src/lib.rs` +- Test: `core/tests/replay.rs` + +**Interfaces:** +- Consumes: `World::to_snapshot`, `World::from_snapshot`, `World::state_hash` und `snapshot::SnapshotError` aus Task 9, `World::step` aus Task 8, `command::Command` aus Task 7, `codec::{DecodeError, Reader, Writer}` aus Task 2 +- Produces: + - `replay::RECORDING_VERSION: u32 = 1` + - `replay::RecordingError` mit den Varianten `NotARecording`, `UnsupportedVersion(u32)`, `Corrupt(DecodeError)`, `BadStart(SnapshotError)`, `Diverged { expected: u64, actual: u64 }` + - `replay::Recording`: `start(world: &World) -> Recording`, `push_tick(&mut self, commands: &[Command])`, `tick_count() -> u32`, `replay(&self) -> Result`, `to_bytes(&self, end: &World) -> Vec` (beginnt mit den vier Bytes `GRWR`), `from_bytes(bytes: &[u8]) -> Result<(Recording, u64), RecordingError>`, `verify(bytes: &[u8]) -> Result` + +So wird aufgezeichnet: `Recording::start` hält den Zustand der Welt fest. Vor jedem `World::step` wird `push_tick` mit denselben Befehlen aufgerufen. `to_bytes` bekommt die Welt nach dem letzten aufgezeichneten Takt und legt deren Prüfsumme bei. `verify` spielt alles nach und meldet `Diverged`, wenn am Ende eine andere Prüfsumme steht. + +- [ ] **Step 1: Fehlschlagenden Test schreiben** + +Create `core/tests/replay.rs`: + +```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::world::World; + +const ADA: PlayerId = PlayerId(0); + +/// Commands of tick `tick` in a short scripted session. +fn commands_for(world: &World, tick: u32) -> Vec { + 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.push_tick(&commands); + world.step(&commands); + } + (recording, world) +} + +#[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, mut world) = recorded_session(); + // One tick more than was recorded: the claimed end state is unreachable. + world.step(&[]); + let bytes = recording.to_bytes(&world); + match Recording::verify(&bytes) { + Err(RecordingError::Diverged { expected, actual }) => { + assert_eq!(expected, world.state_hash()); + assert_ne!(actual, expected); + } + 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.push_tick(&commands); + world.step(&commands); + } + assert_eq!(recording.tick_count(), 70); + 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] +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)) + ); +} +``` + +- [ ] **Step 2: Test ausführen und Fehlschlag sehen** + +Run: `cargo test -p grovewright-core --test replay` +Expected: FAIL mit ``error[E0432]: unresolved import `grovewright_core::replay` `` + +- [ ] **Step 3: Aufzeichnung umsetzen** + +Create `core/src/replay.rs`: + +```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::World; + +const MAGIC: [u8; 4] = *b"GRWR"; +/// Version of the recording format. +pub const RECORDING_VERSION: u32 = 1; + +/// 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 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, + tick_count: u32, + /// Ticks that had commands, as (tick index, commands), in rising order. + busy_ticks: Vec<(u32, Vec)>, +} + +impl Recording { + /// Starts a recording at the current state of `world`. + pub fn start(world: &World) -> Self { + Self { + start: world.to_snapshot(), + tick_count: 0, + busy_ticks: Vec::new(), + } + } + + /// Records the commands of one tick. Call it once for every + /// [`World::step`], with the same commands. + pub fn push_tick(&mut self, commands: &[Command]) { + if !commands.is_empty() { + self.busy_ticks.push((self.tick_count, commands.to_vec())); + } + self.tick_count += 1; + } + + /// Number of recorded ticks. + pub fn tick_count(&self) -> u32 { + self.tick_count + } + + /// Restores the starting state and applies every recorded tick. + pub fn replay(&self) -> Result { + 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. + pub fn to_bytes(&self, end: &World) -> Vec { + 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 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, + tick_count, + busy_ticks, + }, + expected_hash, + )) + } + + /// Replays `bytes` and checks that the recorded final state is reached. + pub fn verify(bytes: &[u8]) -> Result { + 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 }) + } + } +} +``` + +Ergänze in `core/src/lib.rs` die Zeile `pub mod replay;`. Die Datei hat danach genau diesen Inhalt: + +```rust +//! Deterministic core of Grovewright. +//! +//! Everything that belongs to the shared world lives here. The core never +//! touches the engine, the clock, files or the network, and it calculates +//! only with whole numbers. Given the same commands it produces the same +//! world on every machine. That is what lets every player simulate the whole +//! world and exchange nothing but commands. + +pub mod codec; +pub mod command; +pub mod hash; +pub mod inventory; +pub mod map; +pub mod mapgen; +pub mod player; +pub mod replay; +pub mod rng; +pub mod snapshot; +pub mod world; +``` + +- [ ] **Step 4: Test ausführen und Erfolg sehen** + +Run: `cargo test -p grovewright-core --test replay` +Expected: PASS mit `test result: ok. 7 passed` + +- [ ] **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, alle Tests bestanden + +- [ ] **Step 6: Commit und Hochladen** + +```bash +git add core/src/replay.rs core/src/lib.rs core/tests/replay.rs +git commit -F - <<'EOF' +Aufzeichnung und Wiederholung von Sitzungen + +Eine Aufzeichnung hält den Startzustand und die Befehle jedes Takts +fest. Wiederholen ergibt exakt dieselbe Welt; verify meldet, wenn das +Ende von der aufgezeichneten Prüfsumme abweicht. + +Co-Authored-By: Claude Opus 5.5 +EOF +git push +``` + +--- + +### Task 11: Abnahme des Gleichschritts + +**Files:** +- Test: `core/tests/determinism.rs` + +**Interfaces:** +- Consumes: die gesamte öffentliche Schnittstelle aus den Tasks 2 bis 10 +- Produces: fünf Abnahmetests, die das zentrale Versprechen des Kerns prüfen. Spätere Pläne ergänzen hier ihre Befehle im Skript. + +Diese Tests brauchen keinen neuen Code. Sie müssen sofort bestehen. Schlägt einer fehl, verstößt etwas im Kern gegen die Gleichschritt-Regeln; dann ist die Ursache im Kern zu suchen und nicht der Test anzupassen. + +Was geprüft wird: + +- Zwei Welten mit derselben Startzahl und denselben 3000 Takten Befehlen liefern dieselben Ereignisse und denselben Zustand. +- Eine andere Startzahl ergibt eine andere Welt. +- Eine mitten in der Sitzung aus einem Snapshot wiederhergestellte Welt läuft identisch weiter. +- Eine aufgezeichnete Sitzung endet beim Wiederholen in derselben Prüfsumme. +- Befehle, die kodiert und wieder dekodiert wurden, ergeben dieselbe Welt wie die ursprünglichen. + +- [ ] **Step 1: Abnahmetests schreiben** + +Create `core/tests/determinism.rs`: + +```rust +//! The central promise of the core: the same commands always produce the +//! same world. + +use grovewright_core::codec::{Reader, Writer}; +use grovewright_core::command::Command; +use grovewright_core::inventory::ITEM_STONE; +use grovewright_core::map::Pos; +use grovewright_core::mapgen::TEST_MAP_SIZE; +use grovewright_core::player::PlayerId; +use grovewright_core::replay::Recording; +use grovewright_core::rng::Rng; +use grovewright_core::world::World; + +/// Edge length of the square around the map centre in which the script digs. +const DIG_AREA: u32 = 24; + +/// Builds a busy, repeatable command stream: four players join, dig at +/// random places near the centre, and now and then leave and return. +fn script(seed: u64, ticks: usize) -> Vec> { + let mut rng = Rng::new(seed); + let first = (TEST_MAP_SIZE - DIG_AREA) / 2; + let mut out = Vec::with_capacity(ticks); + for tick in 0..ticks { + let mut commands = Vec::new(); + if tick < 4 { + commands.push(Command::Join { + key: [tick as u8 + 1; 16], + name: format!("P{tick}"), + }); + } + for player in 0..4 { + if rng.below(3) == 0 { + let x = first + rng.below(DIG_AREA); + let y = first + rng.below(DIG_AREA); + commands.push(Command::Dig { + player: PlayerId(player), + pos: Pos::new(x as i32, y as i32), + }); + } + } + if rng.below(200) == 0 { + commands.push(Command::Leave { + player: PlayerId(rng.below(4) as u8), + }); + } + if rng.below(200) == 0 { + let n = rng.below(4) as u8; + commands.push(Command::Join { + key: [n + 1; 16], + name: format!("P{n}"), + }); + } + out.push(commands); + } + out +} + +fn stones(world: &World) -> u32 { + world + .players() + .iter() + .map(|player| player.inventory.count(ITEM_STONE)) + .sum() +} + +#[test] +fn the_same_commands_give_the_same_world() { + let script = script(2026, 3_000); + let mut a = World::new(99); + let mut b = World::new(99); + for (tick, commands) in script.iter().enumerate() { + assert_eq!( + a.step(commands), + b.step(commands), + "events differ at tick {tick}" + ); + if tick.is_multiple_of(100) { + assert_eq!( + a.state_hash(), + b.state_hash(), + "state differs at tick {tick}" + ); + } + } + assert_eq!(a, b); + assert!( + stones(&a) > 50, + "the script should break plenty of rock, broke {}", + stones(&a) + ); +} + +#[test] +fn a_different_seed_gives_a_different_world() { + let script = script(2026, 200); + let mut a = World::new(1); + let mut b = World::new(2); + for commands in &script { + a.step(commands); + b.step(commands); + } + assert_ne!(a.state_hash(), b.state_hash()); +} + +#[test] +fn a_world_restored_in_the_middle_of_a_session_stays_in_step() { + let script = script(7, 2_000); + let mut original = World::new(5); + for commands in &script[..1_000] { + original.step(commands); + } + let mut restored = World::from_snapshot(&original.to_snapshot()).unwrap(); + for commands in &script[1_000..] { + assert_eq!(original.step(commands), restored.step(commands)); + } + assert_eq!(original.state_hash(), restored.state_hash()); +} + +#[test] +fn a_recorded_session_replays_to_the_same_hash() { + let script = script(31, 2_000); + let mut world = World::new(8); + let mut recording = Recording::start(&world); + for commands in &script { + recording.push_tick(commands); + world.step(commands); + } + let bytes = recording.to_bytes(&world); + let replayed = Recording::verify(&bytes).expect("the recording replays cleanly"); + assert_eq!(replayed.state_hash(), world.state_hash()); +} + +#[test] +fn commands_sent_as_bytes_give_the_same_world() { + let script = script(3, 1_500); + let mut local = World::new(21); + let mut remote = World::new(21); + for commands in &script { + let mut w = Writer::new(); + for command in commands { + command.encode(&mut w); + } + let bytes = w.into_bytes(); + let mut r = Reader::new(&bytes); + let mut received = Vec::new(); + while r.remaining() > 0 { + received.push(Command::decode(&mut r).expect("command decodes")); + } + local.step(commands); + remote.step(&received); + } + assert_eq!(local.state_hash(), remote.state_hash()); +} +``` + +- [ ] **Step 2: Tests ausführen** + +Run: `cargo test -p grovewright-core --test determinism` +Expected: PASS mit `test result: ok. 5 passed` + +- [ ] **Step 3: Nachweisen, dass die Tests einen Verstoß bemerken** + +Ändere vorübergehend in `core/src/snapshot.rs` in der Funktion `encode_state` die Zeile + +```rust + w.u64(self.tick); +``` + +zu + +```rust + w.u64(0); +``` + +Damit fehlt der Takt in Snapshot und Prüfsumme. Eine wiederhergestellte Welt beginnt dann bei Takt 0, und die Wartezeiten der Spieler laufen anders ab. + +Run: `cargo test -p grovewright-core --test determinism` +Expected: FAIL in `a_world_restored_in_the_middle_of_a_session_stays_in_step` + +Mache die Änderung rückgängig. + +Run: `git status --short` +Expected: genau eine Zeile, `?? core/tests/determinism.rs`. Die Datei `core/src/snapshot.rs` taucht nicht auf. + +- [ ] **Step 4: 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 +``` + +Expected: keine Ausgabe von rustfmt, keine Warnung von Clippy, in beiden Testläufen zusammen je 87 bestandene Tests und kein fehlgeschlagener + +- [ ] **Step 5: Commit und Hochladen** + +```bash +git add core/tests/determinism.rs +git commit -F - <<'EOF' +Abnahmetests für den Gleichschritt + +Fünf Tests prüfen über tausende Takte mit vier Spielern, dass gleiche +Befehle dieselbe Welt ergeben: im direkten Vergleich, nach einem +Snapshot mitten in der Sitzung, beim Wiederholen einer Aufzeichnung +und nach dem Weg über die Kodierung. + +Co-Authored-By: Claude Opus 5.5 +EOF +git push +``` + +--- + +## Abschluss + +Nach Task 11 steht der Kern. Der Branch `m1a-rust-kern` ist hochgeladen und wird über den Skill `superpowers:finishing-a-development-branch` in `main` übernommen. Danach wird der Plan M1b (Brücke und Hülle) geschrieben; er baut auf diesen Schnittstellen auf: + +- `World::new`, `World::step`, `World::map`, `World::players`, `World::tick` +- `World::to_snapshot`, `World::from_snapshot`, `World::state_hash`, `World::set_all_offline` +- `Recording::start`, `Recording::push_tick`, `Recording::to_bytes`, `Recording::verify` +- `Command::{Join, Leave, Dig}` und `Event` From 477b9a41dd4addaf99ebfb39207f97fc346d643a Mon Sep 17 00:00:00 2001 From: Steven Wroblewski Date: Wed, 30 Sep 2026 20:08:01 +0200 Subject: [PATCH 02/15] =?UTF-8?q?Ger=C3=BCst=20f=C3=BCr=20den=20Rust-Kern?= =?UTF-8?q?=20anlegen?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Workspace, leeres Crate grovewright-core und ein Wächter-Test, der den Quelltext des Kerns auf Verstöße gegen die Gleichschritt-Regeln prüft. Co-Authored-By: Claude Opus 5.5 --- .gitattributes | 2 + .gitignore | 2 + CLAUDE.md | 54 +++++++++++++++++++++++++ Cargo.lock | 7 ++++ Cargo.toml | 8 ++++ core/Cargo.toml | 13 ++++++ core/src/lib.rs | 7 ++++ core/tests/forbidden_apis.rs | 78 ++++++++++++++++++++++++++++++++++++ 8 files changed, 171 insertions(+) create mode 100644 .gitattributes create mode 100644 .gitignore create mode 100644 CLAUDE.md create mode 100644 Cargo.lock create mode 100644 Cargo.toml create mode 100644 core/Cargo.toml create mode 100644 core/src/lib.rs create mode 100644 core/tests/forbidden_apis.rs diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..2c10f62 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +# Same line endings on every machine. +* text=auto eol=lf diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4b67358 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +# Rust +/target/ diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..54935aa --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,54 @@ +# 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. | +| `docs/KONZEPT.md` | Spielkonzept und Entscheidungen | +| `docs/superpowers/plans/` | Umsetzungspläne | + +Die Brücke zu Godot (`bridge/`), das Godot-Projekt (`game/`) und die Inhaltsdateien (`content/`) kommen mit späteren Plänen dazu. + +Faustregel: Was im Speicherstand steht oder alle Spieler betrifft, gehört in den Kern. + +## Regeln für den Kern + +Alle Spieler simulieren die Welt selbst und tauschen nur Befehle aus (Gleichschritt). Das funktioniert nur, wenn der Kern auf jedem Rechner exakt gleich rechnet. Deshalb gilt in `core/`: + +- Nur ganze Zahlen, keine Gleitkommazahlen. +- Keine Uhr, keine Dateien, kein Netzwerk, keine Threads, keine Umgebungsvariablen. +- 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`. +- Alles, was zum Zustand der Welt gehört, steht im Snapshot. Wer ein Feld ergänzt, ergänzt es in `snapshot.rs` und erhöht `SNAPSHOT_VERSION`. +- Wo eine Rechnung überlaufen kann, steht eine ausdrückliche Wahl: `saturating_`, `wrapping_` oder `checked_`. +- Keine fremden Crates im Kern. + +Der Test `core/tests/forbidden_apis.rs` durchsucht den Quelltext des Kerns nach verbotenen Begriffen. Sie dürfen dort auch in Kommentaren nicht vorkommen. + +## Prüfen + +Vor jedem Commit müssen alle drei Befehle fehlerfrei laufen: + +```bash +cargo fmt --all -- --check +cargo clippy --workspace --all-targets -- -D warnings +cargo test --workspace +``` + +## Sprache + +- Code, Bezeichner und Kommentare im Code: Englisch. +- Dokumente, Pläne, Commit-Nachrichten, Issues und Pull Requests: Deutsch. +- Texte im Spiel: Deutsch und Englisch über Übersetzungsdateien. + +## Git + +- Archiv: `https://git.race-cave.cloud/steven/Grovewright.git` (Gitea). +- Gearbeitet wird auf einem Branch pro Plan, nicht direkt auf `main`. +- Nach jedem abgeschlossenen Arbeitsschritt wird committet und hochgeladen. Das ist freigegeben. diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..f3da7f1 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,7 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "grovewright-core" +version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..795adc1 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,8 @@ +[workspace] +resolver = "2" +members = ["core"] + +# 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 diff --git a/core/Cargo.toml b/core/Cargo.toml new file mode 100644 index 0000000..58f56d7 --- /dev/null +++ b/core/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "grovewright-core" +version = "0.1.0" +edition = "2021" +rust-version = "1.94" +publish = false +description = "Deterministic simulation core of Grovewright" + +[lints.rust] +unsafe_code = "forbid" + +[lints.clippy] +float_arithmetic = "deny" diff --git a/core/src/lib.rs b/core/src/lib.rs new file mode 100644 index 0000000..de69b99 --- /dev/null +++ b/core/src/lib.rs @@ -0,0 +1,7 @@ +//! Deterministic core of Grovewright. +//! +//! Everything that belongs to the shared world lives here. The core never +//! touches the engine, the clock, files or the network, and it calculates +//! only with whole numbers. Given the same commands it produces the same +//! world on every machine. That is what lets every player simulate the whole +//! world and exchange nothing but commands. diff --git a/core/tests/forbidden_apis.rs b/core/tests/forbidden_apis.rs new file mode 100644 index 0000000..8913c42 --- /dev/null +++ b/core/tests/forbidden_apis.rs @@ -0,0 +1,78 @@ +//! Guards the lockstep rules: the core must not use anything whose result +//! can differ between machines or between runs. + +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", + ), + ("unsafe", "the core forbids unsafe code"), +]; + +fn rust_files(dir: &Path, out: &mut Vec) { + 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 (number, line) in text.lines().enumerate() { + for (needle, reason) in FORBIDDEN { + if line.contains(needle) { + problems.push(format!( + "{}:{}: `{needle}`: {reason}", + file.display(), + number + 1 + )); + } + } + } + } + assert!( + problems.is_empty(), + "forbidden APIs in the core:\n{}", + problems.join("\n") + ); +} From 4eed8efed092417a1a2758a088ec7277b67fa3bc Mon Sep 17 00:00:00 2001 From: Steven Wroblewski Date: Wed, 30 Sep 2026 20:17:02 +0200 Subject: [PATCH 03/15] =?UTF-8?q?Bin=C3=A4re=20Kodierung=20f=C3=BCr=20den?= =?UTF-8?q?=20Kern?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Writer und Reader für Befehle, Snapshots und Aufzeichnungen. Beschädigte Eingaben führen zu einem Fehler statt zu einem Absturz. Co-Authored-By: Claude Opus 5.5 --- core/src/codec.rs | 169 ++++++++++++++++++++++++++++++++++++++++++++ core/src/lib.rs | 2 + core/tests/codec.rs | 78 ++++++++++++++++++++ 3 files changed, 249 insertions(+) create mode 100644 core/src/codec.rs create mode 100644 core/tests/codec.rs diff --git a/core/src/codec.rs b/core/src/codec.rs new file mode 100644 index 0000000..66c6d85 --- /dev/null +++ b/core/src/codec.rs @@ -0,0 +1,169 @@ +//! Minimal little-endian binary encoding for commands, snapshots and +//! recordings. + +use std::fmt; + +/// Error returned when bytes cannot be decoded. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum DecodeError { + /// The input ended before the value was complete. + UnexpectedEnd, + /// The input contained a value that is not allowed at this position. + InvalidValue(&'static str), + /// The input contained bytes after the last expected value. + TrailingBytes, +} + +impl fmt::Display for DecodeError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + DecodeError::UnexpectedEnd => write!(f, "unexpected end of input"), + DecodeError::InvalidValue(what) => write!(f, "invalid value: {what}"), + DecodeError::TrailingBytes => write!(f, "trailing bytes after the last value"), + } + } +} + +impl std::error::Error for DecodeError {} + +/// Appends values to a growing byte buffer. +#[derive(Debug, Default)] +pub struct Writer { + buf: Vec, +} + +impl Writer { + pub fn new() -> Self { + Self::default() + } + + pub fn u8(&mut self, value: u8) { + self.buf.push(value); + } + + pub fn u16(&mut self, value: u16) { + self.buf.extend_from_slice(&value.to_le_bytes()); + } + + pub fn u32(&mut self, value: u32) { + self.buf.extend_from_slice(&value.to_le_bytes()); + } + + pub fn u64(&mut self, value: u64) { + self.buf.extend_from_slice(&value.to_le_bytes()); + } + + pub fn i32(&mut self, value: i32) { + self.buf.extend_from_slice(&value.to_le_bytes()); + } + + pub fn bool(&mut self, value: bool) { + self.buf.push(u8::from(value)); + } + + /// Writes bytes as they are, without a length. + pub fn raw(&mut self, value: &[u8]) { + self.buf.extend_from_slice(value); + } + + /// Writes a `u32` length followed by the bytes. + pub fn bytes(&mut self, value: &[u8]) { + let len = u32::try_from(value.len()).expect("length fits in u32"); + self.u32(len); + self.buf.extend_from_slice(value); + } + + /// Writes a string as UTF-8 with a `u32` length in front. + pub fn str(&mut self, value: &str) { + self.bytes(value.as_bytes()); + } + + pub fn into_bytes(self) -> Vec { + self.buf + } +} + +/// Reads values from a byte slice in the order they were written. +#[derive(Debug)] +pub struct Reader<'a> { + buf: &'a [u8], + pos: usize, +} + +impl<'a> Reader<'a> { + pub fn new(buf: &'a [u8]) -> Self { + Self { buf, pos: 0 } + } + + /// Number of bytes that have not been read yet. + pub fn remaining(&self) -> usize { + self.buf.len() - self.pos + } + + /// Reads exactly `len` bytes. + pub fn raw(&mut self, len: usize) -> Result<&'a [u8], DecodeError> { + if len > self.remaining() { + return Err(DecodeError::UnexpectedEnd); + } + let slice = &self.buf[self.pos..self.pos + len]; + self.pos += len; + Ok(slice) + } + + fn array(&mut self) -> Result<[u8; N], DecodeError> { + let mut out = [0u8; N]; + out.copy_from_slice(self.raw(N)?); + Ok(out) + } + + pub fn u8(&mut self) -> Result { + Ok(self.array::<1>()?[0]) + } + + pub fn u16(&mut self) -> Result { + Ok(u16::from_le_bytes(self.array()?)) + } + + pub fn u32(&mut self) -> Result { + Ok(u32::from_le_bytes(self.array()?)) + } + + pub fn u64(&mut self) -> Result { + Ok(u64::from_le_bytes(self.array()?)) + } + + pub fn i32(&mut self) -> Result { + Ok(i32::from_le_bytes(self.array()?)) + } + + pub fn bool(&mut self) -> Result { + match self.u8()? { + 0 => Ok(false), + 1 => Ok(true), + _ => Err(DecodeError::InvalidValue("bool")), + } + } + + /// Reads a `u32` length followed by that many bytes. + pub fn bytes(&mut self) -> Result<&'a [u8], DecodeError> { + let len = self.u32()? as usize; + self.raw(len) + } + + /// Reads a UTF-8 string with a `u32` length in front. + pub fn string(&mut self) -> Result { + let bytes = self.bytes()?; + std::str::from_utf8(bytes) + .map(str::to_owned) + .map_err(|_| DecodeError::InvalidValue("utf-8 string")) + } + + /// Succeeds only if every byte has been read. + pub fn finish(self) -> Result<(), DecodeError> { + if self.remaining() == 0 { + Ok(()) + } else { + Err(DecodeError::TrailingBytes) + } + } +} diff --git a/core/src/lib.rs b/core/src/lib.rs index de69b99..127e8a1 100644 --- a/core/src/lib.rs +++ b/core/src/lib.rs @@ -5,3 +5,5 @@ //! only with whole numbers. Given the same commands it produces the same //! world on every machine. That is what lets every player simulate the whole //! world and exchange nothing but commands. + +pub mod codec; diff --git a/core/tests/codec.rs b/core/tests/codec.rs new file mode 100644 index 0000000..6fba8b2 --- /dev/null +++ b/core/tests/codec.rs @@ -0,0 +1,78 @@ +use grovewright_core::codec::{DecodeError, Reader, Writer}; + +#[test] +fn every_kind_of_value_survives_a_round_trip() { + let mut w = Writer::new(); + w.u8(0xAB); + w.u16(0xBEEF); + w.u32(0xDEAD_BEEF); + w.u64(0x0123_4567_89AB_CDEF); + w.i32(-42); + w.bool(true); + w.bool(false); + w.bytes(&[1, 2, 3]); + w.str("Grüner Hain"); + w.raw(&[9, 9]); + let bytes = w.into_bytes(); + + let mut r = Reader::new(&bytes); + assert_eq!(r.u8(), Ok(0xAB)); + assert_eq!(r.u16(), Ok(0xBEEF)); + assert_eq!(r.u32(), Ok(0xDEAD_BEEF)); + assert_eq!(r.u64(), Ok(0x0123_4567_89AB_CDEF)); + assert_eq!(r.i32(), Ok(-42)); + assert_eq!(r.bool(), Ok(true)); + assert_eq!(r.bool(), Ok(false)); + assert_eq!(r.bytes(), Ok(&[1u8, 2, 3][..])); + assert_eq!(r.string(), Ok("Grüner Hain".to_owned())); + assert_eq!(r.raw(2), Ok(&[9u8, 9][..])); + assert_eq!(r.finish(), Ok(())); +} + +#[test] +fn integers_are_little_endian() { + let mut w = Writer::new(); + w.u32(0x0102_0304); + assert_eq!(w.into_bytes(), vec![4, 3, 2, 1]); +} + +#[test] +fn reading_past_the_end_fails() { + let mut r = Reader::new(&[1, 2]); + assert_eq!(r.u32(), Err(DecodeError::UnexpectedEnd)); +} + +#[test] +fn an_oversized_length_fails_without_panicking() { + let mut w = Writer::new(); + w.u32(u32::MAX); + let bytes = w.into_bytes(); + let mut r = Reader::new(&bytes); + assert_eq!(r.bytes(), Err(DecodeError::UnexpectedEnd)); +} + +#[test] +fn invalid_bool_and_invalid_utf8_are_rejected() { + let mut r = Reader::new(&[2]); + assert_eq!(r.bool(), Err(DecodeError::InvalidValue("bool"))); + + let mut w = Writer::new(); + w.bytes(&[0xFF, 0xFE]); + let bytes = w.into_bytes(); + let mut r = Reader::new(&bytes); + assert_eq!(r.string(), Err(DecodeError::InvalidValue("utf-8 string"))); +} + +#[test] +fn finish_rejects_bytes_that_were_not_read() { + let r = Reader::new(&[0]); + assert_eq!(r.finish(), Err(DecodeError::TrailingBytes)); +} + +#[test] +fn remaining_counts_down_as_values_are_read() { + let mut r = Reader::new(&[1, 2, 3]); + assert_eq!(r.remaining(), 3); + r.u8().unwrap(); + assert_eq!(r.remaining(), 2); +} From 4df328243c780f86e9e0bee6c5e125e0603ced31 Mon Sep 17 00:00:00 2001 From: Steven Wroblewski Date: Wed, 30 Sep 2026 20:24:15 +0200 Subject: [PATCH 04/15] =?UTF-8?q?Pr=C3=BCfsumme=20und=20wiederholbarer=20Z?= =?UTF-8?q?ufall?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit FNV-1a für Zustandsprüfsummen und SplitMix64 als einzige Zufallsquelle des Kerns. Beide sind gegen veröffentlichte Referenzwerte geprüft. Co-Authored-By: Claude Haiku 4.5 --- core/src/hash.rs | 17 ++++++++++++++++ core/src/lib.rs | 2 ++ core/src/rng.rs | 34 ++++++++++++++++++++++++++++++++ core/tests/hash.rs | 13 ++++++++++++ core/tests/rng.rs | 49 ++++++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 115 insertions(+) create mode 100644 core/src/hash.rs create mode 100644 core/src/rng.rs create mode 100644 core/tests/hash.rs create mode 100644 core/tests/rng.rs diff --git a/core/src/hash.rs b/core/src/hash.rs new file mode 100644 index 0000000..7442e87 --- /dev/null +++ b/core/src/hash.rs @@ -0,0 +1,17 @@ +//! FNV-1a hashing for state checksums. +//! +//! The hash only has to reveal accidental divergence between peers. It is +//! not meant to resist tampering. + +const FNV_OFFSET_BASIS: u64 = 0xcbf2_9ce4_8422_2325; +const FNV_PRIME: u64 = 0x0000_0100_0000_01b3; + +/// Returns the 64-bit FNV-1a hash of `bytes`. +pub fn fnv1a64(bytes: &[u8]) -> u64 { + let mut hash = FNV_OFFSET_BASIS; + for &byte in bytes { + hash ^= u64::from(byte); + hash = hash.wrapping_mul(FNV_PRIME); + } + hash +} diff --git a/core/src/lib.rs b/core/src/lib.rs index 127e8a1..b640c90 100644 --- a/core/src/lib.rs +++ b/core/src/lib.rs @@ -7,3 +7,5 @@ //! world and exchange nothing but commands. pub mod codec; +pub mod hash; +pub mod rng; diff --git a/core/src/rng.rs b/core/src/rng.rs new file mode 100644 index 0000000..53e269f --- /dev/null +++ b/core/src/rng.rs @@ -0,0 +1,34 @@ +//! Repeatable pseudo random numbers (SplitMix64). +//! +//! The core never uses any other source of randomness. The same seed always +//! gives the same sequence, on every machine. + +/// Pseudo random number generator with 64 bits of state. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Rng { + state: u64, +} + +impl Rng { + pub fn new(seed: u64) -> Self { + Self { state: seed } + } + + /// Returns the next value of the sequence. + pub fn next_u64(&mut self) -> u64 { + self.state = self.state.wrapping_add(0x9E37_79B9_7F4A_7C15); + let mut z = self.state; + z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9); + z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB); + z ^ (z >> 31) + } + + /// Returns a value in `0..bound`. + /// + /// # Panics + /// Panics if `bound` is zero. + pub fn below(&mut self, bound: u32) -> u32 { + assert!(bound > 0, "bound must be greater than zero"); + ((u128::from(self.next_u64()) * u128::from(bound)) >> 64) as u32 + } +} diff --git a/core/tests/hash.rs b/core/tests/hash.rs new file mode 100644 index 0000000..96784db --- /dev/null +++ b/core/tests/hash.rs @@ -0,0 +1,13 @@ +use grovewright_core::hash::fnv1a64; + +#[test] +fn matches_the_published_fnv1a_test_vectors() { + assert_eq!(fnv1a64(b""), 0xcbf2_9ce4_8422_2325); + assert_eq!(fnv1a64(b"a"), 0xaf63_dc4c_8601_ec8c); + assert_eq!(fnv1a64(b"foobar"), 0x8594_4171_f739_67e8); +} + +#[test] +fn the_order_of_bytes_matters() { + assert_ne!(fnv1a64(&[0, 1]), fnv1a64(&[1, 0])); +} diff --git a/core/tests/rng.rs b/core/tests/rng.rs new file mode 100644 index 0000000..4060e80 --- /dev/null +++ b/core/tests/rng.rs @@ -0,0 +1,49 @@ +use grovewright_core::rng::Rng; + +#[test] +fn matches_the_splitmix64_reference_values() { + let mut rng = Rng::new(0); + assert_eq!(rng.next_u64(), 0xE220_A839_7B1D_CDAF); + assert_eq!(rng.next_u64(), 0x6E78_9E6A_A1B9_65F4); + assert_eq!(rng.next_u64(), 0x06C4_5D18_8009_454F); +} + +#[test] +fn the_same_seed_gives_the_same_sequence() { + let mut a = Rng::new(2026); + let mut b = Rng::new(2026); + for _ in 0..1_000 { + assert_eq!(a.next_u64(), b.next_u64()); + } +} + +#[test] +fn different_seeds_give_different_sequences() { + assert_ne!(Rng::new(1).next_u64(), Rng::new(2).next_u64()); +} + +#[test] +fn below_stays_inside_the_bound() { + let mut rng = Rng::new(7); + for bound in [1u32, 2, 3, 10, 100, 1_000_000, u32::MAX] { + for _ in 0..1_000 { + assert!(rng.below(bound) < bound); + } + } +} + +#[test] +fn below_reaches_every_value_of_a_small_bound() { + let mut rng = Rng::new(99); + let mut seen = [false; 6]; + for _ in 0..1_000 { + seen[rng.below(6) as usize] = true; + } + assert_eq!(seen, [true; 6]); +} + +#[test] +#[should_panic(expected = "bound must be greater than zero")] +fn below_refuses_a_bound_of_zero() { + Rng::new(1).below(0); +} From 88d8069f8ed95731ce45110c6258eb26ca91283e Mon Sep 17 00:00:00 2001 From: Steven Wroblewski Date: Wed, 30 Sep 2026 20:32:06 +0200 Subject: [PATCH 05/15] Karte aus Kacheln MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Gelände mit Grabregeln, Kacheln mit Schaden und eine rechteckige Karte, die sich kodieren lässt und unmögliche Eingaben ablehnt. Co-Authored-By: Claude Haiku 4.5 --- core/src/lib.rs | 1 + core/src/map.rs | 190 ++++++++++++++++++++++++++++++++++++++++++++++ core/tests/map.rs | 132 ++++++++++++++++++++++++++++++++ 3 files changed, 323 insertions(+) create mode 100644 core/src/map.rs create mode 100644 core/tests/map.rs diff --git a/core/src/lib.rs b/core/src/lib.rs index b640c90..6482889 100644 --- a/core/src/lib.rs +++ b/core/src/lib.rs @@ -8,4 +8,5 @@ pub mod codec; pub mod hash; +pub mod map; pub mod rng; diff --git a/core/src/map.rs b/core/src/map.rs new file mode 100644 index 0000000..e6312e0 --- /dev/null +++ b/core/src/map.rs @@ -0,0 +1,190 @@ +//! Tile map of the shared world. + +use crate::codec::{DecodeError, Reader, Writer}; + +/// Longest accepted map edge, in tiles. +pub const MAX_MAP_SIZE: u32 = 1024; + +/// Tile coordinate. `x` grows to the right, `y` grows downwards. +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)] +pub struct Pos { + pub x: i32, + pub y: i32, +} + +impl Pos { + pub const fn new(x: i32, y: i32) -> Self { + Self { x, y } + } +} + +/// What a tile is made of. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +#[repr(u8)] +pub enum Terrain { + /// Open ground that can be walked on. + Ground = 0, + /// Rock that a stone pickaxe can break. + SoftRock = 1, + /// Rock that needs a copper pickaxe. + HardRock = 2, + /// Indestructible rock at the edge of the map. + Bedrock = 3, +} + +impl Terrain { + pub fn from_u8(value: u8) -> Option { + match value { + 0 => Some(Terrain::Ground), + 1 => Some(Terrain::SoftRock), + 2 => Some(Terrain::HardRock), + 3 => Some(Terrain::Bedrock), + _ => None, + } + } + + pub fn is_walkable(self) -> bool { + matches!(self, Terrain::Ground) + } + + /// Number of hits that break this terrain, or `None` if it cannot be dug. + pub fn hits_to_break(self) -> Option { + match self { + Terrain::SoftRock => Some(3), + Terrain::HardRock => Some(6), + Terrain::Ground | Terrain::Bedrock => None, + } + } + + /// Lowest pickaxe tier that can dig this terrain, or `None` if it cannot + /// be dug. + pub fn required_tool_tier(self) -> Option { + match self { + Terrain::SoftRock => Some(1), + Terrain::HardRock => Some(2), + Terrain::Ground | Terrain::Bedrock => None, + } + } +} + +/// One cell of the map. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct Tile { + pub terrain: Terrain, + /// Hits this tile has taken so far. Always below the number of hits that + /// break its terrain, and zero for terrain that cannot be dug. + pub damage: u8, +} + +impl Tile { + pub const fn new(terrain: Terrain) -> Self { + Self { terrain, damage: 0 } + } +} + +/// Rectangular grid of tiles. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Map { + width: u32, + height: u32, + tiles: Vec, +} + +impl Map { + /// Creates a map in which every tile has the given terrain. + /// + /// # Panics + /// Panics if an edge is zero or longer than [`MAX_MAP_SIZE`]. + pub fn filled(width: u32, height: u32, terrain: Terrain) -> Self { + assert!(width > 0 && height > 0, "map must not be empty"); + assert!( + width <= MAX_MAP_SIZE && height <= MAX_MAP_SIZE, + "map is too large" + ); + let count = (width as usize) * (height as usize); + Self { + width, + height, + tiles: vec![Tile::new(terrain); count], + } + } + + pub fn width(&self) -> u32 { + self.width + } + + pub fn height(&self) -> u32 { + self.height + } + + fn index(&self, pos: Pos) -> Option { + if pos.x < 0 || pos.y < 0 { + return None; + } + let (x, y) = (pos.x as u32, pos.y as u32); + if x >= self.width || y >= self.height { + return None; + } + Some((y as usize) * (self.width as usize) + (x as usize)) + } + + pub fn in_bounds(&self, pos: Pos) -> bool { + self.index(pos).is_some() + } + + pub fn get(&self, pos: Pos) -> Option { + self.index(pos).map(|index| self.tiles[index]) + } + + /// Replaces the tile at `pos`. Returns `false` if `pos` is outside the + /// map. + pub fn set(&mut self, pos: Pos, tile: Tile) -> bool { + match self.index(pos) { + Some(index) => { + self.tiles[index] = tile; + true + } + None => false, + } + } + + /// All tiles, row by row: the tile at `pos` has index `y * width + x`. + pub fn tiles(&self) -> &[Tile] { + &self.tiles + } + + pub fn encode(&self, w: &mut Writer) { + w.u32(self.width); + w.u32(self.height); + for tile in &self.tiles { + w.u8(tile.terrain as u8); + w.u8(tile.damage); + } + } + + pub fn decode(r: &mut Reader<'_>) -> Result { + let width = r.u32()?; + let height = r.u32()?; + if width == 0 || height == 0 || width > MAX_MAP_SIZE || height > MAX_MAP_SIZE { + return Err(DecodeError::InvalidValue("map size")); + } + let count = (width as usize) * (height as usize); + if r.remaining() < count * 2 { + return Err(DecodeError::UnexpectedEnd); + } + let mut tiles = Vec::with_capacity(count); + for _ in 0..count { + let terrain = Terrain::from_u8(r.u8()?).ok_or(DecodeError::InvalidValue("terrain"))?; + let damage = r.u8()?; + if damage > 0 && damage >= terrain.hits_to_break().unwrap_or(0) { + return Err(DecodeError::InvalidValue("tile damage")); + } + tiles.push(Tile { terrain, damage }); + } + Ok(Self { + width, + height, + tiles, + }) + } +} diff --git a/core/tests/map.rs b/core/tests/map.rs new file mode 100644 index 0000000..e078e6a --- /dev/null +++ b/core/tests/map.rs @@ -0,0 +1,132 @@ +use grovewright_core::codec::{DecodeError, Reader, Writer}; +use grovewright_core::map::{Map, Pos, Terrain, Tile}; + +#[test] +fn a_filled_map_has_the_requested_size_and_terrain() { + let map = Map::filled(4, 3, Terrain::SoftRock); + assert_eq!(map.width(), 4); + assert_eq!(map.height(), 3); + assert_eq!(map.tiles().len(), 12); + assert_eq!(map.get(Pos::new(3, 2)), Some(Tile::new(Terrain::SoftRock))); +} + +#[test] +fn positions_outside_the_map_are_rejected() { + let mut map = Map::filled(4, 3, Terrain::Ground); + for pos in [ + Pos::new(-1, 0), + Pos::new(0, -1), + Pos::new(4, 0), + Pos::new(0, 3), + ] { + assert!(!map.in_bounds(pos), "{pos:?}"); + assert_eq!(map.get(pos), None, "{pos:?}"); + assert!(!map.set(pos, Tile::new(Terrain::Bedrock)), "{pos:?}"); + } + assert!(map.in_bounds(Pos::new(3, 2))); +} + +#[test] +fn set_changes_exactly_one_tile() { + let mut map = Map::filled(4, 3, Terrain::Ground); + assert!(map.set(Pos::new(1, 2), Tile::new(Terrain::HardRock))); + let hard_rock = map + .tiles() + .iter() + .filter(|tile| tile.terrain == Terrain::HardRock) + .count(); + assert_eq!(hard_rock, 1); + assert_eq!(map.tiles()[2 * 4 + 1].terrain, Terrain::HardRock); +} + +#[test] +fn only_rock_can_be_dug() { + assert_eq!(Terrain::Ground.hits_to_break(), None); + assert_eq!(Terrain::Bedrock.hits_to_break(), None); + assert_eq!(Terrain::SoftRock.hits_to_break(), Some(3)); + assert_eq!(Terrain::HardRock.hits_to_break(), Some(6)); + assert_eq!(Terrain::Ground.required_tool_tier(), None); + assert_eq!(Terrain::Bedrock.required_tool_tier(), None); + assert_eq!(Terrain::SoftRock.required_tool_tier(), Some(1)); + assert_eq!(Terrain::HardRock.required_tool_tier(), Some(2)); +} + +#[test] +fn only_ground_can_be_walked_on() { + assert!(Terrain::Ground.is_walkable()); + assert!(!Terrain::SoftRock.is_walkable()); + assert!(!Terrain::HardRock.is_walkable()); + assert!(!Terrain::Bedrock.is_walkable()); +} + +#[test] +fn terrain_numbers_are_stable() { + for terrain in [ + Terrain::Ground, + Terrain::SoftRock, + Terrain::HardRock, + Terrain::Bedrock, + ] { + assert_eq!(Terrain::from_u8(terrain as u8), Some(terrain)); + } + assert_eq!(Terrain::from_u8(4), None); +} + +#[test] +fn a_map_survives_encoding() { + let mut map = Map::filled(5, 4, Terrain::Ground); + map.set( + Pos::new(2, 1), + Tile { + terrain: Terrain::SoftRock, + damage: 2, + }, + ); + let mut w = Writer::new(); + map.encode(&mut w); + let bytes = w.into_bytes(); + let mut r = Reader::new(&bytes); + assert_eq!(Map::decode(&mut r), Ok(map)); + assert_eq!(r.finish(), Ok(())); +} + +fn encoded_map(width: u32, height: u32, tiles: &[(u8, u8)]) -> Vec { + let mut w = Writer::new(); + w.u32(width); + w.u32(height); + for (terrain, damage) in tiles { + w.u8(*terrain); + w.u8(*damage); + } + w.into_bytes() +} + +#[test] +fn decoding_rejects_impossible_maps() { + let cases: [(Vec, DecodeError); 6] = [ + ( + encoded_map(0, 4, &[]), + DecodeError::InvalidValue("map size"), + ), + ( + encoded_map(2_000, 4, &[]), + DecodeError::InvalidValue("map size"), + ), + ( + encoded_map(1, 1, &[(9, 0)]), + DecodeError::InvalidValue("terrain"), + ), + ( + encoded_map(1, 1, &[(0, 1)]), + DecodeError::InvalidValue("tile damage"), + ), + ( + encoded_map(1, 1, &[(1, 3)]), + DecodeError::InvalidValue("tile damage"), + ), + (encoded_map(2, 2, &[(0, 0)]), DecodeError::UnexpectedEnd), + ]; + for (bytes, expected) in cases { + assert_eq!(Map::decode(&mut Reader::new(&bytes)), Err(expected)); + } +} From aae0eccc8bd1572901a8da3503b89c664ad9038a Mon Sep 17 00:00:00 2001 From: Steven Wroblewski Date: Wed, 30 Sep 2026 20:39:47 +0200 Subject: [PATCH 06/15] =?UTF-8?q?Generator=20f=C3=BCr=20die=20Testkarte?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Baut aus einer Startzahl eine 64 × 64 Felder große Karte mit Rand aus Grundgestein, Lichtung am Startpunkt, Felsblock daneben, weichem Fels innen und hartem Fels außen. Die Regeln sind für 300 Startzahlen geprüft. Co-Authored-By: Claude Haiku 4.5 --- core/src/lib.rs | 1 + core/src/mapgen.rs | 104 ++++++++++++++++++++++++++++++++++ core/tests/mapgen.rs | 132 +++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 237 insertions(+) create mode 100644 core/src/mapgen.rs create mode 100644 core/tests/mapgen.rs diff --git a/core/src/lib.rs b/core/src/lib.rs index 6482889..2396db1 100644 --- a/core/src/lib.rs +++ b/core/src/lib.rs @@ -9,4 +9,5 @@ pub mod codec; pub mod hash; pub mod map; +pub mod mapgen; pub mod rng; diff --git a/core/src/mapgen.rs b/core/src/mapgen.rs new file mode 100644 index 0000000..686fd00 --- /dev/null +++ b/core/src/mapgen.rs @@ -0,0 +1,104 @@ +//! Generator for the milestone 1 test map. +//! +//! The broad layout is fixed and the details are random: a bedrock border, a +//! clearing around the spawn point, a rock outcrop right next to the +//! clearing, cave-like soft rock in the inner area and hard rock further out. + +use crate::map::{Map, Pos, Terrain, Tile}; +use crate::rng::Rng; + +/// Edge length of the test map, in tiles. +pub const TEST_MAP_SIZE: u32 = 64; +/// Tiles within this distance of the spawn point are always open ground. +pub const SPAWN_CLEARING_RADIUS: i32 = 5; +/// Edge length of the soft rock outcrop that every test map has. +pub const OUTCROP_SIZE: i32 = 4; +/// Rock at least this many tiles from the spawn point, counted along the +/// longer axis, is hard rock. +pub const HARD_ROCK_DISTANCE: i32 = 22; + +const INITIAL_ROCK_PERCENT: u32 = 46; +const SMOOTHING_PASSES: u32 = 4; +/// Keeps the map sequence apart from other uses of the same seed. +const SEED_SALT: u64 = 0x6D61_7067_656E_0001; + +/// Spawn point of a map: its centre tile. +pub fn spawn_pos(map: &Map) -> Pos { + Pos::new((map.width() / 2) as i32, (map.height() / 2) as i32) +} + +/// Top left tile of the soft rock outcrop next to the spawn clearing. +pub fn outcrop_origin(map: &Map) -> Pos { + let spawn = spawn_pos(map); + Pos::new(spawn.x + SPAWN_CLEARING_RADIUS + 2, spawn.y - 2) +} + +/// Builds the test map for `seed`. The same seed always gives the same map. +pub fn generate_test_map(seed: u64) -> Map { + let size = TEST_MAP_SIZE; + let mut rng = Rng::new(seed ^ SEED_SALT); + + let mut rock = vec![false; (size as usize) * (size as usize)]; + for cell in rock.iter_mut() { + *cell = rng.below(100) < INITIAL_ROCK_PERCENT; + } + for _ in 0..SMOOTHING_PASSES { + rock = smooth(&rock, size); + } + + let mut map = Map::filled(size, size, Terrain::Ground); + let spawn = spawn_pos(&map); + let outcrop = outcrop_origin(&map); + let last = size as i32 - 1; + + for y in 0..=last { + for x in 0..=last { + let dx = x - spawn.x; + let dy = y - spawn.y; + let on_border = x == 0 || y == 0 || x == last || y == last; + let in_clearing = dx * dx + dy * dy <= SPAWN_CLEARING_RADIUS * SPAWN_CLEARING_RADIUS; + let in_outcrop = (outcrop.x..outcrop.x + OUTCROP_SIZE).contains(&x) + && (outcrop.y..outcrop.y + OUTCROP_SIZE).contains(&y); + let is_rock = rock[(y as usize) * (size as usize) + (x as usize)]; + + let terrain = if on_border { + Terrain::Bedrock + } else if in_clearing { + Terrain::Ground + } else if in_outcrop { + Terrain::SoftRock + } else if !is_rock { + Terrain::Ground + } else if dx.abs().max(dy.abs()) >= HARD_ROCK_DISTANCE { + Terrain::HardRock + } else { + Terrain::SoftRock + }; + map.set(Pos::new(x, y), Tile::new(terrain)); + } + } + map +} + +/// One pass of cave smoothing: a cell becomes rock when at least five of the +/// nine cells around it, itself included, are rock. Cells outside the grid +/// count as rock. +fn smooth(rock: &[bool], size: u32) -> Vec { + let size = size as i32; + let mut out = vec![false; rock.len()]; + for y in 0..size { + for x in 0..size { + let mut count = 0; + for ny in y - 1..=y + 1 { + for nx in x - 1..=x + 1 { + let outside = nx < 0 || ny < 0 || nx >= size || ny >= size; + if outside || rock[(ny * size + nx) as usize] { + count += 1; + } + } + } + out[(y * size + x) as usize] = count >= 5; + } + } + out +} diff --git a/core/tests/mapgen.rs b/core/tests/mapgen.rs new file mode 100644 index 0000000..5c97e5d --- /dev/null +++ b/core/tests/mapgen.rs @@ -0,0 +1,132 @@ +//! Rules the map generator has to keep for every seed. + +use grovewright_core::map::{Map, Pos, Terrain}; +use grovewright_core::mapgen::{ + generate_test_map, outcrop_origin, spawn_pos, HARD_ROCK_DISTANCE, OUTCROP_SIZE, + SPAWN_CLEARING_RADIUS, TEST_MAP_SIZE, +}; + +/// Number of seeds every rule is checked against. +const SEEDS: u64 = 300; + +fn terrain_at(map: &Map, x: i32, y: i32) -> Terrain { + map.get(Pos::new(x, y)) + .expect("position is on the map") + .terrain +} + +#[test] +fn the_same_seed_gives_the_same_map() { + assert_eq!(generate_test_map(42), generate_test_map(42)); +} + +#[test] +fn different_seeds_give_different_maps() { + assert_ne!(generate_test_map(1), generate_test_map(2)); +} + +#[test] +fn the_map_has_the_test_size() { + let map = generate_test_map(0); + assert_eq!((map.width(), map.height()), (TEST_MAP_SIZE, TEST_MAP_SIZE)); +} + +#[test] +fn bedrock_forms_the_border_and_nothing_else() { + let last = TEST_MAP_SIZE as i32 - 1; + for seed in 0..SEEDS { + let map = generate_test_map(seed); + for y in 0..=last { + for x in 0..=last { + let on_border = x == 0 || y == 0 || x == last || y == last; + let is_bedrock = terrain_at(&map, x, y) == Terrain::Bedrock; + assert_eq!(is_bedrock, on_border, "seed {seed}, tile {x}/{y}"); + } + } + } +} + +#[test] +fn the_spawn_clearing_is_open_ground() { + for seed in 0..SEEDS { + let map = generate_test_map(seed); + let spawn = spawn_pos(&map); + for dy in -SPAWN_CLEARING_RADIUS..=SPAWN_CLEARING_RADIUS { + for dx in -SPAWN_CLEARING_RADIUS..=SPAWN_CLEARING_RADIUS { + if dx * dx + dy * dy > SPAWN_CLEARING_RADIUS * SPAWN_CLEARING_RADIUS { + continue; + } + let terrain = terrain_at(&map, spawn.x + dx, spawn.y + dy); + assert_eq!(terrain, Terrain::Ground, "seed {seed}, offset {dx}/{dy}"); + } + } + } +} + +#[test] +fn a_soft_rock_outcrop_lies_next_to_the_clearing() { + for seed in 0..SEEDS { + let map = generate_test_map(seed); + let spawn = spawn_pos(&map); + let origin = outcrop_origin(&map); + assert_eq!(origin.x - spawn.x, SPAWN_CLEARING_RADIUS + 2, "seed {seed}"); + for y in origin.y..origin.y + OUTCROP_SIZE { + for x in origin.x..origin.x + OUTCROP_SIZE { + assert_eq!( + terrain_at(&map, x, y), + Terrain::SoftRock, + "seed {seed}, tile {x}/{y}" + ); + } + } + } +} + +#[test] +fn hard_rock_appears_only_far_from_the_spawn_point() { + let last = TEST_MAP_SIZE as i32 - 1; + for seed in 0..SEEDS { + let map = generate_test_map(seed); + let spawn = spawn_pos(&map); + for y in 1..last { + for x in 1..last { + let far = (x - spawn.x).abs().max((y - spawn.y).abs()) >= HARD_ROCK_DISTANCE; + match terrain_at(&map, x, y) { + Terrain::HardRock => assert!(far, "seed {seed}: hard rock near spawn"), + Terrain::SoftRock => assert!(!far, "seed {seed}: soft rock far out"), + Terrain::Ground | Terrain::Bedrock => {} + } + } + } + } +} + +#[test] +fn every_map_mixes_rock_and_open_ground() { + for seed in 0..SEEDS { + let map = generate_test_map(seed); + let total = map.tiles().len(); + let rock = map + .tiles() + .iter() + .filter(|tile| !tile.terrain.is_walkable()) + .count(); + let percent = rock * 100 / total; + assert!( + (20..=80).contains(&percent), + "seed {seed}: {percent} percent rock" + ); + let hard = map + .tiles() + .iter() + .filter(|tile| tile.terrain == Terrain::HardRock) + .count(); + assert!(hard > 0, "seed {seed}: no hard rock"); + } +} + +#[test] +fn no_generated_tile_starts_damaged() { + let map = generate_test_map(5); + assert!(map.tiles().iter().all(|tile| tile.damage == 0)); +} From 1a0be06c3b84b3d90ee7c2fccd71eb03c138cf1a Mon Sep 17 00:00:00 2001 From: Steven Wroblewski Date: Wed, 30 Sep 2026 20:49:27 +0200 Subject: [PATCH 07/15] Inventar mit Stapeln MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 24 Plätze mit je bis zu 99 Gegenständen. Hinzufügen füllt zuerst vorhandene Stapel und meldet, was nicht passt; Entfernen nimmt alles oder nichts. Co-Authored-By: Claude Haiku 4.5 --- core/src/inventory.rs | 157 ++++++++++++++++++++++++++++++++++++++++ core/src/lib.rs | 1 + core/tests/inventory.rs | 125 ++++++++++++++++++++++++++++++++ 3 files changed, 283 insertions(+) create mode 100644 core/src/inventory.rs create mode 100644 core/tests/inventory.rs diff --git a/core/src/inventory.rs b/core/src/inventory.rs new file mode 100644 index 0000000..61882f5 --- /dev/null +++ b/core/src/inventory.rs @@ -0,0 +1,157 @@ +//! Item stacks carried by a player. + +use crate::codec::{DecodeError, Reader, Writer}; + +/// Identifies a kind of item. The value zero is reserved for empty slots. +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)] +pub struct ItemId(pub u16); + +/// Stone, gained by breaking rock. +pub const ITEM_STONE: ItemId = ItemId(1); + +/// Number of slots in an inventory. +pub const INVENTORY_SLOTS: usize = 24; +/// Largest number of items in one slot. +pub const STACK_LIMIT: u32 = 99; + +/// Items of one kind in one slot. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct Stack { + pub item: ItemId, + pub count: u32, +} + +/// Fixed number of slots, each empty or holding one stack. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Inventory { + slots: [Option; INVENTORY_SLOTS], +} + +impl Default for Inventory { + fn default() -> Self { + Self::new() + } +} + +impl Inventory { + pub fn new() -> Self { + Self { + slots: [None; INVENTORY_SLOTS], + } + } + + pub fn slots(&self) -> &[Option] { + &self.slots + } + + /// Total number of `item` across all slots. + pub fn count(&self, item: ItemId) -> u32 { + self.slots + .iter() + .flatten() + .filter(|stack| stack.item == item) + .map(|stack| stack.count) + .sum() + } + + /// How many more of `item` fit. + pub fn free_space(&self, item: ItemId) -> u32 { + self.slots + .iter() + .map(|slot| match slot { + None => STACK_LIMIT, + Some(stack) if stack.item == item => STACK_LIMIT - stack.count, + Some(_) => 0, + }) + .sum() + } + + /// Adds up to `count` of `item`. Existing stacks are topped up before + /// empty slots are used. Returns how many did not fit. + /// + /// # Panics + /// Panics if `item` is the reserved id zero. + pub fn add(&mut self, item: ItemId, count: u32) -> u32 { + assert!(item.0 != 0, "item id zero is reserved for empty slots"); + let mut left = count; + for slot in self.slots.iter_mut() { + if left == 0 { + break; + } + let Some(stack) = slot else { continue }; + if stack.item != item { + continue; + } + let moved = left.min(STACK_LIMIT - stack.count); + stack.count += moved; + left -= moved; + } + for slot in self.slots.iter_mut() { + if left == 0 { + break; + } + if slot.is_none() { + let moved = left.min(STACK_LIMIT); + *slot = Some(Stack { item, count: moved }); + left -= moved; + } + } + left + } + + /// Removes exactly `count` of `item`, emptying the last slots first. + /// Returns `false` and changes nothing if fewer are present. + pub fn remove(&mut self, item: ItemId, count: u32) -> bool { + if self.count(item) < count { + return false; + } + let mut left = count; + for slot in self.slots.iter_mut().rev() { + if left == 0 { + break; + } + let Some(stack) = slot else { continue }; + if stack.item != item { + continue; + } + let moved = left.min(stack.count); + stack.count -= moved; + left -= moved; + if stack.count == 0 { + *slot = None; + } + } + true + } + + pub fn encode(&self, w: &mut Writer) { + for slot in &self.slots { + match slot { + None => w.u16(0), + Some(stack) => { + w.u16(stack.item.0); + w.u32(stack.count); + } + } + } + } + + pub fn decode(r: &mut Reader<'_>) -> Result { + let mut slots = [None; INVENTORY_SLOTS]; + for slot in slots.iter_mut() { + let item = r.u16()?; + if item == 0 { + continue; + } + let count = r.u32()?; + if count == 0 || count > STACK_LIMIT { + return Err(DecodeError::InvalidValue("stack count")); + } + *slot = Some(Stack { + item: ItemId(item), + count, + }); + } + Ok(Self { slots }) + } +} diff --git a/core/src/lib.rs b/core/src/lib.rs index 2396db1..130ead2 100644 --- a/core/src/lib.rs +++ b/core/src/lib.rs @@ -8,6 +8,7 @@ pub mod codec; pub mod hash; +pub mod inventory; pub mod map; pub mod mapgen; pub mod rng; diff --git a/core/tests/inventory.rs b/core/tests/inventory.rs new file mode 100644 index 0000000..9f1b67b --- /dev/null +++ b/core/tests/inventory.rs @@ -0,0 +1,125 @@ +use grovewright_core::codec::{DecodeError, Reader, Writer}; +use grovewright_core::inventory::{ + Inventory, ItemId, Stack, INVENTORY_SLOTS, ITEM_STONE, STACK_LIMIT, +}; + +const OTHER: ItemId = ItemId(2); +const CAPACITY: u32 = STACK_LIMIT * INVENTORY_SLOTS as u32; + +#[test] +fn a_new_inventory_is_empty() { + let inventory = Inventory::new(); + assert_eq!(inventory.slots().len(), INVENTORY_SLOTS); + assert!(inventory.slots().iter().all(Option::is_none)); + assert_eq!(inventory.count(ITEM_STONE), 0); + assert_eq!(inventory.free_space(ITEM_STONE), CAPACITY); + assert_eq!(Inventory::default(), inventory); +} + +#[test] +fn add_tops_up_a_stack_before_using_an_empty_slot() { + let mut inventory = Inventory::new(); + assert_eq!(inventory.add(ITEM_STONE, 90), 0); + assert_eq!(inventory.add(OTHER, 5), 0); + assert_eq!(inventory.add(ITEM_STONE, 20), 0); + + let stone = |count| { + Some(Stack { + item: ITEM_STONE, + count, + }) + }; + assert_eq!(inventory.slots()[0], stone(99)); + assert_eq!( + inventory.slots()[1], + Some(Stack { + item: OTHER, + count: 5 + }) + ); + assert_eq!(inventory.slots()[2], stone(11)); + assert_eq!(inventory.count(ITEM_STONE), 110); + assert_eq!(inventory.count(OTHER), 5); +} + +#[test] +fn add_reports_what_does_not_fit() { + let mut inventory = Inventory::new(); + assert_eq!(inventory.add(ITEM_STONE, CAPACITY + 7), 7); + assert_eq!(inventory.count(ITEM_STONE), CAPACITY); + assert_eq!(inventory.free_space(ITEM_STONE), 0); + assert_eq!(inventory.free_space(OTHER), 0); + assert_eq!(inventory.add(OTHER, 1), 1); +} + +#[test] +fn free_space_counts_empty_slots_and_matching_stacks() { + let mut inventory = Inventory::new(); + inventory.add(ITEM_STONE, 10); + inventory.add(OTHER, 1); + assert_eq!( + inventory.free_space(ITEM_STONE), + CAPACITY - STACK_LIMIT - 10 + ); + assert_eq!(inventory.free_space(OTHER), CAPACITY - STACK_LIMIT - 1); +} + +#[test] +fn remove_takes_exactly_the_requested_amount() { + let mut inventory = Inventory::new(); + inventory.add(ITEM_STONE, 150); + assert!(inventory.remove(ITEM_STONE, 60)); + assert_eq!(inventory.count(ITEM_STONE), 90); + assert_eq!( + inventory.slots()[0], + Some(Stack { + item: ITEM_STONE, + count: 90 + }) + ); + assert_eq!(inventory.slots()[1], None); +} + +#[test] +fn remove_changes_nothing_when_too_few_are_present() { + let mut inventory = Inventory::new(); + inventory.add(ITEM_STONE, 10); + let before = inventory.clone(); + assert!(!inventory.remove(ITEM_STONE, 11)); + assert!(!inventory.remove(OTHER, 1)); + assert_eq!(inventory, before); +} + +#[test] +#[should_panic(expected = "item id zero is reserved")] +fn the_reserved_item_id_cannot_be_added() { + Inventory::new().add(ItemId(0), 1); +} + +#[test] +fn an_inventory_survives_encoding() { + let mut inventory = Inventory::new(); + inventory.add(ITEM_STONE, 120); + inventory.add(OTHER, 3); + let mut w = Writer::new(); + inventory.encode(&mut w); + let bytes = w.into_bytes(); + let mut r = Reader::new(&bytes); + assert_eq!(Inventory::decode(&mut r), Ok(inventory)); + assert_eq!(r.finish(), Ok(())); +} + +#[test] +fn decoding_rejects_impossible_stack_counts() { + for count in [0, STACK_LIMIT + 1] { + let mut w = Writer::new(); + w.u16(ITEM_STONE.0); + w.u32(count); + let bytes = w.into_bytes(); + assert_eq!( + Inventory::decode(&mut Reader::new(&bytes)), + Err(DecodeError::InvalidValue("stack count")), + "count {count}" + ); + } +} From 7f904b5e1d319841984e41862ad5e6c17e1a5c90 Mon Sep 17 00:00:00 2001 From: Steven Wroblewski Date: Wed, 30 Sep 2026 20:56:51 +0200 Subject: [PATCH 08/15] Spieler und Befehle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ein Spieler hat eine dauerhafte Kennung, einen Namen, eine Werkzeugstufe und ein Inventar. Befehle (Beitreten, Verlassen, Graben) sind der einzige Weg, die Welt zu ändern, und lassen sich kodieren. Co-Authored-By: Claude Haiku 4.5 --- core/src/command.rs | 69 +++++++++++++++++++++++++++++++ core/src/lib.rs | 2 + core/src/player.rs | 86 ++++++++++++++++++++++++++++++++++++++ core/tests/command.rs | 96 +++++++++++++++++++++++++++++++++++++++++++ core/tests/player.rs | 57 +++++++++++++++++++++++++ 5 files changed, 310 insertions(+) create mode 100644 core/src/command.rs create mode 100644 core/src/player.rs create mode 100644 core/tests/command.rs create mode 100644 core/tests/player.rs diff --git a/core/src/command.rs b/core/src/command.rs new file mode 100644 index 0000000..6042e23 --- /dev/null +++ b/core/src/command.rs @@ -0,0 +1,69 @@ +//! Everything a player can ask the world to do. + +use crate::codec::{DecodeError, Reader, Writer}; +use crate::map::Pos; +use crate::player::{clamp_name, PlayerId, PlayerKey, MAX_NAME_BYTES}; + +/// A request that changes the world. Commands are the only way to change it. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum Command { + /// A player enters the world, new or returning. + Join { key: PlayerKey, name: String }, + /// A player leaves the world. Their inventory stays. + Leave { player: PlayerId }, + /// A player hits the tile at `pos` with their pickaxe. + Dig { player: PlayerId, pos: Pos }, +} + +const TAG_JOIN: u8 = 1; +const TAG_LEAVE: u8 = 2; +const TAG_DIG: u8 = 3; + +impl Command { + pub fn encode(&self, w: &mut Writer) { + match self { + Command::Join { key, name } => { + w.u8(TAG_JOIN); + w.raw(key); + w.str(&clamp_name(name)); + } + Command::Leave { player } => { + w.u8(TAG_LEAVE); + w.u8(player.0); + } + Command::Dig { player, pos } => { + w.u8(TAG_DIG); + w.u8(player.0); + w.i32(pos.x); + w.i32(pos.y); + } + } + } + + pub fn decode(r: &mut Reader<'_>) -> Result { + match r.u8()? { + TAG_JOIN => { + let mut key = [0u8; 16]; + key.copy_from_slice(r.raw(16)?); + let name = r.string()?; + if name.len() > MAX_NAME_BYTES { + return Err(DecodeError::InvalidValue("player name")); + } + Ok(Command::Join { key, name }) + } + TAG_LEAVE => Ok(Command::Leave { + player: PlayerId(r.u8()?), + }), + TAG_DIG => { + let player = PlayerId(r.u8()?); + let x = r.i32()?; + let y = r.i32()?; + Ok(Command::Dig { + player, + pos: Pos::new(x, y), + }) + } + _ => Err(DecodeError::InvalidValue("command tag")), + } + } +} diff --git a/core/src/lib.rs b/core/src/lib.rs index 130ead2..9ec41d3 100644 --- a/core/src/lib.rs +++ b/core/src/lib.rs @@ -7,8 +7,10 @@ //! world and exchange nothing but commands. pub mod codec; +pub mod command; pub mod hash; pub mod inventory; pub mod map; pub mod mapgen; +pub mod player; pub mod rng; diff --git a/core/src/player.rs b/core/src/player.rs new file mode 100644 index 0000000..b28d6d0 --- /dev/null +++ b/core/src/player.rs @@ -0,0 +1,86 @@ +//! Players known to a world. + +use crate::codec::{DecodeError, Reader, Writer}; +use crate::inventory::Inventory; + +/// Stable identity of a player across sessions, chosen by the player's game. +pub type PlayerKey = [u8; 16]; + +/// Index of a player in the world's player list. +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)] +pub struct PlayerId(pub u8); + +/// Most players that can be online at the same time. +pub const MAX_ONLINE_PLAYERS: usize = 4; +/// Most players a world remembers. +pub const MAX_KNOWN_PLAYERS: usize = 16; +/// Longest stored player name, in bytes of UTF-8. +pub const MAX_NAME_BYTES: usize = 32; +/// Pickaxe tier every player starts with: stone. +pub const STARTING_TOOL_TIER: u8 = 1; + +/// A player and everything that belongs to them personally. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Player { + pub key: PlayerKey, + pub name: String, + pub online: bool, + pub tool_tier: u8, + /// First tick at which the player may act again. + pub next_action_tick: u64, + pub inventory: Inventory, +} + +impl Player { + /// Creates an online player with a stone pickaxe and an empty inventory. + pub fn new(key: PlayerKey, name: &str) -> Self { + Self { + key, + name: clamp_name(name), + online: true, + tool_tier: STARTING_TOOL_TIER, + next_action_tick: 0, + inventory: Inventory::new(), + } + } + + pub fn encode(&self, w: &mut Writer) { + w.raw(&self.key); + w.str(&self.name); + w.bool(self.online); + w.u8(self.tool_tier); + w.u64(self.next_action_tick); + self.inventory.encode(w); + } + + pub fn decode(r: &mut Reader<'_>) -> Result { + let mut key = [0u8; 16]; + key.copy_from_slice(r.raw(16)?); + let name = r.string()?; + if name.len() > MAX_NAME_BYTES { + return Err(DecodeError::InvalidValue("player name")); + } + let online = r.bool()?; + let tool_tier = r.u8()?; + let next_action_tick = r.u64()?; + let inventory = Inventory::decode(r)?; + Ok(Self { + key, + name, + online, + tool_tier, + next_action_tick, + inventory, + }) + } +} + +/// Shortens `name` to at most [`MAX_NAME_BYTES`] bytes without splitting a +/// character. +pub fn clamp_name(name: &str) -> String { + let mut end = name.len().min(MAX_NAME_BYTES); + while !name.is_char_boundary(end) { + end -= 1; + } + name[..end].to_owned() +} diff --git a/core/tests/command.rs b/core/tests/command.rs new file mode 100644 index 0000000..c86f27d --- /dev/null +++ b/core/tests/command.rs @@ -0,0 +1,96 @@ +use grovewright_core::codec::{DecodeError, Reader, Writer}; +use grovewright_core::command::Command; +use grovewright_core::map::Pos; +use grovewright_core::player::{PlayerId, MAX_NAME_BYTES}; + +fn round_trip(command: &Command) -> Command { + let mut w = Writer::new(); + command.encode(&mut w); + let bytes = w.into_bytes(); + let mut r = Reader::new(&bytes); + let decoded = Command::decode(&mut r).expect("command decodes"); + r.finish().expect("no bytes are left over"); + decoded +} + +#[test] +fn every_command_survives_encoding() { + let commands = [ + Command::Join { + key: [7; 16], + name: "Ada".to_owned(), + }, + Command::Leave { + player: PlayerId(3), + }, + Command::Dig { + player: PlayerId(1), + pos: Pos::new(-4, 1_000), + }, + ]; + for command in &commands { + assert_eq!(&round_trip(command), command); + } +} + +#[test] +fn an_overlong_name_is_shortened_when_encoded() { + let command = Command::Join { + key: [1; 16], + name: "x".repeat(MAX_NAME_BYTES + 10), + }; + assert_eq!( + round_trip(&command), + Command::Join { + key: [1; 16], + name: "x".repeat(MAX_NAME_BYTES), + } + ); +} + +#[test] +fn an_unknown_command_is_rejected() { + assert_eq!( + Command::decode(&mut Reader::new(&[0xEE])), + Err(DecodeError::InvalidValue("command tag")) + ); +} + +#[test] +fn a_cut_off_command_is_rejected() { + let mut w = Writer::new(); + Command::Dig { + player: PlayerId(0), + pos: Pos::new(1, 2), + } + .encode(&mut w); + let bytes = w.into_bytes(); + assert_eq!( + Command::decode(&mut Reader::new(&bytes[..bytes.len() - 1])), + Err(DecodeError::UnexpectedEnd) + ); +} + +#[test] +fn several_commands_can_follow_each_other_in_one_buffer() { + let commands = vec![ + Command::Leave { + player: PlayerId(2), + }, + Command::Dig { + player: PlayerId(0), + pos: Pos::new(5, 6), + }, + ]; + let mut w = Writer::new(); + for command in &commands { + command.encode(&mut w); + } + let bytes = w.into_bytes(); + let mut r = Reader::new(&bytes); + let mut decoded = Vec::new(); + while r.remaining() > 0 { + decoded.push(Command::decode(&mut r).unwrap()); + } + assert_eq!(decoded, commands); +} diff --git a/core/tests/player.rs b/core/tests/player.rs new file mode 100644 index 0000000..3e3c9f6 --- /dev/null +++ b/core/tests/player.rs @@ -0,0 +1,57 @@ +use grovewright_core::codec::{DecodeError, Reader, Writer}; +use grovewright_core::inventory::ITEM_STONE; +use grovewright_core::player::{clamp_name, Player, MAX_NAME_BYTES, STARTING_TOOL_TIER}; + +#[test] +fn a_new_player_is_online_with_a_stone_pickaxe_and_empty_pockets() { + let player = Player::new([5; 16], "Ada"); + assert_eq!(player.key, [5; 16]); + assert_eq!(player.name, "Ada"); + assert!(player.online); + assert_eq!(player.tool_tier, STARTING_TOOL_TIER); + assert_eq!(player.next_action_tick, 0); + assert!(player.inventory.slots().iter().all(Option::is_none)); +} + +#[test] +fn long_names_are_cut_at_a_character_boundary() { + // "ä" takes two bytes, so twenty of them are forty bytes. + let long = "ä".repeat(20); + let clamped = clamp_name(&long); + assert_eq!(clamped, "ä".repeat(16)); + assert_eq!(clamped.len(), MAX_NAME_BYTES); + + // With one byte in front, the limit falls into the middle of a character. + let odd = format!("a{}", "ä".repeat(20)); + assert_eq!(clamp_name(&odd), format!("a{}", "ä".repeat(15))); + + assert_eq!(clamp_name("Ada"), "Ada"); + assert_eq!(Player::new([0; 16], &long).name, clamped); +} + +#[test] +fn a_player_survives_encoding() { + let mut player = Player::new([9; 16], "Grace"); + player.online = false; + player.next_action_tick = 1234; + player.inventory.add(ITEM_STONE, 17); + + let mut w = Writer::new(); + player.encode(&mut w); + let bytes = w.into_bytes(); + let mut r = Reader::new(&bytes); + assert_eq!(Player::decode(&mut r), Ok(player)); + assert_eq!(r.finish(), Ok(())); +} + +#[test] +fn decoding_rejects_an_overlong_name() { + let mut w = Writer::new(); + w.raw(&[0; 16]); + w.str(&"x".repeat(MAX_NAME_BYTES + 1)); + let bytes = w.into_bytes(); + assert_eq!( + Player::decode(&mut Reader::new(&bytes)), + Err(DecodeError::InvalidValue("player name")) + ); +} From afc6d9ca7732c5a26cd519aeb6ccfa6ba5f5c32d Mon Sep 17 00:00:00 2001 From: Steven Wroblewski Date: Wed, 30 Sep 2026 21:05:46 +0200 Subject: [PATCH 09/15] Welt und ihre Regeln MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit World::step wendet die Befehle eines Takts der Reihe nach an: Spieler treten bei und verlassen die Welt, drei Schläge brechen weichen Fels und bringen einen Stein. Abgelehnte Befehle ändern nichts. Co-Authored-By: Claude Haiku 4.5 --- core/src/lib.rs | 1 + core/src/world.rs | 266 ++++++++++++++++++++++++++++++++++++++++ core/tests/world.rs | 292 ++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 559 insertions(+) create mode 100644 core/src/world.rs create mode 100644 core/tests/world.rs diff --git a/core/src/lib.rs b/core/src/lib.rs index 9ec41d3..13f3dcf 100644 --- a/core/src/lib.rs +++ b/core/src/lib.rs @@ -14,3 +14,4 @@ pub mod map; pub mod mapgen; pub mod player; pub mod rng; +pub mod world; diff --git a/core/src/world.rs b/core/src/world.rs new file mode 100644 index 0000000..2df6b2e --- /dev/null +++ b/core/src/world.rs @@ -0,0 +1,266 @@ +//! The shared world and the rules that change it. + +use crate::command::Command; +use crate::inventory::{ItemId, ITEM_STONE}; +use crate::map::{Map, Pos, Terrain, Tile}; +use crate::mapgen::generate_test_map; +use crate::player::{ + clamp_name, Player, PlayerId, PlayerKey, MAX_KNOWN_PLAYERS, MAX_ONLINE_PLAYERS, +}; + +/// Simulation steps per second of play. +pub const TICKS_PER_SECOND: u32 = 20; +/// Ticks a player has to wait between two pickaxe hits. +pub const DIG_COOLDOWN_TICKS: u64 = 5; + +/// Something that happened during a tick. Events tell the presentation what +/// to show. They are not part of the world state. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum Event { + PlayerJoined { + player: PlayerId, + }, + PlayerLeft { + player: PlayerId, + }, + /// A player could not join because the world is full. + JoinRefused { + key: PlayerKey, + }, + /// A pickaxe hit landed without breaking the tile. + TileHit { + player: PlayerId, + pos: Pos, + }, + /// A tile changed its terrain. + TileChanged { + pos: Pos, + }, + ItemGained { + player: PlayerId, + item: ItemId, + count: u32, + }, + /// A hit was refused because the inventory has no room for the result. + InventoryFull { + player: PlayerId, + }, +} + +/// Complete state of the shared world. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct World { + pub(crate) seed: u64, + pub(crate) tick: u64, + pub(crate) map: Map, + pub(crate) players: Vec, +} + +impl World { + /// Creates a fresh world with the test map for `seed` and no players. + pub fn new(seed: u64) -> Self { + Self { + seed, + tick: 0, + map: generate_test_map(seed), + players: Vec::new(), + } + } + + pub fn seed(&self) -> u64 { + self.seed + } + + /// Number of ticks simulated so far. + pub fn tick(&self) -> u64 { + self.tick + } + + pub fn map(&self) -> &Map { + &self.map + } + + pub fn players(&self) -> &[Player] { + &self.players + } + + pub fn player(&self, id: PlayerId) -> Option<&Player> { + self.players.get(id.0 as usize) + } + + pub fn find_player(&self, key: &PlayerKey) -> Option { + self.players + .iter() + .position(|player| &player.key == key) + .map(|index| PlayerId(index as u8)) + } + + /// Marks every player as offline. Used after loading a save from disk, + /// when nobody has joined yet. + pub fn set_all_offline(&mut self) { + for player in &mut self.players { + player.online = false; + } + } + + /// Applies `commands` in order and advances the world by one tick. + pub fn step(&mut self, commands: &[Command]) -> Vec { + let mut events = Vec::new(); + for command in commands { + match command { + Command::Join { key, name } => self.join(key, name, &mut events), + Command::Leave { player } => self.leave(*player, &mut events), + Command::Dig { player, pos } => self.dig(*player, *pos, &mut events), + } + } + self.tick += 1; + events + } + + fn join(&mut self, key: &PlayerKey, name: &str, events: &mut Vec) { + let online = self.players.iter().filter(|player| player.online).count(); + match self.find_player(key) { + Some(id) => { + let player = &mut self.players[id.0 as usize]; + if player.online { + return; + } + if online >= MAX_ONLINE_PLAYERS { + events.push(Event::JoinRefused { key: *key }); + return; + } + player.online = true; + player.name = clamp_name(name); + events.push(Event::PlayerJoined { player: id }); + } + None => { + if online >= MAX_ONLINE_PLAYERS || self.players.len() >= MAX_KNOWN_PLAYERS { + events.push(Event::JoinRefused { key: *key }); + return; + } + self.players.push(Player::new(*key, name)); + let id = PlayerId((self.players.len() - 1) as u8); + events.push(Event::PlayerJoined { player: id }); + } + } + } + + fn leave(&mut self, id: PlayerId, events: &mut Vec) { + let Some(player) = self.players.get_mut(id.0 as usize) else { + return; + }; + if !player.online { + return; + } + player.online = false; + events.push(Event::PlayerLeft { player: id }); + } + + fn dig(&mut self, id: PlayerId, pos: Pos, events: &mut Vec) { + let tick = self.tick; + let Some(player) = self.players.get_mut(id.0 as usize) else { + return; + }; + if !player.online || tick < player.next_action_tick { + return; + } + let Some(tile) = self.map.get(pos) else { + return; + }; + let (Some(hits), Some(tier)) = ( + tile.terrain.hits_to_break(), + tile.terrain.required_tool_tier(), + ) else { + return; + }; + if player.tool_tier < tier { + return; + } + + let damage = tile.damage.saturating_add(1); + let breaks = damage >= hits; + if breaks && player.inventory.free_space(ITEM_STONE) == 0 { + events.push(Event::InventoryFull { player: id }); + return; + } + + player.next_action_tick = tick + DIG_COOLDOWN_TICKS; + if breaks { + self.map.set(pos, Tile::new(Terrain::Ground)); + player.inventory.add(ITEM_STONE, 1); + events.push(Event::TileChanged { pos }); + events.push(Event::ItemGained { + player: id, + item: ITEM_STONE, + count: 1, + }); + } else { + self.map.set( + pos, + Tile { + terrain: tile.terrain, + damage, + }, + ); + events.push(Event::TileHit { player: id, pos }); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::inventory::{INVENTORY_SLOTS, STACK_LIMIT}; + use crate::mapgen::outcrop_origin; + + // Lives here instead of in `tests/world.rs` because filling an inventory + // needs direct access to the player, which the public API does not give. + #[test] + fn a_full_inventory_refuses_the_breaking_hit() { + let mut world = World::new(42); + world.step(&[Command::Join { + key: [1; 16], + name: "Ada".to_owned(), + }]); + let id = PlayerId(0); + let rock = outcrop_origin(world.map()); + let dig = [Command::Dig { + player: id, + pos: rock, + }]; + let capacity = STACK_LIMIT * INVENTORY_SLOTS as u32; + assert_eq!(world.players[0].inventory.add(ITEM_STONE, capacity), 0); + + for _ in 0..2 { + assert_eq!( + world.step(&dig), + vec![Event::TileHit { + player: id, + pos: rock + }] + ); + for _ in 0..DIG_COOLDOWN_TICKS { + world.step(&[]); + } + } + + assert_eq!(world.step(&dig), vec![Event::InventoryFull { player: id }]); + let tile = world.map().get(rock).unwrap(); + assert_eq!((tile.terrain, tile.damage), (Terrain::SoftRock, 2)); + + // The refused hit starts no cooldown: once there is room, the very + // next tick can break the tile. + assert!(world.players[0].inventory.remove(ITEM_STONE, 1)); + assert_eq!( + world.step(&dig), + vec![ + Event::TileChanged { pos: rock }, + Event::ItemGained { + player: id, + item: ITEM_STONE, + count: 1 + } + ] + ); + } +} diff --git a/core/tests/world.rs b/core/tests/world.rs new file mode 100644 index 0000000..ae70c04 --- /dev/null +++ b/core/tests/world.rs @@ -0,0 +1,292 @@ +use grovewright_core::command::Command; +use grovewright_core::inventory::ITEM_STONE; +use grovewright_core::map::{Pos, Terrain}; +use grovewright_core::mapgen::{outcrop_origin, spawn_pos}; +use grovewright_core::player::{PlayerId, PlayerKey, MAX_KNOWN_PLAYERS, MAX_ONLINE_PLAYERS}; +use grovewright_core::world::{Event, World, DIG_COOLDOWN_TICKS, TICKS_PER_SECOND}; + +fn key(n: u8) -> PlayerKey { + [n; 16] +} + +fn join(n: u8) -> Command { + Command::Join { + key: key(n), + name: format!("Spieler {n}"), + } +} + +/// A world with one online player, and a soft rock tile to dig at. +fn world_with_player() -> (World, PlayerId, Pos) { + let mut world = World::new(42); + world.step(&[join(1)]); + let rock = outcrop_origin(world.map()); + (world, PlayerId(0), rock) +} + +/// Steps without commands until a player who just hit may hit again. +fn wait_for_cooldown(world: &mut World) { + for _ in 0..DIG_COOLDOWN_TICKS { + world.step(&[]); + } +} + +#[test] +fn a_new_world_starts_at_tick_zero_without_players() { + let world = World::new(42); + assert_eq!(world.seed(), 42); + assert_eq!(world.tick(), 0); + assert!(world.players().is_empty()); + assert_eq!(TICKS_PER_SECOND, 20); +} + +#[test] +fn step_advances_the_tick() { + let mut world = World::new(1); + assert!(world.step(&[]).is_empty()); + assert_eq!(world.tick(), 1); +} + +#[test] +fn joining_adds_an_online_player() { + let mut world = World::new(1); + let events = world.step(&[join(7)]); + assert_eq!( + events, + vec![Event::PlayerJoined { + player: PlayerId(0) + }] + ); + let player = world.player(PlayerId(0)).unwrap(); + assert!(player.online); + assert_eq!(player.name, "Spieler 7"); + assert_eq!(world.find_player(&key(7)), Some(PlayerId(0))); + assert_eq!(world.find_player(&key(8)), None); + assert!(world.player(PlayerId(1)).is_none()); +} + +#[test] +fn joining_twice_changes_nothing() { + let mut world = World::new(1); + world.step(&[join(7)]); + assert!(world.step(&[join(7)]).is_empty()); + assert_eq!(world.players().len(), 1); +} + +#[test] +fn leaving_and_returning_keeps_the_inventory() { + let (mut world, id, rock) = world_with_player(); + for _ in 0..3 { + world.step(&[Command::Dig { + player: id, + pos: rock, + }]); + wait_for_cooldown(&mut world); + } + assert_eq!(world.player(id).unwrap().inventory.count(ITEM_STONE), 1); + + assert_eq!( + world.step(&[Command::Leave { player: id }]), + vec![Event::PlayerLeft { player: id }] + ); + assert!(!world.player(id).unwrap().online); + assert!(world.step(&[Command::Leave { player: id }]).is_empty()); + + assert_eq!( + world.step(&[join(1)]), + vec![Event::PlayerJoined { player: id }] + ); + assert_eq!(world.players().len(), 1); + assert!(world.player(id).unwrap().online); + assert_eq!(world.player(id).unwrap().inventory.count(ITEM_STONE), 1); +} + +#[test] +fn a_returning_player_can_change_their_name() { + let (mut world, id, _) = world_with_player(); + world.step(&[Command::Leave { player: id }]); + world.step(&[Command::Join { + key: key(1), + name: "Neuer Name".to_owned(), + }]); + assert_eq!(world.player(id).unwrap().name, "Neuer Name"); +} + +#[test] +fn a_fifth_online_player_is_refused() { + let mut world = World::new(1); + for n in 0..MAX_ONLINE_PLAYERS as u8 { + world.step(&[join(n)]); + } + assert_eq!( + world.step(&[join(99)]), + vec![Event::JoinRefused { key: key(99) }] + ); + assert_eq!(world.players().len(), MAX_ONLINE_PLAYERS); + + // A known player who is offline is refused as well while the world is full. + world.step(&[Command::Leave { + player: PlayerId(0), + }]); + world.step(&[join(50)]); + assert_eq!( + world.step(&[join(0)]), + vec![Event::JoinRefused { key: key(0) }] + ); +} + +#[test] +fn the_world_remembers_a_limited_number_of_players() { + let mut world = World::new(1); + for n in 0..MAX_KNOWN_PLAYERS as u8 { + world.step(&[join(n)]); + world.step(&[Command::Leave { + player: PlayerId(n), + }]); + } + assert_eq!(world.players().len(), MAX_KNOWN_PLAYERS); + assert_eq!( + world.step(&[join(200)]), + vec![Event::JoinRefused { key: key(200) }] + ); + assert_eq!(world.players().len(), MAX_KNOWN_PLAYERS); +} + +#[test] +fn set_all_offline_marks_every_player_offline() { + let mut world = World::new(1); + world.step(&[join(1), join(2)]); + world.set_all_offline(); + assert!(world.players().iter().all(|player| !player.online)); +} + +#[test] +fn three_hits_break_soft_rock_and_give_one_stone() { + let (mut world, id, rock) = world_with_player(); + let dig = [Command::Dig { + player: id, + pos: rock, + }]; + let hit = vec![Event::TileHit { + player: id, + pos: rock, + }]; + + assert_eq!(world.step(&dig), hit); + assert_eq!(world.map().get(rock).unwrap().damage, 1); + wait_for_cooldown(&mut world); + + assert_eq!(world.step(&dig), hit); + assert_eq!(world.map().get(rock).unwrap().damage, 2); + wait_for_cooldown(&mut world); + + assert_eq!( + world.step(&dig), + vec![ + Event::TileChanged { pos: rock }, + Event::ItemGained { + player: id, + item: ITEM_STONE, + count: 1 + } + ] + ); + let tile = world.map().get(rock).unwrap(); + assert_eq!((tile.terrain, tile.damage), (Terrain::Ground, 0)); + assert_eq!(world.player(id).unwrap().inventory.count(ITEM_STONE), 1); +} + +#[test] +fn hits_during_the_cooldown_are_ignored() { + let (mut world, id, rock) = world_with_player(); + let dig = [Command::Dig { + player: id, + pos: rock, + }]; + world.step(&dig); + for _ in 1..DIG_COOLDOWN_TICKS { + assert!(world.step(&dig).is_empty()); + } + assert_eq!(world.map().get(rock).unwrap().damage, 1); + assert_eq!(world.step(&dig).len(), 1); + assert_eq!(world.map().get(rock).unwrap().damage, 2); +} + +#[test] +fn two_hits_in_one_tick_count_once() { + let (mut world, id, rock) = world_with_player(); + let dig = Command::Dig { + player: id, + pos: rock, + }; + let twice = [dig.clone(), dig]; + assert_eq!(world.step(&twice).len(), 1); + assert_eq!(world.map().get(rock).unwrap().damage, 1); +} + +#[test] +fn ground_bedrock_and_positions_off_the_map_cannot_be_dug() { + let (mut world, id, _) = world_with_player(); + let spawn = spawn_pos(world.map()); + for pos in [ + spawn, + Pos::new(0, 0), + Pos::new(-5, 3), + Pos::new(10_000, 10_000), + ] { + let before = world.map().clone(); + let events = world.step(&[Command::Dig { player: id, pos }]); + assert!(events.is_empty(), "{pos:?}"); + assert_eq!(world.map(), &before, "{pos:?}"); + } +} + +#[test] +fn hard_rock_resists_the_stone_pickaxe() { + let (mut world, id, _) = world_with_player(); + let width = world.map().width() as usize; + let index = world + .map() + .tiles() + .iter() + .position(|tile| tile.terrain == Terrain::HardRock) + .expect("the test map has hard rock"); + let pos = Pos::new((index % width) as i32, (index / width) as i32); + + assert!(world.step(&[Command::Dig { player: id, pos }]).is_empty()); + assert_eq!(world.map().get(pos).unwrap().damage, 0); +} + +#[test] +fn unknown_and_offline_players_cannot_dig() { + let (mut world, id, rock) = world_with_player(); + assert!(world + .step(&[Command::Dig { + player: PlayerId(9), + pos: rock + }]) + .is_empty()); + world.step(&[Command::Leave { player: id }]); + assert!(world + .step(&[Command::Dig { + player: id, + pos: rock + }]) + .is_empty()); + assert_eq!(world.map().get(rock).unwrap().damage, 0); +} + +#[test] +fn commands_of_one_tick_are_applied_in_order() { + let (mut world, id, rock) = world_with_player(); + // Leaving first makes the hit in the same tick invalid. + let events = world.step(&[ + Command::Leave { player: id }, + Command::Dig { + player: id, + pos: rock, + }, + ]); + assert_eq!(events, vec![Event::PlayerLeft { player: id }]); + assert_eq!(world.map().get(rock).unwrap().damage, 0); +} From 311516dd0a4e89952dae9ba40ec60a0853904070 Mon Sep 17 00:00:00 2001 From: Steven Wroblewski Date: Wed, 30 Sep 2026 21:18:09 +0200 Subject: [PATCH 10/15] =?UTF-8?q?Snapshot=20und=20Zustandspr=C3=BCfsumme?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Der Snapshot enthält den vollständigen Zustand der Welt hinter einem Kopf mit Format und Version. Er dient als Speicherstand und als Grundlage der Prüfsumme. Das Konzept ist entsprechend korrigiert: Der Speicherstand ist der vollständige Zustand, nicht Startzahl plus Änderungen. Co-Authored-By: Claude Haiku 4.5 --- core/src/lib.rs | 1 + core/src/snapshot.rs | 109 +++++++++++++++++++++++++++++++++++++ core/tests/snapshot.rs | 121 +++++++++++++++++++++++++++++++++++++++++ docs/KONZEPT.md | 2 +- 4 files changed, 232 insertions(+), 1 deletion(-) create mode 100644 core/src/snapshot.rs create mode 100644 core/tests/snapshot.rs diff --git a/core/src/lib.rs b/core/src/lib.rs index 13f3dcf..fd07e7f 100644 --- a/core/src/lib.rs +++ b/core/src/lib.rs @@ -14,4 +14,5 @@ pub mod map; pub mod mapgen; pub mod player; pub mod rng; +pub mod snapshot; pub mod world; diff --git a/core/src/snapshot.rs b/core/src/snapshot.rs new file mode 100644 index 0000000..42a4b6b --- /dev/null +++ b/core/src/snapshot.rs @@ -0,0 +1,109 @@ +//! Complete, versioned encoding of a world. Saves, joining players and state +//! checksums all use it. + +use std::fmt; + +use crate::codec::{DecodeError, Reader, Writer}; +use crate::hash::fnv1a64; +use crate::map::Map; +use crate::player::{Player, MAX_KNOWN_PLAYERS}; +use crate::world::World; + +const MAGIC: [u8; 4] = *b"GRWS"; +/// Version of the snapshot format. Raise it whenever the encoded state +/// changes. +pub const SNAPSHOT_VERSION: u32 = 1; + +/// 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 snapshot is damaged. + Corrupt(DecodeError), +} + +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::Corrupt(error) => write!(f, "damaged snapshot: {error}"), + } + } +} + +impl std::error::Error for SnapshotError {} + +impl From 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. + pub fn to_snapshot(&self) -> Vec { + let mut w = Writer::new(); + w.raw(&MAGIC); + w.u32(SNAPSHOT_VERSION); + self.encode_state(&mut w); + w.into_bytes() + } + + /// Restores a world from bytes written by [`World::to_snapshot`]. + pub fn from_snapshot(bytes: &[u8]) -> Result { + 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)); + } + 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()?; + Ok(World { + seed, + tick, + map, + players, + }) + } +} diff --git a/core/tests/snapshot.rs b/core/tests/snapshot.rs new file mode 100644 index 0000000..204075a --- /dev/null +++ b/core/tests/snapshot.rs @@ -0,0 +1,121 @@ +use grovewright_core::codec::DecodeError; +use grovewright_core::command::Command; +use grovewright_core::map::Pos; +use grovewright_core::mapgen::outcrop_origin; +use grovewright_core::player::PlayerId; +use grovewright_core::snapshot::{SnapshotError, SNAPSHOT_VERSION}; +use grovewright_core::world::World; + +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 +} + +#[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 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 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 cut_off_and_padded_snapshots_are_rejected() { + let bytes = played_world().to_snapshot(); + assert_eq!( + World::from_snapshot(&bytes[..bytes.len() - 1]), + Err(SnapshotError::Corrupt(DecodeError::UnexpectedEnd)) + ); + + let mut padded = bytes.clone(); + padded.push(0); + assert_eq!( + World::from_snapshot(&padded), + Err(SnapshotError::Corrupt(DecodeError::TrailingBytes)) + ); +} diff --git a/docs/KONZEPT.md b/docs/KONZEPT.md index 344464b..a2f01d2 100644 --- a/docs/KONZEPT.md +++ b/docs/KONZEPT.md @@ -177,7 +177,7 @@ Die eigene Spielfigur bewegt sich sofort und läuft außerhalb des Gleichschritt - **Prüfsummen im Spiel.** Die Rechner vergleichen laufend ihren Zustand und gleichen notfalls neu ab. - **Aufzeichnung.** Das Spiel zeichnet die Befehle einer Sitzung auf. Ein Fehler aus deinem Playtest lässt sich damit exakt nachspielen. - **Generator-Regeln.** Regeln wie „Das erste Vorkommen liegt nah am Start" werden über viele Startzahlen geprüft. -- **Speicherstand.** Er hält Startzahl und Änderungen fest. Ein Gast bekommt ihn beim Beitritt geschickt. +- **Speicherstand.** Er enthält den vollständigen Zustand der Welt samt Startzahl und bleibt deshalb gültig, wenn sich der Kartengenerator später ändert. Ein Gast bekommt ihn beim Beitritt geschickt. ### 7.4 Grafik und Ton From 40bef858f353b2085fee820cb0d4b20abb86fcc4 Mon Sep 17 00:00:00 2001 From: Steven Wroblewski Date: Wed, 30 Sep 2026 21:27:36 +0200 Subject: [PATCH 11/15] Aufzeichnung und Wiederholung von Sitzungen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Eine Aufzeichnung hält den Startzustand und die Befehle jedes Takts fest. Wiederholen ergibt exakt dieselbe Welt; verify meldet, wenn das Ende von der aufgezeichneten Prüfsumme abweicht. Co-Authored-By: Claude Haiku 4.5 --- core/src/lib.rs | 1 + core/src/replay.rs | 183 +++++++++++++++++++++++++++++++++++++++++++ core/tests/replay.rs | 135 +++++++++++++++++++++++++++++++ 3 files changed, 319 insertions(+) create mode 100644 core/src/replay.rs create mode 100644 core/tests/replay.rs diff --git a/core/src/lib.rs b/core/src/lib.rs index fd07e7f..d168edc 100644 --- a/core/src/lib.rs +++ b/core/src/lib.rs @@ -13,6 +13,7 @@ pub mod inventory; pub mod map; pub mod mapgen; pub mod player; +pub mod replay; pub mod rng; pub mod snapshot; pub mod world; diff --git a/core/src/replay.rs b/core/src/replay.rs new file mode 100644 index 0000000..96f8f8a --- /dev/null +++ b/core/src/replay.rs @@ -0,0 +1,183 @@ +//! 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::World; + +const MAGIC: [u8; 4] = *b"GRWR"; +/// Version of the recording format. +pub const RECORDING_VERSION: u32 = 1; + +/// 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 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, + tick_count: u32, + /// Ticks that had commands, as (tick index, commands), in rising order. + busy_ticks: Vec<(u32, Vec)>, +} + +impl Recording { + /// Starts a recording at the current state of `world`. + pub fn start(world: &World) -> Self { + Self { + start: world.to_snapshot(), + tick_count: 0, + busy_ticks: Vec::new(), + } + } + + /// Records the commands of one tick. Call it once for every + /// [`World::step`], with the same commands. + pub fn push_tick(&mut self, commands: &[Command]) { + if !commands.is_empty() { + self.busy_ticks.push((self.tick_count, commands.to_vec())); + } + self.tick_count += 1; + } + + /// Number of recorded ticks. + pub fn tick_count(&self) -> u32 { + self.tick_count + } + + /// Restores the starting state and applies every recorded tick. + pub fn replay(&self) -> Result { + 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. + pub fn to_bytes(&self, end: &World) -> Vec { + 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 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, + tick_count, + busy_ticks, + }, + expected_hash, + )) + } + + /// Replays `bytes` and checks that the recorded final state is reached. + pub fn verify(bytes: &[u8]) -> Result { + 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 }) + } + } +} diff --git a/core/tests/replay.rs b/core/tests/replay.rs new file mode 100644 index 0000000..7df5888 --- /dev/null +++ b/core/tests/replay.rs @@ -0,0 +1,135 @@ +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::world::World; + +const ADA: PlayerId = PlayerId(0); + +/// Commands of tick `tick` in a short scripted session. +fn commands_for(world: &World, tick: u32) -> Vec { + 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.push_tick(&commands); + world.step(&commands); + } + (recording, world) +} + +#[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, mut world) = recorded_session(); + // One tick more than was recorded: the claimed end state is unreachable. + world.step(&[]); + let bytes = recording.to_bytes(&world); + match Recording::verify(&bytes) { + Err(RecordingError::Diverged { expected, actual }) => { + assert_eq!(expected, world.state_hash()); + assert_ne!(actual, expected); + } + 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.push_tick(&commands); + world.step(&commands); + } + assert_eq!(recording.tick_count(), 70); + 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] +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)) + ); +} From 7aae093f82e9ff29e446b42fedb0a081c572dfe9 Mon Sep 17 00:00:00 2001 From: Steven Wroblewski Date: Wed, 30 Sep 2026 21:35:44 +0200 Subject: [PATCH 12/15] =?UTF-8?q?Abnahmetests=20f=C3=BCr=20den=20Gleichsch?= =?UTF-8?q?ritt?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fünf Tests prüfen über tausende Takte mit vier Spielern, dass gleiche Befehle dieselbe Welt ergeben: im direkten Vergleich, nach einem Snapshot mitten in der Sitzung, beim Wiederholen einer Aufzeichnung und nach dem Weg über die Kodierung. Co-Authored-By: Claude Haiku 4.5 --- core/tests/determinism.rs | 153 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 153 insertions(+) create mode 100644 core/tests/determinism.rs diff --git a/core/tests/determinism.rs b/core/tests/determinism.rs new file mode 100644 index 0000000..011a49a --- /dev/null +++ b/core/tests/determinism.rs @@ -0,0 +1,153 @@ +//! The central promise of the core: the same commands always produce the +//! same world. + +use grovewright_core::codec::{Reader, Writer}; +use grovewright_core::command::Command; +use grovewright_core::inventory::ITEM_STONE; +use grovewright_core::map::Pos; +use grovewright_core::mapgen::TEST_MAP_SIZE; +use grovewright_core::player::PlayerId; +use grovewright_core::replay::Recording; +use grovewright_core::rng::Rng; +use grovewright_core::world::World; + +/// Edge length of the square around the map centre in which the script digs. +const DIG_AREA: u32 = 24; + +/// Builds a busy, repeatable command stream: four players join, dig at +/// random places near the centre, and now and then leave and return. +fn script(seed: u64, ticks: usize) -> Vec> { + let mut rng = Rng::new(seed); + let first = (TEST_MAP_SIZE - DIG_AREA) / 2; + let mut out = Vec::with_capacity(ticks); + for tick in 0..ticks { + let mut commands = Vec::new(); + if tick < 4 { + commands.push(Command::Join { + key: [tick as u8 + 1; 16], + name: format!("P{tick}"), + }); + } + for player in 0..4 { + if rng.below(3) == 0 { + let x = first + rng.below(DIG_AREA); + let y = first + rng.below(DIG_AREA); + commands.push(Command::Dig { + player: PlayerId(player), + pos: Pos::new(x as i32, y as i32), + }); + } + } + if rng.below(200) == 0 { + commands.push(Command::Leave { + player: PlayerId(rng.below(4) as u8), + }); + } + if rng.below(200) == 0 { + let n = rng.below(4) as u8; + commands.push(Command::Join { + key: [n + 1; 16], + name: format!("P{n}"), + }); + } + out.push(commands); + } + out +} + +fn stones(world: &World) -> u32 { + world + .players() + .iter() + .map(|player| player.inventory.count(ITEM_STONE)) + .sum() +} + +#[test] +fn the_same_commands_give_the_same_world() { + let script = script(2026, 3_000); + let mut a = World::new(99); + let mut b = World::new(99); + for (tick, commands) in script.iter().enumerate() { + assert_eq!( + a.step(commands), + b.step(commands), + "events differ at tick {tick}" + ); + if tick.is_multiple_of(100) { + assert_eq!( + a.state_hash(), + b.state_hash(), + "state differs at tick {tick}" + ); + } + } + assert_eq!(a, b); + assert!( + stones(&a) > 50, + "the script should break plenty of rock, broke {}", + stones(&a) + ); +} + +#[test] +fn a_different_seed_gives_a_different_world() { + let script = script(2026, 200); + let mut a = World::new(1); + let mut b = World::new(2); + for commands in &script { + a.step(commands); + b.step(commands); + } + assert_ne!(a.state_hash(), b.state_hash()); +} + +#[test] +fn a_world_restored_in_the_middle_of_a_session_stays_in_step() { + let script = script(7, 2_000); + let mut original = World::new(5); + for commands in &script[..1_000] { + original.step(commands); + } + let mut restored = World::from_snapshot(&original.to_snapshot()).unwrap(); + for commands in &script[1_000..] { + assert_eq!(original.step(commands), restored.step(commands)); + } + assert_eq!(original.state_hash(), restored.state_hash()); +} + +#[test] +fn a_recorded_session_replays_to_the_same_hash() { + let script = script(31, 2_000); + let mut world = World::new(8); + let mut recording = Recording::start(&world); + for commands in &script { + recording.push_tick(commands); + world.step(commands); + } + let bytes = recording.to_bytes(&world); + let replayed = Recording::verify(&bytes).expect("the recording replays cleanly"); + assert_eq!(replayed.state_hash(), world.state_hash()); +} + +#[test] +fn commands_sent_as_bytes_give_the_same_world() { + let script = script(3, 1_500); + let mut local = World::new(21); + let mut remote = World::new(21); + for commands in &script { + let mut w = Writer::new(); + for command in commands { + command.encode(&mut w); + } + let bytes = w.into_bytes(); + let mut r = Reader::new(&bytes); + let mut received = Vec::new(); + while r.remaining() > 0 { + received.push(Command::decode(&mut r).expect("command decodes")); + } + local.step(commands); + remote.step(&received); + } + assert_eq!(local.state_hash(), remote.state_hash()); +} From d84c3382916af55c08dddc2a7f1f2f23865e72b3 Mon Sep 17 00:00:00 2001 From: Steven Wroblewski Date: Wed, 30 Sep 2026 21:48:42 +0200 Subject: [PATCH 13/15] =?UTF-8?q?Abnahmetest=20pr=C3=BCft,=20dass=20die=20?= =?UTF-8?q?Startzahl=20die=20Karte=20formt?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Der Vergleich der Prüfsummen allein konnte nicht fehlschlagen, weil die Startzahl selbst in die Prüfsumme eingeht. Jetzt vergleicht der Test auch die Karten beider Welten. Co-Authored-By: Claude Haiku 4.5 --- core/tests/determinism.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/core/tests/determinism.rs b/core/tests/determinism.rs index 011a49a..47adfb5 100644 --- a/core/tests/determinism.rs +++ b/core/tests/determinism.rs @@ -95,6 +95,7 @@ fn a_different_seed_gives_a_different_world() { let script = script(2026, 200); let mut a = World::new(1); let mut b = World::new(2); + assert_ne!(a.map(), b.map(), "the seed must shape the map"); for commands in &script { a.step(commands); b.step(commands); From 3857fe99a6f31bb818e02d522736b9e2c3368d46 Mon Sep 17 00:00:00 2001 From: Steven Wroblewski Date: Wed, 30 Sep 2026 22:18:35 +0200 Subject: [PATCH 14/15] =?UTF-8?q?Festgeschriebene=20Werte=20und=20Test=20f?= =?UTF-8?q?=C3=BCr=20=C3=BCberlange=20Namen?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Die neuen Tests in golden.rs vergleichen gegen einmal aufgezeichnete Werte statt gegen eine zweite Berechnung im selben Build. So fällt über Builds hinweg auf, wenn sich Regeln oder Formate unbeabsichtigt ändern: der Startzustand der Welt, das Ende einer festen Befehlsfolge und die Prüfsumme der Aufzeichnung. Die Fehlermeldung nennt die Versionskonstante, die dann erhöht werden muss. Der neue Test in world.rs deckt ab, dass überlange Namen auf beiden Beitrittswegen gekürzt gespeichert werden: beim ersten Beitritt und bei der Rückkehr. Co-Authored-By: Claude Sonnet 5.5 --- core/tests/golden.rs | 147 +++++++++++++++++++++++++++++++++++++++++++ core/tests/world.rs | 26 +++++++- 2 files changed, 172 insertions(+), 1 deletion(-) create mode 100644 core/tests/golden.rs diff --git a/core/tests/golden.rs b/core/tests/golden.rs new file mode 100644 index 0000000..7c462a3 --- /dev/null +++ b/core/tests/golden.rs @@ -0,0 +1,147 @@ +//! Pinned values for everything that must not change unnoticed. +//! +//! Every other test compares 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::command::Command; +use grovewright_core::hash::fnv1a64; +use grovewright_core::map::Pos; +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> { + 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 +} + +/// Covers the map generator, the map encoding (terrain numbers, byte order of +/// width and height) and the snapshot header. +#[test] +fn the_starting_snapshot_is_unchanged() { + let actual = fnv1a64(&World::new(SEED).to_snapshot()); + assert_eq!( + actual, 0x1b78_d996_1586_fbf1, + "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 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)), + 0x2312_5a7d_d8ef_5ae0, + "the recording or command format changed; raise RECORDING_VERSION in \ + core/src/replay.rs; then update this value" + ); +} diff --git a/core/tests/world.rs b/core/tests/world.rs index ae70c04..b839f5d 100644 --- a/core/tests/world.rs +++ b/core/tests/world.rs @@ -2,7 +2,9 @@ use grovewright_core::command::Command; use grovewright_core::inventory::ITEM_STONE; use grovewright_core::map::{Pos, Terrain}; use grovewright_core::mapgen::{outcrop_origin, spawn_pos}; -use grovewright_core::player::{PlayerId, PlayerKey, MAX_KNOWN_PLAYERS, MAX_ONLINE_PLAYERS}; +use grovewright_core::player::{ + PlayerId, PlayerKey, MAX_KNOWN_PLAYERS, MAX_NAME_BYTES, MAX_ONLINE_PLAYERS, +}; use grovewright_core::world::{Event, World, DIG_COOLDOWN_TICKS, TICKS_PER_SECOND}; fn key(n: u8) -> PlayerKey { @@ -112,6 +114,28 @@ fn a_returning_player_can_change_their_name() { assert_eq!(world.player(id).unwrap().name, "Neuer Name"); } +#[test] +fn overlong_names_are_stored_shortened_on_both_join_paths() { + let mut world = World::new(1); + world.step(&[Command::Join { + key: key(1), + name: "ä".repeat(20), + }]); + assert_eq!(world.player(PlayerId(0)).unwrap().name, "ä".repeat(16)); + + world.step(&[Command::Leave { + player: PlayerId(0), + }]); + world.step(&[Command::Join { + key: key(1), + name: "x".repeat(MAX_NAME_BYTES + 10), + }]); + assert_eq!( + world.player(PlayerId(0)).unwrap().name, + "x".repeat(MAX_NAME_BYTES) + ); +} + #[test] fn a_fifth_online_player_is_refused() { let mut world = World::new(1); From c75b629dfd7ecae3914bddd60452e90ef4871777 Mon Sep 17 00:00:00 2001 From: Steven Wroblewski Date: Wed, 30 Sep 2026 22:18:45 +0200 Subject: [PATCH 15/15] Regeln und Konzept an den Stand angleichen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CLAUDE.md: Die Regeln für den Kern gelten für den Quelltext in core/src/, nicht für die Tests. World::set_all_offline ist als einzige Ausnahme von der Regel genannt, dass sich die Welt nur durch Befehle ändert. Die Überlaufregel sagt jetzt, dass Taktzähler normal addiert werden und Werte aus geladenen Dateien beim Laden geprüft werden. Ein neuer Absatz erklärt, was bei einem Fehler in golden.rs zu tun ist. KONZEPT.md: Das Konzept ist bestätigt. Hochladen, Branches, Issues und Pull Requests sind freigegeben, und die veraltete Angabe, das Archiv sei noch leer, ist entfernt. Die Reihenfolge der Rückkehrer steht jetzt bei den Festlegungen statt bei den offenen Punkten. Co-Authored-By: Claude Sonnet 5.5 --- CLAUDE.md | 8 +++++--- docs/KONZEPT.md | 9 ++++----- 2 files changed, 9 insertions(+), 8 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 54935aa..6db26af 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -18,15 +18,15 @@ Faustregel: Was im Speicherstand steht oder alle Spieler betrifft, gehört in de ## Regeln für den Kern -Alle Spieler simulieren die Welt selbst und tauschen nur Befehle aus (Gleichschritt). Das funktioniert nur, wenn der Kern auf jedem Rechner exakt gleich rechnet. Deshalb gilt in `core/`: +Alle Spieler simulieren die Welt selbst und tauschen nur Befehle aus (Gleichschritt). Das funktioniert nur, wenn der Kern auf jedem Rechner exakt gleich rechnet. Deshalb gilt für den Quelltext des Kerns in `core/src/`. Die Tests in `core/tests/` dürfen zum Beispiel Dateien lesen. - Nur ganze Zahlen, keine Gleitkommazahlen. - Keine Uhr, keine Dateien, kein Netzwerk, keine Threads, keine Umgebungsvariablen. - 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`. +- Die Welt ändert sich nur durch einen `Command` in `World::step`. Einzige Ausnahme ist `World::set_all_offline`: Es darf nur direkt nach dem Laden eines Speicherstands aufgerufen werden, bevor jemand beigetreten ist. - Alles, was zum Zustand der Welt gehört, steht im Snapshot. Wer ein Feld ergänzt, ergänzt es in `snapshot.rs` und erhöht `SNAPSHOT_VERSION`. -- Wo eine Rechnung überlaufen kann, steht eine ausdrückliche Wahl: `saturating_`, `wrapping_` oder `checked_`. +- Wo eine Rechnung im Spiel überlaufen kann, steht eine ausdrückliche Wahl: `saturating_`, `wrapping_` oder `checked_`. Taktzähler (`u64` bei 20 Takten pro Sekunde) laufen praktisch nie über und werden normal addiert. Werte aus geladenen Dateien müssen deshalb beim Laden geprüft werden. - Keine fremden Crates im Kern. Der Test `core/tests/forbidden_apis.rs` durchsucht den Quelltext des Kerns nach verbotenen Begriffen. Sie dürfen dort auch in Kommentaren nicht vorkommen. @@ -41,6 +41,8 @@ cargo clippy --workspace --all-targets -- -D warnings cargo test --workspace ``` +Schlägt `core/tests/golden.rs` fehl, haben sich Regeln oder ein Format geändert. Die Fehlermeldung nennt die Versionskonstante, die dann erhöht werden muss. Erst danach wird der festgeschriebene Wert bewusst angepasst. + ## Sprache - Code, Bezeichner und Kommentare im Code: Englisch. diff --git a/docs/KONZEPT.md b/docs/KONZEPT.md index a2f01d2..2ae4b2f 100644 --- a/docs/KONZEPT.md +++ b/docs/KONZEPT.md @@ -1,7 +1,7 @@ # Grovewright: Spielkonzept **Stand:** 30. September 2026 -**Status:** Entwurf, wartet auf Bestätigung +**Status:** Bestätigt am 30. September 2026 Dieses Dokument hält fest, worauf wir uns im Konzeptgespräch geeinigt haben. Es ist die Grundlage für den Bau. Was hier nicht steht, ist nicht beschlossen. @@ -196,7 +196,7 @@ Geprüft am 30. September 2026: | Rust | 1.98.1 | | Rust-Anbindung für Godot | `godot` 0.5.5, unterstützt Godot 4.7, verlangt Rust ab 1.94 | | Git | 2.54.0 | -| Git-Archiv | `https://git.race-cave.cloud/steven/Grovewright.git`, erreichbar und noch leer | +| Git-Archiv | `https://git.race-cave.cloud/steven/Grovewright.git` (Gitea) | Es sind keine weiteren Installationen nötig. @@ -227,7 +227,8 @@ Zwei Meilensteine sind Entscheidungspunkte: | Plattform | Windows | | Scheitern | Kein Game Over, kein Verlust von Fortschritt | | Speichern | Automatisch in festen Abständen und zusätzlich von Hand | -| Versionsverwaltung | Lokales Git-Archiv mit einem Eintrag pro Arbeitsschritt. Hochgeladen wird nur mit deiner Freigabe. | +| Versionsverwaltung | Git-Archiv mit einem Eintrag pro Arbeitsschritt, hochgeladen nach jedem Schritt. Freigegeben sind auch Branches, Issues und Pull Requests. | +| Rückkehrer | Nach der ersten Ruine kommen Schmiedin und Hirte, nach der zweiten die Gärtnerin | ## 10. Backlog für die Steam-Option @@ -250,10 +251,8 @@ Offen, zu klären im jeweiligen Meilenstein: - **Wachstumsdauern.** Die Richtwerte aus 3.1 werden im Playtest eingestellt. - **Verhältnis Werkstatt zu Hain.** Wie viel Hain ein Ofen braucht, entscheidet, ob die Bodenregel reizt oder nervt. - **Andocken.** Wie Ranken, Ladetiere, Kisten und Ruinen genau aneinander anschließen, wird in den Meilensteinen 2 und 3 festgelegt. -- **Reihenfolge der Rückkehrer.** Vorschlag: Nach der ersten Ruine kommen Schmiedin und Hirte, nach der zweiten die Gärtnerin. Das ist noch nicht besprochen. - **Grafikstil.** Du wählst im ersten Meilenstein aus drei Stilproben. - **Namen.** Alle Namen für Arten, Tiere und Waren sind Arbeitstitel. -- **Hochladen.** Ob nach jedem Arbeitsschritt ins Git-Archiv hochgeladen wird, ist noch nicht freigegeben. - **Titel.** Eine Websuche fand kein Spiel namens „Grovewright". Vor einem Steam-Release ist eine Markenrecherche nötig. Risiken: