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

4017 lines
123 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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.
```bash
git switch m1a-rust-kern
```
Expected: `Already on 'm1a-rust-kern'` oder `Switched to branch 'm1a-rust-kern'`
- [ ] **Step 2: Git-Dateien anlegen**
Create `.gitattributes`:
```text
# Same line endings on every machine.
* text=auto eol=lf
```
Create `.gitignore`:
```text
# Rust
/target/
```
- [ ] **Step 3: Workspace und Crate anlegen**
Create `Cargo.toml`:
```toml
[workspace]
resolver = "2"
members = ["core"]
# The shipped game and the tests must agree on what happens when a number
# overflows, so release builds keep the overflow checks that debug builds have.
[profile.release]
overflow-checks = true
```
Create `core/Cargo.toml`:
```toml
[package]
name = "grovewright-core"
version = "0.1.0"
edition = "2021"
rust-version = "1.94"
publish = false
description = "Deterministic simulation core of Grovewright"
[lints.rust]
unsafe_code = "forbid"
[lints.clippy]
float_arithmetic = "deny"
```
Create `core/src/lib.rs`:
```rust
//! Deterministic core of Grovewright.
//!
//! Everything that belongs to the shared world lives here. The core never
//! touches the engine, the clock, files or the network, and it calculates
//! only with whole numbers. Given the same commands it produces the same
//! world on every machine. That is what lets every player simulate the whole
//! world and exchange nothing but commands.
```
- [ ] **Step 4: Wächter-Test schreiben**
Create `core/tests/forbidden_apis.rs`:
```rust
//! Guards the lockstep rules: the core must not use anything whose result
//! can differ between machines or between runs.
use std::fs;
use std::path::{Path, PathBuf};
/// Text that must not appear anywhere in the source of the core, and why.
const FORBIDDEN: &[(&str, &str)] = &[
("f32", "floating point results can differ between machines"),
("f64", "floating point results can differ between machines"),
(
"HashMap",
"iteration order differs between runs; use BTreeMap or Vec",
),
(
"HashSet",
"iteration order differs between runs; use BTreeSet or Vec",
),
("std::time", "the core must not read the clock"),
("Instant", "the core must not read the clock"),
("SystemTime", "the core must not read the clock"),
("std::thread", "the core must stay single-threaded"),
("std::env", "the core must not depend on the environment"),
(
"std::fs",
"the core must not touch files; the shell does that",
),
(
"std::net",
"the core must not open connections; the shell does that",
),
("unsafe", "the core forbids unsafe code"),
];
fn rust_files(dir: &Path, out: &mut Vec<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:
```rust
pub fn broken() -> f32 {
0.5
}
```
Run: `cargo test -p grovewright-core --test forbidden_apis`
Expected: FAIL mit einer Zeile der Form ``...core\src\lib.rs:9: `f32`: floating point results can differ between machines``
Entferne die angehängten Zeilen wieder, sodass `core/src/lib.rs` exakt dem Stand aus Step 3 entspricht.
Run: `cargo test -p grovewright-core --test forbidden_apis`
Expected: PASS
- [ ] **Step 7: Regeln für künftige Sitzungen festhalten**
Create `CLAUDE.md`:
````markdown
# Grovewright
Aufbauspiel für ein bis vier Spieler, in dem die Fabrik lebt. Gebaut wird zuerst eine Vertical Slice mit 2–3 Stunden Spielzeit.
Das verbindliche Konzept steht in [docs/KONZEPT.md](docs/KONZEPT.md). Lies es, bevor du Spielregeln, Architektur oder Umfang änderst. Was dort nicht steht, ist nicht beschlossen.
## Aufbau
| Ordner | Inhalt |
|---|---|
| `core/` | Rust-Kern: die gesamte gemeinsame Welt. Ohne Zugriff auf die Engine, ohne fremde Crates. |
| `docs/KONZEPT.md` | Spielkonzept und Entscheidungen |
| `docs/superpowers/plans/` | Umsetzungspläne |
Die Brücke zu Godot (`bridge/`), das Godot-Projekt (`game/`) und die Inhaltsdateien (`content/`) kommen mit späteren Plänen dazu.
Faustregel: Was im Speicherstand steht oder alle Spieler betrifft, gehört in den Kern.
## Regeln für den Kern
Alle Spieler simulieren die Welt selbst und tauschen nur Befehle aus (Gleichschritt). Das funktioniert nur, wenn der Kern auf jedem Rechner exakt gleich rechnet. Deshalb gilt in `core/`:
- Nur ganze Zahlen, keine Gleitkommazahlen.
- Keine Uhr, keine Dateien, kein Netzwerk, keine Threads, keine Umgebungsvariablen.
- Zufall nur über `rng::Rng` mit fester Startzahl.
- Keine Sammlung mit wechselnder Reihenfolge. Statt einer Hash-Tabelle nimmst du `Vec`, `BTreeMap` oder `BTreeSet`.
- Die Welt ändert sich nur durch einen `Command` in `World::step`.
- Alles, was zum Zustand der Welt gehört, steht im Snapshot. Wer ein Feld ergänzt, ergänzt es in `snapshot.rs` und erhöht `SNAPSHOT_VERSION`.
- Wo eine Rechnung überlaufen kann, steht eine ausdrückliche Wahl: `saturating_`, `wrapping_` oder `checked_`.
- Keine fremden Crates im Kern.
Der Test `core/tests/forbidden_apis.rs` durchsucht den Quelltext des Kerns nach verbotenen Begriffen. Sie dürfen dort auch in Kommentaren nicht vorkommen.
## Prüfen
Vor jedem Commit müssen alle drei Befehle fehlerfrei laufen:
```bash
cargo fmt --all -- --check
cargo clippy --workspace --all-targets -- -D warnings
cargo test --workspace
```
## Sprache
- Code, Bezeichner und Kommentare im Code: Englisch.
- Dokumente, Pläne, Commit-Nachrichten, Issues und Pull Requests: Deutsch.
- Texte im Spiel: Deutsch und Englisch über Übersetzungsdateien.
## Git
- Archiv: `https://git.race-cave.cloud/steven/Grovewright.git` (Gitea).
- Gearbeitet wird auf einem Branch pro Plan, nicht direkt auf `main`.
- Nach jedem abgeschlossenen Arbeitsschritt wird committet und hochgeladen. Das ist freigegeben.
````
- [ ] **Step 8: Alles prüfen**
Run:
```bash
cargo fmt --all -- --check && cargo clippy --workspace --all-targets -- -D warnings && cargo test --workspace
```
Expected: keine Ausgabe von rustfmt, keine Warnung von Clippy, `test result: ok. 1 passed`
- [ ] **Step 9: Commit und Hochladen**
```bash
git add .gitattributes .gitignore Cargo.toml Cargo.lock core/Cargo.toml core/src/lib.rs core/tests/forbidden_apis.rs CLAUDE.md
git commit -F - <<'EOF'
Gerüst für den Rust-Kern anlegen
Workspace, leeres Crate grovewright-core und ein Wächter-Test, der den
Quelltext des Kerns auf Verstöße gegen die Gleichschritt-Regeln prüft.
Co-Authored-By: Claude Opus 5.5 <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`:
```rust
use grovewright_core::codec::{DecodeError, Reader, Writer};
#[test]
fn every_kind_of_value_survives_a_round_trip() {
let mut w = Writer::new();
w.u8(0xAB);
w.u16(0xBEEF);
w.u32(0xDEAD_BEEF);
w.u64(0x0123_4567_89AB_CDEF);
w.i32(-42);
w.bool(true);
w.bool(false);
w.bytes(&[1, 2, 3]);
w.str("Grüner Hain");
w.raw(&[9, 9]);
let bytes = w.into_bytes();
let mut r = Reader::new(&bytes);
assert_eq!(r.u8(), Ok(0xAB));
assert_eq!(r.u16(), Ok(0xBEEF));
assert_eq!(r.u32(), Ok(0xDEAD_BEEF));
assert_eq!(r.u64(), Ok(0x0123_4567_89AB_CDEF));
assert_eq!(r.i32(), Ok(-42));
assert_eq!(r.bool(), Ok(true));
assert_eq!(r.bool(), Ok(false));
assert_eq!(r.bytes(), Ok(&[1u8, 2, 3][..]));
assert_eq!(r.string(), Ok("Grüner Hain".to_owned()));
assert_eq!(r.raw(2), Ok(&[9u8, 9][..]));
assert_eq!(r.finish(), Ok(()));
}
#[test]
fn integers_are_little_endian() {
let mut w = Writer::new();
w.u32(0x0102_0304);
assert_eq!(w.into_bytes(), vec![4, 3, 2, 1]);
}
#[test]
fn reading_past_the_end_fails() {
let mut r = Reader::new(&[1, 2]);
assert_eq!(r.u32(), Err(DecodeError::UnexpectedEnd));
}
#[test]
fn an_oversized_length_fails_without_panicking() {
let mut w = Writer::new();
w.u32(u32::MAX);
let bytes = w.into_bytes();
let mut r = Reader::new(&bytes);
assert_eq!(r.bytes(), Err(DecodeError::UnexpectedEnd));
}
#[test]
fn invalid_bool_and_invalid_utf8_are_rejected() {
let mut r = Reader::new(&[2]);
assert_eq!(r.bool(), Err(DecodeError::InvalidValue("bool")));
let mut w = Writer::new();
w.bytes(&[0xFF, 0xFE]);
let bytes = w.into_bytes();
let mut r = Reader::new(&bytes);
assert_eq!(r.string(), Err(DecodeError::InvalidValue("utf-8 string")));
}
#[test]
fn finish_rejects_bytes_that_were_not_read() {
let r = Reader::new(&[0]);
assert_eq!(r.finish(), Err(DecodeError::TrailingBytes));
}
#[test]
fn remaining_counts_down_as_values_are_read() {
let mut r = Reader::new(&[1, 2, 3]);
assert_eq!(r.remaining(), 3);
r.u8().unwrap();
assert_eq!(r.remaining(), 2);
}
```
- [ ] **Step 2: Test ausführen und Fehlschlag sehen**
Run: `cargo test -p grovewright-core --test codec`
Expected: FAIL mit ``error[E0432]: unresolved import `grovewright_core::codec` ``
- [ ] **Step 3: Kodierung umsetzen**
Create `core/src/codec.rs`:
```rust
//! Minimal little-endian binary encoding for commands, snapshots and
//! recordings.
use std::fmt;
/// Error returned when bytes cannot be decoded.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum DecodeError {
/// The input ended before the value was complete.
UnexpectedEnd,
/// The input contained a value that is not allowed at this position.
InvalidValue(&'static str),
/// The input contained bytes after the last expected value.
TrailingBytes,
}
impl fmt::Display for DecodeError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
DecodeError::UnexpectedEnd => write!(f, "unexpected end of input"),
DecodeError::InvalidValue(what) => write!(f, "invalid value: {what}"),
DecodeError::TrailingBytes => write!(f, "trailing bytes after the last value"),
}
}
}
impl std::error::Error for DecodeError {}
/// Appends values to a growing byte buffer.
#[derive(Debug, Default)]
pub struct Writer {
buf: Vec<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:
```rust
pub mod codec;
```
- [ ] **Step 4: Test ausführen und Erfolg sehen**
Run: `cargo test -p grovewright-core --test codec`
Expected: PASS mit `test result: ok. 7 passed`
- [ ] **Step 5: Alles prüfen**
Run:
```bash
cargo fmt --all -- --check && cargo clippy --workspace --all-targets -- -D warnings && cargo test --workspace
```
Expected: keine Ausgabe von rustfmt, keine Warnung von Clippy, alle Tests bestanden
- [ ] **Step 6: Commit und Hochladen**
```bash
git add core/src/codec.rs core/src/lib.rs core/tests/codec.rs
git commit -F - <<'EOF'
Binäre Kodierung für den Kern
Writer und Reader für Befehle, Snapshots und Aufzeichnungen. Beschädigte
Eingaben führen zu einem Fehler statt zu einem Absturz.
Co-Authored-By: Claude Opus 5.5 <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`:
```rust
use grovewright_core::hash::fnv1a64;
#[test]
fn matches_the_published_fnv1a_test_vectors() {
assert_eq!(fnv1a64(b""), 0xcbf2_9ce4_8422_2325);
assert_eq!(fnv1a64(b"a"), 0xaf63_dc4c_8601_ec8c);
assert_eq!(fnv1a64(b"foobar"), 0x8594_4171_f739_67e8);
}
#[test]
fn the_order_of_bytes_matters() {
assert_ne!(fnv1a64(&[0, 1]), fnv1a64(&[1, 0]));
}
```
Create `core/tests/rng.rs`:
```rust
use grovewright_core::rng::Rng;
#[test]
fn matches_the_splitmix64_reference_values() {
let mut rng = Rng::new(0);
assert_eq!(rng.next_u64(), 0xE220_A839_7B1D_CDAF);
assert_eq!(rng.next_u64(), 0x6E78_9E6A_A1B9_65F4);
assert_eq!(rng.next_u64(), 0x06C4_5D18_8009_454F);
}
#[test]
fn the_same_seed_gives_the_same_sequence() {
let mut a = Rng::new(2026);
let mut b = Rng::new(2026);
for _ in 0..1_000 {
assert_eq!(a.next_u64(), b.next_u64());
}
}
#[test]
fn different_seeds_give_different_sequences() {
assert_ne!(Rng::new(1).next_u64(), Rng::new(2).next_u64());
}
#[test]
fn below_stays_inside_the_bound() {
let mut rng = Rng::new(7);
for bound in [1u32, 2, 3, 10, 100, 1_000_000, u32::MAX] {
for _ in 0..1_000 {
assert!(rng.below(bound) < bound);
}
}
}
#[test]
fn below_reaches_every_value_of_a_small_bound() {
let mut rng = Rng::new(99);
let mut seen = [false; 6];
for _ in 0..1_000 {
seen[rng.below(6) as usize] = true;
}
assert_eq!(seen, [true; 6]);
}
#[test]
#[should_panic(expected = "bound must be greater than zero")]
fn below_refuses_a_bound_of_zero() {
Rng::new(1).below(0);
}
```
- [ ] **Step 2: Tests ausführen und Fehlschlag sehen**
Run: `cargo test -p grovewright-core --test hash --test rng`
Expected: FAIL mit ``error[E0432]: unresolved import `grovewright_core::hash` `` und ``unresolved import `grovewright_core::rng` ``
- [ ] **Step 3: Prüfsumme und Zufall umsetzen**
Create `core/src/hash.rs`:
```rust
//! FNV-1a hashing for state checksums.
//!
//! The hash only has to reveal accidental divergence between peers. It is
//! not meant to resist tampering.
const FNV_OFFSET_BASIS: u64 = 0xcbf2_9ce4_8422_2325;
const FNV_PRIME: u64 = 0x0000_0100_0000_01b3;
/// Returns the 64-bit FNV-1a hash of `bytes`.
pub fn fnv1a64(bytes: &[u8]) -> u64 {
let mut hash = FNV_OFFSET_BASIS;
for &byte in bytes {
hash ^= u64::from(byte);
hash = hash.wrapping_mul(FNV_PRIME);
}
hash
}
```
Create `core/src/rng.rs`:
```rust
//! Repeatable pseudo random numbers (SplitMix64).
//!
//! The core never uses any other source of randomness. The same seed always
//! gives the same sequence, on every machine.
/// Pseudo random number generator with 64 bits of state.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Rng {
state: u64,
}
impl Rng {
pub fn new(seed: u64) -> Self {
Self { state: seed }
}
/// Returns the next value of the sequence.
pub fn next_u64(&mut self) -> u64 {
self.state = self.state.wrapping_add(0x9E37_79B9_7F4A_7C15);
let mut z = self.state;
z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
z ^ (z >> 31)
}
/// Returns a value in `0..bound`.
///
/// # Panics
/// Panics if `bound` is zero.
pub fn below(&mut self, bound: u32) -> u32 {
assert!(bound > 0, "bound must be greater than zero");
((u128::from(self.next_u64()) * u128::from(bound)) >> 64) as u32
}
}
```
Ergänze in `core/src/lib.rs` die Zeilen `pub mod hash;` und `pub mod rng;`. Die `pub mod`-Zeilen bleiben alphabetisch sortiert:
```rust
pub mod codec;
pub mod hash;
pub mod rng;
```
- [ ] **Step 4: Tests ausführen und Erfolg sehen**
Run: `cargo test -p grovewright-core --test hash --test rng`
Expected: PASS mit `test result: ok. 2 passed` für `hash` und `test result: ok. 6 passed` für `rng`
- [ ] **Step 5: Alles prüfen**
Run:
```bash
cargo fmt --all -- --check && cargo clippy --workspace --all-targets -- -D warnings && cargo test --workspace
```
Expected: keine Ausgabe von rustfmt, keine Warnung von Clippy, alle Tests bestanden
- [ ] **Step 6: Commit und Hochladen**
```bash
git add core/src/hash.rs core/src/rng.rs core/src/lib.rs core/tests/hash.rs core/tests/rng.rs
git commit -F - <<'EOF'
Prüfsumme und wiederholbarer Zufall
FNV-1a für Zustandsprüfsummen und SplitMix64 als einzige Zufallsquelle
des Kerns. Beide sind gegen veröffentlichte Referenzwerte geprüft.
Co-Authored-By: Claude Opus 5.5 <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`:
```rust
use grovewright_core::codec::{DecodeError, Reader, Writer};
use grovewright_core::map::{Map, Pos, Terrain, Tile};
#[test]
fn a_filled_map_has_the_requested_size_and_terrain() {
let map = Map::filled(4, 3, Terrain::SoftRock);
assert_eq!(map.width(), 4);
assert_eq!(map.height(), 3);
assert_eq!(map.tiles().len(), 12);
assert_eq!(map.get(Pos::new(3, 2)), Some(Tile::new(Terrain::SoftRock)));
}
#[test]
fn positions_outside_the_map_are_rejected() {
let mut map = Map::filled(4, 3, Terrain::Ground);
for pos in [
Pos::new(-1, 0),
Pos::new(0, -1),
Pos::new(4, 0),
Pos::new(0, 3),
] {
assert!(!map.in_bounds(pos), "{pos:?}");
assert_eq!(map.get(pos), None, "{pos:?}");
assert!(!map.set(pos, Tile::new(Terrain::Bedrock)), "{pos:?}");
}
assert!(map.in_bounds(Pos::new(3, 2)));
}
#[test]
fn set_changes_exactly_one_tile() {
let mut map = Map::filled(4, 3, Terrain::Ground);
assert!(map.set(Pos::new(1, 2), Tile::new(Terrain::HardRock)));
let hard_rock = map
.tiles()
.iter()
.filter(|tile| tile.terrain == Terrain::HardRock)
.count();
assert_eq!(hard_rock, 1);
assert_eq!(map.tiles()[2 * 4 + 1].terrain, Terrain::HardRock);
}
#[test]
fn only_rock_can_be_dug() {
assert_eq!(Terrain::Ground.hits_to_break(), None);
assert_eq!(Terrain::Bedrock.hits_to_break(), None);
assert_eq!(Terrain::SoftRock.hits_to_break(), Some(3));
assert_eq!(Terrain::HardRock.hits_to_break(), Some(6));
assert_eq!(Terrain::Ground.required_tool_tier(), None);
assert_eq!(Terrain::Bedrock.required_tool_tier(), None);
assert_eq!(Terrain::SoftRock.required_tool_tier(), Some(1));
assert_eq!(Terrain::HardRock.required_tool_tier(), Some(2));
}
#[test]
fn only_ground_can_be_walked_on() {
assert!(Terrain::Ground.is_walkable());
assert!(!Terrain::SoftRock.is_walkable());
assert!(!Terrain::HardRock.is_walkable());
assert!(!Terrain::Bedrock.is_walkable());
}
#[test]
fn terrain_numbers_are_stable() {
for terrain in [
Terrain::Ground,
Terrain::SoftRock,
Terrain::HardRock,
Terrain::Bedrock,
] {
assert_eq!(Terrain::from_u8(terrain as u8), Some(terrain));
}
assert_eq!(Terrain::from_u8(4), None);
}
#[test]
fn a_map_survives_encoding() {
let mut map = Map::filled(5, 4, Terrain::Ground);
map.set(
Pos::new(2, 1),
Tile {
terrain: Terrain::SoftRock,
damage: 2,
},
);
let mut w = Writer::new();
map.encode(&mut w);
let bytes = w.into_bytes();
let mut r = Reader::new(&bytes);
assert_eq!(Map::decode(&mut r), Ok(map));
assert_eq!(r.finish(), Ok(()));
}
fn encoded_map(width: u32, height: u32, tiles: &[(u8, u8)]) -> Vec<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`:
```rust
//! Tile map of the shared world.
use crate::codec::{DecodeError, Reader, Writer};
/// Longest accepted map edge, in tiles.
pub const MAX_MAP_SIZE: u32 = 1024;
/// Tile coordinate. `x` grows to the right, `y` grows downwards.
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
pub struct Pos {
pub x: i32,
pub y: i32,
}
impl Pos {
pub const fn new(x: i32, y: i32) -> Self {
Self { x, y }
}
}
/// What a tile is made of.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
#[repr(u8)]
pub enum Terrain {
/// Open ground that can be walked on.
Ground = 0,
/// Rock that a stone pickaxe can break.
SoftRock = 1,
/// Rock that needs a copper pickaxe.
HardRock = 2,
/// Indestructible rock at the edge of the map.
Bedrock = 3,
}
impl Terrain {
pub fn from_u8(value: u8) -> Option<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:
```rust
pub mod codec;
pub mod hash;
pub mod map;
pub mod rng;
```
- [ ] **Step 4: Test ausführen und Erfolg sehen**
Run: `cargo test -p grovewright-core --test map`
Expected: PASS mit `test result: ok. 8 passed`
- [ ] **Step 5: Alles prüfen**
Run:
```bash
cargo fmt --all -- --check && cargo clippy --workspace --all-targets -- -D warnings && cargo test --workspace
```
Expected: keine Ausgabe von rustfmt, keine Warnung von Clippy, alle Tests bestanden
- [ ] **Step 6: Commit und Hochladen**
```bash
git add core/src/map.rs core/src/lib.rs core/tests/map.rs
git commit -F - <<'EOF'
Karte aus Kacheln
Gelände mit Grabregeln, Kacheln mit Schaden und eine rechteckige Karte,
die sich kodieren lässt und unmögliche Eingaben ablehnt.
Co-Authored-By: Claude Opus 5.5 <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`:
```rust
//! Rules the map generator has to keep for every seed.
use grovewright_core::map::{Map, Pos, Terrain};
use grovewright_core::mapgen::{
generate_test_map, outcrop_origin, spawn_pos, HARD_ROCK_DISTANCE, OUTCROP_SIZE,
SPAWN_CLEARING_RADIUS, TEST_MAP_SIZE,
};
/// Number of seeds every rule is checked against.
const SEEDS: u64 = 300;
fn terrain_at(map: &Map, x: i32, y: i32) -> Terrain {
map.get(Pos::new(x, y))
.expect("position is on the map")
.terrain
}
#[test]
fn the_same_seed_gives_the_same_map() {
assert_eq!(generate_test_map(42), generate_test_map(42));
}
#[test]
fn different_seeds_give_different_maps() {
assert_ne!(generate_test_map(1), generate_test_map(2));
}
#[test]
fn the_map_has_the_test_size() {
let map = generate_test_map(0);
assert_eq!((map.width(), map.height()), (TEST_MAP_SIZE, TEST_MAP_SIZE));
}
#[test]
fn bedrock_forms_the_border_and_nothing_else() {
let last = TEST_MAP_SIZE as i32 - 1;
for seed in 0..SEEDS {
let map = generate_test_map(seed);
for y in 0..=last {
for x in 0..=last {
let on_border = x == 0 || y == 0 || x == last || y == last;
let is_bedrock = terrain_at(&map, x, y) == Terrain::Bedrock;
assert_eq!(is_bedrock, on_border, "seed {seed}, tile {x}/{y}");
}
}
}
}
#[test]
fn the_spawn_clearing_is_open_ground() {
for seed in 0..SEEDS {
let map = generate_test_map(seed);
let spawn = spawn_pos(&map);
for dy in -SPAWN_CLEARING_RADIUS..=SPAWN_CLEARING_RADIUS {
for dx in -SPAWN_CLEARING_RADIUS..=SPAWN_CLEARING_RADIUS {
if dx * dx + dy * dy > SPAWN_CLEARING_RADIUS * SPAWN_CLEARING_RADIUS {
continue;
}
let terrain = terrain_at(&map, spawn.x + dx, spawn.y + dy);
assert_eq!(terrain, Terrain::Ground, "seed {seed}, offset {dx}/{dy}");
}
}
}
}
#[test]
fn a_soft_rock_outcrop_lies_next_to_the_clearing() {
for seed in 0..SEEDS {
let map = generate_test_map(seed);
let spawn = spawn_pos(&map);
let origin = outcrop_origin(&map);
assert_eq!(origin.x - spawn.x, SPAWN_CLEARING_RADIUS + 2, "seed {seed}");
for y in origin.y..origin.y + OUTCROP_SIZE {
for x in origin.x..origin.x + OUTCROP_SIZE {
assert_eq!(
terrain_at(&map, x, y),
Terrain::SoftRock,
"seed {seed}, tile {x}/{y}"
);
}
}
}
}
#[test]
fn hard_rock_appears_only_far_from_the_spawn_point() {
let last = TEST_MAP_SIZE as i32 - 1;
for seed in 0..SEEDS {
let map = generate_test_map(seed);
let spawn = spawn_pos(&map);
for y in 1..last {
for x in 1..last {
let far = (x - spawn.x).abs().max((y - spawn.y).abs()) >= HARD_ROCK_DISTANCE;
match terrain_at(&map, x, y) {
Terrain::HardRock => assert!(far, "seed {seed}: hard rock near spawn"),
Terrain::SoftRock => assert!(!far, "seed {seed}: soft rock far out"),
Terrain::Ground | Terrain::Bedrock => {}
}
}
}
}
}
#[test]
fn every_map_mixes_rock_and_open_ground() {
for seed in 0..SEEDS {
let map = generate_test_map(seed);
let total = map.tiles().len();
let rock = map
.tiles()
.iter()
.filter(|tile| !tile.terrain.is_walkable())
.count();
let percent = rock * 100 / total;
assert!(
(20..=80).contains(&percent),
"seed {seed}: {percent} percent rock"
);
let hard = map
.tiles()
.iter()
.filter(|tile| tile.terrain == Terrain::HardRock)
.count();
assert!(hard > 0, "seed {seed}: no hard rock");
}
}
#[test]
fn no_generated_tile_starts_damaged() {
let map = generate_test_map(5);
assert!(map.tiles().iter().all(|tile| tile.damage == 0));
}
```
- [ ] **Step 2: Test ausführen und Fehlschlag sehen**
Run: `cargo test -p grovewright-core --test mapgen`
Expected: FAIL mit ``error[E0432]: unresolved import `grovewright_core::mapgen` ``
- [ ] **Step 3: Generator umsetzen**
Create `core/src/mapgen.rs`:
```rust
//! Generator for the milestone 1 test map.
//!
//! The broad layout is fixed and the details are random: a bedrock border, a
//! clearing around the spawn point, a rock outcrop right next to the
//! clearing, cave-like soft rock in the inner area and hard rock further out.
use crate::map::{Map, Pos, Terrain, Tile};
use crate::rng::Rng;
/// Edge length of the test map, in tiles.
pub const TEST_MAP_SIZE: u32 = 64;
/// Tiles within this distance of the spawn point are always open ground.
pub const SPAWN_CLEARING_RADIUS: i32 = 5;
/// Edge length of the soft rock outcrop that every test map has.
pub const OUTCROP_SIZE: i32 = 4;
/// Rock at least this many tiles from the spawn point, counted along the
/// longer axis, is hard rock.
pub const HARD_ROCK_DISTANCE: i32 = 22;
const INITIAL_ROCK_PERCENT: u32 = 46;
const SMOOTHING_PASSES: u32 = 4;
/// Keeps the map sequence apart from other uses of the same seed.
const SEED_SALT: u64 = 0x6D61_7067_656E_0001;
/// Spawn point of a map: its centre tile.
pub fn spawn_pos(map: &Map) -> Pos {
Pos::new((map.width() / 2) as i32, (map.height() / 2) as i32)
}
/// Top left tile of the soft rock outcrop next to the spawn clearing.
pub fn outcrop_origin(map: &Map) -> Pos {
let spawn = spawn_pos(map);
Pos::new(spawn.x + SPAWN_CLEARING_RADIUS + 2, spawn.y - 2)
}
/// Builds the test map for `seed`. The same seed always gives the same map.
pub fn generate_test_map(seed: u64) -> Map {
let size = TEST_MAP_SIZE;
let mut rng = Rng::new(seed ^ SEED_SALT);
let mut rock = vec![false; (size as usize) * (size as usize)];
for cell in rock.iter_mut() {
*cell = rng.below(100) < INITIAL_ROCK_PERCENT;
}
for _ in 0..SMOOTHING_PASSES {
rock = smooth(&rock, size);
}
let mut map = Map::filled(size, size, Terrain::Ground);
let spawn = spawn_pos(&map);
let outcrop = outcrop_origin(&map);
let last = size as i32 - 1;
for y in 0..=last {
for x in 0..=last {
let dx = x - spawn.x;
let dy = y - spawn.y;
let on_border = x == 0 || y == 0 || x == last || y == last;
let in_clearing = dx * dx + dy * dy <= SPAWN_CLEARING_RADIUS * SPAWN_CLEARING_RADIUS;
let in_outcrop = (outcrop.x..outcrop.x + OUTCROP_SIZE).contains(&x)
&& (outcrop.y..outcrop.y + OUTCROP_SIZE).contains(&y);
let is_rock = rock[(y as usize) * (size as usize) + (x as usize)];
let terrain = if on_border {
Terrain::Bedrock
} else if in_clearing {
Terrain::Ground
} else if in_outcrop {
Terrain::SoftRock
} else if !is_rock {
Terrain::Ground
} else if dx.abs().max(dy.abs()) >= HARD_ROCK_DISTANCE {
Terrain::HardRock
} else {
Terrain::SoftRock
};
map.set(Pos::new(x, y), Tile::new(terrain));
}
}
map
}
/// One pass of cave smoothing: a cell becomes rock when at least five of the
/// nine cells around it, itself included, are rock. Cells outside the grid
/// count as rock.
fn smooth(rock: &[bool], size: u32) -> Vec<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:
```rust
pub mod codec;
pub mod hash;
pub mod map;
pub mod mapgen;
pub mod rng;
```
- [ ] **Step 4: Test ausführen und Erfolg sehen**
Run: `cargo test -p grovewright-core --test mapgen`
Expected: PASS mit `test result: ok. 9 passed` (dauert etwa eine Sekunde)
- [ ] **Step 5: Alles prüfen**
Run:
```bash
cargo fmt --all -- --check && cargo clippy --workspace --all-targets -- -D warnings && cargo test --workspace
```
Expected: keine Ausgabe von rustfmt, keine Warnung von Clippy, alle Tests bestanden
- [ ] **Step 6: Commit und Hochladen**
```bash
git add core/src/mapgen.rs core/src/lib.rs core/tests/mapgen.rs
git commit -F - <<'EOF'
Generator für die Testkarte
Baut aus einer Startzahl eine 64 × 64 Felder große Karte mit Rand aus
Grundgestein, Lichtung am Startpunkt, Felsblock daneben, weichem Fels
innen und hartem Fels außen. Die Regeln sind für 300 Startzahlen geprüft.
Co-Authored-By: Claude Opus 5.5 <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`:
```rust
use grovewright_core::codec::{DecodeError, Reader, Writer};
use grovewright_core::inventory::{
Inventory, ItemId, Stack, INVENTORY_SLOTS, ITEM_STONE, STACK_LIMIT,
};
const OTHER: ItemId = ItemId(2);
const CAPACITY: u32 = STACK_LIMIT * INVENTORY_SLOTS as u32;
#[test]
fn a_new_inventory_is_empty() {
let inventory = Inventory::new();
assert_eq!(inventory.slots().len(), INVENTORY_SLOTS);
assert!(inventory.slots().iter().all(Option::is_none));
assert_eq!(inventory.count(ITEM_STONE), 0);
assert_eq!(inventory.free_space(ITEM_STONE), CAPACITY);
assert_eq!(Inventory::default(), inventory);
}
#[test]
fn add_tops_up_a_stack_before_using_an_empty_slot() {
let mut inventory = Inventory::new();
assert_eq!(inventory.add(ITEM_STONE, 90), 0);
assert_eq!(inventory.add(OTHER, 5), 0);
assert_eq!(inventory.add(ITEM_STONE, 20), 0);
let stone = |count| {
Some(Stack {
item: ITEM_STONE,
count,
})
};
assert_eq!(inventory.slots()[0], stone(99));
assert_eq!(
inventory.slots()[1],
Some(Stack {
item: OTHER,
count: 5
})
);
assert_eq!(inventory.slots()[2], stone(11));
assert_eq!(inventory.count(ITEM_STONE), 110);
assert_eq!(inventory.count(OTHER), 5);
}
#[test]
fn add_reports_what_does_not_fit() {
let mut inventory = Inventory::new();
assert_eq!(inventory.add(ITEM_STONE, CAPACITY + 7), 7);
assert_eq!(inventory.count(ITEM_STONE), CAPACITY);
assert_eq!(inventory.free_space(ITEM_STONE), 0);
assert_eq!(inventory.free_space(OTHER), 0);
assert_eq!(inventory.add(OTHER, 1), 1);
}
#[test]
fn free_space_counts_empty_slots_and_matching_stacks() {
let mut inventory = Inventory::new();
inventory.add(ITEM_STONE, 10);
inventory.add(OTHER, 1);
assert_eq!(
inventory.free_space(ITEM_STONE),
CAPACITY - STACK_LIMIT - 10
);
assert_eq!(inventory.free_space(OTHER), CAPACITY - STACK_LIMIT - 1);
}
#[test]
fn remove_takes_exactly_the_requested_amount() {
let mut inventory = Inventory::new();
inventory.add(ITEM_STONE, 150);
assert!(inventory.remove(ITEM_STONE, 60));
assert_eq!(inventory.count(ITEM_STONE), 90);
assert_eq!(
inventory.slots()[0],
Some(Stack {
item: ITEM_STONE,
count: 90
})
);
assert_eq!(inventory.slots()[1], None);
}
#[test]
fn remove_changes_nothing_when_too_few_are_present() {
let mut inventory = Inventory::new();
inventory.add(ITEM_STONE, 10);
let before = inventory.clone();
assert!(!inventory.remove(ITEM_STONE, 11));
assert!(!inventory.remove(OTHER, 1));
assert_eq!(inventory, before);
}
#[test]
#[should_panic(expected = "item id zero is reserved")]
fn the_reserved_item_id_cannot_be_added() {
Inventory::new().add(ItemId(0), 1);
}
#[test]
fn an_inventory_survives_encoding() {
let mut inventory = Inventory::new();
inventory.add(ITEM_STONE, 120);
inventory.add(OTHER, 3);
let mut w = Writer::new();
inventory.encode(&mut w);
let bytes = w.into_bytes();
let mut r = Reader::new(&bytes);
assert_eq!(Inventory::decode(&mut r), Ok(inventory));
assert_eq!(r.finish(), Ok(()));
}
#[test]
fn decoding_rejects_impossible_stack_counts() {
for count in [0, STACK_LIMIT + 1] {
let mut w = Writer::new();
w.u16(ITEM_STONE.0);
w.u32(count);
let bytes = w.into_bytes();
assert_eq!(
Inventory::decode(&mut Reader::new(&bytes)),
Err(DecodeError::InvalidValue("stack count")),
"count {count}"
);
}
}
```
- [ ] **Step 2: Test ausführen und Fehlschlag sehen**
Run: `cargo test -p grovewright-core --test inventory`
Expected: FAIL mit ``error[E0432]: unresolved import `grovewright_core::inventory` ``
- [ ] **Step 3: Inventar umsetzen**
Create `core/src/inventory.rs`:
```rust
//! Item stacks carried by a player.
use crate::codec::{DecodeError, Reader, Writer};
/// Identifies a kind of item. The value zero is reserved for empty slots.
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
pub struct ItemId(pub u16);
/// Stone, gained by breaking rock.
pub const ITEM_STONE: ItemId = ItemId(1);
/// Number of slots in an inventory.
pub const INVENTORY_SLOTS: usize = 24;
/// Largest number of items in one slot.
pub const STACK_LIMIT: u32 = 99;
/// Items of one kind in one slot.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Stack {
pub item: ItemId,
pub count: u32,
}
/// Fixed number of slots, each empty or holding one stack.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Inventory {
slots: [Option<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:
```rust
pub mod codec;
pub mod hash;
pub mod inventory;
pub mod map;
pub mod mapgen;
pub mod rng;
```
- [ ] **Step 4: Test ausführen und Erfolg sehen**
Run: `cargo test -p grovewright-core --test inventory`
Expected: PASS mit `test result: ok. 9 passed`
- [ ] **Step 5: Alles prüfen**
Run:
```bash
cargo fmt --all -- --check && cargo clippy --workspace --all-targets -- -D warnings && cargo test --workspace
```
Expected: keine Ausgabe von rustfmt, keine Warnung von Clippy, alle Tests bestanden
- [ ] **Step 6: Commit und Hochladen**
```bash
git add core/src/inventory.rs core/src/lib.rs core/tests/inventory.rs
git commit -F - <<'EOF'
Inventar mit Stapeln
24 Plätze mit je bis zu 99 Gegenständen. Hinzufügen füllt zuerst
vorhandene Stapel und meldet, was nicht passt; Entfernen nimmt alles
oder nichts.
Co-Authored-By: Claude Opus 5.5 <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`:
```rust
use grovewright_core::codec::{DecodeError, Reader, Writer};
use grovewright_core::inventory::ITEM_STONE;
use grovewright_core::player::{clamp_name, Player, MAX_NAME_BYTES, STARTING_TOOL_TIER};
#[test]
fn a_new_player_is_online_with_a_stone_pickaxe_and_empty_pockets() {
let player = Player::new([5; 16], "Ada");
assert_eq!(player.key, [5; 16]);
assert_eq!(player.name, "Ada");
assert!(player.online);
assert_eq!(player.tool_tier, STARTING_TOOL_TIER);
assert_eq!(player.next_action_tick, 0);
assert!(player.inventory.slots().iter().all(Option::is_none));
}
#[test]
fn long_names_are_cut_at_a_character_boundary() {
// "ä" takes two bytes, so twenty of them are forty bytes.
let long = "ä".repeat(20);
let clamped = clamp_name(&long);
assert_eq!(clamped, "ä".repeat(16));
assert_eq!(clamped.len(), MAX_NAME_BYTES);
// With one byte in front, the limit falls into the middle of a character.
let odd = format!("a{}", "ä".repeat(20));
assert_eq!(clamp_name(&odd), format!("a{}", "ä".repeat(15)));
assert_eq!(clamp_name("Ada"), "Ada");
assert_eq!(Player::new([0; 16], &long).name, clamped);
}
#[test]
fn a_player_survives_encoding() {
let mut player = Player::new([9; 16], "Grace");
player.online = false;
player.next_action_tick = 1234;
player.inventory.add(ITEM_STONE, 17);
let mut w = Writer::new();
player.encode(&mut w);
let bytes = w.into_bytes();
let mut r = Reader::new(&bytes);
assert_eq!(Player::decode(&mut r), Ok(player));
assert_eq!(r.finish(), Ok(()));
}
#[test]
fn decoding_rejects_an_overlong_name() {
let mut w = Writer::new();
w.raw(&[0; 16]);
w.str(&"x".repeat(MAX_NAME_BYTES + 1));
let bytes = w.into_bytes();
assert_eq!(
Player::decode(&mut Reader::new(&bytes)),
Err(DecodeError::InvalidValue("player name"))
);
}
```
Create `core/tests/command.rs`:
```rust
use grovewright_core::codec::{DecodeError, Reader, Writer};
use grovewright_core::command::Command;
use grovewright_core::map::Pos;
use grovewright_core::player::{PlayerId, MAX_NAME_BYTES};
fn round_trip(command: &Command) -> Command {
let mut w = Writer::new();
command.encode(&mut w);
let bytes = w.into_bytes();
let mut r = Reader::new(&bytes);
let decoded = Command::decode(&mut r).expect("command decodes");
r.finish().expect("no bytes are left over");
decoded
}
#[test]
fn every_command_survives_encoding() {
let commands = [
Command::Join {
key: [7; 16],
name: "Ada".to_owned(),
},
Command::Leave {
player: PlayerId(3),
},
Command::Dig {
player: PlayerId(1),
pos: Pos::new(-4, 1_000),
},
];
for command in &commands {
assert_eq!(&round_trip(command), command);
}
}
#[test]
fn an_overlong_name_is_shortened_when_encoded() {
let command = Command::Join {
key: [1; 16],
name: "x".repeat(MAX_NAME_BYTES + 10),
};
assert_eq!(
round_trip(&command),
Command::Join {
key: [1; 16],
name: "x".repeat(MAX_NAME_BYTES),
}
);
}
#[test]
fn an_unknown_command_is_rejected() {
assert_eq!(
Command::decode(&mut Reader::new(&[0xEE])),
Err(DecodeError::InvalidValue("command tag"))
);
}
#[test]
fn a_cut_off_command_is_rejected() {
let mut w = Writer::new();
Command::Dig {
player: PlayerId(0),
pos: Pos::new(1, 2),
}
.encode(&mut w);
let bytes = w.into_bytes();
assert_eq!(
Command::decode(&mut Reader::new(&bytes[..bytes.len() - 1])),
Err(DecodeError::UnexpectedEnd)
);
}
#[test]
fn several_commands_can_follow_each_other_in_one_buffer() {
let commands = vec![
Command::Leave {
player: PlayerId(2),
},
Command::Dig {
player: PlayerId(0),
pos: Pos::new(5, 6),
},
];
let mut w = Writer::new();
for command in &commands {
command.encode(&mut w);
}
let bytes = w.into_bytes();
let mut r = Reader::new(&bytes);
let mut decoded = Vec::new();
while r.remaining() > 0 {
decoded.push(Command::decode(&mut r).unwrap());
}
assert_eq!(decoded, commands);
}
```
- [ ] **Step 2: Tests ausführen und Fehlschlag sehen**
Run: `cargo test -p grovewright-core --test player --test command`
Expected: FAIL mit ``error[E0432]: unresolved import `grovewright_core::player` `` und ``unresolved import `grovewright_core::command` ``
- [ ] **Step 3: Spieler und Befehle umsetzen**
Create `core/src/player.rs`:
```rust
//! Players known to a world.
use crate::codec::{DecodeError, Reader, Writer};
use crate::inventory::Inventory;
/// Stable identity of a player across sessions, chosen by the player's game.
pub type PlayerKey = [u8; 16];
/// Index of a player in the world's player list.
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
pub struct PlayerId(pub u8);
/// Most players that can be online at the same time.
pub const MAX_ONLINE_PLAYERS: usize = 4;
/// Most players a world remembers.
pub const MAX_KNOWN_PLAYERS: usize = 16;
/// Longest stored player name, in bytes of UTF-8.
pub const MAX_NAME_BYTES: usize = 32;
/// Pickaxe tier every player starts with: stone.
pub const STARTING_TOOL_TIER: u8 = 1;
/// A player and everything that belongs to them personally.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Player {
pub key: PlayerKey,
pub name: String,
pub online: bool,
pub tool_tier: u8,
/// First tick at which the player may act again.
pub next_action_tick: u64,
pub inventory: Inventory,
}
impl Player {
/// Creates an online player with a stone pickaxe and an empty inventory.
pub fn new(key: PlayerKey, name: &str) -> Self {
Self {
key,
name: clamp_name(name),
online: true,
tool_tier: STARTING_TOOL_TIER,
next_action_tick: 0,
inventory: Inventory::new(),
}
}
pub fn encode(&self, w: &mut Writer) {
w.raw(&self.key);
w.str(&self.name);
w.bool(self.online);
w.u8(self.tool_tier);
w.u64(self.next_action_tick);
self.inventory.encode(w);
}
pub fn decode(r: &mut Reader<'_>) -> Result<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`:
```rust
//! Everything a player can ask the world to do.
use crate::codec::{DecodeError, Reader, Writer};
use crate::map::Pos;
use crate::player::{clamp_name, PlayerId, PlayerKey, MAX_NAME_BYTES};
/// A request that changes the world. Commands are the only way to change it.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum Command {
/// A player enters the world, new or returning.
Join { key: PlayerKey, name: String },
/// A player leaves the world. Their inventory stays.
Leave { player: PlayerId },
/// A player hits the tile at `pos` with their pickaxe.
Dig { player: PlayerId, pos: Pos },
}
const TAG_JOIN: u8 = 1;
const TAG_LEAVE: u8 = 2;
const TAG_DIG: u8 = 3;
impl Command {
pub fn encode(&self, w: &mut Writer) {
match self {
Command::Join { key, name } => {
w.u8(TAG_JOIN);
w.raw(key);
w.str(&clamp_name(name));
}
Command::Leave { player } => {
w.u8(TAG_LEAVE);
w.u8(player.0);
}
Command::Dig { player, pos } => {
w.u8(TAG_DIG);
w.u8(player.0);
w.i32(pos.x);
w.i32(pos.y);
}
}
}
pub fn decode(r: &mut Reader<'_>) -> Result<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:
```rust
pub mod codec;
pub mod command;
pub mod hash;
pub mod inventory;
pub mod map;
pub mod mapgen;
pub mod player;
pub mod rng;
```
- [ ] **Step 4: Tests ausführen und Erfolg sehen**
Run: `cargo test -p grovewright-core --test player --test command`
Expected: PASS mit `test result: ok. 4 passed` für `player` und `test result: ok. 5 passed` für `command`
- [ ] **Step 5: Alles prüfen**
Run:
```bash
cargo fmt --all -- --check && cargo clippy --workspace --all-targets -- -D warnings && cargo test --workspace
```
Expected: keine Ausgabe von rustfmt, keine Warnung von Clippy, alle Tests bestanden
- [ ] **Step 6: Commit und Hochladen**
```bash
git add core/src/player.rs core/src/command.rs core/src/lib.rs core/tests/player.rs core/tests/command.rs
git commit -F - <<'EOF'
Spieler und Befehle
Ein Spieler hat eine dauerhafte Kennung, einen Namen, eine
Werkzeugstufe und ein Inventar. Befehle (Beitreten, Verlassen, Graben)
sind der einzige Weg, die Welt zu ändern, und lassen sich kodieren.
Co-Authored-By: Claude Opus 5.5 <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`:
```rust
use grovewright_core::command::Command;
use grovewright_core::inventory::ITEM_STONE;
use grovewright_core::map::{Pos, Terrain};
use grovewright_core::mapgen::{outcrop_origin, spawn_pos};
use grovewright_core::player::{PlayerId, PlayerKey, MAX_KNOWN_PLAYERS, MAX_ONLINE_PLAYERS};
use grovewright_core::world::{Event, World, DIG_COOLDOWN_TICKS, TICKS_PER_SECOND};
fn key(n: u8) -> PlayerKey {
[n; 16]
}
fn join(n: u8) -> Command {
Command::Join {
key: key(n),
name: format!("Spieler {n}"),
}
}
/// A world with one online player, and a soft rock tile to dig at.
fn world_with_player() -> (World, PlayerId, Pos) {
let mut world = World::new(42);
world.step(&[join(1)]);
let rock = outcrop_origin(world.map());
(world, PlayerId(0), rock)
}
/// Steps without commands until a player who just hit may hit again.
fn wait_for_cooldown(world: &mut World) {
for _ in 0..DIG_COOLDOWN_TICKS {
world.step(&[]);
}
}
#[test]
fn a_new_world_starts_at_tick_zero_without_players() {
let world = World::new(42);
assert_eq!(world.seed(), 42);
assert_eq!(world.tick(), 0);
assert!(world.players().is_empty());
assert_eq!(TICKS_PER_SECOND, 20);
}
#[test]
fn step_advances_the_tick() {
let mut world = World::new(1);
assert!(world.step(&[]).is_empty());
assert_eq!(world.tick(), 1);
}
#[test]
fn joining_adds_an_online_player() {
let mut world = World::new(1);
let events = world.step(&[join(7)]);
assert_eq!(
events,
vec![Event::PlayerJoined {
player: PlayerId(0)
}]
);
let player = world.player(PlayerId(0)).unwrap();
assert!(player.online);
assert_eq!(player.name, "Spieler 7");
assert_eq!(world.find_player(&key(7)), Some(PlayerId(0)));
assert_eq!(world.find_player(&key(8)), None);
assert!(world.player(PlayerId(1)).is_none());
}
#[test]
fn joining_twice_changes_nothing() {
let mut world = World::new(1);
world.step(&[join(7)]);
assert!(world.step(&[join(7)]).is_empty());
assert_eq!(world.players().len(), 1);
}
#[test]
fn leaving_and_returning_keeps_the_inventory() {
let (mut world, id, rock) = world_with_player();
for _ in 0..3 {
world.step(&[Command::Dig {
player: id,
pos: rock,
}]);
wait_for_cooldown(&mut world);
}
assert_eq!(world.player(id).unwrap().inventory.count(ITEM_STONE), 1);
assert_eq!(
world.step(&[Command::Leave { player: id }]),
vec![Event::PlayerLeft { player: id }]
);
assert!(!world.player(id).unwrap().online);
assert!(world.step(&[Command::Leave { player: id }]).is_empty());
assert_eq!(
world.step(&[join(1)]),
vec![Event::PlayerJoined { player: id }]
);
assert_eq!(world.players().len(), 1);
assert!(world.player(id).unwrap().online);
assert_eq!(world.player(id).unwrap().inventory.count(ITEM_STONE), 1);
}
#[test]
fn a_returning_player_can_change_their_name() {
let (mut world, id, _) = world_with_player();
world.step(&[Command::Leave { player: id }]);
world.step(&[Command::Join {
key: key(1),
name: "Neuer Name".to_owned(),
}]);
assert_eq!(world.player(id).unwrap().name, "Neuer Name");
}
#[test]
fn a_fifth_online_player_is_refused() {
let mut world = World::new(1);
for n in 0..MAX_ONLINE_PLAYERS as u8 {
world.step(&[join(n)]);
}
assert_eq!(
world.step(&[join(99)]),
vec![Event::JoinRefused { key: key(99) }]
);
assert_eq!(world.players().len(), MAX_ONLINE_PLAYERS);
// A known player who is offline is refused as well while the world is full.
world.step(&[Command::Leave {
player: PlayerId(0),
}]);
world.step(&[join(50)]);
assert_eq!(
world.step(&[join(0)]),
vec![Event::JoinRefused { key: key(0) }]
);
}
#[test]
fn the_world_remembers_a_limited_number_of_players() {
let mut world = World::new(1);
for n in 0..MAX_KNOWN_PLAYERS as u8 {
world.step(&[join(n)]);
world.step(&[Command::Leave {
player: PlayerId(n),
}]);
}
assert_eq!(world.players().len(), MAX_KNOWN_PLAYERS);
assert_eq!(
world.step(&[join(200)]),
vec![Event::JoinRefused { key: key(200) }]
);
assert_eq!(world.players().len(), MAX_KNOWN_PLAYERS);
}
#[test]
fn set_all_offline_marks_every_player_offline() {
let mut world = World::new(1);
world.step(&[join(1), join(2)]);
world.set_all_offline();
assert!(world.players().iter().all(|player| !player.online));
}
#[test]
fn three_hits_break_soft_rock_and_give_one_stone() {
let (mut world, id, rock) = world_with_player();
let dig = [Command::Dig {
player: id,
pos: rock,
}];
let hit = vec![Event::TileHit {
player: id,
pos: rock,
}];
assert_eq!(world.step(&dig), hit);
assert_eq!(world.map().get(rock).unwrap().damage, 1);
wait_for_cooldown(&mut world);
assert_eq!(world.step(&dig), hit);
assert_eq!(world.map().get(rock).unwrap().damage, 2);
wait_for_cooldown(&mut world);
assert_eq!(
world.step(&dig),
vec![
Event::TileChanged { pos: rock },
Event::ItemGained {
player: id,
item: ITEM_STONE,
count: 1
}
]
);
let tile = world.map().get(rock).unwrap();
assert_eq!((tile.terrain, tile.damage), (Terrain::Ground, 0));
assert_eq!(world.player(id).unwrap().inventory.count(ITEM_STONE), 1);
}
#[test]
fn hits_during_the_cooldown_are_ignored() {
let (mut world, id, rock) = world_with_player();
let dig = [Command::Dig {
player: id,
pos: rock,
}];
world.step(&dig);
for _ in 1..DIG_COOLDOWN_TICKS {
assert!(world.step(&dig).is_empty());
}
assert_eq!(world.map().get(rock).unwrap().damage, 1);
assert_eq!(world.step(&dig).len(), 1);
assert_eq!(world.map().get(rock).unwrap().damage, 2);
}
#[test]
fn two_hits_in_one_tick_count_once() {
let (mut world, id, rock) = world_with_player();
let dig = Command::Dig {
player: id,
pos: rock,
};
let twice = [dig.clone(), dig];
assert_eq!(world.step(&twice).len(), 1);
assert_eq!(world.map().get(rock).unwrap().damage, 1);
}
#[test]
fn ground_bedrock_and_positions_off_the_map_cannot_be_dug() {
let (mut world, id, _) = world_with_player();
let spawn = spawn_pos(world.map());
for pos in [
spawn,
Pos::new(0, 0),
Pos::new(-5, 3),
Pos::new(10_000, 10_000),
] {
let before = world.map().clone();
let events = world.step(&[Command::Dig { player: id, pos }]);
assert!(events.is_empty(), "{pos:?}");
assert_eq!(world.map(), &before, "{pos:?}");
}
}
#[test]
fn hard_rock_resists_the_stone_pickaxe() {
let (mut world, id, _) = world_with_player();
let width = world.map().width() as usize;
let index = world
.map()
.tiles()
.iter()
.position(|tile| tile.terrain == Terrain::HardRock)
.expect("the test map has hard rock");
let pos = Pos::new((index % width) as i32, (index / width) as i32);
assert!(world.step(&[Command::Dig { player: id, pos }]).is_empty());
assert_eq!(world.map().get(pos).unwrap().damage, 0);
}
#[test]
fn unknown_and_offline_players_cannot_dig() {
let (mut world, id, rock) = world_with_player();
assert!(world
.step(&[Command::Dig {
player: PlayerId(9),
pos: rock
}])
.is_empty());
world.step(&[Command::Leave { player: id }]);
assert!(world
.step(&[Command::Dig {
player: id,
pos: rock
}])
.is_empty());
assert_eq!(world.map().get(rock).unwrap().damage, 0);
}
#[test]
fn commands_of_one_tick_are_applied_in_order() {
let (mut world, id, rock) = world_with_player();
// Leaving first makes the hit in the same tick invalid.
let events = world.step(&[
Command::Leave { player: id },
Command::Dig {
player: id,
pos: rock,
},
]);
assert_eq!(events, vec![Event::PlayerLeft { player: id }]);
assert_eq!(world.map().get(rock).unwrap().damage, 0);
}
```
- [ ] **Step 2: Test ausführen und Fehlschlag sehen**
Run: `cargo test -p grovewright-core --test world`
Expected: FAIL mit ``error[E0432]: unresolved import `grovewright_core::world` ``
- [ ] **Step 3: Welt umsetzen**
Create `core/src/world.rs`. Die Datei endet mit einem Modultest. Er steht hier und nicht in `core/tests/world.rs`, weil er das Inventar eines Spielers direkt füllen muss, was die öffentliche Schnittstelle nicht erlaubt.
```rust
//! The shared world and the rules that change it.
use crate::command::Command;
use crate::inventory::{ItemId, ITEM_STONE};
use crate::map::{Map, Pos, Terrain, Tile};
use crate::mapgen::generate_test_map;
use crate::player::{
clamp_name, Player, PlayerId, PlayerKey, MAX_KNOWN_PLAYERS, MAX_ONLINE_PLAYERS,
};
/// Simulation steps per second of play.
pub const TICKS_PER_SECOND: u32 = 20;
/// Ticks a player has to wait between two pickaxe hits.
pub const DIG_COOLDOWN_TICKS: u64 = 5;
/// Something that happened during a tick. Events tell the presentation what
/// to show. They are not part of the world state.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum Event {
PlayerJoined {
player: PlayerId,
},
PlayerLeft {
player: PlayerId,
},
/// A player could not join because the world is full.
JoinRefused {
key: PlayerKey,
},
/// A pickaxe hit landed without breaking the tile.
TileHit {
player: PlayerId,
pos: Pos,
},
/// A tile changed its terrain.
TileChanged {
pos: Pos,
},
ItemGained {
player: PlayerId,
item: ItemId,
count: u32,
},
/// A hit was refused because the inventory has no room for the result.
InventoryFull {
player: PlayerId,
},
}
/// Complete state of the shared world.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct World {
pub(crate) seed: u64,
pub(crate) tick: u64,
pub(crate) map: Map,
pub(crate) players: Vec<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:
```rust
pub mod codec;
pub mod command;
pub mod hash;
pub mod inventory;
pub mod map;
pub mod mapgen;
pub mod player;
pub mod rng;
pub mod world;
```
- [ ] **Step 4: Tests ausführen und Erfolg sehen**
Run: `cargo test -p grovewright-core --test world`
Expected: PASS mit `test result: ok. 16 passed`
Run: `cargo test -p grovewright-core --lib`
Expected: PASS mit `test world::tests::a_full_inventory_refuses_the_breaking_hit ... ok`
- [ ] **Step 5: Alles prüfen**
Run:
```bash
cargo fmt --all -- --check && cargo clippy --workspace --all-targets -- -D warnings && cargo test --workspace
```
Expected: keine Ausgabe von rustfmt, keine Warnung von Clippy, alle Tests bestanden
- [ ] **Step 6: Commit und Hochladen**
```bash
git add core/src/world.rs core/src/lib.rs core/tests/world.rs
git commit -F - <<'EOF'
Welt und ihre Regeln
World::step wendet die Befehle eines Takts der Reihe nach an: Spieler
treten bei und verlassen die Welt, drei Schläge brechen weichen Fels
und bringen einen Stein. Abgelehnte Befehle ändern nichts.
Co-Authored-By: Claude Opus 5.5 <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`:
```rust
use grovewright_core::codec::DecodeError;
use grovewright_core::command::Command;
use grovewright_core::map::Pos;
use grovewright_core::mapgen::outcrop_origin;
use grovewright_core::player::PlayerId;
use grovewright_core::snapshot::{SnapshotError, SNAPSHOT_VERSION};
use grovewright_core::world::World;
const ADA: PlayerId = PlayerId(0);
fn rock(world: &World) -> Pos {
outcrop_origin(world.map())
}
/// A world at tick 12 with one player who has hit a rock once.
fn played_world() -> World {
let mut world = World::new(7);
world.step(&[Command::Join {
key: [3; 16],
name: "Ada".to_owned(),
}]);
let pos = rock(&world);
world.step(&[Command::Dig { player: ADA, pos }]);
for _ in 0..10 {
world.step(&[]);
}
world
}
#[test]
fn a_snapshot_restores_an_identical_world() {
let world = played_world();
let restored = World::from_snapshot(&world.to_snapshot()).unwrap();
assert_eq!(restored, world);
assert_eq!(restored.state_hash(), world.state_hash());
}
#[test]
fn the_state_hash_covers_every_part_of_the_state() {
let base = played_world();
let pos = rock(&base);
let mut waited = base.clone();
waited.step(&[]);
assert_ne!(waited.state_hash(), base.state_hash(), "tick");
let mut dug = base.clone();
dug.step(&[Command::Dig { player: ADA, pos }]);
assert_ne!(dug.state_hash(), waited.state_hash(), "map and cooldown");
let mut left = base.clone();
left.step(&[Command::Leave { player: ADA }]);
assert_ne!(left.state_hash(), waited.state_hash(), "players");
assert_ne!(
World::new(1).state_hash(),
World::new(2).state_hash(),
"seed"
);
}
#[test]
fn equal_worlds_have_equal_hashes() {
assert_eq!(played_world().state_hash(), played_world().state_hash());
}
#[test]
fn a_restored_world_continues_exactly_like_the_original() {
let mut original = played_world();
let mut restored = World::from_snapshot(&original.to_snapshot()).unwrap();
let pos = rock(&original);
for tick in 0..40u32 {
let commands = if tick.is_multiple_of(6) {
vec![Command::Dig { player: ADA, pos }]
} else {
vec![]
};
assert_eq!(original.step(&commands), restored.step(&commands));
}
assert_eq!(original.state_hash(), restored.state_hash());
assert_eq!(original, restored);
}
#[test]
fn bytes_that_are_not_a_snapshot_are_rejected() {
assert_eq!(World::from_snapshot(b""), Err(SnapshotError::NotASnapshot));
assert_eq!(
World::from_snapshot(b"GR"),
Err(SnapshotError::NotASnapshot)
);
assert_eq!(
World::from_snapshot(b"nope, not a save"),
Err(SnapshotError::NotASnapshot)
);
}
#[test]
fn snapshots_of_another_version_are_rejected() {
let mut bytes = played_world().to_snapshot();
bytes[4..8].copy_from_slice(&(SNAPSHOT_VERSION + 1).to_le_bytes());
assert_eq!(
World::from_snapshot(&bytes),
Err(SnapshotError::UnsupportedVersion(SNAPSHOT_VERSION + 1))
);
}
#[test]
fn cut_off_and_padded_snapshots_are_rejected() {
let bytes = played_world().to_snapshot();
assert_eq!(
World::from_snapshot(&bytes[..bytes.len() - 1]),
Err(SnapshotError::Corrupt(DecodeError::UnexpectedEnd))
);
let mut padded = bytes.clone();
padded.push(0);
assert_eq!(
World::from_snapshot(&padded),
Err(SnapshotError::Corrupt(DecodeError::TrailingBytes))
);
}
```
- [ ] **Step 2: Test ausführen und Fehlschlag sehen**
Run: `cargo test -p grovewright-core --test snapshot`
Expected: FAIL mit ``error[E0432]: unresolved import `grovewright_core::snapshot` ``
- [ ] **Step 3: Snapshot umsetzen**
Create `core/src/snapshot.rs`:
```rust
//! Complete, versioned encoding of a world. Saves, joining players and state
//! checksums all use it.
use std::fmt;
use crate::codec::{DecodeError, Reader, Writer};
use crate::hash::fnv1a64;
use crate::map::Map;
use crate::player::{Player, MAX_KNOWN_PLAYERS};
use crate::world::World;
const MAGIC: [u8; 4] = *b"GRWS";
/// Version of the snapshot format. Raise it whenever the encoded state
/// changes.
pub const SNAPSHOT_VERSION: u32 = 1;
/// Reason a snapshot could not be restored.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum SnapshotError {
/// The bytes do not start with the snapshot marker.
NotASnapshot,
/// The snapshot was written by an incompatible version of the game.
UnsupportedVersion(u32),
/// The snapshot is damaged.
Corrupt(DecodeError),
}
impl fmt::Display for SnapshotError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
SnapshotError::NotASnapshot => write!(f, "not a snapshot"),
SnapshotError::UnsupportedVersion(version) => {
write!(f, "unsupported snapshot version {version}")
}
SnapshotError::Corrupt(error) => write!(f, "damaged snapshot: {error}"),
}
}
}
impl std::error::Error for SnapshotError {}
impl From<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:
```rust
pub mod codec;
pub mod command;
pub mod hash;
pub mod inventory;
pub mod map;
pub mod mapgen;
pub mod player;
pub mod rng;
pub mod snapshot;
pub mod world;
```
- [ ] **Step 4: Test ausführen und Erfolg sehen**
Run: `cargo test -p grovewright-core --test snapshot`
Expected: PASS mit `test result: ok. 7 passed`
- [ ] **Step 5: Konzept korrigieren**
Das Konzept beschreibt den Speicherstand als Startzahl plus Änderungen. Umgesetzt ist der vollständige Zustand, weil ein solcher Speicherstand gültig bleibt, wenn sich der Kartengenerator zwischen zwei Versionen ändert.
Ersetze in `docs/KONZEPT.md` im Abschnitt 7.3 diese Zeile:
```markdown
- **Speicherstand.** Er hält Startzahl und Änderungen fest. Ein Gast bekommt ihn beim Beitritt geschickt.
```
durch:
```markdown
- **Speicherstand.** Er enthält den vollständigen Zustand der Welt samt Startzahl und bleibt deshalb gültig, wenn sich der Kartengenerator später ändert. Ein Gast bekommt ihn beim Beitritt geschickt.
```
- [ ] **Step 6: Alles prüfen**
Run:
```bash
cargo fmt --all -- --check && cargo clippy --workspace --all-targets -- -D warnings && cargo test --workspace
```
Expected: keine Ausgabe von rustfmt, keine Warnung von Clippy, alle Tests bestanden
- [ ] **Step 7: Commit und Hochladen**
```bash
git add core/src/snapshot.rs core/src/lib.rs core/tests/snapshot.rs docs/KONZEPT.md
git commit -F - <<'EOF'
Snapshot und Zustandsprüfsumme
Der Snapshot enthält den vollständigen Zustand der Welt hinter einem
Kopf mit Format und Version. Er dient als Speicherstand und als
Grundlage der Prüfsumme. Das Konzept ist entsprechend korrigiert: Der
Speicherstand ist der vollständige Zustand, nicht Startzahl plus
Änderungen.
Co-Authored-By: Claude Opus 5.5 <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`:
```rust
use grovewright_core::codec::DecodeError;
use grovewright_core::command::Command;
use grovewright_core::inventory::ITEM_STONE;
use grovewright_core::mapgen::outcrop_origin;
use grovewright_core::player::PlayerId;
use grovewright_core::replay::{Recording, RecordingError, RECORDING_VERSION};
use grovewright_core::world::World;
const ADA: PlayerId = PlayerId(0);
/// Commands of tick `tick` in a short scripted session.
fn commands_for(world: &World, tick: u32) -> Vec<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`:
```rust
//! Recording of a session: the starting state and every command, tick by
//! tick. Replaying a recording reproduces the session exactly.
use std::fmt;
use crate::codec::{DecodeError, Reader, Writer};
use crate::command::Command;
use crate::snapshot::SnapshotError;
use crate::world::World;
const MAGIC: [u8; 4] = *b"GRWR";
/// Version of the recording format.
pub const RECORDING_VERSION: u32 = 1;
/// Reason a recording could not be read or replayed.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum RecordingError {
/// The bytes do not start with the recording marker.
NotARecording,
/// The recording was written by an incompatible version of the game.
UnsupportedVersion(u32),
/// The recording is damaged.
Corrupt(DecodeError),
/// The starting state inside the recording could not be restored.
BadStart(SnapshotError),
/// Replaying did not end in the recorded state.
Diverged { expected: u64, actual: u64 },
}
impl fmt::Display for RecordingError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
RecordingError::NotARecording => write!(f, "not a recording"),
RecordingError::UnsupportedVersion(version) => {
write!(f, "unsupported recording version {version}")
}
RecordingError::Corrupt(error) => write!(f, "damaged recording: {error}"),
RecordingError::BadStart(error) => write!(f, "unusable starting state: {error}"),
RecordingError::Diverged { expected, actual } => write!(
f,
"replay ended in state {actual:016x} instead of {expected:016x}"
),
}
}
}
impl std::error::Error for RecordingError {}
impl From<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:
```rust
//! Deterministic core of Grovewright.
//!
//! Everything that belongs to the shared world lives here. The core never
//! touches the engine, the clock, files or the network, and it calculates
//! only with whole numbers. Given the same commands it produces the same
//! world on every machine. That is what lets every player simulate the whole
//! world and exchange nothing but commands.
pub mod codec;
pub mod command;
pub mod hash;
pub mod inventory;
pub mod map;
pub mod mapgen;
pub mod player;
pub mod replay;
pub mod rng;
pub mod snapshot;
pub mod world;
```
- [ ] **Step 4: Test ausführen und Erfolg sehen**
Run: `cargo test -p grovewright-core --test replay`
Expected: PASS mit `test result: ok. 7 passed`
- [ ] **Step 5: Alles prüfen**
Run:
```bash
cargo fmt --all -- --check && cargo clippy --workspace --all-targets -- -D warnings && cargo test --workspace
```
Expected: keine Ausgabe von rustfmt, keine Warnung von Clippy, alle Tests bestanden
- [ ] **Step 6: Commit und Hochladen**
```bash
git add core/src/replay.rs core/src/lib.rs core/tests/replay.rs
git commit -F - <<'EOF'
Aufzeichnung und Wiederholung von Sitzungen
Eine Aufzeichnung hält den Startzustand und die Befehle jedes Takts
fest. Wiederholen ergibt exakt dieselbe Welt; verify meldet, wenn das
Ende von der aufgezeichneten Prüfsumme abweicht.
Co-Authored-By: Claude Opus 5.5 <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`:
```rust
//! The central promise of the core: the same commands always produce the
//! same world.
use grovewright_core::codec::{Reader, Writer};
use grovewright_core::command::Command;
use grovewright_core::inventory::ITEM_STONE;
use grovewright_core::map::Pos;
use grovewright_core::mapgen::TEST_MAP_SIZE;
use grovewright_core::player::PlayerId;
use grovewright_core::replay::Recording;
use grovewright_core::rng::Rng;
use grovewright_core::world::World;
/// Edge length of the square around the map centre in which the script digs.
const DIG_AREA: u32 = 24;
/// Builds a busy, repeatable command stream: four players join, dig at
/// random places near the centre, and now and then leave and return.
fn script(seed: u64, ticks: usize) -> Vec<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
```rust
w.u64(self.tick);
```
zu
```rust
w.u64(0);
```
Damit fehlt der Takt in Snapshot und Prüfsumme. Eine wiederhergestellte Welt beginnt dann bei Takt 0, und die Wartezeiten der Spieler laufen anders ab.
Run: `cargo test -p grovewright-core --test determinism`
Expected: FAIL in `a_world_restored_in_the_middle_of_a_session_stays_in_step`
Mache die Änderung rückgängig.
Run: `git status --short`
Expected: genau eine Zeile, `?? core/tests/determinism.rs`. Die Datei `core/src/snapshot.rs` taucht nicht auf.
- [ ] **Step 4: Alles prüfen, auch im Release-Modus**
Run:
```bash
cargo fmt --all -- --check && cargo clippy --workspace --all-targets -- -D warnings && cargo test --workspace && cargo test --workspace --release
```
Expected: keine Ausgabe von rustfmt, keine Warnung von Clippy, in beiden Testläufen zusammen je 87 bestandene Tests und kein fehlgeschlagener
- [ ] **Step 5: Commit und Hochladen**
```bash
git add core/tests/determinism.rs
git commit -F - <<'EOF'
Abnahmetests für den Gleichschritt
Fünf Tests prüfen über tausende Takte mit vier Spielern, dass gleiche
Befehle dieselbe Welt ergeben: im direkten Vergleich, nach einem
Snapshot mitten in der Sitzung, beim Wiederholen einer Aufzeichnung
und nach dem Weg über die Kodierung.
Co-Authored-By: Claude Opus 5.5 <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`