M1a: deterministischer Rust-Kern #3

Merged
steven merged 15 commits from m1a-rust-kern into main 2026-10-01 06:03:36 +00:00
8 changed files with 171 additions and 0 deletions
Showing only changes of commit 477b9a41dd - Show all commits
+2
View File
@@ -0,0 +1,2 @@
# Same line endings on every machine.
* text=auto eol=lf
+2
View File
@@ -0,0 +1,2 @@
# Rust
/target/
+54
View File
@@ -0,0 +1,54 @@
# Grovewright
Aufbauspiel für ein bis vier Spieler, in dem die Fabrik lebt. Gebaut wird zuerst eine Vertical Slice mit 2–3 Stunden Spielzeit.
Das verbindliche Konzept steht in [docs/KONZEPT.md](docs/KONZEPT.md). Lies es, bevor du Spielregeln, Architektur oder Umfang änderst. Was dort nicht steht, ist nicht beschlossen.
## Aufbau
| Ordner | Inhalt |
|---|---|
| `core/` | Rust-Kern: die gesamte gemeinsame Welt. Ohne Zugriff auf die Engine, ohne fremde Crates. |
| `docs/KONZEPT.md` | Spielkonzept und Entscheidungen |
| `docs/superpowers/plans/` | Umsetzungspläne |
Die Brücke zu Godot (`bridge/`), das Godot-Projekt (`game/`) und die Inhaltsdateien (`content/`) kommen mit späteren Plänen dazu.
Faustregel: Was im Speicherstand steht oder alle Spieler betrifft, gehört in den Kern.
## Regeln für den Kern
Alle Spieler simulieren die Welt selbst und tauschen nur Befehle aus (Gleichschritt). Das funktioniert nur, wenn der Kern auf jedem Rechner exakt gleich rechnet. Deshalb gilt in `core/`:
- Nur ganze Zahlen, keine Gleitkommazahlen.
- Keine Uhr, keine Dateien, kein Netzwerk, keine Threads, keine Umgebungsvariablen.
- Zufall nur über `rng::Rng` mit fester Startzahl.
- Keine Sammlung mit wechselnder Reihenfolge. Statt einer Hash-Tabelle nimmst du `Vec`, `BTreeMap` oder `BTreeSet`.
- Die Welt ändert sich nur durch einen `Command` in `World::step`.
- Alles, was zum Zustand der Welt gehört, steht im Snapshot. Wer ein Feld ergänzt, ergänzt es in `snapshot.rs` und erhöht `SNAPSHOT_VERSION`.
- Wo eine Rechnung überlaufen kann, steht eine ausdrückliche Wahl: `saturating_`, `wrapping_` oder `checked_`.
- Keine fremden Crates im Kern.
Der Test `core/tests/forbidden_apis.rs` durchsucht den Quelltext des Kerns nach verbotenen Begriffen. Sie dürfen dort auch in Kommentaren nicht vorkommen.
## Prüfen
Vor jedem Commit müssen alle drei Befehle fehlerfrei laufen:
```bash
cargo fmt --all -- --check
cargo clippy --workspace --all-targets -- -D warnings
cargo test --workspace
```
## Sprache
- Code, Bezeichner und Kommentare im Code: Englisch.
- Dokumente, Pläne, Commit-Nachrichten, Issues und Pull Requests: Deutsch.
- Texte im Spiel: Deutsch und Englisch über Übersetzungsdateien.
## Git
- Archiv: `https://git.race-cave.cloud/steven/Grovewright.git` (Gitea).
- Gearbeitet wird auf einem Branch pro Plan, nicht direkt auf `main`.
- Nach jedem abgeschlossenen Arbeitsschritt wird committet und hochgeladen. Das ist freigegeben.
Generated
+7
View File
@@ -0,0 +1,7 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "grovewright-core"
version = "0.1.0"
+8
View File
@@ -0,0 +1,8 @@
[workspace]
resolver = "2"
members = ["core"]
# The shipped game and the tests must agree on what happens when a number
# overflows, so release builds keep the overflow checks that debug builds have.
[profile.release]
overflow-checks = true
+13
View File
@@ -0,0 +1,13 @@
[package]
name = "grovewright-core"
version = "0.1.0"
edition = "2021"
rust-version = "1.94"
publish = false
description = "Deterministic simulation core of Grovewright"
[lints.rust]
unsafe_code = "forbid"
[lints.clippy]
float_arithmetic = "deny"
+7
View File
@@ -0,0 +1,7 @@
//! Deterministic core of Grovewright.
//!
//! Everything that belongs to the shared world lives here. The core never
//! touches the engine, the clock, files or the network, and it calculates
//! only with whole numbers. Given the same commands it produces the same
//! world on every machine. That is what lets every player simulate the whole
//! world and exchange nothing but commands.
+78
View File
@@ -0,0 +1,78 @@
//! Guards the lockstep rules: the core must not use anything whose result
//! can differ between machines or between runs.
use std::fs;
use std::path::{Path, PathBuf};
/// Text that must not appear anywhere in the source of the core, and why.
const FORBIDDEN: &[(&str, &str)] = &[
("f32", "floating point results can differ between machines"),
("f64", "floating point results can differ between machines"),
(
"HashMap",
"iteration order differs between runs; use BTreeMap or Vec",
),
(
"HashSet",
"iteration order differs between runs; use BTreeSet or Vec",
),
("std::time", "the core must not read the clock"),
("Instant", "the core must not read the clock"),
("SystemTime", "the core must not read the clock"),
("std::thread", "the core must stay single-threaded"),
("std::env", "the core must not depend on the environment"),
(
"std::fs",
"the core must not touch files; the shell does that",
),
(
"std::net",
"the core must not open connections; the shell does that",
),
("unsafe", "the core forbids unsafe code"),
];
fn rust_files(dir: &Path, out: &mut Vec<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")
);
}