Files
Grovewright/docs/superpowers/plans/2026-09-30-m1a-rust-kern.md
T
Steven Wroblewski fc909f963d Umsetzungsplan M1a: Rust-Kern
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 <noreply@anthropic.com>
2026-09-30 16:10:40 +02:00

123 KiB
Raw Blame History

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 <noreply@anthropic.com>.
  • 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/<modul>.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.

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:

# Same line endings on every machine.
* text=auto eol=lf

Create .gitignore:

# Rust
/target/
  • Step 3: Workspace und Crate anlegen

Create Cargo.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:

[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:

//! 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:

//! 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<PathBuf>) {
    for entry in fs::read_dir(dir).expect("source directory is readable") {
        let path = entry.expect("directory entry is readable").path();
        if path.is_dir() {
            rust_files(&path, out);
        } else if path.extension().is_some_and(|ext| ext == "rs") {
            out.push(path);
        }
    }
}

#[test]
fn core_source_uses_no_forbidden_apis() {
    let src = Path::new(env!("CARGO_MANIFEST_DIR")).join("src");
    let mut files = Vec::new();
    rust_files(&src, &mut files);
    files.sort();
    assert!(
        !files.is_empty(),
        "no source files found in {}",
        src.display()
    );

    let mut problems = Vec::new();
    for file in &files {
        let text = fs::read_to_string(file).expect("source file is readable");
        for (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:


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:

# 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:

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
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 <noreply@anthropic.com>
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<u8>
    • 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<String, DecodeError>, finish(self) -> Result<(), DecodeError>
  • Step 1: Fehlschlagenden Test schreiben

Create core/tests/codec.rs:

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:

//! 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<u8>,
}

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<u8> {
        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<const N: usize>(&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<u8, DecodeError> {
        Ok(self.array::<1>()?[0])
    }

    pub fn u16(&mut self) -> Result<u16, DecodeError> {
        Ok(u16::from_le_bytes(self.array()?))
    }

    pub fn u32(&mut self) -> Result<u32, DecodeError> {
        Ok(u32::from_le_bytes(self.array()?))
    }

    pub fn u64(&mut self) -> Result<u64, DecodeError> {
        Ok(u64::from_le_bytes(self.array()?))
    }

    pub fn i32(&mut self) -> Result<i32, DecodeError> {
        Ok(i32::from_le_bytes(self.array()?))
    }

    pub fn bool(&mut self) -> Result<bool, DecodeError> {
        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<String, DecodeError> {
        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:

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:

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
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 <noreply@anthropic.com>
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:

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:

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:

//! 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:

//! 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:

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:

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
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 <noreply@anthropic.com>
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<Terrain>, is_walkable(self) -> bool, hits_to_break(self) -> Option<u8>, required_tool_tier(self) -> Option<u8>
    • 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<Tile>, set(&mut self, Pos, Tile) -> bool, tiles() -> &[Tile] (Index y * width + x), encode(&self, &mut Writer), decode(&mut Reader) -> Result<Map, DecodeError>
  • Step 1: Fehlschlagenden Test schreiben

Create core/tests/map.rs:

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<u8> {
    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<u8>, 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:

//! 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<Self> {
        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<u8> {
        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<u8> {
        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<Tile>,
}

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<usize> {
        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<Tile> {
        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<Self, DecodeError> {
        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:

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:

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
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 <noreply@anthropic.com>
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:

//! 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:

//! 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<bool> {
    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:

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:

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
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 <noreply@anthropic.com>
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<Stack>], 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<Inventory, DecodeError>; außerdem Default, Clone, PartialEq
  • Step 1: Fehlschlagenden Test schreiben

Create core/tests/inventory.rs:

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:

//! 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<Stack>; 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<Stack>] {
        &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<Self, DecodeError> {
        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:

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:

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
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 <noreply@anthropic.com>
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, DecodeError>
    • 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<Command, DecodeError>; Clone, Debug, PartialEq, Eq
  • Step 1: Fehlschlagende Tests schreiben

Create core/tests/player.rs:

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:

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:

//! 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<Self, DecodeError> {
        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:

//! 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<Self, DecodeError> {
        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:

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:

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
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 <noreply@anthropic.com>
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<Player> und den Methoden new(seed: u64) -> World, seed() -> u64, tick() -> u64, map() -> &Map, players() -> &[Player], player(PlayerId) -> Option<&Player>, find_player(&PlayerKey) -> Option<PlayerId>, set_all_offline(&mut self), step(&mut self, commands: &[Command]) -> Vec<Event>; 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:

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.

//! 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<Player>,
}

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<PlayerId> {
        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<Event> {
        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<Event>) {
        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<Event>) {
        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<Event>) {
        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:

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:

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
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 <noreply@anthropic.com>
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<DecodeError>
    • World::state_hash(&self) -> u64
    • World::to_snapshot(&self) -> Vec<u8> (beginnt mit den vier Bytes GRWS, dann die Version als u32)
    • World::from_snapshot(bytes: &[u8]) -> Result<World, SnapshotError>

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:

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:

//! 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<DecodeError> for SnapshotError {
    fn from(error: DecodeError) -> Self {
        SnapshotError::Corrupt(error)
    }
}

impl World {
    fn encode_state(&self, w: &mut Writer) {
        w.u64(self.seed);
        w.u64(self.tick);
        self.map.encode(w);
        w.u8(self.players.len() as u8);
        for player in &self.players {
            player.encode(w);
        }
    }

    /// Checksum of the complete world state. Two worlds with the same value
    /// are in the same state, apart from hash collisions.
    pub fn state_hash(&self) -> u64 {
        let mut w = Writer::new();
        self.encode_state(&mut w);
        fnv1a64(&w.into_bytes())
    }

    /// Encodes the complete world behind a header that names format and
    /// version.
    pub fn to_snapshot(&self) -> Vec<u8> {
        let mut w = Writer::new();
        w.raw(&MAGIC);
        w.u32(SNAPSHOT_VERSION);
        self.encode_state(&mut w);
        w.into_bytes()
    }

    /// Restores a world from bytes written by [`World::to_snapshot`].
    pub fn from_snapshot(bytes: &[u8]) -> Result<World, SnapshotError> {
        let mut r = Reader::new(bytes);
        let magic = r
            .raw(MAGIC.len())
            .map_err(|_| SnapshotError::NotASnapshot)?;
        if magic != MAGIC.as_slice() {
            return Err(SnapshotError::NotASnapshot);
        }
        let version = r.u32()?;
        if version != SNAPSHOT_VERSION {
            return Err(SnapshotError::UnsupportedVersion(version));
        }
        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:

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:

- **Speicherstand.** Er hält Startzahl und Änderungen fest. Ein Gast bekommt ihn beim Beitritt geschickt.

durch:

- **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:

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
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 <noreply@anthropic.com>
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<World, RecordingError>, to_bytes(&self, end: &World) -> Vec<u8> (beginnt mit den vier Bytes GRWR), from_bytes(bytes: &[u8]) -> Result<(Recording, u64), RecordingError>, verify(bytes: &[u8]) -> Result<World, RecordingError>

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:

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<Command> {
    if tick == 0 {
        vec![Command::Join {
            key: [1; 16],
            name: "Ada".to_owned(),
        }]
    } else if tick.is_multiple_of(7) {
        vec![Command::Dig {
            player: ADA,
            pos: outcrop_origin(world.map()),
        }]
    } else {
        vec![]
    }
}

/// Plays 200 ticks from a fresh world and records them.
fn recorded_session() -> (Recording, World) {
    let mut world = World::new(11);
    let mut recording = Recording::start(&world);
    for tick in 0..200 {
        let commands = commands_for(&world, tick);
        recording.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:

//! 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<DecodeError> for RecordingError {
    fn from(error: DecodeError) -> Self {
        RecordingError::Corrupt(error)
    }
}

/// The starting state of a session and the commands of every tick after it.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Recording {
    start: Vec<u8>,
    tick_count: u32,
    /// Ticks that had commands, as (tick index, commands), in rising order.
    busy_ticks: Vec<(u32, Vec<Command>)>,
}

impl Recording {
    /// Starts a recording at the current state of `world`.
    pub fn start(world: &World) -> Self {
        Self {
            start: world.to_snapshot(),
            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<World, RecordingError> {
        let mut world = World::from_snapshot(&self.start).map_err(RecordingError::BadStart)?;
        let mut busy = self.busy_ticks.iter().peekable();
        for index in 0..self.tick_count {
            match busy.peek() {
                Some((tick, commands)) if *tick == index => {
                    world.step(commands);
                    busy.next();
                }
                _ => {
                    world.step(&[]);
                }
            }
        }
        Ok(world)
    }

    /// Encodes the recording together with the state that replaying it must
    /// reach. `end` is the world after the last recorded tick.
    pub fn to_bytes(&self, end: &World) -> Vec<u8> {
        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<World, RecordingError> {
        let (recording, expected) = Self::from_bytes(bytes)?;
        let world = recording.replay()?;
        let actual = world.state_hash();
        if actual == expected {
            Ok(world)
        } else {
            Err(RecordingError::Diverged { expected, actual })
        }
    }
}

Ergänze in core/src/lib.rs die Zeile pub mod replay;. Die Datei hat danach genau diesen Inhalt:

//! 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:

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
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 <noreply@anthropic.com>
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:

//! 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<Vec<Command>> {
    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

        w.u64(self.tick);

zu

        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:

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
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 <noreply@anthropic.com>
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