Files
Grovewright/docs/superpowers/plans/2026-10-01-m1c-gleichschritt-im-netz.md
T
Steven Wroblewski 760bea36fd Plan M1c: Gleichschritt im Netz
Zehn Tasks machen Grovewright zum Koop-Spiel für bis zu vier Spieler:
Regel-Kennung und Aufzeichnung Version 3, Nachrichten zwischen Host und
Gästen, Host und Gast im Kern, die Brücke für beide Rollen, die
Verbindung über ENet, Profile und Startoptionen, Startmenü und fremde
Figuren, die Hauptszene für Host und Gast, ein Test mit zwei echten
Spielinstanzen und zum Schluss Startskripte, Regeln und Fragebogen.
Jede Task-Grenze wurde vorab mit rotem und grünem Lauf nachgestellt.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 14:47:44 +02:00

259 KiB
Raw Blame History

M1c Gleichschritt im Netz 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: Bis zu vier Spieler teilen eine Welt. Ein Host öffnet seine Welt, Gäste treten über eine direkte Verbindung bei, jeder Rechner rechnet jeden Takt selbst, Prüfsummen decken Abweichungen auf und gleichen sie aus, und zwei Spielinstanzen bleiben nachweislich im Gleichschritt.

Architecture: Das Protokoll gehört zum Kern, denn Konzept 7.1 legt „Takt, Befehle, Prüfsummen“ dorthin. protocol.rs beschreibt die Nachrichten. host.rs legt fest, welche Befehle in welchem Takt laufen, und schickt jeden Takt an die Gäste. guest.rs rechnet jeden Takt nach und vergleicht Prüfsummen. Die Brücke gibt beides als GwSession an GDScript weiter; ihre Behandlung interner Fehler liegt in einem reinen Rust-Teil, der sich testen lässt. Die Hülle trägt die Nachrichten über ENet, zeigt die Figuren der anderen Spieler und beginnt mit einem Startmenü.

Tech Stack: Rust 1.98.1 (Edition 2021), godot-rust godot 0.5.5 mit Feature api-4-7, Godot 4.7.2 (Standard-Ausgabe) mit ENetMultiplayerPeer, GDScript mit Typangaben, Cargo, rustfmt, Clippy.

Spec: docs/KONZEPT.md, vor allem die Abschnitte 5 (Koop), 7.1 bis 7.3 (Technik) und 8 (Meilenstein 1: „Zwei Spielinstanzen bleiben im Gleichschritt“). Dazu die M1c-Punkte aus den Gitea-Issues #2 und #5.

Einordnung

Plan Inhalt Stand
M1a Rust-Kern Deterministischer Kern mit Karte, Spielern, Befehlen, Speicherstand, Aufzeichnung fertig, in main
M1b Brücke und Hülle GDExtension, Godot-Hülle, Takt, Figur, Speichern und Laden fertig, in main
M1c Gleichschritt im Netz Dieser Plan offen
M1d Stilproben Dieselbe Szene in drei Grafikstilen zur Auswahl nach M1c

Was dieser Plan aus den Issues #2 und #5 erledigt

Punkt Task
#2: Befehle an die sendende Verbindung binden 3: Gäste schicken Aktionen ohne Spielernummer; der Host macht daraus Befehle für den Spieler der Verbindung
#2 und #5: Befehle pro Spieler und Takt begrenzen, pending unbegrenzt 3: höchstens vier Aktionen pro Spieler und Takt
#2: Regel-Identität im Handshake, abgeleitet vom festgeschriebenen End-Hash 1 bis 3: rules_id() aus den eingefrorenen Skripten, im Gruß und in jeder Aufzeichnung
#2: Verdrängungsregel für bekannte Spieler entschieden: keine Verdrängung. Der Host lehnt einen 17. Spieler mit Grund ab, denn Verdrängen würde die Tasche eines Spielers löschen (Konzept 9).
#2: Ausstehende Befehle mit dem Beitritts-Snapshot senden durch den Aufbau erledigt (3): Der Gast bekommt die Welt und danach jeden Takt ab dieser Welt über denselben geordneten Kanal
#2: Ablehnung überlanger Namen in Command::decode testen 1
#2: Takte fremder Aufzeichnungen begrenzen 1: höchstens 30 Tage
#5: Gäste über World::from_snapshot mit eigenem Einstieg 3 und 4: Guest, GwSession.start_guest
#5: join_refused nennt keinen Schlüssel 4; dazu Ablehnungen des Hosts mit Grund (3, 8)
#5: TickClock und Pause gegen einen gemeinsamen Takt 3 und 8: Nur der Host hat eine Uhr, Gäste rechnen jeden Takt, den er schickt; Pause nur ohne Gäste
#5: Zwei Instanzen teilen user:// 6: Profile; 10: play-zweites-fenster.bat
#5: Vertrauensmodell der Reichweite aufschreiben 10: CLAUDE.md
#5: Vorübergehende Dateifehler gelten als dauerhaft 6 und 8
#5: Panik-Pfad der Brücke automatisch testen 4: driver.rs

Nicht Teil dieses Plans

  • Verbindung über Steam (Konzept 10)
  • Automatische Portfreigabe (UPnP). Konzept 5 sieht für das Internet eine Portfreigabe oder ein virtuelles Netz vor.
  • Ein Puffer, der beim Gast die Takte gegen Netzruckler glättet. Ob er nötig ist, zeigt der Probelauf.
  • Vorhersage für fremde Figuren, Position der Figur im Speicherstand
  • Zurück ins Menü aus dem laufenden Spiel, Namen im laufenden Spiel ändern
  • Verdrängen bekannter Spieler
  • Die Punkte aus #5 in den Abschnitten „Vor M2“, „Hülle und Spielgefühl“ und „Prüfwerkzeuge“

Technische Festlegungen

Thema Festlegung Grund
Ort des Protokolls Nachrichten, Host und Gast liegen im Kern (protocol.rs, host.rs, guest.rs) und rechnen ohne Uhr und Netzwerk Konzept 7.1: Takt, Befehle und Prüfsummen gehören in den Kern. Ohne Netzwerk lassen sich ganze Sitzungen im Speicher testen.
Wer den Takt bestimmt Nur der Host hat eine Uhr. Er führt jeden Takt mit allen angekommenen Befehlen aus und schickt ihn an seine Gäste. Gäste rechnen jeden Takt, der ankommt, höchstens 20 pro Bild. Alle Rechner führen dieselben Befehle in derselben Reihenfolge aus, ohne sich über Taktnummern abstimmen zu müssen
Beitritt Gruß mit Protokollversion, Regel-Kennung, Schlüssel und Name. Der Host antwortet mit seiner Welt als Snapshot; der Beitritt läuft im nächsten Takt. Konzept 7.3: Ein Gast bekommt den Speicherstand beim Beitritt
Prüfsummen Jeder 20. Takt (einmal pro Sekunde) trägt die Prüfsumme des Hosts. Weicht der Gast ab, meldet er das; der Host schickt seine Welt erneut, und der Gast hebt die Aufzeichnung der abgewichenen Welt auf. Konzept 7.3: „gleichen notfalls neu ab“; die Aufzeichnung macht die Abweichung nachspielbar
Regel-Kennung Ein Hash über die Endzustände der eingefrorenen Skripte, die von core/tests/golden.rs nach core/src/rules.rs umziehen. Aufzeichnungen tragen sie (Version 3). Ändert sich eine Regel, ändert sich die Kennung von selbst. Rechner mit anderen Regeln finden nicht zusammen, und alte Aufzeichnungen melden „andere Regeln“ statt einer falschen Abweichung.
Grenzen Höchstens 4 Aktionen pro Spieler und Takt, Nachrichten von Gästen höchstens 128 Bytes, Aufzeichnungen höchstens 30 Tage Kein Gast kann den Host fluten, keine Datei kann das Nachspielen stundenlang beschäftigen
Ablehnung Fünf Gründe: andere Protokollversion, andere Regeln, Welt voll, 16 bekannte Spieler, Spieler schon in der Welt. Die Ablehnung kommt an, bevor die Verbindung schließt. Ein Gast soll erfahren, warum es nicht geht
Netzwerk ENet über Godots ENetMultiplayerPeer, UDP-Port 24680. Kanal 0 zuverlässig und geordnet für Nachrichten des Kerns, Kanal 1 unzuverlässig für Positionen der Figuren (zehnmal pro Sekunde). Ein Gast gibt nach 8 Sekunden ohne Antwort auf. In Godot enthalten. Steam ersetzt später nur die Verbindung.
Figuren Die Position der eigenen Figur geht an die anderen Rechner, außerhalb des Gleichschritts. Fremde Figuren gleiten zur neuesten Position und tragen den Namen des Spielers. Konzept 7.2: Die Figur läuft außerhalb des Gleichschritts
Start Ein Startmenü mit Name, „Allein spielen“, „Welt öffnen“ und Beitreten über eine Adresse. Die Befehlszeile kann Profil, Name und Direktstart wählen. Konzept 5: Solo ist derselbe Modus ohne Gäste; der Host nennt seine Adresse
Profile --profile=NAME legt Schlüssel, Spielstand und Aufzeichnungen in user://profiles/NAME. Ohne Profil bleibt alles im Benutzerordner wie bisher. Zwei Fenster auf einem Rechner sind sonst derselbe Spieler
Speichern, Pause, Laden Nur der Host speichert. Pause gibt es nur ohne Gäste; ein ankommender Gast beendet sie. F9 lädt nur beim Spielen allein. Die Welt gehört dem Host (Konzept 5); Pause nur im Solo-Spiel
Tests mit Netzwerk Lauschen nur auf 127.0.0.1 und lassen das System den Port wählen (Port 0). Ein Test startet einen zweiten Godot-Prozess als Gast. Kein Test scheitert an einem belegten Port; die Prüffrage von Meilenstein 1 wird automatisch beantwortet

Global Constraints

  • Arbeitsverzeichnis ist die Wurzel des Archivs: F:/Development/_kiZeug/Grovewright. Alle Befehle laufen dort in der Bash.
  • Gearbeitet wird auf dem Branch m1c-gleichschritt-im-netz, 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, keine Uhr, kein Netzwerk und keine fremden Crates. Die Wächter in core/clippy.toml, core/tests/forbidden_apis.rs und core/tests/no_dependencies.rs prüfen das.
  • Die Brücke übersetzt nur; Spielregeln gehören in den Kern, nie in bridge/ oder game/.
  • GDScript mit Typangaben. Texte, die Spieler sehen, kommen über tr() aus game/i18n/texts.csv.
  • Tests, die Dateien schreiben, nutzen TestFiles.temporary_dir(), nie die echten Dateien des Spielers. Tests mit Netzwerk lauschen nur auf 127.0.0.1 und nutzen Port 0.
  • Code, Bezeichner und Kommentare im Code sind Englisch. Dokumente und Commit-Nachrichten sind Deutsch.
  • Jede Commit-Nachricht endet mit einer Zeile Co-Authored-By: Claude <Modell> <noreply@anthropic.com>, die das Claude-Modell nennt, das den Commit erstellt hat. Die Commit-Nachrichten in diesem Plan zeigen Claude Opus 5.5; wer auf einem anderen Modell läuft, setzt dessen Namen ein.
  • Nach jedem Task wird committet und hochgeladen. Das Hochladen ist vom Nutzer freigegeben.
  • Vor jedem Commit laufen fehlerfrei: cargo fmt --all -- --check, cargo clippy --workspace --all-targets -- -D warnings, cargo test --workspace, cargo build -p grovewright-bridge, godot --headless --path game --import und godot --headless --path game -s res://tests/run_tests.gd.
  • Die GDScript-Tests geben zwei bekannte Warnzeilen aus, beide mit „save not usable“. Sie stammen aus Tests, die absichtlich unbrauchbare Spielstände laden. Andere Warnungen oder Fehler gehören nicht in die Ausgabe.
  • Godot erzeugt beim Import zu jedem Skript eine .uid-Datei. Sie gehört in den Commit. Der Ordner game/.godot/ gehört nicht hinein.
  • Werkzeugstand: Rust 1.98.1, Godot 4.7.2 (Standard-Ausgabe, im Pfad als godot), godot-rust 0.5.5, Git 2.54.0.

Hinweis zur Vorabprüfung

Der gesamte Code dieses Plans wurde am 1. Oktober 2026 vorab gebaut. Danach wurde jede der zehn Task-Grenzen einzeln in einer Kopie von main nachgestellt, jeweils mit dem roten und dem grünen Lauf, die der Plan beschreibt:

  • Rust-Tests nach den Tasks 1, 2 und 3: 114, 123 und 137; ab Task 4: 141, auch im Release-Modus.
  • GDScript-Tests bis Task 3: 44 wie in main. Nach den Tasks 4 bis 9: 48, 61, 68, 75, 86 und 87.
  • Clippy und rustfmt meldeten nichts.
  • Das Bildschirmfoto-Werkzeug lieferte nach Task 8 ein Bild, und die Startskripte gaben mit Platzhaltern die erwarteten Argumente weiter.
  • Die ganze GDScript-Suite lief außerdem fünfmal hintereinander ohne Fehlschlag. Damit sind wackelnde Netzwerktests ausgeschlossen.

Weicht ein Schritt ab, ist zuerst ein Übertragungsfehler zu vermuten.

Dateiübersicht

Datei Aufgabe
core/src/rules.rs Eingefrorene Skripte und die Regel-Kennung rules_id()
core/src/replay.rs Aufzeichnung Version 3 mit Regel-Kennung und Taktgrenze
core/src/protocol.rs Nachrichten zwischen Host und Gästen
core/src/host.rs Der Host: nimmt Gäste auf, bindet und begrenzt ihre Aktionen, führt die Takte aus und verschickt sie
core/src/guest.rs Ein Gast: übernimmt die Welt, rechnet jeden Takt nach, vergleicht Prüfsummen
core/tests/protocol.rs, core/tests/session.rs Tests der Nachrichten und ganzer Sitzungen im Speicher
core/tests/golden.rs Festgeschriebene Werte, dazu Regel-Kennung und Nachrichtenformat
bridge/src/driver.rs Die laufende Sitzung als Host oder Gast, ohne Godot-Typen; fängt interne Fehler ab
bridge/src/session.rs, bridge/src/events.rs GwSession für GDScript
game/scripts/net_link.gd Verbindung über ENet
game/scripts/session_link.gd Trägt die Nachrichten einer Sitzung und die Positionen der Figuren über die Verbindung
game/scripts/launch_options.gd Befehlszeile: Profil, Name, Direktstart
game/scripts/save_store.gd Dazu: Einstellungen, vorsichtiges Lesen
game/scripts/menu.gd Startmenü
game/scripts/figures.gd Figuren der anderen Spieler
game/scripts/hud.gd Dazu: Meldungen mit Werten, Statuszeile
game/scripts/main.gd Hauptszene für Spielen allein, als Host und als Gast
game/i18n/texts.csv Texte für Menü und Netzwerk
game/tools/guest_bot.gd Zweite Spielinstanz für den Test zweier Instanzen
game/tests/test_*.gd, game/tests/support/held_file_store.gd Tests der Hülle
play.bat, play-zweites-fenster.bat Starten, auch ein zweites Fenster mit eigenem Profil
CLAUDE.md, docs/playtests/m1c.md Regeln für die gemeinsame Welt, Fragen für deinen Probelauf

Task 1: Regel-Kennung und Aufzeichnung Version 3

Files:

  • Create: core/src/rules.rs
  • Modify: core/src/lib.rs (ganze Datei ersetzen)
  • Modify: core/src/replay.rs (ganze Datei ersetzen)
  • Modify: core/tests/golden.rs (ganze Datei ersetzen)
  • Modify: core/tests/replay.rs (ganze Datei ersetzen)
  • Modify: core/tests/command.rs (ganze Datei ersetzen)

Interfaces:

  • Consumes: World::new, World::step, World::state_hash, World::to_snapshot, World::from_snapshot, Writer, fnv1a64, outcrop_origin, Recording, Command, TICKS_PER_SECOND

  • Produces:

    • rules::RULES_SEED: u64 = 2026, der Typ rules::Script = fn(&World) -> Vec<Vec<Command>> und rules::SCRIPTS: [Script; 2]
    • rules::frozen_script und rules::frozen_offline_script: die beiden Skripte, die bisher in core/tests/golden.rs standen, unverändert
    • rules::end_hash(script: Script) -> u64: Hash des Endzustands eines Skripts auf einer frischen Welt
    • rules::rules_id() -> u64: die Regel-Kennung dieses Builds
    • replay::RECORDING_VERSION: u32 = 3. Eine Aufzeichnung beginnt jetzt mit Marke, Version, Regel-Kennung und dem Hash des Endzustands.
    • replay::MAX_RECORDING_TICKS: u32 (30 Tage zu 20 Takten pro Sekunde)
    • replay::RecordingError::OtherRules { recorded: u64, current: u64 }, die Recording::from_bytes vor allem anderen prüft
  • Step 1: Auf den Branch wechseln

Der Branch existiert bereits. Er wurde zusammen mit diesem Plan angelegt und hochgeladen.

git switch m1c-gleichschritt-im-netz

Expected: Already on 'm1c-gleichschritt-im-netz' oder Switched to branch 'm1c-gleichschritt-im-netz'

  • Step 2: Tests schreiben

Ersetze den gesamten Inhalt von core/tests/replay.rs durch:

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, MAX_RECORDING_TICKS, RECORDING_VERSION};
use grovewright_core::rules::rules_id;
use grovewright_core::snapshot::SnapshotError;
use grovewright_core::world::World;

const ADA: PlayerId = PlayerId(0);
/// Offset of the starting snapshot in an encoded recording: marker, version,
/// rules identity, expected hash and the snapshot's length come first.
const START: usize = 4 + 4 + 8 + 8 + 4;

/// Commands of tick `tick` in a short scripted session.
fn commands_for(world: &World, tick: u32) -> Vec<Command> {
    if tick == 0 {
        vec![Command::Join {
            key: [1; 16],
            name: "Ada".to_owned(),
        }]
    } else if tick.is_multiple_of(7) {
        vec![Command::Dig {
            player: ADA,
            pos: outcrop_origin(world.map()),
        }]
    } else {
        vec![]
    }
}

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

#[test]
fn stepping_through_a_recording_gives_the_events_of_the_world() {
    let mut plain = World::new(11);
    let mut recorded = World::new(11);
    let mut recording = Recording::start(&recorded);
    for tick in 0..30 {
        let commands = commands_for(&plain, tick);
        assert_eq!(
            recording.step(&mut recorded, &commands),
            plain.step(&commands),
            "tick {tick}"
        );
    }
    assert_eq!(recorded, plain);
    assert_eq!(recording.end_tick(), 30);
}

#[test]
fn replaying_reaches_the_same_state() {
    let (recording, world) = recorded_session();
    assert_eq!(recording.tick_count(), 200);
    assert_eq!(world.player(ADA).unwrap().inventory.count(ITEM_STONE), 1);
    assert_eq!(recording.replay(), Ok(world));
}

#[test]
fn a_recording_survives_encoding() {
    let (recording, world) = recorded_session();
    let bytes = recording.to_bytes(&world);
    let (decoded, expected_hash) = Recording::from_bytes(&bytes).unwrap();
    assert_eq!(decoded, recording);
    assert_eq!(expected_hash, world.state_hash());
}

#[test]
fn verify_accepts_a_faithful_recording() {
    let (recording, world) = recorded_session();
    assert_eq!(Recording::verify(&recording.to_bytes(&world)), Ok(world));
}

#[test]
fn verify_reports_a_replay_that_ends_in_another_state() {
    let (recording, world) = recorded_session();
    let mut bytes = recording.to_bytes(&world);
    // The expected state hash follows the marker, the version and the rules
    // identity.
    bytes[16] ^= 0x01;
    match Recording::verify(&bytes) {
        Err(RecordingError::Diverged { expected, actual }) => {
            assert_eq!(actual, world.state_hash());
            assert_ne!(expected, actual);
        }
        other => panic!("expected a divergence, got {other:?}"),
    }
}

#[test]
fn a_recording_can_start_in_the_middle_of_a_session() {
    let mut world = World::new(11);
    for tick in 0..50 {
        let commands = commands_for(&world, tick);
        world.step(&commands);
    }
    let mut recording = Recording::start(&world);
    for tick in 50..120 {
        let commands = commands_for(&world, tick);
        recording.step(&mut world, &commands);
    }
    assert_eq!(recording.tick_count(), 70);
    assert_eq!(recording.end_tick(), 120);

    // Decoding derives the start tick from the stored start world. The
    // decoded recording must end at the same tick and equal the original.
    let (decoded, _) = Recording::from_bytes(&recording.to_bytes(&world)).unwrap();
    assert_eq!(decoded.end_tick(), 120);
    assert_eq!(decoded, recording);

    assert_eq!(recording.replay(), Ok(world));
}

#[test]
fn an_empty_recording_replays_to_its_starting_state() {
    let world = World::new(3);
    let recording = Recording::start(&world);
    assert_eq!(recording.tick_count(), 0);
    assert_eq!(Recording::verify(&recording.to_bytes(&world)), Ok(world));
}

#[test]
#[should_panic(expected = "the world is not at the tick this recording has reached")]
fn a_recording_refuses_a_world_it_does_not_follow() {
    let mut world = World::new(3);
    let mut recording = Recording::start(&world);
    world.step(&[]);
    recording.step(&mut world, &[]);
}

#[test]
#[should_panic(expected = "the end state is not at the tick this recording has reached")]
fn a_recording_refuses_an_end_state_at_another_tick() {
    let (recording, mut world) = recorded_session();
    world.step(&[]);
    recording.to_bytes(&world);
}

#[test]
fn damaged_recordings_are_rejected() {
    let (recording, world) = recorded_session();
    let bytes = recording.to_bytes(&world);

    assert_eq!(
        Recording::from_bytes(b""),
        Err(RecordingError::NotARecording)
    );
    assert_eq!(
        Recording::from_bytes(&world.to_snapshot()),
        Err(RecordingError::NotARecording)
    );

    let mut other_version = bytes.clone();
    other_version[4..8].copy_from_slice(&(RECORDING_VERSION + 1).to_le_bytes());
    assert_eq!(
        Recording::from_bytes(&other_version),
        Err(RecordingError::UnsupportedVersion(RECORDING_VERSION + 1))
    );

    assert_eq!(
        Recording::from_bytes(&bytes[..bytes.len() - 1]),
        Err(RecordingError::Corrupt(DecodeError::UnexpectedEnd))
    );

    let mut padded = bytes.clone();
    padded.push(0);
    assert_eq!(
        Recording::from_bytes(&padded),
        Err(RecordingError::Corrupt(DecodeError::TrailingBytes))
    );
}

#[test]
fn a_recording_with_a_damaged_start_is_rejected() {
    let (recording, world) = recorded_session();
    let mut bytes = recording.to_bytes(&world);
    // The snapshot starts after marker, version, rules identity, expected
    // hash and its own length: 4 + 4 + 8 + 8 + 4 bytes.
    bytes[START + 10] ^= 0x01;
    assert_eq!(
        Recording::from_bytes(&bytes),
        Err(RecordingError::BadStart(SnapshotError::ChecksumMismatch))
    );
}

#[test]
fn a_recording_of_other_rules_is_refused_before_replaying() {
    let (recording, world) = recorded_session();
    let mut bytes = recording.to_bytes(&world);
    // The rules identity follows the marker and the version.
    bytes[8] ^= 0x01;
    assert_eq!(
        Recording::from_bytes(&bytes),
        Err(RecordingError::OtherRules {
            recorded: rules_id() ^ 0x01,
            current: rules_id()
        })
    );
}

/// The encoding of `recording` with its tick count replaced by `ticks`. The
/// tick count follows the starting snapshot.
fn with_tick_count(recording: &Recording, world: &World, ticks: u32) -> Vec<u8> {
    let mut bytes = recording.to_bytes(world);
    let length = u32::from_le_bytes(bytes[START - 4..START].try_into().unwrap()) as usize;
    let at = START + length;
    bytes[at..at + 4].copy_from_slice(&ticks.to_le_bytes());
    bytes
}

#[test]
fn a_recording_longer_than_thirty_days_is_refused() {
    let world = World::new(3);
    let recording = Recording::start(&world);
    let (longest, _) =
        Recording::from_bytes(&with_tick_count(&recording, &world, MAX_RECORDING_TICKS)).unwrap();
    assert_eq!(longest.tick_count(), MAX_RECORDING_TICKS);
    assert_eq!(
        Recording::from_bytes(&with_tick_count(
            &recording,
            &world,
            MAX_RECORDING_TICKS + 1
        )),
        Err(RecordingError::Corrupt(DecodeError::InvalidValue(
            "tick count"
        )))
    );
}

Ersetze den gesamten Inhalt von core/tests/golden.rs durch:

//! Pinned values for everything that must not change unnoticed.
//!
//! Most other tests compare two computations inside the same build, so a
//! change that affects both sides alike stays green. This file compares
//! against values recorded once. If one of them fails, rules or encodings
//! changed, on purpose or by accident. If saves or recordings are affected,
//! raise the version constant named in the failure message, then update the
//! value here deliberately.

use grovewright_core::codec::Writer;
use grovewright_core::hash::fnv1a64;
use grovewright_core::map::{Map, Pos, Terrain, Tile};
use grovewright_core::replay::Recording;
use grovewright_core::rules::{
    end_hash, frozen_offline_script, frozen_script, rules_id, RULES_SEED,
};
use grovewright_core::world::World;

/// Covers the map generator, the map encoding and the snapshot header and
/// checksum.
#[test]
fn the_starting_snapshot_is_unchanged() {
    let actual = fnv1a64(&World::new(RULES_SEED).to_snapshot());
    assert_eq!(
        actual, 0x6812_937b_f2b2_19ae,
        "the starting world or the snapshot format changed; if saves are \
         affected, raise SNAPSHOT_VERSION in core/src/snapshot.rs; then \
         update this value"
    );
}

/// Covers the rules of joining, leaving and digging, the shortening of names
/// and the encoding of players.
#[test]
fn the_frozen_session_ends_in_the_same_state() {
    assert_eq!(
        end_hash(frozen_script),
        0xc304_43df_fc74_dcfc,
        "the rules or the snapshot format changed, and with them the rules \
         id; if saves are affected, raise SNAPSHOT_VERSION in \
         core/src/snapshot.rs; then update this value"
    );
}

/// Covers the encoding of a player who is offline.
#[test]
fn the_frozen_offline_session_ends_in_the_same_state() {
    assert_eq!(
        end_hash(frozen_offline_script),
        0x904f_10e1_7c52_8be4,
        "the rules or the snapshot format changed, and with them the rules \
         id; if saves are affected, raise SNAPSHOT_VERSION in \
         core/src/snapshot.rs; then update this value"
    );
}

/// Covers how the identity of the rules is combined from the end states of
/// the frozen scripts.
#[test]
fn the_rules_id_is_unchanged() {
    assert_eq!(
        rules_id(),
        0x04f1_1b1e_16c5_feff,
        "the rules id changed: builds with other rules can no longer play \
         together, and older recordings report other rules; if the tests \
         above fail as well, deal with those first; then update this value"
    );
}

/// Covers the recording header and layout and the wire format of commands.
#[test]
fn the_recording_format_is_unchanged() {
    let mut world = World::new(RULES_SEED);
    let mut recording = Recording::start(&world);
    for commands in frozen_script(&world) {
        recording.step(&mut world, &commands);
    }
    assert_eq!(
        fnv1a64(&recording.to_bytes(&world)),
        0xf203_b229_8dad_7c53,
        "the recording or command format changed; if other tests in this \
         file fail as well, deal with those first, because a recording \
         contains a snapshot and an end state; otherwise raise \
         RECORDING_VERSION in core/src/replay.rs; then update this value"
    );
}

/// Covers the byte layout of a map: width before height, little endian,
/// terrain numbers and damage. The map is not square, so a swap of width
/// and height shows.
#[test]
fn the_map_encoding_is_unchanged() {
    let mut map = Map::filled(3, 2, Terrain::Ground);
    map.set(Pos::new(1, 0), Tile::new(Terrain::Bedrock));
    map.set(Pos::new(0, 1), Tile::new(Terrain::HardRock));
    map.set(
        Pos::new(2, 1),
        Tile {
            terrain: Terrain::SoftRock,
            damage: 1,
        },
    );
    let mut w = Writer::new();
    map.encode(&mut w);
    assert_eq!(
        w.into_bytes(),
        vec![
            3, 0, 0, 0, // width
            2, 0, 0, 0, // height
            0, 0, 3, 0, 0, 0, // row 0: ground, bedrock, ground
            2, 0, 0, 0, 1, 1, // row 1: hard rock, ground, soft rock hit once
        ],
        "the map encoding changed; raise SNAPSHOT_VERSION in \
         core/src/snapshot.rs; then update these bytes"
    );
}

Ersetze den gesamten Inhalt von core/tests/command.rs durch:

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 a_name_longer_than_allowed_is_rejected_when_decoded() {
    // A join written by hand: the encoder would shorten the name. The tag of
    // a join is 1; the recording format pins the wire format of commands.
    let mut w = Writer::new();
    w.u8(1);
    w.raw(&[1; 16]);
    w.str(&"x".repeat(MAX_NAME_BYTES + 1));
    let bytes = w.into_bytes();
    assert_eq!(
        Command::decode(&mut Reader::new(&bytes)),
        Err(DecodeError::InvalidValue("player name"))
    );
}

#[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);
}

Der neue Test a_name_longer_than_allowed_is_rejected_when_decoded hält ein Verhalten fest, das es schon gibt. Er besteht deshalb sofort.

  • Step 3: Tests ausführen und Fehlschlag sehen

Run: cargo test -p grovewright-core --test replay Expected: FAIL beim Übersetzen mit error[E0432]: unresolved import `grovewright_core::replay::MAX_RECORDING_TICKS` , error[E0432]: unresolved import `grovewright_core::rules` und error[E0599]: no variant named `OtherRules` found for enum `RecordingError`

  • Step 4: Regel-Kennung und Aufzeichnung Version 3 einbauen

Create core/src/rules.rs:

//! Identity of the rules. Machines can share a world only if their rules
//! produce the same results, and a recording can only be replayed by the
//! rules it was made with.
//!
//! The identity is derived from fixed sessions: each runs a frozen script of
//! commands on a fresh world, and the identity combines the state hashes they
//! end in. A change to a rule that one of the scripts touches changes the
//! identity, without anybody having to raise a number by hand. A new rule
//! needs a new script in [`SCRIPTS`], so that the identity covers it too.

use crate::codec::Writer;
use crate::command::Command;
use crate::hash::fnv1a64;
use crate::map::Pos;
use crate::mapgen::outcrop_origin;
use crate::player::PlayerId;
use crate::world::World;

/// Seed of the world every frozen script runs on.
pub const RULES_SEED: u64 = 2026;

/// A frozen script: the commands of every tick of a fixed session.
pub type Script = fn(&World) -> Vec<Vec<Command>>;

/// Every frozen script the rules identity covers, in a fixed order.
pub const SCRIPTS: [Script; 2] = [frozen_script, frozen_offline_script];

/// The commands of a session of 60 ticks. It is built from literal values
/// and the outcrop origin of the map, not from constants such as
/// `DIG_COOLDOWN_TICKS`, so that changing a rule changes the result and not
/// the script.
///
/// Never change or extend this script. To cover more, add a new script to
/// [`SCRIPTS`] instead.
pub fn frozen_script(world: &World) -> Vec<Vec<Command>> {
    let origin = outcrop_origin(world.map());
    let right_of_origin = Pos::new(origin.x + 1, origin.y);
    // Pairs of (tick, command). Commands of the same tick are applied in the
    // order listed here.
    let scripted = [
        // Two players join in the same tick.
        (
            0,
            Command::Join {
                key: [1; 16],
                name: "Ada".to_owned(),
            },
        ),
        (
            0,
            Command::Join {
                key: [2; 16],
                name: "Grace".to_owned(),
            },
        ),
        // Player 0 hits the outcrop origin three times, five ticks apart.
        // The third hit breaks the rock and gives a stone.
        (
            2,
            Command::Dig {
                player: PlayerId(0),
                pos: origin,
            },
        ),
        (
            7,
            Command::Dig {
                player: PlayerId(0),
                pos: origin,
            },
        ),
        (
            12,
            Command::Dig {
                player: PlayerId(0),
                pos: origin,
            },
        ),
        // Player 1 hits the tile to the right of the origin once.
        (
            20,
            Command::Dig {
                player: PlayerId(1),
                pos: right_of_origin,
            },
        ),
        // Player 1 leaves and returns with a name of 40 bytes, which the
        // world shortens to 32.
        (
            30,
            Command::Leave {
                player: PlayerId(1),
            },
        ),
        (
            40,
            Command::Join {
                key: [2; 16],
                name: "ä".repeat(20),
            },
        ),
    ];
    let mut ticks = vec![Vec::new(); 60];
    for (tick, command) in scripted {
        ticks[tick].push(command);
    }
    ticks
}

/// The commands of a second session of 30 ticks that ends with one player
/// offline, so that the encoding of offline players is covered as well.
///
/// Never change or extend this script either.
pub fn frozen_offline_script(world: &World) -> Vec<Vec<Command>> {
    let origin = outcrop_origin(world.map());
    let scripted = [
        (
            0,
            Command::Join {
                key: [7; 16],
                name: "Linus".to_owned(),
            },
        ),
        (
            1,
            Command::Join {
                key: [8; 16],
                name: "Margaret".to_owned(),
            },
        ),
        (
            3,
            Command::Dig {
                player: PlayerId(1),
                pos: origin,
            },
        ),
        (
            10,
            Command::Leave {
                player: PlayerId(0),
            },
        ),
    ];
    let mut ticks = vec![Vec::new(); 30];
    for (tick, command) in scripted {
        ticks[tick].push(command);
    }
    ticks
}

/// Runs `script` on a fresh world from [`RULES_SEED`] and returns the hash
/// of the state it ends in.
pub fn end_hash(script: Script) -> u64 {
    let mut world = World::new(RULES_SEED);
    for commands in script(&world) {
        world.step(&commands);
    }
    world.state_hash()
}

/// The identity of the rules of this build: one hash over the end states of
/// all [`SCRIPTS`]. Computing it runs every script, which takes about a
/// millisecond, so callers that need it often keep the value.
pub fn rules_id() -> u64 {
    let mut w = Writer::new();
    for script in SCRIPTS {
        w.u64(end_hash(script));
    }
    fnv1a64(&w.into_bytes())
}

Ersetze den gesamten Inhalt von core/src/lib.rs durch:

//! 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 rules;
pub mod snapshot;
pub mod world;

Ersetze den gesamten Inhalt von core/src/replay.rs durch:

//! Recording of a session: the starting state and every command, tick by
//! tick. Replaying a recording reproduces the session exactly.
//!
//! Layout: the marker `GRWR`, the format version, the identity of the rules
//! that made the recording, the hash of the state the session ended in, the
//! starting snapshot, and the commands of every tick that had any.

use std::fmt;

use crate::codec::{DecodeError, Reader, Writer};
use crate::command::Command;
use crate::rules::rules_id;
use crate::snapshot::SnapshotError;
use crate::world::{Event, World, TICKS_PER_SECOND};

const MAGIC: [u8; 4] = *b"GRWR";
/// Version of the recording format. Raise it whenever the encoded recording,
/// including the snapshot inside it, changes.
pub const RECORDING_VERSION: u32 = 3;
/// Most ticks a recording may hold when it is read: thirty days of play.
/// Longer recordings are refused, so that a damaged or crafted file cannot
/// keep [`Recording::verify`] busy for hours.
pub const MAX_RECORDING_TICKS: u32 = 30 * 24 * 60 * 60 * TICKS_PER_SECOND;

/// 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 was made with other rules than this build has, so
    /// replaying it would not show what happened.
    OtherRules { recorded: u64, current: u64 },
    /// 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::OtherRules { recorded, current } => write!(
                f,
                "recorded with rules {recorded:016x}, this build has rules {current:016x}"
            ),
            RecordingError::Corrupt(error) => write!(f, "damaged recording: {error}"),
            RecordingError::BadStart(error) => write!(f, "unusable starting state: {error}"),
            RecordingError::Diverged { expected, actual } => write!(
                f,
                "replay ended in state {actual:016x} instead of {expected:016x}"
            ),
        }
    }
}

impl std::error::Error for RecordingError {}

impl From<DecodeError> for RecordingError {
    fn from(error: DecodeError) -> Self {
        RecordingError::Corrupt(error)
    }
}

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

impl Recording {
    /// Starts a recording at the current state of `world`.
    pub fn start(world: &World) -> Self {
        Self {
            start: world.to_snapshot(),
            start_tick: world.tick(),
            tick_count: 0,
            busy_ticks: Vec::new(),
        }
    }

    /// Advances `world` by one tick with `commands` and records them. This is
    /// the only way to add to a recording, so it can never hold other
    /// commands than the ones the world executed.
    ///
    /// # Panics
    /// Panics if `world` is not at [`Recording::end_tick`], which means it is
    /// not the world this recording follows.
    pub fn step(&mut self, world: &mut World, commands: &[Command]) -> Vec<Event> {
        assert_eq!(
            world.tick(),
            self.end_tick(),
            "the world is not at the tick this recording has reached"
        );
        if !commands.is_empty() {
            self.busy_ticks.push((self.tick_count, commands.to_vec()));
        }
        self.tick_count = self
            .tick_count
            .checked_add(1)
            .expect("a recording holds fewer than 2^32 ticks");
        world.step(commands)
    }

    /// Number of recorded ticks.
    pub fn tick_count(&self) -> u32 {
        self.tick_count
    }

    /// Tick of the recorded world after the last recorded tick.
    pub fn end_tick(&self) -> u64 {
        self.start_tick + u64::from(self.tick_count)
    }

    /// Restores the starting state and applies every recorded tick.
    pub fn replay(&self) -> Result<World, RecordingError> {
        let mut world = World::from_snapshot(&self.start).map_err(RecordingError::BadStart)?;
        let mut busy = self.busy_ticks.iter().peekable();
        for index in 0..self.tick_count {
            match busy.peek() {
                Some((tick, commands)) if *tick == index => {
                    world.step(commands);
                    busy.next();
                }
                _ => {
                    world.step(&[]);
                }
            }
        }
        Ok(world)
    }

    /// Encodes the recording together with the state that replaying it must
    /// reach. `end` is the world after the last recorded tick.
    ///
    /// # Panics
    /// Panics if `end` is not at [`Recording::end_tick`].
    pub fn to_bytes(&self, end: &World) -> Vec<u8> {
        assert_eq!(
            end.tick(),
            self.end_tick(),
            "the end state is not at the tick this recording has reached"
        );
        let mut w = Writer::new();
        w.raw(&MAGIC);
        w.u32(RECORDING_VERSION);
        w.u64(rules_id());
        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.
    /// Fails with [`RecordingError::OtherRules`] before anything else is
    /// decoded if the recording was made with other rules.
    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 recorded = r.u64()?;
        let current = rules_id();
        if recorded != current {
            return Err(RecordingError::OtherRules { recorded, current });
        }
        let expected_hash = r.u64()?;
        let start = r.bytes()?.to_vec();
        let start_tick = World::from_snapshot(&start)
            .map_err(RecordingError::BadStart)?
            .tick();
        let tick_count = r.u32()?;
        if tick_count > MAX_RECORDING_TICKS {
            return Err(DecodeError::InvalidValue("tick count").into());
        }
        let busy_count = r.u32()?;
        let mut busy_ticks = Vec::new();
        let mut first_free_tick = 0;
        for _ in 0..busy_count {
            let tick = r.u32()?;
            if tick < first_free_tick || tick >= tick_count {
                return Err(DecodeError::InvalidValue("tick index").into());
            }
            first_free_tick = tick + 1;
            let command_count = r.u16()?;
            if command_count == 0 {
                return Err(DecodeError::InvalidValue("command count").into());
            }
            let mut commands = Vec::new();
            for _ in 0..command_count {
                commands.push(Command::decode(&mut r)?);
            }
            busy_ticks.push((tick, commands));
        }
        r.finish()?;
        Ok((
            Self {
                start,
                start_tick,
                tick_count,
                busy_ticks,
            },
            expected_hash,
        ))
    }

    /// Replays `bytes` and checks that the recorded final state is reached.
    pub fn verify(bytes: &[u8]) -> Result<World, RecordingError> {
        let (recording, expected) = Self::from_bytes(bytes)?;
        let world = recording.replay()?;
        let actual = world.state_hash();
        if actual == expected {
            Ok(world)
        } else {
            Err(RecordingError::Diverged { expected, actual })
        }
    }
}
  • Step 5: Tests ausführen und Erfolg sehen

Run: cargo test -p grovewright-core --test replay --test golden --test command Expected: PASS mit test result: ok. 13 passed für replay, test result: ok. 6 passed für golden und test result: ok. 6 passed für command

  • Step 6: Alles prüfen

Run:

cargo fmt --all -- --check && cargo clippy --workspace --all-targets -- -D warnings && cargo test --workspace
cargo build -p grovewright-bridge
godot --headless --path game --import
godot --headless --path game -s res://tests/run_tests.gd

Expected: keine Ausgabe von rustfmt, keine Warnung von Clippy, zusammen 114 bestandene Rust-Tests; der GDScript-Lauf endet mit 44 passed, 0 failed

  • Step 7: Commit und Hochladen
git add core/src/rules.rs core/src/lib.rs core/src/replay.rs core/tests/golden.rs core/tests/replay.rs core/tests/command.rs
git commit -F - <<'EOF'
Regel-Kennung und Aufzeichnung Version 3

Die eingefrorenen Skripte ziehen von den festgeschriebenen Tests in den
Kern. Aus ihren Endzuständen leitet sich die Regel-Kennung ab, die sich
mit jeder Regel ändert, die ein Skript berührt. Aufzeichnungen tragen
sie und werden mit anderen Regeln abgelehnt, statt eine scheinbare
Abweichung zu melden. Fremde Aufzeichnungen dürfen höchstens 30 Tage
lang sein. Ein Test hält fest, dass zu lange Namen beim Dekodieren
abgelehnt werden.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
EOF
git push -u origin m1c-gleichschritt-im-netz

Task 2: Nachrichten zwischen Host und Gästen

Files:

  • Create: core/src/protocol.rs
  • Modify: core/src/lib.rs (ganze Datei ersetzen)
  • Modify: core/tests/golden.rs (ganze Datei ersetzen)
  • Test: core/tests/protocol.rs

Interfaces:

  • Consumes: Writer, Reader, DecodeError, Command, Pos, PlayerId, PlayerKey, clamp_name, MAX_NAME_BYTES, TICKS_PER_SECOND

  • Produces:

    • protocol::PROTOCOL_VERSION: u32 = 1, protocol::MAX_GUEST_MESSAGE_BYTES: usize = 128, protocol::CHECK_INTERVAL_TICKS: u64 = 20
    • protocol::Action::Dig { pos: Pos } mit command(self, player: PlayerId) -> Command
    • protocol::Refusal mit OtherProtocol (1), OtherRules (2), WorldFull (3), TooManyPlayers (4), AlreadyPlaying (5), dazu code(self) -> u8 und from_code(u8) -> Option<Refusal>
    • protocol::ToHost mit Hello { rules: u64, key: PlayerKey, name: String }, ForeignHello { protocol: u32 }, Act(Action) und Diverged { tick: u64, hash: u64 }
    • protocol::ToGuest mit World { snapshot: Vec<u8> }, Refused(Refusal) und Tick { tick: u64, commands: Vec<Command>, check: Option<u64> }
    • Beide Nachrichtentypen haben encode(&self) -> Vec<u8> und decode(bytes: &[u8]) -> Result<Self, DecodeError>. ToHost::decode lehnt Nachrichten über MAX_GUEST_MESSAGE_BYTES ungelesen ab und liefert für einen Gruß einer anderen Protokollversion ForeignHello.
  • Step 1: Fehlschlagende Tests schreiben

Create core/tests/protocol.rs:

use grovewright_core::codec::{DecodeError, Writer};
use grovewright_core::command::Command;
use grovewright_core::map::Pos;
use grovewright_core::player::{PlayerId, MAX_NAME_BYTES};
use grovewright_core::protocol::{
    Action, Refusal, ToGuest, ToHost, MAX_GUEST_MESSAGE_BYTES, PROTOCOL_VERSION,
};

#[test]
fn every_message_to_the_host_survives_encoding() {
    let messages = [
        ToHost::Hello {
            rules: 0x0123_4567_89ab_cdef,
            key: [9; 16],
            name: "Ada".to_owned(),
        },
        ToHost::Act(Action::Dig {
            pos: Pos::new(-3, 70),
        }),
        ToHost::Diverged {
            tick: 1_234,
            hash: u64::MAX,
        },
    ];
    for message in &messages {
        assert_eq!(&ToHost::decode(&message.encode()).unwrap(), message);
    }
}

#[test]
fn every_message_to_a_guest_survives_encoding() {
    let messages = [
        ToGuest::World {
            snapshot: vec![1, 2, 3],
        },
        ToGuest::Refused(Refusal::TooManyPlayers),
        ToGuest::Tick {
            tick: 99,
            commands: vec![
                Command::Leave {
                    player: PlayerId(2),
                },
                Command::Dig {
                    player: PlayerId(0),
                    pos: Pos::new(4, 5),
                },
            ],
            check: Some(7),
        },
        ToGuest::Tick {
            tick: 100,
            commands: vec![],
            check: None,
        },
    ];
    for message in &messages {
        assert_eq!(&ToGuest::decode(&message.encode()).unwrap(), message);
    }
}

#[test]
fn an_action_becomes_a_command_for_the_given_player() {
    let action = Action::Dig {
        pos: Pos::new(1, 2),
    };
    assert_eq!(
        action.command(PlayerId(3)),
        Command::Dig {
            player: PlayerId(3),
            pos: Pos::new(1, 2)
        }
    );
}

#[test]
fn a_greeting_in_another_protocol_version_can_still_be_recognised() {
    let mut w = Writer::new();
    w.u8(1); // the tag of a greeting, which never changes
    w.u32(PROTOCOL_VERSION + 1);
    w.raw(b"whatever the other version sends after its version");
    assert_eq!(
        ToHost::decode(&w.into_bytes()),
        Ok(ToHost::ForeignHello {
            protocol: PROTOCOL_VERSION + 1
        })
    );
}

#[test]
fn every_refusal_keeps_its_code() {
    let codes = [
        (Refusal::OtherProtocol, 1),
        (Refusal::OtherRules, 2),
        (Refusal::WorldFull, 3),
        (Refusal::TooManyPlayers, 4),
        (Refusal::AlreadyPlaying, 5),
    ];
    for (refusal, code) in codes {
        assert_eq!(refusal.code(), code);
        assert_eq!(Refusal::from_code(code), Some(refusal));
    }
    assert_eq!(Refusal::from_code(0), None);
    assert_eq!(Refusal::from_code(6), None);
}

#[test]
fn a_greeting_shortens_an_overlong_name() {
    let hello = ToHost::Hello {
        rules: 1,
        key: [1; 16],
        name: "x".repeat(MAX_NAME_BYTES + 5),
    };
    assert_eq!(
        ToHost::decode(&hello.encode()),
        Ok(ToHost::Hello {
            rules: 1,
            key: [1; 16],
            name: "x".repeat(MAX_NAME_BYTES),
        })
    );
}

#[test]
fn a_greeting_with_an_overlong_name_written_by_hand_is_rejected() {
    let mut w = Writer::new();
    w.u8(1);
    w.u32(PROTOCOL_VERSION);
    w.u64(1);
    w.raw(&[1; 16]);
    w.str(&"x".repeat(MAX_NAME_BYTES + 1));
    assert_eq!(
        ToHost::decode(&w.into_bytes()),
        Err(DecodeError::InvalidValue("player name"))
    );
}

#[test]
fn broken_messages_are_rejected() {
    assert_eq!(ToHost::decode(&[]), Err(DecodeError::UnexpectedEnd));
    assert_eq!(
        ToHost::decode(&[0xEE]),
        Err(DecodeError::InvalidValue("message tag"))
    );
    assert_eq!(
        ToHost::decode(&[2, 0xEE]),
        Err(DecodeError::InvalidValue("action tag"))
    );
    assert_eq!(
        ToHost::decode(&[2; MAX_GUEST_MESSAGE_BYTES + 1]),
        Err(DecodeError::InvalidValue("message length"))
    );
    let mut act = ToHost::Act(Action::Dig {
        pos: Pos::new(0, 0),
    })
    .encode();
    act.push(0);
    assert_eq!(ToHost::decode(&act), Err(DecodeError::TrailingBytes));

    assert_eq!(
        ToGuest::decode(&[0xEE]),
        Err(DecodeError::InvalidValue("message tag"))
    );
    assert_eq!(
        ToGuest::decode(&[2, 99]),
        Err(DecodeError::InvalidValue("refusal"))
    );
    let tick = ToGuest::Tick {
        tick: 1,
        commands: vec![],
        check: Some(5),
    }
    .encode();
    assert_eq!(
        ToGuest::decode(&tick[..tick.len() - 1]),
        Err(DecodeError::UnexpectedEnd)
    );
}

Ersetze den gesamten Inhalt von core/tests/golden.rs durch die Fassung mit dem festgeschriebenen Nachrichtenformat:

//! Pinned values for everything that must not change unnoticed.
//!
//! Most other tests compare two computations inside the same build, so a
//! change that affects both sides alike stays green. This file compares
//! against values recorded once. If one of them fails, rules or encodings
//! changed, on purpose or by accident. If saves or recordings are affected,
//! raise the version constant named in the failure message, then update the
//! value here deliberately.

use grovewright_core::codec::Writer;
use grovewright_core::command::Command;
use grovewright_core::hash::fnv1a64;
use grovewright_core::map::{Map, Pos, Terrain, Tile};
use grovewright_core::player::PlayerId;
use grovewright_core::protocol::{Action, Refusal, ToGuest, ToHost};
use grovewright_core::replay::Recording;
use grovewright_core::rules::{
    end_hash, frozen_offline_script, frozen_script, rules_id, RULES_SEED,
};
use grovewright_core::world::World;

/// Covers the map generator, the map encoding and the snapshot header and
/// checksum.
#[test]
fn the_starting_snapshot_is_unchanged() {
    let actual = fnv1a64(&World::new(RULES_SEED).to_snapshot());
    assert_eq!(
        actual, 0x6812_937b_f2b2_19ae,
        "the starting world or the snapshot format changed; if saves are \
         affected, raise SNAPSHOT_VERSION in core/src/snapshot.rs; then \
         update this value"
    );
}

/// Covers the rules of joining, leaving and digging, the shortening of names
/// and the encoding of players.
#[test]
fn the_frozen_session_ends_in_the_same_state() {
    assert_eq!(
        end_hash(frozen_script),
        0xc304_43df_fc74_dcfc,
        "the rules or the snapshot format changed, and with them the rules \
         id; if saves are affected, raise SNAPSHOT_VERSION in \
         core/src/snapshot.rs; then update this value"
    );
}

/// Covers the encoding of a player who is offline.
#[test]
fn the_frozen_offline_session_ends_in_the_same_state() {
    assert_eq!(
        end_hash(frozen_offline_script),
        0x904f_10e1_7c52_8be4,
        "the rules or the snapshot format changed, and with them the rules \
         id; if saves are affected, raise SNAPSHOT_VERSION in \
         core/src/snapshot.rs; then update this value"
    );
}

/// Covers how the identity of the rules is combined from the end states of
/// the frozen scripts.
#[test]
fn the_rules_id_is_unchanged() {
    assert_eq!(
        rules_id(),
        0x04f1_1b1e_16c5_feff,
        "the rules id changed: builds with other rules can no longer play \
         together, and older recordings report other rules; if the tests \
         above fail as well, deal with those first; then update this value"
    );
}

/// Covers the recording header and layout and the wire format of commands.
#[test]
fn the_recording_format_is_unchanged() {
    let mut world = World::new(RULES_SEED);
    let mut recording = Recording::start(&world);
    for commands in frozen_script(&world) {
        recording.step(&mut world, &commands);
    }
    assert_eq!(
        fnv1a64(&recording.to_bytes(&world)),
        0xf203_b229_8dad_7c53,
        "the recording or command format changed; if other tests in this \
         file fail as well, deal with those first, because a recording \
         contains a snapshot and an end state; otherwise raise \
         RECORDING_VERSION in core/src/replay.rs; then update this value"
    );
}

/// Covers the byte layout of a map: width before height, little endian,
/// terrain numbers and damage. The map is not square, so a swap of width
/// and height shows.
#[test]
fn the_map_encoding_is_unchanged() {
    let mut map = Map::filled(3, 2, Terrain::Ground);
    map.set(Pos::new(1, 0), Tile::new(Terrain::Bedrock));
    map.set(Pos::new(0, 1), Tile::new(Terrain::HardRock));
    map.set(
        Pos::new(2, 1),
        Tile {
            terrain: Terrain::SoftRock,
            damage: 1,
        },
    );
    let mut w = Writer::new();
    map.encode(&mut w);
    assert_eq!(
        w.into_bytes(),
        vec![
            3, 0, 0, 0, // width
            2, 0, 0, 0, // height
            0, 0, 3, 0, 0, 0, // row 0: ground, bedrock, ground
            2, 0, 0, 0, 1, 1, // row 1: hard rock, ground, soft rock hit once
        ],
        "the map encoding changed; raise SNAPSHOT_VERSION in \
         core/src/snapshot.rs; then update these bytes"
    );
}

/// Covers every message between host and guests, built from literal values.
#[test]
fn the_protocol_format_is_unchanged() {
    let to_host = [
        ToHost::Hello {
            rules: 0x0102_0304_0506_0708,
            key: [3; 16],
            name: "Ada".to_owned(),
        },
        ToHost::Act(Action::Dig {
            pos: Pos::new(-2, 9),
        }),
        ToHost::Diverged { tick: 40, hash: 77 },
    ];
    let to_guest = [
        ToGuest::World {
            snapshot: vec![5, 6, 7],
        },
        ToGuest::Refused(Refusal::WorldFull),
        ToGuest::Tick {
            tick: 12,
            commands: vec![Command::Dig {
                player: PlayerId(1),
                pos: Pos::new(3, 4),
            }],
            check: Some(99),
        },
        ToGuest::Tick {
            tick: 13,
            commands: vec![],
            check: None,
        },
    ];
    let mut bytes = Vec::new();
    for message in &to_host {
        bytes.extend(message.encode());
    }
    for message in &to_guest {
        bytes.extend(message.encode());
    }
    assert_eq!(
        fnv1a64(&bytes),
        0xc263_69c0_f892_247b,
        "the messages between host and guests changed; raise \
         PROTOCOL_VERSION in core/src/protocol.rs; then update this value"
    );
}
  • Step 2: Tests ausführen und Fehlschlag sehen

Run: cargo test -p grovewright-core --test protocol Expected: FAIL beim Übersetzen mit error[E0432]: unresolved import `grovewright_core::protocol`

  • Step 3: Nachrichten einbauen

Create core/src/protocol.rs:

//! Messages between the host of a shared world and its guests.
//!
//! Every machine simulates the whole world. The host decides which commands
//! run in which tick and sends every tick to every guest; a guest sends the
//! host only what its player wants to do. The shell carries these messages
//! as they are, and it must deliver the host's messages to a guest reliably
//! and in the order they were sent.
//!
//! The tag and the version at the start of a greeting, and the whole refusal
//! message, never change. That way games of every version can still tell
//! each other that they do not fit together.

use crate::codec::{DecodeError, Reader, Writer};
use crate::command::Command;
use crate::map::Pos;
use crate::player::{clamp_name, PlayerId, PlayerKey, MAX_NAME_BYTES};
use crate::world::TICKS_PER_SECOND;

/// Version of the messages. Raise it whenever a message changes.
pub const PROTOCOL_VERSION: u32 = 1;
/// Longest message a guest may send, in bytes. The longest one is a greeting
/// with a name of full length, which needs 65 bytes.
pub const MAX_GUEST_MESSAGE_BYTES: usize = 128;
/// Ticks between two state checksums in the stream of ticks: one second.
pub const CHECK_INTERVAL_TICKS: u64 = TICKS_PER_SECOND as u64;

const TO_HOST_HELLO: u8 = 1;
const TO_HOST_ACT: u8 = 2;
const TO_HOST_DIVERGED: u8 = 3;

const TO_GUEST_WORLD: u8 = 1;
const TO_GUEST_REFUSED: u8 = 2;
const TO_GUEST_TICK: u8 = 3;

const ACTION_DIG: u8 = 1;

/// Something a player wants to do. The host turns it into a command for the
/// player of the connection it came from, so that no guest can act for
/// another player.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Action {
    /// Hit the tile at `pos` with the pickaxe.
    Dig { pos: Pos },
}

impl Action {
    /// The command that carries out this action for `player`.
    pub fn command(self, player: PlayerId) -> Command {
        match self {
            Action::Dig { pos } => Command::Dig { player, pos },
        }
    }

    fn encode(&self, w: &mut Writer) {
        match self {
            Action::Dig { pos } => {
                w.u8(ACTION_DIG);
                w.i32(pos.x);
                w.i32(pos.y);
            }
        }
    }

    fn decode(r: &mut Reader<'_>) -> Result<Self, DecodeError> {
        match r.u8()? {
            ACTION_DIG => {
                let x = r.i32()?;
                let y = r.i32()?;
                Ok(Action::Dig {
                    pos: Pos::new(x, y),
                })
            }
            _ => Err(DecodeError::InvalidValue("action tag")),
        }
    }
}

/// Why a host turned a guest away.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Refusal {
    /// The guest's game speaks another version of these messages.
    OtherProtocol = 1,
    /// The guest's game has other rules than the host's.
    OtherRules = 2,
    /// Four players are online already.
    WorldFull = 3,
    /// The world remembers as many players as it can and does not know this
    /// one.
    TooManyPlayers = 4,
    /// A player with the same key is in the world already.
    AlreadyPlaying = 5,
}

impl Refusal {
    /// The number that stands for this refusal in a message.
    pub fn code(self) -> u8 {
        self as u8
    }

    pub fn from_code(code: u8) -> Option<Self> {
        match code {
            1 => Some(Refusal::OtherProtocol),
            2 => Some(Refusal::OtherRules),
            3 => Some(Refusal::WorldFull),
            4 => Some(Refusal::TooManyPlayers),
            5 => Some(Refusal::AlreadyPlaying),
            _ => None,
        }
    }
}

/// A message from a guest to the host.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum ToHost {
    /// The first message of every guest.
    Hello {
        rules: u64,
        key: PlayerKey,
        name: String,
    },
    /// A greeting in another version of the messages. Nothing after the
    /// version can be read.
    ForeignHello { protocol: u32 },
    /// The guest's player wants to do something.
    Act(Action),
    /// After `tick` ticks the guest's state had the hash `hash`, which
    /// differs from the host's checksum. The host answers with its world.
    Diverged { tick: u64, hash: u64 },
}

impl ToHost {
    pub fn encode(&self) -> Vec<u8> {
        let mut w = Writer::new();
        match self {
            ToHost::Hello { rules, key, name } => {
                w.u8(TO_HOST_HELLO);
                w.u32(PROTOCOL_VERSION);
                w.u64(*rules);
                w.raw(key);
                w.str(&clamp_name(name));
            }
            ToHost::ForeignHello { protocol } => {
                w.u8(TO_HOST_HELLO);
                w.u32(*protocol);
            }
            ToHost::Act(action) => {
                w.u8(TO_HOST_ACT);
                action.encode(&mut w);
            }
            ToHost::Diverged { tick, hash } => {
                w.u8(TO_HOST_DIVERGED);
                w.u64(*tick);
                w.u64(*hash);
            }
        }
        w.into_bytes()
    }

    /// Decodes a whole message. Messages longer than
    /// [`MAX_GUEST_MESSAGE_BYTES`] are rejected unread.
    pub fn decode(bytes: &[u8]) -> Result<Self, DecodeError> {
        if bytes.len() > MAX_GUEST_MESSAGE_BYTES {
            return Err(DecodeError::InvalidValue("message length"));
        }
        let mut r = Reader::new(bytes);
        let message = match r.u8()? {
            TO_HOST_HELLO => {
                let protocol = r.u32()?;
                if protocol != PROTOCOL_VERSION {
                    return Ok(ToHost::ForeignHello { protocol });
                }
                let rules = r.u64()?;
                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"));
                }
                ToHost::Hello { rules, key, name }
            }
            TO_HOST_ACT => ToHost::Act(Action::decode(&mut r)?),
            TO_HOST_DIVERGED => ToHost::Diverged {
                tick: r.u64()?,
                hash: r.u64()?,
            },
            _ => return Err(DecodeError::InvalidValue("message tag")),
        };
        r.finish()?;
        Ok(message)
    }
}

/// A message from the host to a guest.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum ToGuest {
    /// The world as it is now. The host sends it when it accepts a guest and
    /// again after the guest reported a divergence. The ticks that follow
    /// start at this world's tick.
    World { snapshot: Vec<u8> },
    /// The host turned the guest away and closes the connection.
    Refused(Refusal),
    /// The commands of tick `tick` in the order the host ran them. Every
    /// [`CHECK_INTERVAL_TICKS`] ticks, `check` holds the hash of the host's
    /// state after the tick.
    Tick {
        tick: u64,
        commands: Vec<Command>,
        check: Option<u64>,
    },
}

impl ToGuest {
    pub fn encode(&self) -> Vec<u8> {
        let mut w = Writer::new();
        match self {
            ToGuest::World { snapshot } => {
                w.u8(TO_GUEST_WORLD);
                w.bytes(snapshot);
            }
            ToGuest::Refused(refusal) => {
                w.u8(TO_GUEST_REFUSED);
                w.u8(refusal.code());
            }
            ToGuest::Tick {
                tick,
                commands,
                check,
            } => {
                w.u8(TO_GUEST_TICK);
                w.u64(*tick);
                w.u16(u16::try_from(commands.len()).expect("command count fits in u16"));
                for command in commands {
                    command.encode(&mut w);
                }
                match check {
                    Some(hash) => {
                        w.bool(true);
                        w.u64(*hash);
                    }
                    None => w.bool(false),
                }
            }
        }
        w.into_bytes()
    }

    /// Decodes a whole message.
    pub fn decode(bytes: &[u8]) -> Result<Self, DecodeError> {
        let mut r = Reader::new(bytes);
        let message = match r.u8()? {
            TO_GUEST_WORLD => ToGuest::World {
                snapshot: r.bytes()?.to_vec(),
            },
            TO_GUEST_REFUSED => ToGuest::Refused(
                Refusal::from_code(r.u8()?).ok_or(DecodeError::InvalidValue("refusal"))?,
            ),
            TO_GUEST_TICK => {
                let tick = r.u64()?;
                let count = r.u16()?;
                let mut commands = Vec::new();
                for _ in 0..count {
                    commands.push(Command::decode(&mut r)?);
                }
                let check = if r.bool()? { Some(r.u64()?) } else { None };
                ToGuest::Tick {
                    tick,
                    commands,
                    check,
                }
            }
            _ => return Err(DecodeError::InvalidValue("message tag")),
        };
        r.finish()?;
        Ok(message)
    }
}

Ersetze den gesamten Inhalt von core/src/lib.rs durch:

//! 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 protocol;
pub mod replay;
pub mod rng;
pub mod rules;
pub mod snapshot;
pub mod world;
  • Step 4: Tests ausführen und Erfolg sehen

Run: cargo test -p grovewright-core --test protocol --test golden Expected: PASS mit test result: ok. 8 passed für protocol und test result: ok. 7 passed für golden

  • Step 5: Alles prüfen

Run:

cargo fmt --all -- --check && cargo clippy --workspace --all-targets -- -D warnings && cargo test --workspace
cargo build -p grovewright-bridge
godot --headless --path game --import
godot --headless --path game -s res://tests/run_tests.gd

Expected: keine Ausgabe von rustfmt, keine Warnung von Clippy, zusammen 123 bestandene Rust-Tests; der GDScript-Lauf endet mit 44 passed, 0 failed

  • Step 6: Commit und Hochladen
git add core/src/protocol.rs core/src/lib.rs core/tests/protocol.rs core/tests/golden.rs
git commit -F - <<'EOF'
Nachrichten zwischen Host und Gästen

Gäste grüßen mit Protokollversion, Regel-Kennung, Schlüssel und Name
und schicken danach nur Aktionen ohne Spielernummer. Der Host schickt
seine Welt, Ablehnungen mit Grund und jeden Takt mit seinen Befehlen,
jede Sekunde mit einer Prüfsumme. Ein Gruß einer anderen Version bleibt
erkennbar, und das Format ist in den festgeschriebenen Werten
verankert.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
EOF
git push

Task 3: Host und Gast im Kern

Files:

  • Create: core/src/host.rs, core/src/guest.rs
  • Modify: core/src/lib.rs (ganze Datei ersetzen)
  • Test: core/tests/session.rs

Interfaces:

  • Consumes: World::new, World::from_snapshot, World::find_player, World::player, World::players, World::to_snapshot, World::state_hash, Recording::start, Recording::step, Recording::to_bytes, rules_id, alles aus protocol, MAX_ONLINE_PLAYERS, MAX_KNOWN_PLAYERS, clamp_name

  • Produces:

    • host::Peer = u32 (die Nummer einer Verbindung; die Hülle wählt sie), host::MAX_ACTIONS_PER_TICK: u32 = 4, host::Divergence { peer: Peer, tick: u64, hash: u64 }
    • host::Host mit new(world: World), world(), recording(), join_local(key: PlayerKey, name: &str), local_player() -> Option<PlayerId>, act_local(action: Action), connect(peer: Peer), disconnect(peer: Peer), receive(peer: Peer, bytes: &[u8]), step() -> Vec<Event>, guest_count() -> usize, player_of(peer: Peer) -> Option<PlayerId>, take_outbox() -> Vec<(Peer, Vec<u8>)>, take_closing() -> Vec<Peer> und take_divergences() -> Vec<Divergence>
    • guest::GuestError mit Unreadable(DecodeError), BadWorld(SnapshotError) und TickBeforeWorld
    • guest::Arrival mit Nothing, World { replaced: bool } und Refused(Refusal)
    • guest::Guest mit new(key: PlayerKey, name: &str), world() -> Option<&World>, recording() -> Option<&Recording>, local_player() -> Option<PlayerId>, receive(bytes: &[u8]) -> Result<Arrival, GuestError>, pending_ticks() -> usize, step() -> Option<Vec<Event>>, has_diverged() -> bool, act(action: Action), take_outbox() -> Vec<Vec<u8>> und take_diverged_recording() -> Option<Vec<u8>>
  • Step 1: Fehlschlagende Tests schreiben

Create core/tests/session.rs:

//! A host and its guests, connected in memory instead of over a network.
//! They exchange the same bytes the shell sends.

use std::collections::BTreeMap;

use grovewright_core::guest::{Arrival, Guest};
use grovewright_core::host::{Host, Peer, MAX_ACTIONS_PER_TICK};
use grovewright_core::inventory::ITEM_STONE;
use grovewright_core::map::{Pos, Terrain};
use grovewright_core::mapgen::{outcrop_origin, TEST_MAP_SIZE};
use grovewright_core::player::{PlayerKey, MAX_KNOWN_PLAYERS};
use grovewright_core::protocol::{
    Action, Refusal, ToGuest, ToHost, CHECK_INTERVAL_TICKS, PROTOCOL_VERSION,
};
use grovewright_core::replay::Recording;
use grovewright_core::rng::Rng;
use grovewright_core::rules::rules_id;
use grovewright_core::world::{World, DIG_COOLDOWN_TICKS};

const HOST_KEY: PlayerKey = [100; 16];

/// A host and its guests in one process.
struct Lan {
    host: Host,
    guests: BTreeMap<Peer, Guest>,
    /// Guests whose connection the host closed, with the refusal they got.
    closed: BTreeMap<Peer, Option<Refusal>>,
    next_peer: Peer,
}

impl Lan {
    /// A host whose own player is in a new world from `seed`.
    fn new(seed: u64) -> Self {
        let mut host = Host::new(World::new(seed));
        host.join_local(HOST_KEY, "Host");
        host.step();
        Self {
            host,
            guests: BTreeMap::new(),
            closed: BTreeMap::new(),
            next_peer: 2,
        }
    }

    /// Connects a guest with a key made of `key` and lets it greet.
    fn connect(&mut self, key: u8, name: &str) -> Peer {
        let peer = self.next_peer;
        self.next_peer += 1;
        self.host.connect(peer);
        self.guests.insert(peer, Guest::new([key; 16], name));
        self.deliver();
        peer
    }

    /// Closes the connection of `peer`, as a guest that quits does.
    fn disconnect(&mut self, peer: Peer) {
        self.guests.remove(&peer);
        self.host.disconnect(peer);
    }

    /// Carries messages both ways until none are left.
    fn deliver(&mut self) {
        self.deliver_changed(|_, bytes| bytes);
    }

    /// Like [`Lan::deliver`], but `change` may alter what the host sends.
    fn deliver_changed(&mut self, mut change: impl FnMut(Peer, Vec<u8>) -> Vec<u8>) {
        loop {
            let mut moved = false;
            for (peer, guest) in self.guests.iter_mut() {
                for bytes in guest.take_outbox() {
                    self.host.receive(*peer, &bytes);
                    moved = true;
                }
            }
            for (peer, bytes) in self.host.take_outbox() {
                let bytes = change(peer, bytes);
                if let Some(guest) = self.guests.get_mut(&peer) {
                    let arrival = guest
                        .receive(&bytes)
                        .expect("the host's message is readable");
                    if let Arrival::Refused(refusal) = arrival {
                        self.closed.insert(peer, Some(refusal));
                    }
                }
                moved = true;
            }
            for peer in self.host.take_closing() {
                self.guests.remove(&peer);
                self.closed.entry(peer).or_insert(None);
                // The connection closes on both ends, and the host hears it.
                self.host.disconnect(peer);
            }
            if !moved {
                break;
            }
        }
    }

    /// Runs one tick on the host and lets every guest run what arrived.
    fn tick(&mut self) {
        self.host.step();
        self.deliver();
        for guest in self.guests.values_mut() {
            while guest.step().is_some() {}
        }
        self.deliver();
    }

    fn ticks(&mut self, count: u64) {
        for _ in 0..count {
            self.tick();
        }
    }

    fn guest(&mut self, peer: Peer) -> &mut Guest {
        self.guests.get_mut(&peer).expect("the guest is connected")
    }

    /// Asserts that every guest is in exactly the state of the host.
    fn assert_in_lockstep(&self) {
        let expected = self.host.world().state_hash();
        for (peer, guest) in &self.guests {
            assert_eq!(
                guest.world().map(World::state_hash),
                Some(expected),
                "guest {peer}"
            );
        }
    }

    /// The rock right beside the spawn clearing.
    fn rock(&self) -> Pos {
        outcrop_origin(self.host.world().map())
    }
}

/// The commands of the next tick as one guest receives them. Takes the
/// host's outbox, so the guests miss this tick.
fn commands_sent_to(host: &mut Host, peer: Peer) -> usize {
    host.take_outbox()
        .into_iter()
        .filter(|(to, _)| *to == peer)
        .find_map(|(_, bytes)| match ToGuest::decode(&bytes) {
            Ok(ToGuest::Tick { commands, .. }) => Some(commands.len()),
            _ => None,
        })
        .expect("a tick was sent")
}

#[test]
fn a_guest_gets_the_world_and_follows_every_tick() {
    let mut lan = Lan::new(5);
    let ada = lan.connect(1, "Ada");
    lan.ticks(3 * CHECK_INTERVAL_TICKS);

    lan.assert_in_lockstep();
    let guest = lan.guest(ada);
    assert!(!guest.has_diverged());
    let me = guest.local_player().expect("the guest is in the world");
    let world = guest.world().unwrap();
    assert_eq!(world.player(me).unwrap().name, "Ada");
    assert_eq!(
        world
            .players()
            .iter()
            .filter(|player| player.online)
            .count(),
        2
    );
}

#[test]
fn a_guest_joins_a_running_world_at_its_current_tick() {
    let mut lan = Lan::new(5);
    lan.ticks(100);
    let ada = lan.connect(1, "Ada");
    let tick = lan.host.world().tick();
    assert_eq!(lan.guest(ada).world().unwrap().tick(), tick);
    assert_eq!(
        lan.guest(ada).local_player(),
        None,
        "its join has not run yet"
    );

    lan.tick();
    assert!(lan.guest(ada).local_player().is_some());
    assert_eq!(lan.host.player_of(ada), lan.guest(ada).local_player());
    lan.assert_in_lockstep();
}

#[test]
fn the_actions_of_a_guest_change_the_world_of_everyone() {
    let mut lan = Lan::new(5);
    let ada = lan.connect(1, "Ada");
    let grace = lan.connect(2, "Grace");
    lan.tick();
    let rock = lan.rock();
    for _ in 0..3 {
        lan.guest(ada).act(Action::Dig { pos: rock });
        lan.ticks(DIG_COOLDOWN_TICKS + 1);
    }

    lan.assert_in_lockstep();
    let ada_player = lan.host.player_of(ada).unwrap();
    let grace_player = lan.host.player_of(grace).unwrap();
    let seen_by_grace = lan.guest(grace).world().unwrap().clone();
    assert_eq!(
        seen_by_grace.map().get(rock).unwrap().terrain,
        Terrain::Ground
    );
    assert_eq!(
        seen_by_grace
            .player(ada_player)
            .unwrap()
            .inventory
            .count(ITEM_STONE),
        1
    );
    assert_eq!(
        seen_by_grace
            .player(grace_player)
            .unwrap()
            .inventory
            .count(ITEM_STONE),
        0
    );
}

#[test]
fn the_host_takes_at_most_four_actions_per_player_and_tick() {
    let mut lan = Lan::new(5);
    let ada = lan.connect(1, "Ada");
    lan.tick();
    for x in 0..10 {
        lan.guest(ada).act(Action::Dig {
            pos: Pos::new(x, 1),
        });
        lan.host.act_local(Action::Dig {
            pos: Pos::new(x, 2),
        });
    }
    lan.deliver();
    lan.host.step();
    assert_eq!(
        commands_sent_to(&mut lan.host, ada),
        2 * MAX_ACTIONS_PER_TICK as usize
    );

    // The count starts again in the next tick.
    lan.host.act_local(Action::Dig {
        pos: Pos::new(1, 3),
    });
    lan.host.step();
    assert_eq!(commands_sent_to(&mut lan.host, ada), 1);
}

#[test]
fn a_guest_of_another_protocol_version_is_told_so() {
    let mut lan = Lan::new(5);
    let peer = 9;
    lan.host.connect(peer);
    let foreign = ToHost::ForeignHello {
        protocol: PROTOCOL_VERSION + 1,
    };
    lan.host.receive(peer, &foreign.encode());
    assert_eq!(
        lan.host.take_outbox(),
        vec![(peer, ToGuest::Refused(Refusal::OtherProtocol).encode())]
    );
    assert_eq!(lan.host.take_closing(), vec![peer]);
}

#[test]
fn a_guest_with_other_rules_is_refused() {
    let mut lan = Lan::new(5);
    let peer = 9;
    lan.host.connect(peer);
    let hello = ToHost::Hello {
        rules: rules_id() ^ 1,
        key: [1; 16],
        name: "Ada".to_owned(),
    };
    lan.host.receive(peer, &hello.encode());
    assert_eq!(
        lan.host.take_outbox(),
        vec![(peer, ToGuest::Refused(Refusal::OtherRules).encode())]
    );
    assert_eq!(lan.host.take_closing(), vec![peer]);
}

#[test]
fn a_fifth_player_is_refused_even_before_the_others_have_joined() {
    let mut lan = Lan::new(5);
    let guests = [1, 2, 3].map(|key| lan.connect(key, "Guest"));
    let fifth = lan.connect(4, "Fifth");
    assert_eq!(lan.closed.get(&fifth), Some(&Some(Refusal::WorldFull)));

    lan.tick();
    let late = lan.connect(5, "Late");
    assert_eq!(lan.closed.get(&late), Some(&Some(Refusal::WorldFull)));
    assert_eq!(lan.host.guest_count(), 3);

    // Once a guest has left, there is room again.
    lan.disconnect(guests[0]);
    lan.tick();
    let next = lan.connect(6, "Next");
    lan.tick();
    assert!(lan.host.player_of(next).is_some());
    lan.assert_in_lockstep();
}

#[test]
fn a_world_that_knows_sixteen_players_refuses_a_new_one_but_not_an_old_one() {
    let mut lan = Lan::new(5);
    // The host and fifteen guests who come and go.
    for key in 1..MAX_KNOWN_PLAYERS as u8 {
        let peer = lan.connect(key, "Visitor");
        lan.tick();
        lan.disconnect(peer);
        lan.tick();
    }
    assert_eq!(lan.host.world().players().len(), MAX_KNOWN_PLAYERS);

    let stranger = lan.connect(200, "Stranger");
    assert_eq!(
        lan.closed.get(&stranger),
        Some(&Some(Refusal::TooManyPlayers))
    );
    let regular = lan.connect(3, "Visitor");
    lan.tick();
    assert!(lan.host.player_of(regular).is_some());
}

#[test]
fn a_key_that_is_in_the_world_already_is_refused() {
    let mut lan = Lan::new(5);
    let ada = lan.connect(1, "Ada");
    let twin = lan.connect(1, "Ada");
    assert_eq!(lan.closed.get(&twin), Some(&Some(Refusal::AlreadyPlaying)));
    lan.tick();
    let impostor = lan.connect(HOST_KEY[0], "Host");
    assert_eq!(
        lan.closed.get(&impostor),
        Some(&Some(Refusal::AlreadyPlaying))
    );
    assert!(lan.host.player_of(ada).is_some());
}

#[test]
fn a_guest_that_leaves_is_offline_everywhere_and_keeps_its_bag() {
    let mut lan = Lan::new(5);
    let ada = lan.connect(1, "Ada");
    let grace = lan.connect(2, "Grace");
    lan.tick();
    let rock = lan.rock();
    for _ in 0..3 {
        lan.guest(ada).act(Action::Dig { pos: rock });
        lan.ticks(DIG_COOLDOWN_TICKS + 1);
    }
    let player = lan.host.player_of(ada).unwrap();

    lan.disconnect(ada);
    lan.tick();
    assert!(!lan.host.world().player(player).unwrap().online);
    assert!(
        !lan.guest(grace)
            .world()
            .unwrap()
            .player(player)
            .unwrap()
            .online
    );
    lan.assert_in_lockstep();

    let back = lan.connect(1, "Ada");
    lan.tick();
    assert_eq!(lan.host.player_of(back), Some(player));
    assert_eq!(lan.guest(back).local_player(), Some(player));
    assert_eq!(
        lan.host
            .world()
            .player(player)
            .unwrap()
            .inventory
            .count(ITEM_STONE),
        1
    );
    lan.assert_in_lockstep();
}

#[test]
fn a_guest_that_goes_before_its_join_ran_never_enters() {
    let mut lan = Lan::new(5);
    let ada = lan.connect(1, "Ada");
    lan.disconnect(ada);
    lan.tick();
    assert_eq!(lan.host.world().find_player(&[1; 16]), None);
    assert_eq!(lan.host.guest_count(), 0);
}

#[test]
fn a_guest_that_breaks_the_protocol_is_closed() {
    let mut lan = Lan::new(5);
    let act = ToHost::Act(Action::Dig {
        pos: Pos::new(1, 1),
    })
    .encode();
    let broken = [vec![], vec![0xEE], vec![2; 200], act.clone()];
    for (index, bytes) in broken.iter().enumerate() {
        let peer = 50 + index as Peer;
        lan.host.connect(peer);
        lan.host.receive(peer, bytes);
        assert_eq!(lan.host.take_closing(), vec![peer], "message {index}");
    }

    // Messages from a connection the host does not know are ignored.
    lan.host.receive(77, &act);
    assert!(lan.host.take_outbox().is_empty());
    assert!(lan.host.take_closing().is_empty());

    // A second greeting from a guest in the world closes it, and its
    // player leaves.
    let ada = lan.connect(1, "Ada");
    lan.tick();
    let player = lan.host.player_of(ada).unwrap();
    let hello = ToHost::Hello {
        rules: rules_id(),
        key: [1; 16],
        name: "Ada".to_owned(),
    };
    lan.host.receive(ada, &hello.encode());
    assert_eq!(lan.host.take_closing(), vec![ada]);
    lan.host.step();
    assert!(!lan.host.world().player(player).unwrap().online);
}

#[test]
fn a_guest_that_diverges_gets_the_world_again_and_keeps_the_evidence() {
    let mut lan = Lan::new(5);
    let ada = lan.connect(1, "Ada");
    lan.tick();
    // The host's player hits the rock, but Ada's copy of that tick loses the
    // command, as if her machine had run it differently.
    lan.host.act_local(Action::Dig { pos: lan.rock() });
    lan.host.step();
    lan.deliver_changed(|_, bytes| match ToGuest::decode(&bytes) {
        Ok(ToGuest::Tick { tick, check, .. }) => ToGuest::Tick {
            tick,
            commands: vec![],
            check,
        }
        .encode(),
        _ => bytes,
    });
    while lan.guest(ada).step().is_some() {}
    assert!(
        !lan.guest(ada).has_diverged(),
        "nobody notices before the next checksum"
    );

    lan.ticks(CHECK_INTERVAL_TICKS);
    lan.assert_in_lockstep();
    assert!(!lan.guest(ada).has_diverged());
    let divergences = lan.host.take_divergences();
    assert_eq!(divergences.len(), 1);
    assert_eq!(divergences[0].peer, ada);

    let evidence = lan
        .guest(ada)
        .take_diverged_recording()
        .expect("the recording of the diverged world is kept");
    let replayed = Recording::verify(&evidence).expect("the evidence replays");
    assert_eq!(replayed.state_hash(), divergences[0].hash);
    assert_eq!(lan.guest(ada).take_diverged_recording(), None);
}

#[test]
fn a_long_busy_session_stays_in_lockstep() {
    let mut lan = Lan::new(77);
    let mut rng = Rng::new(9);
    let mut peers: Vec<Peer> = [1, 2, 3].map(|key| lan.connect(key, "Busy")).to_vec();
    let area = TEST_MAP_SIZE / 3;
    let random_pos = |rng: &mut Rng| {
        Pos::new(
            (area + rng.below(area)) as i32,
            (area + rng.below(area)) as i32,
        )
    };
    for tick in 0..2_000 {
        for &peer in &peers {
            if rng.below(3) == 0 {
                let pos = random_pos(&mut rng);
                lan.guest(peer).act(Action::Dig { pos });
            }
        }
        if rng.below(2) == 0 {
            let pos = random_pos(&mut rng);
            lan.host.act_local(Action::Dig { pos });
        }
        if tick == 700 {
            lan.disconnect(peers.remove(1));
        }
        if tick == 1_100 {
            peers.push(lan.connect(2, "Busy"));
        }
        lan.tick();
    }

    lan.assert_in_lockstep();
    assert!(lan.host.take_divergences().is_empty());
    for guest in lan.guests.values() {
        let replayed = guest.recording().unwrap().replay().unwrap();
        assert_eq!(&replayed, guest.world().unwrap());
    }
}
  • Step 2: Tests ausführen und Fehlschlag sehen

Run: cargo test -p grovewright-core --test session Expected: FAIL beim Übersetzen mit error[E0432]: unresolved import `grovewright_core::guest` und error[E0432]: unresolved import `grovewright_core::host` , dazu einigen Folgefehlern

  • Step 3: Host und Gast einbauen

Create core/src/host.rs:

//! The host's side of a shared world: the world, its recording and the
//! guests connected to it.
//!
//! The host runs the ticks. Its own player's actions and the actions its
//! guests send become commands of the next tick, and every tick goes out to
//! every accepted guest. A solo game is a host without guests.

use std::collections::BTreeMap;

use crate::command::Command;
use crate::player::{clamp_name, PlayerId, PlayerKey, MAX_KNOWN_PLAYERS, MAX_ONLINE_PLAYERS};
use crate::protocol::{Action, Refusal, ToGuest, ToHost, CHECK_INTERVAL_TICKS};
use crate::replay::Recording;
use crate::rules::rules_id;
use crate::world::{Event, World};

/// Identifies a connection. The shell chooses the numbers.
pub type Peer = u32;

/// Most actions one player may take per tick. Further actions in the same
/// tick are dropped, so that no guest can flood the world with commands.
pub const MAX_ACTIONS_PER_TICK: u32 = 4;

/// A guest reported that its state differs from the host's checksum.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Divergence {
    pub peer: Peer,
    /// The guest's tick when it noticed.
    pub tick: u64,
    /// The hash of the guest's state at that tick.
    pub hash: u64,
}

/// Where the connection of a guest stands.
#[derive(Clone, Debug, PartialEq, Eq)]
enum Link {
    /// Connected, but the guest has not greeted yet.
    Greeting,
    /// Accepted. The guest has the world, and its join runs in the next tick.
    Joining { key: PlayerKey },
    /// In the world as `player`. `actions` counts its actions in this tick.
    Playing { player: PlayerId, actions: u32 },
}

/// The host of a shared world.
pub struct Host {
    world: World,
    recording: Recording,
    rules: u64,
    local_key: Option<PlayerKey>,
    local_actions: u32,
    /// Commands for the next tick, in the order they arrived.
    inbox: Vec<Command>,
    links: BTreeMap<Peer, Link>,
    outbox: Vec<(Peer, Vec<u8>)>,
    closing: Vec<Peer>,
    divergences: Vec<Divergence>,
}

impl Host {
    /// Hosts `world`, new or loaded. Nobody is in it yet.
    pub fn new(world: World) -> Self {
        Self {
            recording: Recording::start(&world),
            world,
            rules: rules_id(),
            local_key: None,
            local_actions: 0,
            inbox: Vec::new(),
            links: BTreeMap::new(),
            outbox: Vec::new(),
            closing: Vec::new(),
            divergences: Vec::new(),
        }
    }

    pub fn world(&self) -> &World {
        &self.world
    }

    /// The recording of everything that happened since the world was hosted.
    pub fn recording(&self) -> &Recording {
        &self.recording
    }

    /// Queues the entry of the host's own player for the next tick.
    pub fn join_local(&mut self, key: PlayerKey, name: &str) {
        self.local_key = Some(key);
        self.inbox.push(Command::Join {
            key,
            name: clamp_name(name),
        });
    }

    /// The host's own player, once it is in the world.
    pub fn local_player(&self) -> Option<PlayerId> {
        online_player(&self.world, &self.local_key?)
    }

    /// Queues an action of the host's own player for the next tick. Does
    /// nothing before the player is in the world.
    pub fn act_local(&mut self, action: Action) {
        let Some(player) = self.local_player() else {
            return;
        };
        if self.local_actions < MAX_ACTIONS_PER_TICK {
            self.local_actions += 1;
            self.inbox.push(action.command(player));
        }
    }

    /// A guest connected as `peer`. Its first message must be a greeting.
    pub fn connect(&mut self, peer: Peer) {
        self.links.entry(peer).or_insert(Link::Greeting);
    }

    /// The connection `peer` closed. A guest in the world leaves it in the
    /// next tick; an accepted guest whose join has not run yet never enters.
    pub fn disconnect(&mut self, peer: Peer) {
        match self.links.remove(&peer) {
            Some(Link::Joining { key }) => self.inbox.retain(
                |command| !matches!(command, Command::Join { key: joining, .. } if *joining == key),
            ),
            Some(Link::Playing { player, .. }) => self.inbox.push(Command::Leave { player }),
            Some(Link::Greeting) | None => {}
        }
    }

    /// Handles a message that arrived from `peer`. A guest that breaks the
    /// protocol is closed; see [`Host::take_closing`].
    pub fn receive(&mut self, peer: Peer, bytes: &[u8]) {
        let Some(link) = self.links.get(&peer).cloned() else {
            return;
        };
        match (link, ToHost::decode(bytes)) {
            (Link::Greeting, Ok(ToHost::Hello { rules, key, name })) => {
                self.greet(peer, rules, key, &name);
            }
            (Link::Greeting, Ok(ToHost::ForeignHello { .. })) => {
                self.refuse(peer, Refusal::OtherProtocol);
            }
            (Link::Playing { player, actions }, Ok(ToHost::Act(action))) => {
                if actions < MAX_ACTIONS_PER_TICK {
                    self.links.insert(
                        peer,
                        Link::Playing {
                            player,
                            actions: actions + 1,
                        },
                    );
                    self.inbox.push(action.command(player));
                }
            }
            // The guest acted before its join ran. It cannot know its player
            // yet, so the action is dropped.
            (Link::Joining { .. }, Ok(ToHost::Act(_))) => {}
            (Link::Joining { .. } | Link::Playing { .. }, Ok(ToHost::Diverged { tick, hash })) => {
                self.divergences.push(Divergence { peer, tick, hash });
                self.send(
                    peer,
                    &ToGuest::World {
                        snapshot: self.world.to_snapshot(),
                    },
                );
            }
            _ => {
                self.disconnect(peer);
                self.closing.push(peer);
            }
        }
    }

    fn greet(&mut self, peer: Peer, rules: u64, key: PlayerKey, name: &str) {
        if let Err(refusal) = self.admission(rules, &key) {
            self.refuse(peer, refusal);
            return;
        }
        self.send(
            peer,
            &ToGuest::World {
                snapshot: self.world.to_snapshot(),
            },
        );
        self.inbox.push(Command::Join {
            key,
            name: clamp_name(name),
        });
        self.links.insert(peer, Link::Joining { key });
    }

    /// Whether a guest with `rules` and `key` may join now. Joins that are
    /// queued for the next tick count as if they had run.
    fn admission(&self, rules: u64, key: &PlayerKey) -> Result<(), Refusal> {
        if rules != self.rules {
            return Err(Refusal::OtherRules);
        }
        let queued: Vec<&PlayerKey> = self
            .inbox
            .iter()
            .filter_map(|command| match command {
                Command::Join { key, .. } => Some(key),
                _ => None,
            })
            .collect();
        if online_player(&self.world, key).is_some() || queued.contains(&key) {
            return Err(Refusal::AlreadyPlaying);
        }
        let online = self
            .world
            .players()
            .iter()
            .filter(|player| player.online)
            .count();
        let entering = queued
            .iter()
            .filter(|queued| online_player(&self.world, queued).is_none())
            .count();
        if online + entering >= MAX_ONLINE_PLAYERS {
            return Err(Refusal::WorldFull);
        }
        let new = queued
            .iter()
            .filter(|queued| self.world.find_player(queued).is_none())
            .count();
        if self.world.find_player(key).is_none()
            && self.world.players().len() + new >= MAX_KNOWN_PLAYERS
        {
            return Err(Refusal::TooManyPlayers);
        }
        Ok(())
    }

    fn refuse(&mut self, peer: Peer, refusal: Refusal) {
        self.send(peer, &ToGuest::Refused(refusal));
        self.links.remove(&peer);
        self.closing.push(peer);
    }

    fn send(&mut self, peer: Peer, message: &ToGuest) {
        self.outbox.push((peer, message.encode()));
    }

    /// Runs the next tick with every queued command, sends it to every
    /// accepted guest and returns what happened.
    pub fn step(&mut self) -> Vec<Event> {
        let commands = std::mem::take(&mut self.inbox);
        let tick = self.world.tick();
        let events = self.recording.step(&mut self.world, &commands);
        let check = self
            .world
            .tick()
            .is_multiple_of(CHECK_INTERVAL_TICKS)
            .then(|| self.world.state_hash());
        let message = ToGuest::Tick {
            tick,
            commands,
            check,
        }
        .encode();
        for (peer, link) in &self.links {
            if *link != Link::Greeting {
                self.outbox.push((*peer, message.clone()));
            }
        }

        self.local_actions = 0;
        let mut refused = Vec::new();
        for (peer, link) in self.links.iter_mut() {
            match link {
                Link::Joining { key } => match online_player(&self.world, key) {
                    Some(player) => *link = Link::Playing { player, actions: 0 },
                    None => refused.push(*peer),
                },
                Link::Playing { actions, .. } => *actions = 0,
                Link::Greeting => {}
            }
        }
        // The admission check keeps the world from refusing an accepted
        // guest. Should it happen anyway, the guest learns why.
        for peer in refused {
            self.refuse(peer, Refusal::WorldFull);
        }
        events
    }

    /// Number of accepted guests, including those whose join has not run.
    pub fn guest_count(&self) -> usize {
        self.links
            .values()
            .filter(|link| **link != Link::Greeting)
            .count()
    }

    /// The player of the guest connected as `peer`, once it is in the world.
    pub fn player_of(&self, peer: Peer) -> Option<PlayerId> {
        match self.links.get(&peer) {
            Some(Link::Playing { player, .. }) => Some(*player),
            _ => None,
        }
    }

    /// Messages to send, as (peer, bytes), in the order they were made.
    pub fn take_outbox(&mut self) -> Vec<(Peer, Vec<u8>)> {
        std::mem::take(&mut self.outbox)
    }

    /// Connections the host has given up. The shell closes them after it
    /// has sent the messages for them.
    pub fn take_closing(&mut self) -> Vec<Peer> {
        std::mem::take(&mut self.closing)
    }

    /// Divergences the guests reported since the last call.
    pub fn take_divergences(&mut self) -> Vec<Divergence> {
        std::mem::take(&mut self.divergences)
    }
}

/// The player with `key`, if it is online in `world`.
fn online_player(world: &World, key: &PlayerKey) -> Option<PlayerId> {
    let id = world.find_player(key)?;
    world.player(id)?.online.then_some(id)
}

Create core/src/guest.rs:

//! A guest's side of a shared world. The guest gets the host's world once
//! and then runs every tick the host sends, with the same commands, so that
//! both worlds stay the same. Checksums from the host show whether they do.

use std::collections::VecDeque;
use std::fmt;

use crate::codec::DecodeError;
use crate::command::Command;
use crate::player::{clamp_name, PlayerId, PlayerKey};
use crate::protocol::{Action, Refusal, ToGuest, ToHost};
use crate::replay::Recording;
use crate::rules::rules_id;
use crate::snapshot::SnapshotError;
use crate::world::{Event, World};

/// Reason a guest cannot go on with the session.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum GuestError {
    /// A message from the host could not be read.
    Unreadable(DecodeError),
    /// The world the host sent could not be restored.
    BadWorld(SnapshotError),
    /// The host sent a tick before it sent its world.
    TickBeforeWorld,
}

impl fmt::Display for GuestError {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            GuestError::Unreadable(error) => write!(f, "unreadable message from the host: {error}"),
            GuestError::BadWorld(error) => write!(f, "the host's world is unusable: {error}"),
            GuestError::TickBeforeWorld => write!(f, "the host sent a tick before its world"),
        }
    }
}

impl std::error::Error for GuestError {}

/// What a message from the host changed, apart from queued ticks.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Arrival {
    /// Nothing the shell has to react to right away.
    Nothing,
    /// The host's world arrived. `replaced` is true when it replaced a world
    /// that had diverged.
    World { replaced: bool },
    /// The host turned the guest away.
    Refused(Refusal),
}

/// A tick from the host that has not run yet.
struct PendingTick {
    tick: u64,
    commands: Vec<Command>,
    check: Option<u64>,
}

/// The world the guest follows, with the recording of everything it ran.
struct Following {
    world: World,
    recording: Recording,
}

/// The guest side of a shared world.
pub struct Guest {
    key: PlayerKey,
    following: Option<Following>,
    /// True after a divergence, until the host's world arrives again.
    diverged: bool,
    pending: VecDeque<PendingTick>,
    outbox: Vec<Vec<u8>>,
    diverged_recording: Option<Vec<u8>>,
}

impl Guest {
    /// A guest that greets the host as the player `key` named `name`. The
    /// greeting waits in the outbox.
    pub fn new(key: PlayerKey, name: &str) -> Self {
        let hello = ToHost::Hello {
            rules: rules_id(),
            key,
            name: clamp_name(name),
        };
        Self {
            key,
            following: None,
            diverged: false,
            pending: VecDeque::new(),
            outbox: vec![hello.encode()],
            diverged_recording: None,
        }
    }

    /// The world, once the host has sent it.
    pub fn world(&self) -> Option<&World> {
        self.following.as_ref().map(|following| &following.world)
    }

    /// The recording of every tick this guest ran since the host's world
    /// last arrived.
    pub fn recording(&self) -> Option<&Recording> {
        self.following
            .as_ref()
            .map(|following| &following.recording)
    }

    /// The guest's player, once its join has run.
    pub fn local_player(&self) -> Option<PlayerId> {
        let world = self.world()?;
        let id = world.find_player(&self.key)?;
        world.player(id)?.online.then_some(id)
    }

    /// Handles a message from the host.
    pub fn receive(&mut self, bytes: &[u8]) -> Result<Arrival, GuestError> {
        match ToGuest::decode(bytes).map_err(GuestError::Unreadable)? {
            ToGuest::World { snapshot } => {
                let world = World::from_snapshot(&snapshot).map_err(GuestError::BadWorld)?;
                let replaced = self.diverged;
                if let Some(old) = self.following.take() {
                    if replaced {
                        self.diverged_recording = Some(old.recording.to_bytes(&old.world));
                    }
                }
                // Ticks sent before this world are part of it already.
                let tick = world.tick();
                self.pending.retain(|pending| pending.tick >= tick);
                self.following = Some(Following {
                    recording: Recording::start(&world),
                    world,
                });
                self.diverged = false;
                Ok(Arrival::World { replaced })
            }
            ToGuest::Refused(refusal) => Ok(Arrival::Refused(refusal)),
            ToGuest::Tick {
                tick,
                commands,
                check,
            } => {
                if self.following.is_none() {
                    return Err(GuestError::TickBeforeWorld);
                }
                self.pending.push_back(PendingTick {
                    tick,
                    commands,
                    check,
                });
                Ok(Arrival::Nothing)
            }
        }
    }

    /// Ticks that arrived and have not run yet.
    pub fn pending_ticks(&self) -> usize {
        self.pending.len()
    }

    /// Runs the oldest tick that arrived and returns what happened, or
    /// `None` if there is none to run. After a divergence no tick runs until
    /// the host's world arrives again.
    pub fn step(&mut self) -> Option<Vec<Event>> {
        if self.diverged {
            return None;
        }
        let following = self.following.as_mut()?;
        while let Some(next) = self.pending.pop_front() {
            let expected = following.world.tick();
            if next.tick < expected {
                continue;
            }
            if next.tick > expected {
                // A tick is missing: this world can no longer follow.
                self.diverge();
                return None;
            }
            let events = following
                .recording
                .step(&mut following.world, &next.commands);
            if next
                .check
                .is_some_and(|check| check != following.world.state_hash())
            {
                self.diverge();
            }
            return Some(events);
        }
        None
    }

    /// Reports the current state to the host and stops running ticks until
    /// the host's world arrives again.
    fn diverge(&mut self) {
        let Some(world) = self.world() else {
            return;
        };
        let report = ToHost::Diverged {
            tick: world.tick(),
            hash: world.state_hash(),
        };
        self.outbox.push(report.encode());
        self.diverged = true;
    }

    /// True after a divergence, until the host's world arrives again.
    pub fn has_diverged(&self) -> bool {
        self.diverged
    }

    /// Asks the host to carry out an action of the guest's player. Does
    /// nothing before the player is in the world or while the world is
    /// diverged.
    pub fn act(&mut self, action: Action) {
        if self.diverged || self.local_player().is_none() {
            return;
        }
        self.outbox.push(ToHost::Act(action).encode());
    }

    /// Messages for the host, in the order they were made.
    pub fn take_outbox(&mut self) -> Vec<Vec<u8>> {
        std::mem::take(&mut self.outbox)
    }

    /// The encoded recording of the world that diverged last, kept so that
    /// the divergence can be replayed. Empty after it has been taken.
    pub fn take_diverged_recording(&mut self) -> Option<Vec<u8>> {
        self.diverged_recording.take()
    }
}

Ersetze den gesamten Inhalt von core/src/lib.rs durch:

//! 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 guest;
pub mod hash;
pub mod host;
pub mod inventory;
pub mod map;
pub mod mapgen;
pub mod player;
pub mod protocol;
pub mod replay;
pub mod rng;
pub mod rules;
pub mod snapshot;
pub mod world;
  • Step 4: Tests ausführen und Erfolg sehen

Run: cargo test -p grovewright-core --test session Expected: PASS mit test result: ok. 14 passed

  • Step 5: Alles prüfen

Run:

cargo fmt --all -- --check && cargo clippy --workspace --all-targets -- -D warnings && cargo test --workspace
cargo build -p grovewright-bridge
godot --headless --path game --import
godot --headless --path game -s res://tests/run_tests.gd

Expected: keine Ausgabe von rustfmt, keine Warnung von Clippy, zusammen 137 bestandene Rust-Tests; der GDScript-Lauf endet mit 44 passed, 0 failed

  • Step 6: Commit und Hochladen
git add core/src/host.rs core/src/guest.rs core/src/lib.rs core/tests/session.rs
git commit -F - <<'EOF'
Host und Gast im Kern

Der Host nimmt Gäste auf, macht aus ihren Aktionen Befehle für den
Spieler ihrer Verbindung, begrenzt sie pro Takt und schickt jeden Takt
an alle Gäste. Gäste rechnen jeden Takt nach, vergleichen die
Prüfsummen und holen sich nach einer Abweichung die Welt des Hosts neu.
Tests verbinden Host und Gäste im Speicher, auch über 2000 Takte mit
Gehen und Wiederkommen.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
EOF
git push

Task 4: Brücke für Host und Gast

Files:

  • Create: bridge/src/driver.rs (mit eigenen Rust-Tests)
  • Modify: bridge/src/session.rs (ganze Datei ersetzen)
  • Modify: bridge/src/events.rs (ganze Datei ersetzen)
  • Modify: bridge/src/lib.rs (ganze Datei ersetzen)
  • Test: game/tests/test_session.gd (ganze Datei ersetzen)

Interfaces:

  • Consumes: Host, Guest, Arrival, GuestError, Divergence, Peer, Action, Refusal, World::new, World::load, spawn_pos

  • Produces:

    • driver::HOST_PEER: Peer = 1, driver::Role mit Host(Host) und Guest(Guest), driver::Driver mit host(world), guest(key, name), guarded(f) und den Methoden, die GwSession braucht. Eine Panik im Kern macht den Driver dauerhaft defekt: Er rechnet, speichert und zeichnet nichts mehr auf.
    • Für GDScript bleibt GwSession mit allem aus M1b. Neu sind die Konstanten MAX_ONLINE_PLAYERS (4), HOST_PEER (1) und REFUSED_OTHER_PROTOCOL (1), REFUSED_OTHER_RULES (2), REFUSED_WORLD_FULL (3), REFUSED_TOO_MANY_PLAYERS (4), REFUSED_ALREADY_PLAYING (5).
    • Neue Methoden:
      • start_guest(key: PackedByteArray, name: String) -> bool, is_host() -> bool, pending_ticks() -> int
      • receive(peer: int, bytes: PackedByteArray) -> Array[Dictionary]. Ein Gast bekommt Hinweise mit type gleich world_received (Eintrag resync), refused (Eintrag reason) oder protocol_error.
      • connect_peer(peer: int), disconnect_peer(peer: int)
      • take_messages() -> Array[Dictionary] mit den Einträgen peer und bytes; ein Gast schreibt immer an HOST_PEER
      • take_closing() -> PackedInt64Array, take_divergences() -> Array[Dictionary] (Einträge peer, tick, hash), take_diverged_recording() -> PackedByteArray
      • guest_count() -> int, player_of_peer(peer: int) -> int, online_players() -> PackedInt64Array, player_name(player: int) -> String
    • Geändert:
      • join gilt nur für den Host, ein Gast tritt mit start_guest bei.
      • dig reiht beim Host einen Befehl ein und schickt beim Gast eine Aktion an den Host.
      • step rechnet beim Gast den ältesten angekommenen Takt.
      • save ist beim Gast immer leer.
      • Das Ereignis join_refused trägt jetzt den Schlüssel unter key.
  • Step 1: Fehlschlagende Tests schreiben

Ersetze den gesamten Inhalt von game/tests/test_session.gd durch:

extends TestCase
## Tests of the bridge: GwSession is the Rust core, seen from GDScript.

var key := PackedByteArray([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16])


func _joined_session() -> GwSession:
	var session := GwSession.new()
	session.start_new(7)
	session.join(key, "Ada")
	session.step()
	return session


func _first_soft_rock(session: GwSession) -> Vector2i:
	var terrain := session.terrain_codes()
	var width := session.map_width()
	for index in terrain.size():
		if terrain[index] == GwSession.TERRAIN_SOFT_ROCK:
			@warning_ignore("integer_division")
			return Vector2i(index % width, index / width)
	return Vector2i(-1, -1)


## Hits `tile` three times, waiting out the cooldown, and returns all events.
func _dig_three_times(session: GwSession, tile: Vector2i) -> Array[Dictionary]:
	var events: Array[Dictionary] = []
	for hit in 3:
		session.dig(tile.x, tile.y)
		events.append_array(session.step())
		for wait in GwSession.DIG_COOLDOWN_TICKS:
			session.step()
	return events


func test_a_session_without_a_world_is_harmless() -> void:
	var session := GwSession.new()
	check(not session.is_running(), "nothing runs yet")
	check_eq(session.tick(), 0, "tick")
	check_eq(session.step().size(), 0, "no events")
	check_eq(session.save().size(), 0, "nothing to save")
	check_eq(session.local_player(), -1, "no local player")


func test_joining_makes_the_player_local() -> void:
	var session := GwSession.new()
	session.start_new(7)
	check(session.join(key, "Ada"), "join accepted")
	check_eq(session.local_player(), -1, "not before the tick")
	var events := session.step()
	check_eq(events.size(), 1, "one event")
	check_eq(events[0]["type"], "player_joined", "event type")
	check_eq(session.local_player(), 0, "local player after the tick")
	check_eq(session.tick(), 1, "one tick ran")


func test_a_key_needs_sixteen_bytes() -> void:
	var session := GwSession.new()
	session.start_new(7)
	check(not session.join(PackedByteArray([1, 2, 3]), "Ada"), "short key refused")
	check(not session.last_error().is_empty(), "the reason is given")


func test_the_map_can_be_read() -> void:
	var session := _joined_session()
	check_eq(session.map_width(), 64, "width")
	check_eq(session.map_height(), 64, "height")
	check_eq(session.terrain_codes().size(), 64 * 64, "one code per tile")
	check_eq(session.damage_codes().size(), 64 * 64, "one damage per tile")
	var spawn := session.spawn_point()
	check_eq(spawn, Vector2i(32, 32), "spawn in the centre")
	check(session.is_walkable(spawn.x, spawn.y), "the spawn can be walked on")
	check_eq(session.terrain_at(0, 0), GwSession.TERRAIN_BEDROCK, "the border is bedrock")
	check_eq(session.terrain_at(-1, 0), -1, "outside the map")
	check(not session.is_walkable(500, 500), "outside the map is not walkable")


func test_three_hits_break_soft_rock_and_give_a_stone() -> void:
	var session := _joined_session()
	var rock := _first_soft_rock(session)
	check(rock.x >= 0, "the map has soft rock")
	var events := _dig_three_times(session, rock)
	var types := events.map(func(event: Dictionary) -> String: return event["type"])
	check_eq(types, ["tile_hit", "tile_hit", "tile_changed", "item_gained"], "event order")
	check_eq(session.terrain_at(rock.x, rock.y), GwSession.TERRAIN_GROUND, "the rock is gone")
	check_eq(session.item_count(GwSession.ITEM_STONE), 1, "one stone")


func test_digging_before_joining_does_nothing() -> void:
	var session := GwSession.new()
	session.start_new(7)
	var rock := _first_soft_rock(session)
	session.dig(rock.x, rock.y)
	check_eq(session.step().size(), 0, "no events")
	check_eq(session.damage_codes()[rock.y * session.map_width() + rock.x], 0, "no damage")


func test_a_save_keeps_the_world_and_the_player_comes_back() -> void:
	var session := _joined_session()
	_dig_three_times(session, _first_soft_rock(session))
	var save := session.save()
	check(save.size() > 0, "a save was made")

	var loaded := GwSession.new()
	check(loaded.start_loaded(save), "the save loads")
	check_eq(loaded.tick(), session.tick(), "same tick")
	check_eq(loaded.terrain_codes(), session.terrain_codes(), "same map")
	check_eq(loaded.local_player(), -1, "nobody has joined the loaded world yet")
	loaded.join(key, "Ada")
	loaded.step()
	check_eq(loaded.local_player(), 0, "the player is the same")
	check_eq(loaded.item_count(GwSession.ITEM_STONE), 1, "the stone is still there")


func test_a_damaged_save_is_refused_and_the_running_world_stays() -> void:
	var session := _joined_session()
	var hash_before := session.state_hash_hex()
	var save := session.save()
	var middle := save.size() >> 1
	save[middle] = save[middle] ^ 0xFF
	check(not session.start_loaded(save), "a damaged save is refused")
	check(session.last_error().contains("checksum"), "the reason names the checksum: %s" % session.last_error())
	check(not session.start_loaded(PackedByteArray([1, 2, 3])), "random bytes are refused")
	check_eq(session.state_hash_hex(), hash_before, "the running world is untouched")


func test_the_recording_of_a_session_is_available() -> void:
	var session := _joined_session()
	session.step()
	var recording := session.recording_bytes()
	check(recording.size() > 0, "a recording exists")
	check_eq(recording.slice(0, 4), "GRWR".to_ascii_buffer(), "it starts with the recording marker")


func test_the_state_hash_changes_with_every_tick() -> void:
	var session := _joined_session()
	var first := session.state_hash_hex()
	session.step()
	check_eq(first.length(), 16, "sixteen hexadecimal digits")
	check(session.state_hash_hex() != first, "the tick is part of the state")


var guest_key := PackedByteArray([2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2])


## Carries every waiting message between `host` and `guest`, which is
## connected to the host as peer 5, until none are left. Returns the notices
## the guest got.
func _carry(host: GwSession, guest: GwSession) -> Array[Dictionary]:
	var notices: Array[Dictionary] = []
	var moved := true
	while moved:
		moved = false
		for message in guest.take_messages():
			check_eq(message["peer"], GwSession.HOST_PEER, "a guest writes to its host")
			host.receive(5, message["bytes"])
			moved = true
		for message in host.take_messages():
			if message["peer"] == 5:
				notices.append_array(guest.receive(GwSession.HOST_PEER, message["bytes"]))
				moved = true
	return notices


## A host whose player Ada is in the world, and the guest Grace at peer 5,
## whose join has run.
func _host_and_guest() -> Array[GwSession]:
	var host := _joined_session()
	host.connect_peer(5)
	var guest := GwSession.new()
	guest.start_guest(guest_key, "Grace")
	_carry(host, guest)
	host.step()
	_carry(host, guest)
	while guest.pending_ticks() > 0:
		guest.step()
	return [host, guest]


func test_a_guest_follows_its_host_through_the_bridge() -> void:
	var host := _joined_session()
	host.connect_peer(5)
	var guest := GwSession.new()
	check(guest.start_guest(guest_key, "Grace"), "the guest starts")
	check(not guest.is_host(), "as a guest")
	check(not guest.is_running(), "without a world before the host answers")
	check_eq(_carry(host, guest), [{"type": "world_received", "resync": false}], "the host's world arrives")
	check_eq(guest.tick(), host.tick(), "at the host's tick")

	for tick in 25:
		host.step()
	_carry(host, guest)
	check_eq(guest.pending_ticks(), 25, "the ticks wait until the guest runs them")
	while guest.pending_ticks() > 0:
		guest.step()
	check_eq(guest.state_hash_hex(), host.state_hash_hex(), "both worlds are the same")
	check_eq(guest.local_player(), 1, "the guest is player 1")
	check_eq(host.player_of_peer(5), 1, "the host knows whose connection it is")
	check_eq(host.guest_count(), 1, "one guest")
	check_eq(guest.player_name(0), "Ada", "the guest knows the host's player")
	check_eq(guest.online_players(), PackedInt64Array([0, 1]), "two players online")
	check_eq(guest.save().size(), 0, "a guest never saves the host's world")
	check(guest.recording_bytes().size() > 0, "but it records its session")


func test_the_hit_of_a_guest_becomes_a_message_to_the_host() -> void:
	var pair := _host_and_guest()
	var host := pair[0]
	var guest := pair[1]
	var rock := _first_soft_rock(host)
	guest.dig(rock.x, rock.y)
	_carry(host, guest)
	host.step()
	_carry(host, guest)
	var events: Array[Dictionary] = []
	while guest.pending_ticks() > 0:
		events.append_array(guest.step())
	check_eq(events.map(func(event: Dictionary) -> String: return event["type"]), ["tile_hit"], "the guest sees its hit")
	check_eq(host.damage_codes()[rock.y * host.map_width() + rock.x], 1, "and so does the host")


func test_a_guest_learns_why_it_is_turned_away() -> void:
	var host := _joined_session()
	host.connect_peer(5)
	var twin := GwSession.new()
	twin.start_guest(key, "Ada")
	check_eq(_carry(host, twin), [{"type": "refused", "reason": GwSession.REFUSED_ALREADY_PLAYING}], "Ada is in the world already")
	check_eq(host.take_closing(), PackedInt64Array([5]), "the host gives up the connection")


func test_an_unreadable_message_from_the_host_is_reported() -> void:
	var guest := GwSession.new()
	guest.start_guest(guest_key, "Grace")
	check_eq(guest.receive(GwSession.HOST_PEER, PackedByteArray([0xEE])), [{"type": "protocol_error"}], "a notice")
	check(guest.last_error().contains("unreadable"), "with the reason: %s" % guest.last_error())
	check(not guest.start_guest(PackedByteArray([1, 2]), "Grace"), "a short key is refused")
  • Step 2: Tests ausführen und Fehlschlag sehen

Run:

godot --headless --path game --import
godot --headless --path game -s res://tests/run_tests.gd

Expected: Der Testlauf endet mit Exit-Code 1 und 34 passed, 1 failed. Davor stehen Zeilen wie SCRIPT ERROR: Parse Error: Cannot find member "HOST_PEER" in base "GwSession". und FAIL test_session.gd the script does not load; see the error above.

  • Step 3: Brücke umbauen

Create bridge/src/driver.rs:

//! The running session behind `GwSession`, without Godot types, so that its
//! handling of internal errors can be tested in plain Rust.

use std::panic::{catch_unwind, AssertUnwindSafe};

use grovewright_core::guest::{Arrival, Guest, GuestError};
use grovewright_core::host::{Divergence, Host, Peer};
use grovewright_core::player::{PlayerId, PlayerKey};
use grovewright_core::protocol::Action;
use grovewright_core::world::{Event, World};

/// The connection number under which a guest reaches its host. The shell's
/// network layer gives the host this number.
pub const HOST_PEER: Peer = 1;

/// The machine's part in the session.
pub enum Role {
    Host(Host),
    Guest(Guest),
}

/// A session in either role. After an internal error of the core it is
/// broken: the world may be half-updated, so nothing runs, saves or records
/// any more.
pub struct Driver {
    role: Role,
    broken: bool,
}

impl Driver {
    /// Hosts `world`, new or loaded.
    pub fn host(world: World) -> Self {
        Self {
            role: Role::Host(Host::new(world)),
            broken: false,
        }
    }

    /// Joins a host as the player `key` named `name`. The greeting waits in
    /// the outbox.
    pub fn guest(key: PlayerKey, name: &str) -> Self {
        Self {
            role: Role::Guest(Guest::new(key, name)),
            broken: false,
        }
    }

    pub fn is_host(&self) -> bool {
        matches!(self.role, Role::Host(_))
    }

    pub fn is_broken(&self) -> bool {
        self.broken
    }

    /// The world, once there is one. A guest has it after the host sent it.
    pub fn world(&self) -> Option<&World> {
        match &self.role {
            Role::Host(host) => Some(host.world()),
            Role::Guest(guest) => guest.world(),
        }
    }

    /// The player of this machine, once it is in the world.
    pub fn local_player(&self) -> Option<PlayerId> {
        match &self.role {
            Role::Host(host) => host.local_player(),
            Role::Guest(guest) => guest.local_player(),
        }
    }

    /// Runs `f` on the role unless the session is broken. A panic inside
    /// marks the session as broken and gives `None`.
    pub fn guarded<R>(&mut self, f: impl FnOnce(&mut Role) -> R) -> Option<R> {
        if self.broken {
            return None;
        }
        let role = &mut self.role;
        match catch_unwind(AssertUnwindSafe(|| f(role))) {
            Ok(result) => Some(result),
            Err(_) => {
                self.broken = true;
                None
            }
        }
    }

    /// Queues the entry of the host's own player. Returns false for a guest,
    /// which enters through its greeting.
    pub fn join_local(&mut self, key: PlayerKey, name: &str) -> bool {
        self.guarded(|role| match role {
            Role::Host(host) => {
                host.join_local(key, name);
                true
            }
            Role::Guest(_) => false,
        })
        .unwrap_or(false)
    }

    /// Carries out an action of this machine's player: the host queues it
    /// for the next tick, a guest sends it to the host.
    pub fn act(&mut self, action: Action) {
        self.guarded(|role| match role {
            Role::Host(host) => host.act_local(action),
            Role::Guest(guest) => guest.act(action),
        });
    }

    /// Runs one tick: the host its next tick, a guest the oldest tick that
    /// arrived. `None` if a guest has no tick to run or the session broke.
    pub fn step(&mut self) -> Option<Vec<Event>> {
        self.guarded(|role| match role {
            Role::Host(host) => Some(host.step()),
            Role::Guest(guest) => guest.step(),
        })
        .flatten()
    }

    /// Ticks that arrived at a guest and have not run yet.
    pub fn pending_ticks(&self) -> usize {
        match &self.role {
            Role::Host(_) => 0,
            Role::Guest(guest) => guest.pending_ticks(),
        }
    }

    /// Hands a message to the session. A host learns from `peer` who sent
    /// it; a guest only hears from its host.
    pub fn receive(&mut self, peer: Peer, bytes: &[u8]) -> Result<Arrival, GuestError> {
        self.guarded(|role| match role {
            Role::Host(host) => {
                host.receive(peer, bytes);
                Ok(Arrival::Nothing)
            }
            Role::Guest(guest) => guest.receive(bytes),
        })
        .unwrap_or(Ok(Arrival::Nothing))
    }

    /// A guest connected to the host as `peer`.
    pub fn connect(&mut self, peer: Peer) {
        if let Role::Host(host) = &mut self.role {
            host.connect(peer);
        }
    }

    /// The connection `peer` of the host closed.
    pub fn disconnect(&mut self, peer: Peer) {
        if let Role::Host(host) = &mut self.role {
            host.disconnect(peer);
        }
    }

    /// Messages to send, as (peer, bytes). A guest's messages go to
    /// [`HOST_PEER`].
    pub fn take_messages(&mut self) -> Vec<(Peer, Vec<u8>)> {
        match &mut self.role {
            Role::Host(host) => host.take_outbox(),
            Role::Guest(guest) => guest
                .take_outbox()
                .into_iter()
                .map(|bytes| (HOST_PEER, bytes))
                .collect(),
        }
    }

    /// Connections the host has given up.
    pub fn take_closing(&mut self) -> Vec<Peer> {
        match &mut self.role {
            Role::Host(host) => host.take_closing(),
            Role::Guest(_) => Vec::new(),
        }
    }

    /// Divergences the host's guests reported.
    pub fn take_divergences(&mut self) -> Vec<Divergence> {
        match &mut self.role {
            Role::Host(host) => host.take_divergences(),
            Role::Guest(_) => Vec::new(),
        }
    }

    /// The encoded recording of a guest's world that diverged.
    pub fn take_diverged_recording(&mut self) -> Option<Vec<u8>> {
        match &mut self.role {
            Role::Host(_) => None,
            Role::Guest(guest) => guest.take_diverged_recording(),
        }
    }

    /// Number of the host's accepted guests.
    pub fn guest_count(&self) -> usize {
        match &self.role {
            Role::Host(host) => host.guest_count(),
            Role::Guest(_) => 0,
        }
    }

    /// The player of the host's guest at `peer`.
    pub fn player_of(&self, peer: Peer) -> Option<PlayerId> {
        match &self.role {
            Role::Host(host) => host.player_of(peer),
            Role::Guest(_) => None,
        }
    }

    /// The world as a save. Only the host saves: the world belongs to the
    /// host, and a guest must never overwrite its own save with it.
    pub fn save(&self) -> Option<Vec<u8>> {
        match &self.role {
            Role::Host(host) if !self.broken => Some(host.world().to_snapshot()),
            _ => None,
        }
    }

    /// The recording of this machine's session.
    pub fn recording_bytes(&self) -> Option<Vec<u8>> {
        if self.broken {
            return None;
        }
        match &self.role {
            Role::Host(host) => Some(host.recording().to_bytes(host.world())),
            Role::Guest(guest) => Some(guest.recording()?.to_bytes(guest.world()?)),
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::panic;

    #[test]
    fn a_host_saves_and_records_its_world() {
        let mut driver = Driver::host(World::new(1));
        assert!(driver.join_local([1; 16], "Ada"));
        assert!(driver.step().is_some());
        assert!(driver.local_player().is_some());
        assert!(driver.save().is_some());
        assert!(driver.recording_bytes().is_some());
    }

    #[test]
    fn a_guest_never_saves_the_world_of_its_host() {
        let mut driver = Driver::guest([2; 16], "Grace");
        assert!(!driver.join_local([2; 16], "Grace"));
        assert_eq!(driver.take_messages().len(), 1, "the greeting");
        assert!(driver.take_messages().is_empty());
        assert_eq!(driver.step(), None, "no tick has arrived");
        assert_eq!(driver.save(), None);
        assert_eq!(driver.recording_bytes(), None, "no world yet");
    }

    #[test]
    fn a_panic_breaks_the_session_for_good() {
        // Keep the expected panic from printing a message into the output.
        let previous = panic::take_hook();
        panic::set_hook(Box::new(|_| {}));
        let mut driver = Driver::host(World::new(1));
        let result = driver.guarded(|_| -> u32 { panic!("an internal error") });
        panic::set_hook(previous);

        assert_eq!(result, None);
        assert!(driver.is_broken());
        assert_eq!(driver.guarded(|_| 7), None, "nothing runs any more");
        assert_eq!(driver.step(), None);
        assert_eq!(driver.save(), None);
        assert_eq!(driver.recording_bytes(), None);
        assert!(driver.world().is_some(), "the world can still be shown");
    }
}

Ersetze den gesamten Inhalt von bridge/src/session.rs durch:

//! A game session for GDScript, as host or as guest of a shared world.

use godot::prelude::*;
use grovewright_core::guest::Arrival;
use grovewright_core::host::Peer;
use grovewright_core::inventory::ItemId;
use grovewright_core::map::{Pos, Terrain};
use grovewright_core::mapgen::spawn_pos;
use grovewright_core::player::{PlayerId, PlayerKey};
use grovewright_core::protocol::{Action, Refusal};
use grovewright_core::world::World;

use crate::driver::{Driver, HOST_PEER};
use crate::events;

/// A session. The host runs the ticks and GDScript calls
/// [`GwSession::step`] once per tick; a guest runs the ticks its host sends,
/// one [`GwSession::step`] per tick that arrived. GDScript carries the
/// messages between the machines and reads the world back for display.
#[derive(GodotClass)]
#[class(base = RefCounted, init)]
pub struct GwSession {
    driver: Option<Driver>,
    last_error: GString,
}

#[godot_api]
impl GwSession {
    /// Simulation ticks per second.
    #[constant]
    const TICKS_PER_SECOND: i64 = grovewright_core::world::TICKS_PER_SECOND as i64;
    /// Ticks a player waits between two pickaxe hits.
    #[constant]
    const DIG_COOLDOWN_TICKS: i64 = grovewright_core::world::DIG_COOLDOWN_TICKS as i64;
    #[constant]
    const TERRAIN_GROUND: i64 = Terrain::Ground as i64;
    #[constant]
    const TERRAIN_SOFT_ROCK: i64 = Terrain::SoftRock as i64;
    #[constant]
    const TERRAIN_HARD_ROCK: i64 = Terrain::HardRock as i64;
    #[constant]
    const TERRAIN_BEDROCK: i64 = Terrain::Bedrock as i64;
    #[constant]
    const ITEM_STONE: i64 = grovewright_core::inventory::ITEM_STONE.0 as i64;
    /// Most players online at the same time.
    #[constant]
    const MAX_ONLINE_PLAYERS: i64 = grovewright_core::player::MAX_ONLINE_PLAYERS as i64;
    /// The connection number of the host, as a guest sees it.
    #[constant]
    const HOST_PEER: i64 = HOST_PEER as i64;
    /// Reasons a host turns a guest away, as in `refused` notices.
    #[constant]
    const REFUSED_OTHER_PROTOCOL: i64 = Refusal::OtherProtocol as i64;
    #[constant]
    const REFUSED_OTHER_RULES: i64 = Refusal::OtherRules as i64;
    #[constant]
    const REFUSED_WORLD_FULL: i64 = Refusal::WorldFull as i64;
    #[constant]
    const REFUSED_TOO_MANY_PLAYERS: i64 = Refusal::TooManyPlayers as i64;
    #[constant]
    const REFUSED_ALREADY_PLAYING: i64 = Refusal::AlreadyPlaying as i64;

    /// Hosts a fresh world from `seed`, replacing any running session.
    #[func]
    fn start_new(&mut self, seed: i64) {
        self.driver = Some(Driver::host(World::new(seed as u64)));
        self.last_error = GString::new();
    }

    /// Hosts a saved world. Returns false and keeps any running session if
    /// the save cannot be used; [`GwSession::last_error`] then says why.
    #[func]
    fn start_loaded(&mut self, save: PackedByteArray) -> bool {
        match World::load(save.as_slice()) {
            Ok(world) => {
                self.driver = Some(Driver::host(world));
                self.last_error = GString::new();
                true
            }
            Err(error) => {
                self.fail(&error.to_string());
                false
            }
        }
    }

    /// Becomes the guest of a host as the player `key` (16 bytes) named
    /// `name`, replacing any running session. The greeting waits in
    /// [`GwSession::take_messages`]; the world comes with the host's answer.
    #[func]
    fn start_guest(&mut self, key: PackedByteArray, name: GString) -> bool {
        let Some(key) = self.player_key(&key) else {
            return false;
        };
        self.driver = Some(Driver::guest(key, &name.to_string()));
        self.last_error = GString::new();
        true
    }

    /// Why the last call failed, or an empty text.
    #[func]
    fn last_error(&self) -> GString {
        self.last_error.clone()
    }

    /// True once there is a world: at once for a host, for a guest after
    /// the host's world arrived.
    #[func]
    fn is_running(&self) -> bool {
        self.world().is_some()
    }

    #[func]
    fn is_host(&self) -> bool {
        self.driver.as_ref().is_some_and(Driver::is_host)
    }

    /// True after the core stopped with an internal error.
    #[func]
    fn is_broken(&self) -> bool {
        self.driver.as_ref().is_some_and(Driver::is_broken)
    }

    /// Queues the entry of the host's own player for the next tick. `key`
    /// identifies the player across sessions and must have 16 bytes. A
    /// guest enters with [`GwSession::start_guest`] instead.
    #[func]
    fn join(&mut self, key: PackedByteArray, name: GString) -> bool {
        let Some(key) = self.player_key(&key) else {
            return false;
        };
        let Some(driver) = self.driver.as_mut() else {
            return false;
        };
        driver.join_local(key, &name.to_string())
    }

    /// A pickaxe hit of this machine's player on tile (`x`, `y`): the host
    /// queues it for the next tick, a guest sends it to the host. Does
    /// nothing before the player is in the world.
    #[func]
    fn dig(&mut self, x: i64, y: i64) {
        let (Some(driver), Some(pos)) = (self.driver.as_mut(), to_pos(x, y)) else {
            return;
        };
        driver.act(Action::Dig { pos });
    }

    /// Runs one tick and returns what happened, as dictionaries with a
    /// `type` entry. A host runs its next tick; a guest runs the oldest tick
    /// that arrived, or nothing if none is waiting.
    #[func]
    fn step(&mut self) -> Array<VarDictionary> {
        let mut happened = Array::new();
        let Some(driver) = self.driver.as_mut() else {
            return happened;
        };
        if let Some(events) = driver.step() {
            for event in &events {
                happened.push(&events::to_dictionary(event));
            }
        }
        if driver.is_broken() {
            self.fail("the simulation stopped after an internal error");
        }
        happened
    }

    /// Ticks that arrived at a guest and have not run yet.
    #[func]
    fn pending_ticks(&self) -> i64 {
        self.driver
            .as_ref()
            .map_or(0, |driver| driver.pending_ticks() as i64)
    }

    /// Hands over a message that arrived from `peer`. A guest answers with
    /// notices: `world_received` (entry `resync`), `refused` (entry
    /// `reason`, a `REFUSED_*` constant) or `protocol_error`, after which
    /// [`GwSession::last_error`] says what went wrong.
    #[func]
    fn receive(&mut self, peer: i64, bytes: PackedByteArray) -> Array<VarDictionary> {
        let mut notices = Array::new();
        let (Some(driver), Some(peer)) = (self.driver.as_mut(), to_peer(peer)) else {
            return notices;
        };
        match driver.receive(peer, bytes.as_slice()) {
            Ok(Arrival::Nothing) => {}
            Ok(Arrival::World { replaced }) => notices.push(&vdict! {
                "type" => "world_received",
                "resync" => replaced,
            }),
            Ok(Arrival::Refused(refusal)) => notices.push(&vdict! {
                "type" => "refused",
                "reason" => i64::from(refusal.code()),
            }),
            Err(error) => {
                self.fail(&error.to_string());
                notices.push(&vdict! { "type" => "protocol_error" });
            }
        }
        if self.is_broken() {
            self.fail("the simulation stopped after an internal error");
        }
        notices
    }

    /// The host learned that a guest connected as `peer`.
    #[func]
    fn connect_peer(&mut self, peer: i64) {
        if let (Some(driver), Some(peer)) = (self.driver.as_mut(), to_peer(peer)) {
            driver.connect(peer);
        }
    }

    /// The host learned that the connection `peer` closed.
    #[func]
    fn disconnect_peer(&mut self, peer: i64) {
        if let (Some(driver), Some(peer)) = (self.driver.as_mut(), to_peer(peer)) {
            driver.disconnect(peer);
        }
    }

    /// Messages to send, oldest first, as dictionaries with the entries
    /// `peer` and `bytes`. They must arrive reliably and in this order.
    #[func]
    fn take_messages(&mut self) -> Array<VarDictionary> {
        let mut messages = Array::new();
        if let Some(driver) = self.driver.as_mut() {
            for (peer, bytes) in driver.take_messages() {
                messages.push(&vdict! {
                    "peer" => i64::from(peer),
                    "bytes" => &PackedByteArray::from(bytes.as_slice()),
                });
            }
        }
        messages
    }

    /// Connections the host has given up. Close them after sending the
    /// messages that are waiting for them.
    #[func]
    fn take_closing(&mut self) -> PackedInt64Array {
        let peers: Vec<i64> = self
            .driver
            .as_mut()
            .map(|driver| driver.take_closing().into_iter().map(i64::from).collect())
            .unwrap_or_default();
        PackedInt64Array::from(peers.as_slice())
    }

    /// Divergences the host's guests reported, as dictionaries with the
    /// entries `peer`, `tick` and `hash` (16 hexadecimal digits).
    #[func]
    fn take_divergences(&mut self) -> Array<VarDictionary> {
        let mut reports = Array::new();
        if let Some(driver) = self.driver.as_mut() {
            for divergence in driver.take_divergences() {
                reports.push(&vdict! {
                    "peer" => i64::from(divergence.peer),
                    "tick" => divergence.tick as i64,
                    "hash" => format!("{:016x}", divergence.hash),
                });
            }
        }
        reports
    }

    /// The recording of a guest's world that diverged from the host's, or
    /// empty. Taking it empties it.
    #[func]
    fn take_diverged_recording(&mut self) -> PackedByteArray {
        self.driver
            .as_mut()
            .and_then(Driver::take_diverged_recording)
            .map_or_else(PackedByteArray::new, |bytes| {
                PackedByteArray::from(bytes.as_slice())
            })
    }

    /// Number of the host's guests.
    #[func]
    fn guest_count(&self) -> i64 {
        self.driver
            .as_ref()
            .map_or(0, |driver| driver.guest_count() as i64)
    }

    /// The player of the host's guest at `peer`, or -1.
    #[func]
    fn player_of_peer(&self, peer: i64) -> i64 {
        let (Some(driver), Some(peer)) = (self.driver.as_ref(), to_peer(peer)) else {
            return -1;
        };
        driver
            .player_of(peer)
            .map_or(-1, |player| i64::from(player.0))
    }

    /// Number of ticks the world has run.
    #[func]
    fn tick(&self) -> i64 {
        self.world().map_or(0, |world| world.tick() as i64)
    }

    #[func]
    fn map_width(&self) -> i64 {
        self.world()
            .map_or(0, |world| i64::from(world.map().width()))
    }

    #[func]
    fn map_height(&self) -> i64 {
        self.world()
            .map_or(0, |world| i64::from(world.map().height()))
    }

    /// The terrain of every tile, row by row (`TERRAIN_*` constants).
    #[func]
    fn terrain_codes(&self) -> PackedByteArray {
        self.tile_bytes(|terrain, _| terrain as u8)
    }

    /// The hits every tile has taken, row by row.
    #[func]
    fn damage_codes(&self) -> PackedByteArray {
        self.tile_bytes(|_, damage| damage)
    }

    /// The terrain of tile (`x`, `y`), or -1 outside the map.
    #[func]
    fn terrain_at(&self, x: i64, y: i64) -> i64 {
        self.tile(x, y).map_or(-1, |(terrain, _)| terrain as i64)
    }

    /// Whether a figure can stand on tile (`x`, `y`).
    #[func]
    fn is_walkable(&self, x: i64, y: i64) -> bool {
        self.tile(x, y)
            .is_some_and(|(terrain, _)| terrain.is_walkable())
    }

    /// The tile where figures appear.
    #[func]
    fn spawn_point(&self) -> Vector2i {
        self.world().map_or(Vector2i::ZERO, |world| {
            let spawn = spawn_pos(world.map());
            Vector2i::new(spawn.x, spawn.y)
        })
    }

    /// Index of this machine's player in the world, or -1 before it is in.
    #[func]
    fn local_player(&self) -> i64 {
        self.driver
            .as_ref()
            .and_then(Driver::local_player)
            .map_or(-1, |id| i64::from(id.0))
    }

    /// The players who are online, by index.
    #[func]
    fn online_players(&self) -> PackedInt64Array {
        let online: Vec<i64> = self.world().map_or_else(Vec::new, |world| {
            world
                .players()
                .iter()
                .enumerate()
                .filter(|(_, player)| player.online)
                .map(|(index, _)| index as i64)
                .collect()
        });
        PackedInt64Array::from(online.as_slice())
    }

    /// The name of player `player`, or an empty text.
    #[func]
    fn player_name(&self, player: i64) -> GString {
        let name = u8::try_from(player)
            .ok()
            .and_then(|index| self.world()?.player(PlayerId(index)))
            .map_or("", |player| player.name.as_str());
        GString::from(name)
    }

    /// How many of `item` the local player carries.
    #[func]
    fn item_count(&self, item: i64) -> i64 {
        let (Some(world), Some(player), Ok(item)) = (
            self.world(),
            self.driver.as_ref().and_then(Driver::local_player),
            u16::try_from(item),
        ) else {
            return 0;
        };
        world
            .player(player)
            .map_or(0, |player| i64::from(player.inventory.count(ItemId(item))))
    }

    /// The world as a save, or empty. Only a healthy host saves.
    #[func]
    fn save(&self) -> PackedByteArray {
        self.driver
            .as_ref()
            .and_then(Driver::save)
            .map_or_else(PackedByteArray::new, |bytes| {
                PackedByteArray::from(bytes.as_slice())
            })
    }

    /// The recording of this machine's session, or empty if there is no
    /// healthy session with a world.
    #[func]
    fn recording_bytes(&self) -> PackedByteArray {
        self.driver
            .as_ref()
            .and_then(Driver::recording_bytes)
            .map_or_else(PackedByteArray::new, |bytes| {
                PackedByteArray::from(bytes.as_slice())
            })
    }

    /// The checksum of the world state, as 16 hexadecimal digits.
    #[func]
    fn state_hash_hex(&self) -> GString {
        self.world().map_or_else(GString::new, |world| {
            GString::from(format!("{:016x}", world.state_hash()).as_str())
        })
    }
}

impl GwSession {
    fn world(&self) -> Option<&World> {
        self.driver.as_ref()?.world()
    }

    fn fail(&mut self, reason: &str) {
        self.last_error = GString::from(reason);
    }

    /// A player key from GDScript bytes, or `None` with a reason.
    fn player_key(&mut self, bytes: &PackedByteArray) -> Option<PlayerKey> {
        match PlayerKey::try_from(bytes.as_slice()) {
            Ok(key) => Some(key),
            Err(_) => {
                self.fail("a player key has 16 bytes");
                None
            }
        }
    }

    fn tile(&self, x: i64, y: i64) -> Option<(Terrain, u8)> {
        let tile = self.world()?.map().get(to_pos(x, y)?)?;
        Some((tile.terrain, tile.damage))
    }

    fn tile_bytes(&self, byte: impl Fn(Terrain, u8) -> u8) -> PackedByteArray {
        let Some(world) = self.world() else {
            return PackedByteArray::new();
        };
        let bytes: Vec<u8> = world
            .map()
            .tiles()
            .iter()
            .map(|tile| byte(tile.terrain, tile.damage))
            .collect();
        PackedByteArray::from(bytes.as_slice())
    }
}

/// A tile position from GDScript integers, or `None` if it cannot exist.
fn to_pos(x: i64, y: i64) -> Option<Pos> {
    Some(Pos::new(i32::try_from(x).ok()?, i32::try_from(y).ok()?))
}

/// A connection number from GDScript, or `None` if it cannot exist.
fn to_peer(peer: i64) -> Option<Peer> {
    Peer::try_from(peer).ok()
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn positions_outside_the_i32_range_are_refused() {
        assert_eq!(to_pos(3, -4), Some(Pos::new(3, -4)));
        assert_eq!(to_pos(i64::from(i32::MAX) + 1, 0), None);
        assert_eq!(to_pos(0, i64::from(i32::MIN) - 1), None);
    }

    #[test]
    fn connection_numbers_outside_the_u32_range_are_refused() {
        assert_eq!(to_peer(1), Some(1));
        assert_eq!(to_peer(-1), None);
        assert_eq!(to_peer(i64::from(u32::MAX) + 1), None);
    }
}

Ersetze den gesamten Inhalt von bridge/src/events.rs durch:

//! Turns events of the core into dictionaries that GDScript can read.

use godot::prelude::*;
use grovewright_core::world::Event;

/// One event as a dictionary with a `type` entry and the event's fields.
pub fn to_dictionary(event: &Event) -> VarDictionary {
    match event {
        Event::PlayerJoined { player } => vdict! {
            "type" => "player_joined",
            "player" => i64::from(player.0),
        },
        Event::PlayerLeft { player } => vdict! {
            "type" => "player_left",
            "player" => i64::from(player.0),
        },
        Event::JoinRefused { key } => vdict! {
            "type" => "join_refused",
            "key" => &PackedByteArray::from(key.as_slice()),
        },
        Event::TileHit { player, pos } => vdict! {
            "type" => "tile_hit",
            "player" => i64::from(player.0),
            "x" => i64::from(pos.x),
            "y" => i64::from(pos.y),
        },
        Event::TileChanged { pos } => vdict! {
            "type" => "tile_changed",
            "x" => i64::from(pos.x),
            "y" => i64::from(pos.y),
        },
        Event::ItemGained {
            player,
            item,
            count,
        } => vdict! {
            "type" => "item_gained",
            "player" => i64::from(player.0),
            "item" => i64::from(item.0),
            "count" => i64::from(*count),
        },
        Event::InventoryFull { player } => vdict! {
            "type" => "inventory_full",
            "player" => i64::from(player.0),
        },
    }
}

Ersetze den gesamten Inhalt von bridge/src/lib.rs durch:

//! GDExtension that makes the deterministic core callable from Godot.
//!
//! The bridge only translates. Every rule lives in `grovewright-core`, and
//! everything that is shown, heard or typed lives in the Godot project.

mod driver;
mod events;
mod session;

use godot::prelude::*;

struct GrovewrightExtension;

// SAFETY: godot-rust requires this marker to register the extension's entry
// point. The implementation keeps every default and adds no code.
#[gdextension]
unsafe impl ExtensionLibrary for GrovewrightExtension {}
  • Step 4: Tests ausführen und Erfolg sehen

Run:

cargo test -p grovewright-bridge
cargo build -p grovewright-bridge
godot --headless --path game --import
godot --headless --path game -s res://tests/run_tests.gd

Expected: test result: ok. 5 passed für die Brücke, und zwar ohne die Meldung einer Panik in der Ausgabe; der GDScript-Lauf endet mit Exit-Code 0 und 48 passed, 0 failed

  • Step 5: Alles prüfen

Run:

cargo fmt --all -- --check && cargo clippy --workspace --all-targets -- -D warnings && cargo test --workspace

Expected: keine Ausgabe von rustfmt, keine Warnung von Clippy, zusammen 141 bestandene Rust-Tests

  • Step 6: Commit und Hochladen
git add bridge game
git commit -F - <<'EOF'
Brücke für Host und Gast

GwSession führt eine Sitzung als Host oder als Gast und reicht die
Nachrichten des Kerns an GDScript weiter. Nur der Host speichert. Die
Behandlung interner Fehler liegt jetzt in einem reinen Rust-Teil und hat
einen Test, und das Ereignis join_refused nennt den Schlüssel.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
EOF
git push

Task 5: Verbindung über ENet

Files:

  • Create: game/scripts/net_link.gd, game/scripts/session_link.gd
  • Test: game/tests/test_net_link.gd, game/tests/test_session_link.gd

Interfaces:

  • Consumes: GwSession aus Task 4, TestCase

  • Produces:

    • NetLink (RefCounted) mit den Konstanten DEFAULT_PORT (24680), MAX_CONNECTIONS (8), CHANNEL_CORE (0), CHANNEL_FIGURES (1) und CONNECT_SECONDS (8.0) und der Eigenschaft connect_seconds
    • NetLink.split_address(text: String) -> Array (statisch) liefert [host, port] oder ein leeres Array
    • host(port: int, address := "*") -> Error (Port 0: das System wählt), local_port() -> int, join(address: String, port: int) -> Error, is_open() -> bool, is_connected_to_peers() -> bool
    • poll(delta: float) -> Array[Dictionary] mit den Typen connected und disconnected (Eintrag peer), message und figure (Einträge peer und bytes) und failed
    • send(peer: int, bytes: PackedByteArray), send_figure(peer: int, bytes: PackedByteArray) (Peer 0: alle; negativ: alle außer diesem), close_peer(peer: int), close()
    • SessionLink (RefCounted) mit session: GwSession, link: NetLink, _init(session, link) und flush()
    • SessionLink.pump(delta: float) -> Array[Dictionary] liefert Hinweise mit den Typen guest_arrived, guest_left, world_received, refused, protocol_error, host_lost, failed, divergence und figure (Einträge player, position)
    • SessionLink.send_figure(position: Vector2)
  • Step 1: Fehlschlagende Tests schreiben

Create game/tests/test_net_link.gd:

extends TestCase
## Two NetLinks in one process, connected over the loopback interface.

## Events per link, collected while frames pass.
var _events: Dictionary[NetLink, Array] = {}


## Polls `links` for `frames` frames and collects their events.
func _pump(links: Array[NetLink], frames: int) -> void:
	for frame in frames:
		for link in links:
			if not _events.has(link):
				_events[link] = []
			_events[link].append_array(link.poll(1.0 / 60.0))
		await tree.process_frame


func _types(link: NetLink) -> Array:
	return _events.get(link, []).map(func(event: Dictionary) -> String: return event["type"])


func _first(link: NetLink, type: String) -> Dictionary:
	for event: Dictionary in _events.get(link, []):
		if event["type"] == type:
			return event
	return {}


## A host and a guest that have connected.
func _connected_pair() -> Array[NetLink]:
	var host := NetLink.new()
	check_eq(host.host(0, "127.0.0.1"), OK, "the host listens")
	check(host.local_port() > 0, "on a port the system chose")
	var guest := NetLink.new()
	check_eq(guest.join("127.0.0.1", host.local_port()), OK, "the guest starts connecting")
	await _pump([host, guest], 10)
	return [host, guest]


func test_a_guest_connects_and_both_sides_hear_of_it() -> void:
	var links := await _connected_pair()
	var host := links[0]
	var guest := links[1]
	check(guest.is_connected_to_peers(), "the guest is connected")
	check_eq(_first(guest, "connected").get("peer"), GwSession.HOST_PEER, "the guest sees the host as peer 1")
	check(_first(host, "connected").get("peer", 0) > 1, "the host sees the guest")
	host.close()
	guest.close()


func test_messages_arrive_in_order_on_both_channels() -> void:
	var links := await _connected_pair()
	var host := links[0]
	var guest := links[1]
	var guest_peer: int = _first(host, "connected")["peer"]
	_events.clear()
	var big := PackedByteArray()
	big.resize(50000)
	big.fill(7)
	host.send(guest_peer, big)
	host.send(guest_peer, PackedByteArray([1]))
	guest.send(GwSession.HOST_PEER, PackedByteArray([2, 3]))
	guest.send_figure(GwSession.HOST_PEER, PackedByteArray([4]))
	await _pump([host, guest], 20)

	var received := _events[guest].filter(func(event: Dictionary) -> bool: return event["type"] == "message")
	check_eq(received.size(), 2, "two messages for the guest")
	if received.size() == 2:
		check_eq(received[0]["bytes"].size(), 50000, "the big one first")
		check_eq(received[1]["bytes"], PackedByteArray([1]), "then the small one")
	check_eq(_first(host, "message").get("bytes"), PackedByteArray([2, 3]), "the host got the guest's message")
	check_eq(_first(host, "figure").get("bytes"), PackedByteArray([4]), "and the position on the other channel")
	host.close()
	guest.close()


func test_closing_a_guest_delivers_what_was_sent_before() -> void:
	var links := await _connected_pair()
	var host := links[0]
	var guest := links[1]
	var guest_peer: int = _first(host, "connected")["peer"]
	_events.clear()
	host.send(guest_peer, PackedByteArray([9, 9]))
	host.close_peer(guest_peer)
	await _pump([host, guest], 30)
	check_eq(_types(guest), ["message", "disconnected"], "the message comes before the end")
	check_eq(_types(host), ["disconnected"], "the host sees the guest go")
	check(not guest.is_open(), "the guest's connection is closed")
	host.close()


func test_a_guest_that_quits_is_seen_leaving() -> void:
	var links := await _connected_pair()
	var host := links[0]
	var guest := links[1]
	_events.clear()
	guest.close()
	await _pump([host, guest], 10)
	check_eq(_types(host), ["disconnected"], "the host sees the guest go")
	host.close()


func test_messages_to_a_connection_that_is_gone_are_dropped() -> void:
	var host := NetLink.new()
	check_eq(host.host(0, "127.0.0.1"), OK, "the host listens")
	host.send(12345, PackedByteArray([1]))
	host.send_figure(12345, PackedByteArray([1]))
	host.close_peer(12345)
	check_eq(host.poll(0.0), [], "nothing happened")
	host.close()


func test_a_guest_gives_up_when_nobody_answers() -> void:
	# A port that was free a moment ago and has nobody listening now.
	var probe := NetLink.new()
	probe.host(0, "127.0.0.1")
	var port := probe.local_port()
	probe.close()
	var guest := NetLink.new()
	guest.connect_seconds = 0.2
	check_eq(guest.join("127.0.0.1", port), OK, "connecting starts")
	await _pump([guest], 30)
	check_eq(_types(guest), ["failed"], "the guest gives up")
	check(not guest.is_open(), "and closes")


func test_addresses_are_split_into_host_and_port() -> void:
	check_eq(NetLink.split_address("192.168.1.20"), ["192.168.1.20", NetLink.DEFAULT_PORT], "default port")
	check_eq(NetLink.split_address(" example.net:25000 "), ["example.net", 25000], "with port")
	for text in ["", ":25000", "host:", "host:0", "host:70000", "host:port", "a:1:2"]:
		check_eq(NetLink.split_address(text), [], "refused: '%s'" % text)

Create game/tests/test_session_link.gd:

extends TestCase
## A host and guests in one process, each with its own GwSession and
## NetLink, connected over the loopback interface.

var host_key := PackedByteArray([1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1])
var guest_key := PackedByteArray([2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2])

var host: SessionLink
## Notices per SessionLink, collected while frames pass.
var notices: Dictionary[SessionLink, Array] = {}


func _start_host() -> void:
	var session := GwSession.new()
	session.start_new(7)
	session.join(host_key, "Host")
	session.step()
	var link := NetLink.new()
	check_eq(link.host(0, "127.0.0.1"), OK, "the host listens")
	host = SessionLink.new(session, link)
	notices[host] = []


func _start_guest(key: PackedByteArray, name: String) -> SessionLink:
	var session := GwSession.new()
	session.start_guest(key, name)
	var link := NetLink.new()
	link.join("127.0.0.1", host.link.local_port())
	var guest := SessionLink.new(session, link)
	notices[guest] = []
	return guest


## Lets `frames` frames pass. The host runs a tick per frame if `ticking`;
## every guest runs what arrived.
func _play(guests: Array[SessionLink], frames: int, ticking := true) -> void:
	for frame in frames:
		notices[host].append_array(host.pump(1.0 / 60.0))
		if ticking:
			host.session.step()
			host.flush()
		for guest in guests:
			notices[guest].append_array(guest.pump(1.0 / 60.0))
			while guest.session.pending_ticks() > 0:
				guest.session.step()
		await tree.process_frame


func _types(link: SessionLink) -> Array:
	return notices[link].map(func(notice: Dictionary) -> String: return notice["type"])


func _first(link: SessionLink, type: String) -> Dictionary:
	for notice: Dictionary in notices[link]:
		if notice["type"] == type:
			return notice
	return {}


func _close(links: Array[SessionLink]) -> void:
	for link in links:
		link.link.close()


func test_a_guest_joins_over_the_network_and_follows_the_host() -> void:
	_start_host()
	var guest := _start_guest(guest_key, "Grace")
	await _play([guest], 40)
	await _play([guest], 10, false)

	check(_types(host).has("guest_arrived"), "the host sees the guest arrive")
	check_eq(_first(guest, "world_received").get("resync"), false, "the guest got the host's world")
	check_eq(guest.session.local_player(), 1, "the guest is player 1")
	check_eq(guest.session.player_name(0), "Host", "the guest knows the host's name")
	check_eq(guest.session.tick(), host.session.tick(), "the guest is at the host's tick")
	check_eq(guest.session.state_hash_hex(), host.session.state_hash_hex(), "both worlds are the same")
	check_eq(guest.session.save().size(), 0, "a guest does not save the host's world")
	_close([host, guest])


func test_a_hit_by_the_guest_reaches_the_host() -> void:
	_start_host()
	var guest := _start_guest(guest_key, "Grace")
	await _play([guest], 30)
	var rock := host.session.spawn_point() + Vector2i(7, -2)
	guest.session.dig(rock.x, rock.y)
	await _play([guest], 10)
	await _play([guest], 10, false)
	var index := rock.y * host.session.map_width() + rock.x
	check_eq(host.session.damage_codes()[index], 1, "the host's rock took the hit")
	check_eq(guest.session.state_hash_hex(), host.session.state_hash_hex(), "both worlds are the same")
	_close([host, guest])


func test_figures_travel_to_the_other_machines() -> void:
	_start_host()
	var guest := _start_guest(guest_key, "Grace")
	var other := _start_guest(PackedByteArray([3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3]), "Linus")
	await _play([guest, other], 30)
	host.send_figure(Vector2(10.0, 20.0))
	guest.send_figure(Vector2(5.5, 6.5))
	await _play([guest, other], 10)

	check_eq(_first(host, "figure"), {"type": "figure", "player": 1, "position": Vector2(5.5, 6.5)}, "the host sees the guest")
	var seen := notices[other].filter(func(notice: Dictionary) -> bool: return notice["type"] == "figure")
	check(seen.has({"type": "figure", "player": 0, "position": Vector2(10.0, 20.0)}), "the other guest sees the host")
	check(seen.has({"type": "figure", "player": 1, "position": Vector2(5.5, 6.5)}), "and the first guest, passed on by the host")
	check(not notices[guest].any(func(notice: Dictionary) -> bool: return notice["type"] == "figure" and notice["player"] == 1), "nobody gets their own figure back")
	_close([host, guest, other])


func test_a_guest_with_the_key_of_a_player_in_the_world_is_turned_away() -> void:
	_start_host()
	var twin := _start_guest(host_key, "Host")
	await _play([twin], 30)
	check_eq(_first(twin, "refused").get("reason"), GwSession.REFUSED_ALREADY_PLAYING, "the reason is given")
	check(_types(twin).has("host_lost"), "then the host closes the connection")
	check(not twin.session.is_running(), "the guest never got a world")
	_close([host, twin])


func test_a_guest_that_quits_leaves_the_world() -> void:
	_start_host()
	var guest := _start_guest(guest_key, "Grace")
	await _play([guest], 30)
	check_eq(host.session.online_players(), PackedInt64Array([0, 1]), "two players online")
	guest.link.close()
	await _play([], 10)
	check(_types(host).has("guest_left"), "the host sees the guest leave")
	check_eq(host.session.online_players(), PackedInt64Array([0]), "only the host is online")
	_close([host])


func test_a_guest_learns_when_the_host_is_gone() -> void:
	_start_host()
	var guest := _start_guest(guest_key, "Grace")
	await _play([guest], 30)
	host.link.close()
	await _play([guest], 10, false)
	check(_types(guest).has("host_lost"), "the guest notices")
	_close([guest])
  • Step 2: Tests ausführen und Fehlschlag sehen

Run:

godot --headless --path game --import
godot --headless --path game -s res://tests/run_tests.gd

Expected: Der Testlauf endet mit Exit-Code 1 und 48 passed, 2 failed. Davor stehen Zeilen wie SCRIPT ERROR: Parse Error: Could not find type "NetLink" in the current scope. und je eine Zeile FAIL test_net_link.gd the script does not load; see the error above und FAIL test_session_link.gd the script does not load; see the error above.

  • Step 3: Verbindung schreiben

Create game/scripts/net_link.gd:

class_name NetLink
extends RefCounted
## The network connection of a session, over ENet. A host listens for guests;
## a guest connects to its host. Messages of the core travel on a reliable,
## ordered channel. Positions of figures travel on a second, unreliable
## channel, because a lost one is soon replaced by the next.

## Port a host listens on unless told otherwise.
const DEFAULT_PORT := 24680
## Most guests that may connect to a host. More than can play, so that the
## core can tell the extra ones why it turns them away.
const MAX_CONNECTIONS := 8
const CHANNEL_CORE := 0
const CHANNEL_FIGURES := 1
## Seconds a guest waits for its host to answer before it gives up.
const CONNECT_SECONDS := 8.0

## Seconds this guest waits for its host. Tests shorten it.
var connect_seconds := CONNECT_SECONDS

var _peer: ENetMultiplayerPeer
var _peers: Dictionary[int, bool] = {}
var _events: Array[Dictionary] = []
var _connecting := false
var _waited := 0.0


## Splits "host" or "host:port" into [host, port], with DEFAULT_PORT if the
## text names none. Returns an empty array if `text` is no such address.
static func split_address(text: String) -> Array:
	var parts := text.strip_edges().split(":")
	if parts[0].is_empty() or parts.size() > 2:
		return []
	if parts.size() == 1:
		return [parts[0], DEFAULT_PORT]
	if not parts[1].is_valid_int() or parts[1].to_int() < 1 or parts[1].to_int() > 65535:
		return []
	return [parts[0], parts[1].to_int()]


## Starts listening for guests on `port`, or on a free port that the system
## chooses if `port` is 0; see local_port(). `address` limits listening to
## one network interface, for example "127.0.0.1"; "*" listens on all.
func host(port: int, address := "*") -> Error:
	_start()
	_peer.set_bind_ip(address)
	return _peer.create_server(port, MAX_CONNECTIONS, 2)


## The port a host listens on, or 0.
func local_port() -> int:
	if not is_open() or _peer.host == null:
		return 0
	return _peer.host.get_local_port()


## Starts connecting to the host at `address` and `port`.
func join(address: String, port: int) -> Error:
	_start()
	var error := _peer.create_client(address, port, 2)
	_connecting = error == OK
	_waited = 0.0
	return error


func _start() -> void:
	close()
	_peer = ENetMultiplayerPeer.new()
	_peers.clear()
	_peer.peer_connected.connect(_on_connected)
	_peer.peer_disconnected.connect(_on_disconnected)


func _on_connected(peer: int) -> void:
	_peers[peer] = true
	_events.append({"type": "connected", "peer": peer})


func _on_disconnected(peer: int) -> void:
	_peers.erase(peer)
	_events.append({"type": "disconnected", "peer": peer})


## True while the connection is up or being made.
func is_open() -> bool:
	return _peer != null and _peer.get_connection_status() != MultiplayerPeer.CONNECTION_DISCONNECTED


## True when a guest has reached its host, or a host is listening.
func is_connected_to_peers() -> bool:
	return _peer != null and _peer.get_connection_status() == MultiplayerPeer.CONNECTION_CONNECTED


## Returns what happened since the last call, oldest first, as dictionaries
## with a `type` entry: "connected" and "disconnected" (entry `peer`),
## "message" and "figure" (entries `peer` and `bytes`), and "failed" when a
## guest could not reach its host. `delta` is the time since the last call.
func poll(delta: float) -> Array[Dictionary]:
	if is_open():
		_peer.poll()
		while _peer.get_available_packet_count() > 0:
			var from := _peer.get_packet_peer()
			var channel := _peer.get_packet_channel()
			var bytes := _peer.get_packet()
			var type := "message" if channel == CHANNEL_CORE else "figure"
			_events.append({"type": type, "peer": from, "bytes": bytes})
	if _connecting:
		_waited += delta
		if is_connected_to_peers():
			_connecting = false
		elif not is_open() or _waited > connect_seconds:
			_connecting = false
			close()
			_events.append({"type": "failed"})
	var events := _events
	_events = []
	return events


## Sends a message of the core to `peer`, reliably and in order. A message
## to a connection that is gone is dropped.
func send(peer: int, bytes: PackedByteArray) -> void:
	_put(peer, bytes, CHANNEL_CORE, MultiplayerPeer.TRANSFER_MODE_RELIABLE)


## Sends the position of a figure to `peer`, to every connection if `peer`
## is 0, or to every connection but one if `peer` is that one negated.
func send_figure(peer: int, bytes: PackedByteArray) -> void:
	_put(peer, bytes, CHANNEL_FIGURES, MultiplayerPeer.TRANSFER_MODE_UNRELIABLE)


func _put(peer: int, bytes: PackedByteArray, channel: int, mode: MultiplayerPeer.TransferMode) -> void:
	if not is_connected_to_peers() or _peers.is_empty():
		return
	if peer > 0 and not _peers.has(peer):
		return
	_peer.set_target_peer(peer)
	_peer.set_transfer_channel(channel)
	_peer.set_transfer_mode(mode)
	_peer.put_packet(bytes)


## Closes the connection to `peer` once the messages waiting for it are out.
func close_peer(peer: int) -> void:
	if _peers.has(peer):
		_peer.get_peer(peer).peer_disconnect_later()


## Closes everything at once.
func close() -> void:
	if is_open():
		_peer.close()
	_peers.clear()
	_connecting = false

Create game/scripts/session_link.gd:

class_name SessionLink
extends RefCounted
## Carries the messages of a GwSession over a NetLink, and the positions of
## the figures, which are not part of the shared world. Tells the game what
## happened on the way.

## Bytes of a position: x and y as 32-bit floats.
const POSITION_BYTES := 8

var session: GwSession
var link: NetLink


func _init(game_session: GwSession, net_link: NetLink) -> void:
	session = game_session
	link = net_link


## Moves messages in both directions and returns notices, oldest first, as
## dictionaries with a `type` entry:
## - "guest_arrived" and "guest_left" (entry `peer`), at the host
## - "world_received" (entry `resync`), "refused" (entry `reason`),
##   "protocol_error", "host_lost" and "failed", at a guest
## - "divergence" (entries `peer`, `tick`, `hash`), at the host
## - "figure" (entries `player`, `position`), everywhere
## `delta` is the time since the last call.
func pump(delta: float) -> Array[Dictionary]:
	var notices: Array[Dictionary] = []
	for event in link.poll(delta):
		match event["type"]:
			"connected":
				if session.is_host():
					session.connect_peer(event["peer"])
					notices.append({"type": "guest_arrived", "peer": event["peer"]})
			"disconnected":
				if session.is_host():
					session.disconnect_peer(event["peer"])
					notices.append({"type": "guest_left", "peer": event["peer"]})
				else:
					notices.append({"type": "host_lost"})
			"failed":
				notices.append({"type": "failed"})
			"message":
				notices.append_array(session.receive(event["peer"], event["bytes"]))
			"figure":
				_receive_figure(event["peer"], event["bytes"], notices)
	for divergence in session.take_divergences():
		divergence["type"] = "divergence"
		notices.append(divergence)
	flush()
	return notices


## Sends what the session has for the network and closes the connections
## the host gave up. A guest's messages wait until its host is reached.
func flush() -> void:
	if not link.is_connected_to_peers():
		return
	for message in session.take_messages():
		link.send(message["peer"], message["bytes"])
	for peer in session.take_closing():
		link.close_peer(peer)


## Tells the other machines where this machine's figure stands.
func send_figure(position: Vector2) -> void:
	if session.is_host():
		var player := session.local_player()
		if player >= 0:
			link.send_figure(0, _figure_bytes(player, position))
	elif session.local_player() >= 0:
		link.send_figure(GwSession.HOST_PEER, _position_bytes(position))


func _receive_figure(peer: int, bytes: PackedByteArray, notices: Array[Dictionary]) -> void:
	if session.is_host():
		# A guest sends only a position; the host knows whose it is and
		# passes it on to every other guest.
		var player := session.player_of_peer(peer)
		if player < 0 or bytes.size() != POSITION_BYTES:
			return
		var position := Vector2(bytes.decode_float(0), bytes.decode_float(4))
		notices.append({"type": "figure", "player": player, "position": position})
		link.send_figure(-peer, _figure_bytes(player, position))
	elif bytes.size() == 1 + POSITION_BYTES:
		var player := bytes.decode_u8(0)
		if player != session.local_player():
			var position := Vector2(bytes.decode_float(1), bytes.decode_float(5))
			notices.append({"type": "figure", "player": player, "position": position})


func _position_bytes(position: Vector2) -> PackedByteArray:
	var bytes := PackedByteArray()
	bytes.resize(POSITION_BYTES)
	bytes.encode_float(0, position.x)
	bytes.encode_float(4, position.y)
	return bytes


func _figure_bytes(player: int, position: Vector2) -> PackedByteArray:
	var bytes := PackedByteArray([player])
	bytes.append_array(_position_bytes(position))
	return bytes
  • Step 4: Tests ausführen und Erfolg sehen

Run:

godot --headless --path game --import
godot --headless --path game -s res://tests/run_tests.gd

Expected: Exit-Code 0 und 61 passed, 0 failed. Windows fragt nicht nach einer Freigabe in der Firewall, weil die Tests nur auf 127.0.0.1 lauschen.

  • Step 5: Alles prüfen

Run:

cargo fmt --all -- --check && cargo clippy --workspace --all-targets -- -D warnings && cargo test --workspace
cargo build -p grovewright-bridge

Expected: keine Ausgabe von rustfmt, keine Warnung von Clippy, zusammen 141 bestandene Rust-Tests

  • Step 6: Commit und Hochladen
git add game
git commit -F - <<'EOF'
Verbindung über ENet

NetLink verbindet Host und Gäste über ENet: Nachrichten des Kerns
zuverlässig und in Reihenfolge, Positionen der Figuren unzuverlässig.
Eine Verbindung schließt erst, wenn wartende Nachrichten draußen sind.
SessionLink trägt die Nachrichten einer Sitzung über die Verbindung und
reicht Positionen weiter. Die Tests verbinden zwei Seiten über die
Loopback-Schnittstelle.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
EOF
git push

Task 6: Profile, Startoptionen und vorsichtiges Lesen

Files:

  • Create: game/scripts/launch_options.gd, game/tests/support/held_file_store.gd
  • Modify: game/scripts/save_store.gd (ganze Datei ersetzen)
  • Test: game/tests/test_launch_options.gd, game/tests/test_save_store.gd (ganze Datei ersetzen)

Interfaces:

  • Consumes: NetLink.DEFAULT_PORT aus Task 5, TestFiles

  • Produces:

    • LaunchOptions (RefCounted) mit profile, player_name, start ("", "solo", "host" oder "join"), port, address und ignored (nicht verstandene Argumente)
    • LaunchOptions.parse(arguments: PackedStringArray) -> LaunchOptions und LaunchOptions.is_valid_profile(name: String) -> bool (beide statisch), dazu storage_dir() -> String
    • SaveStore bekommt SETTINGS_FILE und last_read_error: Error. player_key() liefert einen leeren Schlüssel, wenn die Schlüsseldatei gerade nicht lesbar ist, und ersetzt sie dann nicht. read_save() setzt last_read_error.
    • SaveStore bekommt player_name(), set_player_name(name), last_address() und set_last_address(address). _read_file(path) ist die Stelle, an der Tests ein Lesen scheitern lassen.
    • HeldFileStore (nur für Tests): ein SaveStore, der die Datei unreadable nicht lesen kann
  • Step 1: Fehlschlagende Tests schreiben

Create game/tests/support/held_file_store.gd:

class_name HeldFileStore
extends SaveStore
## A SaveStore that cannot read the file named `unreadable`, as if another
## program held it.

var unreadable := ""


func _read_file(path: String) -> PackedByteArray:
	if path.get_file() == unreadable:
		last_read_error = ERR_FILE_CANT_READ
		return PackedByteArray()
	return super(path)

Create game/tests/test_launch_options.gd:

extends TestCase


func test_without_arguments_the_menu_shows_and_the_default_profile_is_used() -> void:
	var options := LaunchOptions.parse(PackedStringArray())
	check_eq(options.start, "", "no direct start")
	check_eq(options.storage_dir(), "user://", "the user folder itself")
	check_eq(options.port, NetLink.DEFAULT_PORT, "the default port")


func test_every_argument_is_read() -> void:
	var options := LaunchOptions.parse(PackedStringArray(["--profile=second", "--name=Grace", "--host", "--port=25000"]))
	check_eq(options.storage_dir(), "user://profiles/second", "a folder of its own")
	check_eq(options.player_name, "Grace", "name")
	check_eq(options.start, "host", "opens the world")
	check_eq(options.port, 25000, "port")
	var joining := LaunchOptions.parse(PackedStringArray(["--join=192.168.1.20:25000"]))
	check_eq(joining.start, "join", "joins")
	check_eq(joining.address, "192.168.1.20:25000", "address")
	check_eq(LaunchOptions.parse(PackedStringArray(["--solo"])).start, "solo", "plays alone")


func test_profile_names_cannot_leave_the_profile_folder() -> void:
	check(LaunchOptions.is_valid_profile("Zweiter_2-b"), "letters, digits, - and _")
	for name in ["", "../save", "a/b", "a b", "x".repeat(33)]:
		check(not LaunchOptions.is_valid_profile(name), "refused: '%s'" % name)
	var options := LaunchOptions.parse(PackedStringArray(["--profile=../oops", "--port=70000", "--fly"]))
	check_eq(options.storage_dir(), "user://", "a bad profile is ignored")
	check_eq(options.port, NetLink.DEFAULT_PORT, "a bad port is ignored")
	check_eq(options.ignored, PackedStringArray(["--profile=../oops", "--port=70000", "--fly"]), "everything ignored is listed")

Ersetze den gesamten Inhalt von game/tests/test_save_store.gd durch:

extends TestCase


func test_the_player_key_is_created_once_and_kept() -> void:
	var directory := TestFiles.temporary_dir("key")
	var first := SaveStore.new(directory).player_key()
	var second := SaveStore.new(directory).player_key()
	check_eq(first.size(), SaveStore.KEY_BYTES, "sixteen bytes")
	check_eq(second, first, "the same key on the next start")
	TestFiles.remove_dir(directory)


func test_a_save_is_written_and_read_back() -> void:
	var directory := TestFiles.temporary_dir("save")
	var store := SaveStore.new(directory)
	check(not store.has_save(), "no save at first")
	check(store.write_save(PackedByteArray([1, 2, 3])), "written")
	check(store.write_save(PackedByteArray([4, 5])), "overwritten")
	check(store.has_save(), "a save exists")
	check_eq(store.read_save(), PackedByteArray([4, 5]), "the newest save is read")
	check(not FileAccess.file_exists(directory.path_join(SaveStore.SAVE_FILE + ".tmp")), "no temporary file is left")
	check(not FileAccess.file_exists(directory.path_join(SaveStore.BACKUP_FILE)), "no backup is left")
	TestFiles.remove_dir(directory)


func test_an_empty_save_is_not_written() -> void:
	var directory := TestFiles.temporary_dir("empty")
	var store := SaveStore.new(directory)
	check(not store.write_save(PackedByteArray()), "refused")
	check(not store.has_save(), "no file")
	TestFiles.remove_dir(directory)


func test_a_save_that_cannot_be_written_keeps_the_previous_one() -> void:
	var directory := TestFiles.temporary_dir("blocked")
	var store := SaveStore.new(directory)
	store.write_save(PackedByteArray([1, 2, 3]))
	# A folder in place of the temporary file makes the write fail.
	DirAccess.make_dir_absolute(directory.path_join(SaveStore.SAVE_FILE + ".tmp"))
	check(not store.write_save(PackedByteArray([4, 5])), "the failed save is reported")
	check_eq(store.read_save(), PackedByteArray([1, 2, 3]), "the previous save is untouched")
	check(not store.write_save(PackedByteArray()), "an empty save is refused")
	check_eq(store.read_save(), PackedByteArray([1, 2, 3]), "still untouched")
	TestFiles.remove_dir(directory)


func test_a_save_that_cannot_be_moved_into_place_keeps_the_previous_one() -> void:
	var directory := TestFiles.temporary_dir("held")
	var store := SaveStore.new(directory)
	store.write_save(PackedByteArray([1, 2, 3]))
	# The open handle stands for an outside program, for example a virus
	# scanner. The new save can be written, but Windows refuses to move a file
	# that is held open.
	var holder := FileAccess.open(directory.path_join(SaveStore.SAVE_FILE + ".tmp"), FileAccess.WRITE)
	check(not store.write_save(PackedByteArray([4, 5])), "the failed save is reported")
	check_eq(store.read_save(), PackedByteArray([1, 2, 3]), "the previous save is untouched")
	check(not FileAccess.file_exists(directory.path_join(SaveStore.BACKUP_FILE)), "no backup is left")
	holder.close()
	TestFiles.remove_dir(directory)


func test_a_save_that_cannot_move_the_previous_one_aside_keeps_it() -> void:
	var directory := TestFiles.temporary_dir("aside-blocked")
	var store := SaveStore.new(directory)
	store.write_save(PackedByteArray([1, 2, 3]))
	# A folder under the backup name keeps the previous save from moving.
	DirAccess.make_dir_absolute(directory.path_join(SaveStore.BACKUP_FILE))
	check(not store.write_save(PackedByteArray([4, 5])), "the failed save is reported")
	check_eq(store.read_save(), PackedByteArray([1, 2, 3]), "the previous save is untouched")
	check(not FileAccess.file_exists(directory.path_join(SaveStore.SAVE_FILE + ".tmp")), "no temporary file is left")
	TestFiles.remove_dir(directory)


func test_an_interrupted_save_is_recovered_from_the_backup() -> void:
	var directory := TestFiles.temporary_dir("recover")
	# A crash between the two moves of write_save leaves only the backup.
	var backup := directory.path_join(SaveStore.BACKUP_FILE)
	var file := FileAccess.open(backup, FileAccess.WRITE)
	file.store_buffer(PackedByteArray([7, 8, 9]))
	file.close()
	var store := SaveStore.new(directory)
	check(store.has_save(), "the previous save is back")
	check_eq(store.read_save(), PackedByteArray([7, 8, 9]), "with its content")
	check(not FileAccess.file_exists(backup), "the backup is moved, not copied")
	TestFiles.remove_dir(directory)


func test_a_backup_never_replaces_an_existing_save() -> void:
	var directory := TestFiles.temporary_dir("keep")
	SaveStore.new(directory).write_save(PackedByteArray([1, 2, 3]))
	var file := FileAccess.open(directory.path_join(SaveStore.BACKUP_FILE), FileAccess.WRITE)
	file.store_buffer(PackedByteArray([9, 9]))
	file.close()
	var store := SaveStore.new(directory)
	check_eq(store.read_save(), PackedByteArray([1, 2, 3]), "the save is not replaced by the older backup")
	TestFiles.remove_dir(directory)


func test_setting_aside_without_a_save_reports_nothing_moved() -> void:
	var directory := TestFiles.temporary_dir("nothing")
	check_eq(SaveStore.new(directory).set_aside_save(), "", "no save, nothing moved")
	TestFiles.remove_dir(directory)


func test_an_unusable_save_can_be_set_aside() -> void:
	var directory := TestFiles.temporary_dir("aside")
	var store := SaveStore.new(directory)
	store.write_save(PackedByteArray([9, 9]))
	var aside := store.set_aside_save()
	check(not store.has_save(), "the save is out of the way")
	check_eq(FileAccess.get_file_as_bytes(aside), PackedByteArray([9, 9]), "its content is kept")
	TestFiles.remove_dir(directory)


func test_only_the_newest_recordings_are_kept() -> void:
	var directory := TestFiles.temporary_dir("recordings")
	var store := SaveStore.new(directory)
	var paths: PackedStringArray = []
	for number in SaveStore.KEEP_RECORDINGS + 2:
		paths.append(store.write_recording(PackedByteArray([number])))
		OS.delay_usec(10)
	var names := store.recordings()
	check_eq(names.size(), SaveStore.KEEP_RECORDINGS, "only the newest are kept")
	check(not FileAccess.file_exists(paths[0]), "the oldest is gone")
	check(not FileAccess.file_exists(paths[1]), "the second oldest is gone")
	check(FileAccess.file_exists(paths[paths.size() - 1]), "the newest is there")
	check_eq(store.write_recording(PackedByteArray()), "", "an empty recording is not written")
	TestFiles.remove_dir(directory)


func test_a_key_that_cannot_be_read_right_now_is_not_replaced() -> void:
	var directory := TestFiles.temporary_dir("held-key")
	var key := SaveStore.new(directory).player_key()
	var store := HeldFileStore.new(directory)
	store.unreadable = SaveStore.KEY_FILE
	check_eq(store.player_key(), PackedByteArray(), "no key for now")
	check_eq(store.last_read_error, ERR_FILE_CANT_READ, "the reason is kept")
	check_eq(SaveStore.new(directory).player_key(), key, "the key on disk is the old one")
	TestFiles.remove_dir(directory)


func test_a_damaged_key_is_replaced() -> void:
	var directory := TestFiles.temporary_dir("damaged-key")
	var file := FileAccess.open(directory.path_join(SaveStore.KEY_FILE), FileAccess.WRITE)
	file.store_buffer(PackedByteArray([1, 2, 3]))
	file.close()
	var store := SaveStore.new(directory)
	var key := store.player_key()
	check_eq(key.size(), SaveStore.KEY_BYTES, "a new key")
	check_eq(SaveStore.new(directory).player_key(), key, "which is kept")
	TestFiles.remove_dir(directory)


func test_a_save_that_cannot_be_read_right_now_reports_why() -> void:
	var directory := TestFiles.temporary_dir("held-save")
	SaveStore.new(directory).write_save(PackedByteArray([1, 2, 3]))
	var store := HeldFileStore.new(directory)
	store.unreadable = SaveStore.SAVE_FILE
	check_eq(store.read_save(), PackedByteArray(), "nothing read")
	check_eq(store.last_read_error, ERR_FILE_CANT_READ, "the reason")
	store.unreadable = ""
	check_eq(store.read_save(), PackedByteArray([1, 2, 3]), "readable again later")
	check_eq(store.last_read_error, OK, "no error now")
	TestFiles.remove_dir(directory)


func test_the_settings_are_kept() -> void:
	var directory := TestFiles.temporary_dir("settings")
	var store := SaveStore.new(directory)
	check_eq(store.player_name(), "", "no name at first")
	store.set_player_name("Ada")
	store.set_last_address("192.168.1.20")
	var later := SaveStore.new(directory)
	check_eq(later.player_name(), "Ada", "name")
	check_eq(later.last_address(), "192.168.1.20", "address")
	TestFiles.remove_dir(directory)
  • Step 2: Tests ausführen und Fehlschlag sehen

Run:

godot --headless --path game --import
godot --headless --path game -s res://tests/run_tests.gd

Expected: Der Testlauf endet mit Exit-Code 1 und 62 passed, 4 failed. test_launch_options.gd lädt nicht (Identifier "LaunchOptions" not declared in the current scope.). In test_save_store.gd scheitern test_a_key_that_cannot_be_read_right_now_is_not_replaced, test_a_save_that_cannot_be_read_right_now_reports_why und test_the_settings_are_kept.

  • Step 3: Startoptionen und Dateien schreiben

Create game/scripts/launch_options.gd:

class_name LaunchOptions
extends RefCounted
## What the command line asks for. Godot passes the arguments after "--":
##   --profile=NAME   keeps key, save and recordings in a folder of their own,
##                    so that two windows on one machine are two players
##   --name=NAME      the player's name for this start
##   --solo           starts playing alone at once, without the menu
##   --host           opens the world for guests at once
##   --port=PORT      the port an open world listens on
##   --join=ADDRESS   joins a host at once; ADDRESS may end in ":PORT"

## Longest profile name.
const MAX_PROFILE_LENGTH := 32
## Characters a profile name may contain.
const PROFILE_CHARACTERS := "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_"

var profile := ""
var player_name := ""
## How to start without the menu: "", "solo", "host" or "join".
var start := ""
var port := NetLink.DEFAULT_PORT
var address := ""
## Arguments that were not understood and are ignored.
var ignored := PackedStringArray()


## Reads `arguments`. Unknown arguments and bad values end up in `ignored`.
static func parse(arguments: PackedStringArray) -> LaunchOptions:
	var options := LaunchOptions.new()
	for argument in arguments:
		var value := argument.get_slice("=", 1) if argument.contains("=") else ""
		match argument.get_slice("=", 0):
			"--profile":
				if is_valid_profile(value):
					options.profile = value
				else:
					options.ignored.append(argument)
			"--name":
				options.player_name = value
			"--solo":
				options.start = "solo"
			"--host":
				options.start = "host"
			"--port":
				if value.is_valid_int() and value.to_int() >= 0 and value.to_int() <= 65535:
					options.port = value.to_int()
				else:
					options.ignored.append(argument)
			"--join":
				options.start = "join"
				options.address = value
			_:
				options.ignored.append(argument)
	return options


## Whether `name` can name a profile folder: up to 32 of the letters A to Z,
## digits, "-" and "_".
static func is_valid_profile(name: String) -> bool:
	if name.is_empty() or name.length() > MAX_PROFILE_LENGTH:
		return false
	for character in name:
		if not PROFILE_CHARACTERS.contains(character):
			return false
	return true


## The folder for the player's files. The default profile uses the user
## folder itself, so that saves of earlier versions stay where they are.
func storage_dir() -> String:
	if profile.is_empty():
		return "user://"
	return "user://profiles".path_join(profile)

Ersetze den gesamten Inhalt von game/scripts/save_store.gd durch:

class_name SaveStore
extends RefCounted
## The game's files on the player's machine: the player key, the save, the
## session recordings for bug reports and the player's settings.

const KEY_FILE := "player.key"
const SAVE_FILE := "world.grw"
## Holds the previous save while write_save replaces it.
const BACKUP_FILE := SAVE_FILE + ".bak"
const RECORDINGS_DIR := "recordings"
const SETTINGS_FILE := "settings.cfg"
## Recordings beyond this number are deleted, oldest first.
const KEEP_RECORDINGS := 10
const KEY_BYTES := 16

var base_dir: String
## The result of the last attempt to read the key or the save: OK, or why
## the file could not be read. A file that exists but cannot be read right
## now, for example because another program holds it, is never replaced.
var last_read_error: Error = OK


## Opens the folder and creates it if it is missing. If a crash interrupted a
## save and left only the backup of the previous one (see write_save), the
## backup becomes the save again.
func _init(directory: String = "user://") -> void:
	base_dir = directory
	DirAccess.make_dir_recursive_absolute(base_dir)
	_restore_backup()


## The key that identifies this installation in every world. It is created
## on first use and kept from then on. Returns an empty key if the key file
## exists but cannot be read; last_read_error then says why.
func player_key() -> PackedByteArray:
	var path := base_dir.path_join(KEY_FILE)
	if FileAccess.file_exists(path):
		var stored := _read_file(path)
		if last_read_error != OK:
			return PackedByteArray()
		if stored.size() == KEY_BYTES:
			return stored
	var key := Crypto.new().generate_random_bytes(KEY_BYTES)
	if not _write(path, key):
		push_error("player key could not be stored at %s" % path)
	return key


func has_save() -> bool:
	return FileAccess.file_exists(base_dir.path_join(SAVE_FILE))


## The bytes of the save. If it cannot be read, they are empty and
## last_read_error says why.
func read_save() -> PackedByteArray:
	return _read_file(base_dir.path_join(SAVE_FILE))


## Writes the save through a temporary file, so that a crash or a failed
## write never destroys the previous save. Returns false if the new save was
## not stored completely; the previous save then stays as it was.
##
## The temporary file replaces the previous save in two moves: the previous
## save becomes the backup, then the temporary file becomes the save. A single
## move onto the previous save would not be safe, because on Windows Godot
## deletes the target first and only then moves the file. If that move failed,
## for example because a virus scanner holds the temporary file, the previous
## save would already be gone. If the second move fails, the backup is moved
## back. A crash between the two moves leaves only the backup; the next
## SaveStore on the folder restores it.
func write_save(bytes: PackedByteArray) -> bool:
	if bytes.is_empty():
		return false
	var path := base_dir.path_join(SAVE_FILE)
	var temporary := path + ".tmp"
	var backup := base_dir.path_join(BACKUP_FILE)
	if not _write(temporary, bytes) or FileAccess.get_file_as_bytes(temporary) != bytes:
		DirAccess.remove_absolute(temporary)
		return false
	var has_previous := FileAccess.file_exists(path)
	if has_previous and DirAccess.rename_absolute(path, backup) != OK:
		DirAccess.remove_absolute(temporary)
		return false
	if DirAccess.rename_absolute(temporary, path) != OK:
		if has_previous:
			DirAccess.rename_absolute(backup, path)
		return false
	if has_previous:
		DirAccess.remove_absolute(backup)
	return true


## Moves an unusable save out of the way, so that the next save does not
## overwrite it. An older set-aside save is replaced. Returns the new path,
## or an empty text if there was no save or it could not be moved.
func set_aside_save() -> String:
	var path := base_dir.path_join(SAVE_FILE)
	var aside := path + ".broken"
	if DirAccess.rename_absolute(path, aside) != OK:
		return ""
	return aside


## Stores a session recording and deletes the oldest ones beyond
## KEEP_RECORDINGS. Returns the path, or an empty text if nothing was written.
func write_recording(bytes: PackedByteArray) -> String:
	if bytes.is_empty():
		return ""
	var directory := base_dir.path_join(RECORDINGS_DIR)
	DirAccess.make_dir_recursive_absolute(directory)
	# Microseconds since 1970, padded so that names sort by age.
	var stamp := int(Time.get_unix_time_from_system() * 1_000_000.0)
	var path := directory.path_join("session-%016d.grwr" % stamp)
	if not _write(path, bytes):
		return ""
	var names := recordings()
	while names.size() > KEEP_RECORDINGS:
		DirAccess.remove_absolute(directory.path_join(names[0]))
		names.remove_at(0)
	return path


## File names of the stored recordings, oldest first.
func recordings() -> PackedStringArray:
	var names := DirAccess.get_files_at(base_dir.path_join(RECORDINGS_DIR))
	names.sort()
	return names


## The name the player gave last time, or an empty text.
func player_name() -> String:
	return _setting("player", "name")


func set_player_name(player_name: String) -> void:
	_set_setting("player", "name", player_name)


## The address of the host the player joined last time, or an empty text.
func last_address() -> String:
	return _setting("network", "address")


func set_last_address(address: String) -> void:
	_set_setting("network", "address", address)


func _setting(section: String, key: String) -> String:
	var settings := ConfigFile.new()
	if settings.load(base_dir.path_join(SETTINGS_FILE)) != OK:
		return ""
	return str(settings.get_value(section, key, ""))


func _set_setting(section: String, key: String, value: String) -> void:
	var path := base_dir.path_join(SETTINGS_FILE)
	var settings := ConfigFile.new()
	settings.load(path)
	settings.set_value(section, key, value)
	if settings.save(path) != OK:
		push_warning("settings could not be stored at %s" % path)


## Moves the backup back to the save if there is no save, see write_save.
func _restore_backup() -> void:
	var path := base_dir.path_join(SAVE_FILE)
	var backup := base_dir.path_join(BACKUP_FILE)
	if FileAccess.file_exists(path) or not FileAccess.file_exists(backup):
		return
	if DirAccess.rename_absolute(backup, path) != OK:
		push_warning("the previous save could not be restored from %s" % backup)


## Reads a whole file and records the result in last_read_error.
func _read_file(path: String) -> PackedByteArray:
	var bytes := FileAccess.get_file_as_bytes(path)
	last_read_error = FileAccess.get_open_error()
	return bytes


func _write(path: String, bytes: PackedByteArray) -> bool:
	var file := FileAccess.open(path, FileAccess.WRITE)
	if file == null:
		return false
	var stored := file.store_buffer(bytes)
	file.close()
	return stored
  • Step 4: Tests ausführen und Erfolg sehen

Run:

godot --headless --path game --import
godot --headless --path game -s res://tests/run_tests.gd

Expected: Exit-Code 0 und 68 passed, 0 failed

  • Step 5: Alles prüfen

Run:

cargo fmt --all -- --check && cargo clippy --workspace --all-targets -- -D warnings && cargo test --workspace
cargo build -p grovewright-bridge

Expected: keine Ausgabe von rustfmt, keine Warnung von Clippy, zusammen 141 bestandene Rust-Tests

  • Step 6: Commit und Hochladen
git add game
git commit -F - <<'EOF'
Profile, Startoptionen und vorsichtiges Lesen

Die Befehlszeile wählt ein Profil, einen Namen und einen Direktstart.
Zwei Fenster auf einem Rechner sind so zwei Spieler. Ein Schlüssel oder
Spielstand, der sich gerade nicht lesen lässt, wird nicht mehr ersetzt.
Name und zuletzt genutzte Adresse bleiben gespeichert.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
EOF
git push

Task 7: Startmenü, fremde Figuren und Statuszeile

Files:

  • Create: game/scripts/menu.gd, game/scripts/figures.gd
  • Modify: game/scripts/hud.gd (ganze Datei ersetzen)
  • Modify: game/i18n/texts.csv (ganze Datei ersetzen)
  • Test: game/tests/test_menu.gd, game/tests/test_figures.gd, game/tests/test_hud.gd

Interfaces:

  • Consumes: GwSession.player_name aus Task 4, Avatar.RADIUS, I18n.install()

  • Produces:

    • Menu (CanvasLayer) mit den Signalen play_chosen(open: bool) und join_chosen(address: String)
    • Menu mit den Feldern name_field, address_field, solo_button, host_button und join_button und den Methoden show_status(text), status() -> String und player_name() -> String
    • Figures (Node2D) mit session: GwSession, place(player: int, position: Vector2), forget(player: int), clear() und shown_at(player: int) -> Vector2 (Vector2.INF, wenn nicht gezeigt)
    • Hud.flash(key: String, values: Array = []) füllt Werte in den Text ein. Dazu kommen Hud.show_status(text: String) und Hud.status_text() -> String.
    • Neue Texte:
      • für den Namen: DEFAULT_NAME
      • für das Menü: MENU_NAME, MENU_SOLO, MENU_HOST, MENU_ADDRESS, MENU_JOIN
      • für den Verbindungsaufbau: CONNECTING, CONNECT_FAILED, ADDRESS_INVALID, HOST_LEFT, PROTOCOL_ERROR
      • für Ablehnungen: REFUSED_OTHER_PROTOCOL, REFUSED_OTHER_RULES, REFUSED_WORLD_FULL, REFUSED_TOO_MANY_PLAYERS, REFUSED_ALREADY_PLAYING
      • für die Statuszeile und Meldungen im Spiel: STATUS_HOST, STATUS_GUEST, OPEN_FAILED, PLAYER_JOINED, PLAYER_LEFT, RESYNCED
      • für Speichern, Pause und Laden: PAUSE_ONLY_ALONE, LOAD_ONLY_ALONE, HOST_SAVES
      • für Lesefehler: KEY_UNREADABLE, SAVE_UNREADABLE
  • Step 1: Fehlschlagende Tests schreiben

Create game/tests/test_menu.gd:

extends TestCase


func _menu() -> Menu:
	I18n.install()
	var menu := Menu.new()
	tree.root.add_child(menu)
	return menu


func _free(menu: Menu) -> void:
	tree.root.remove_child(menu)
	menu.free()


func test_the_buttons_say_what_the_player_chose() -> void:
	var menu := _menu()
	var chosen: Array = []
	menu.play_chosen.connect(func(open: bool) -> void: chosen.append(["play", open]))
	menu.join_chosen.connect(func(address: String) -> void: chosen.append(["join", address]))
	menu.address_field.text = " 192.168.178.20:25000 "
	menu.solo_button.pressed.emit()
	menu.host_button.pressed.emit()
	menu.join_button.pressed.emit()
	menu.address_field.text_submitted.emit(menu.address_field.text)
	check_eq(chosen, [["play", false], ["play", true], ["join", "192.168.178.20:25000"], ["join", "192.168.178.20:25000"]], "in order")
	_free(menu)


func test_the_name_and_the_status_line() -> void:
	var menu := _menu()
	menu.name_field.text = "  Ada  "
	check_eq(menu.player_name(), "Ada", "without the spaces around it")
	menu.show_status("Verbinde …")
	check_eq(menu.status(), "Verbinde …", "the status is shown")
	check_eq(menu.solo_button.text, tr("MENU_SOLO"), "the labels come from the texts")
	_free(menu)

Create game/tests/test_figures.gd:

extends TestCase


func test_a_figure_appears_where_it_is_first_placed() -> void:
	var figures := Figures.new()
	check_eq(figures.shown_at(2), Vector2.INF, "nobody is shown at first")
	figures.place(2, Vector2(10.0, 20.0))
	check_eq(figures.shown_at(2), Vector2(10.0, 20.0), "at once at its first position")
	figures.free()


func test_a_figure_glides_toward_its_latest_position() -> void:
	var figures := Figures.new()
	figures.place(1, Vector2.ZERO)
	figures.place(1, Vector2(100.0, 0.0))
	figures._process(0.02)
	var halfway := figures.shown_at(1).x
	check(halfway > 0.0 and halfway < 100.0, "part of the way after a frame: %s" % halfway)
	for frame in 60:
		figures._process(0.02)
	check(figures.shown_at(1).distance_to(Vector2(100.0, 0.0)) < 0.01, "there after a second")
	figures.free()


func test_figures_can_be_forgotten() -> void:
	var figures := Figures.new()
	figures.place(1, Vector2.ZERO)
	figures.place(3, Vector2.ONE)
	figures.forget(1)
	check_eq(figures.shown_at(1), Vector2.INF, "one is gone")
	check_eq(figures.shown_at(3), Vector2.ONE, "the other stays")
	figures.clear()
	check_eq(figures.shown_at(3), Vector2.INF, "all are gone")
	figures.free()

Create game/tests/test_hud.gd:

extends TestCase


func _hud() -> Hud:
	I18n.install()
	var hud := Hud.new()
	tree.root.add_child(hud)
	return hud


func _free(hud: Hud) -> void:
	tree.root.remove_child(hud)
	hud.free()


func test_a_message_can_carry_values() -> void:
	var hud := _hud()
	hud.flash("PLAYER_JOINED", ["Grace"])
	check_eq(hud.message(), tr("PLAYER_JOINED") % "Grace", "the name is filled in")
	hud.flash("SAVED")
	check_eq(hud.message(), tr("SAVED"), "a message without values")
	_free(hud)


func test_the_status_line_stays_until_it_is_replaced() -> void:
	var hud := _hud()
	hud.show_status(tr("STATUS_HOST") % [24680, 2])
	hud.flash("SAVED")
	hud._process(10.0)
	check_eq(hud.message(), "", "the message is gone after a while")
	check_eq(hud.status_text(), tr("STATUS_HOST") % [24680, 2], "the status line is not")
	_free(hud)
  • Step 2: Tests ausführen und Fehlschlag sehen

Run:

godot --headless --path game --import
godot --headless --path game -s res://tests/run_tests.gd

Expected: Der Testlauf endet mit Exit-Code 1 und 68 passed, 3 failed. test_figures.gd, test_hud.gd und test_menu.gd laden nicht; davor stehen Zeilen wie SCRIPT ERROR: Parse Error: Identifier "Figures" not declared in the current scope.

  • Step 3: Menü, Figuren, Anzeige und Texte schreiben

Create game/scripts/menu.gd:

class_name Menu
extends CanvasLayer
## The start screen: the player's name, then playing alone, opening the world
## for guests, or joining a host.

signal play_chosen(open: bool)
signal join_chosen(address: String)

var name_field := LineEdit.new()
var address_field := LineEdit.new()
var solo_button := Button.new()
var host_button := Button.new()
var join_button := Button.new()
var _status := Label.new()


func _ready() -> void:
	var center := CenterContainer.new()
	center.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
	add_child(center)
	var panel := PanelContainer.new()
	center.add_child(panel)
	var box := VBoxContainer.new()
	box.custom_minimum_size = Vector2(380, 0)
	box.add_theme_constant_override("separation", 10)
	panel.add_child(box)

	var title := Label.new()
	title.text = "Grovewright"
	title.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
	title.add_theme_font_size_override("font_size", 32)
	name_field.placeholder_text = tr("MENU_NAME")
	name_field.max_length = 32
	solo_button.text = tr("MENU_SOLO")
	host_button.text = tr("MENU_HOST")
	var row := HBoxContainer.new()
	for button in [solo_button, host_button]:
		button.size_flags_horizontal = Control.SIZE_EXPAND_FILL
		row.add_child(button)
	address_field.placeholder_text = tr("MENU_ADDRESS")
	join_button.text = tr("MENU_JOIN")
	_status.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
	_status.custom_minimum_size = Vector2(380, 0)
	for control: Control in [title, name_field, row, HSeparator.new(), address_field, join_button, _status]:
		box.add_child(control)

	solo_button.pressed.connect(func() -> void: play_chosen.emit(false))
	host_button.pressed.connect(func() -> void: play_chosen.emit(true))
	join_button.pressed.connect(func() -> void: join_chosen.emit(address_field.text.strip_edges()))
	address_field.text_submitted.connect(func(_text: String) -> void: join_button.pressed.emit())


## Shows `text` below the buttons.
func show_status(text: String) -> void:
	_status.text = text


## The text shown below the buttons. Tests read it.
func status() -> String:
	return _status.text


## The name the player typed, without spaces around it.
func player_name() -> String:
	return name_field.text.strip_edges()

Create game/scripts/figures.gd:

class_name Figures
extends Node2D
## The figures of the other players, with their names. A new position comes
## over the network about ten times a second; in between, each figure glides
## toward the latest one.

## How quickly a figure closes the gap to its latest position, per second.
const GLIDE := 15.0
const COLOR := Color(0.55, 0.8, 0.95)
const OUTLINE := Color(0.05, 0.1, 0.15)
const NAME_SIZE := 8
const NAME_WIDTH := 80.0

var session: GwSession
var _shown: Dictionary[int, Vector2] = {}
var _latest: Dictionary[int, Vector2] = {}


## Moves the figure of `player` toward `position`. A figure that was not
## shown yet appears there at once.
func place(player: int, position: Vector2) -> void:
	if not _shown.has(player):
		_shown[player] = position
	_latest[player] = position
	queue_redraw()


## Removes the figure of `player`.
func forget(player: int) -> void:
	_shown.erase(player)
	_latest.erase(player)
	queue_redraw()


func clear() -> void:
	_shown.clear()
	_latest.clear()
	queue_redraw()


## Where the figure of `player` is shown, or Vector2.INF if it is not.
func shown_at(player: int) -> Vector2:
	return _shown.get(player, Vector2.INF)


func _process(delta: float) -> void:
	if _shown.is_empty():
		return
	var weight := minf(1.0, GLIDE * delta)
	for player in _shown:
		_shown[player] = _shown[player].lerp(_latest[player], weight)
	queue_redraw()


func _draw() -> void:
	var font := ThemeDB.fallback_font
	for player in _shown:
		var at := _shown[player]
		draw_circle(at, Avatar.RADIUS + 1.0, OUTLINE)
		draw_circle(at, Avatar.RADIUS, COLOR)
		if session != null:
			var label := Vector2(at.x - NAME_WIDTH / 2.0, at.y - Avatar.RADIUS - 3.0)
			draw_string(font, label, session.player_name(player), HORIZONTAL_ALIGNMENT_CENTER, NAME_WIDTH, NAME_SIZE)

Ersetze den gesamten Inhalt von game/scripts/hud.gd durch:

class_name Hud
extends CanvasLayer
## Text over the game: the stone count, short messages, a status line for
## playing together and a help line.

const MESSAGE_SECONDS := 2.5

var _stone := Label.new()
var _message := Label.new()
var _status := Label.new()
var _help := Label.new()
var _message_left := 0.0


func _ready() -> void:
	_stone.position = Vector2(16, 12)
	_stone.add_theme_font_size_override("font_size", 22)
	_message.position = Vector2(16, 48)
	_message.add_theme_font_size_override("font_size", 18)
	_status.add_theme_font_size_override("font_size", 14)
	_status.set_anchors_and_offsets_preset(Control.PRESET_TOP_RIGHT, Control.PRESET_MODE_MINSIZE, 16)
	_status.grow_horizontal = Control.GROW_DIRECTION_BEGIN
	_help.text = tr("HELP")
	_help.add_theme_font_size_override("font_size", 14)
	for label: Label in [_stone, _message, _status, _help]:
		label.add_theme_color_override("font_outline_color", Color.BLACK)
		label.add_theme_constant_override("outline_size", 4)
		add_child(label)
	_help.set_anchors_and_offsets_preset(Control.PRESET_BOTTOM_LEFT, Control.PRESET_MODE_MINSIZE, 16)


func _process(delta: float) -> void:
	if _message_left <= 0.0:
		return
	_message_left -= delta
	if _message_left <= 0.0:
		_message.text = ""


func show_stone(count: int) -> void:
	_stone.text = tr("STONE_COUNT") % count


## Shows the text for `key` for a few seconds, with `values` filled into
## its placeholders.
func flash(key: String, values: Array = []) -> void:
	_message.text = tr(key) % values if not values.is_empty() else tr(key)
	_message_left = MESSAGE_SECONDS


## Shows or hides the pause notice. It stays until the game continues.
func show_paused(paused: bool) -> void:
	_message.text = tr("PAUSED") if paused else ""
	_message_left = 0.0


## Shows `text` in the top right corner until it is replaced.
func show_status(text: String) -> void:
	_status.text = text


## The text currently shown in the status line. Tests read it.
func status_text() -> String:
	return _status.text


## The text currently shown as a message. Tests read it.
func message() -> String:
	return _message.text


## The text currently shown as the stone count. Tests read it.
func stone_text() -> String:
	return _stone.text

Ersetze den gesamten Inhalt von game/i18n/texts.csv durch:

keys,de,en
STONE_COUNT,Stein: %d,Stone: %d
SAVED,Gespeichert,Saved
SAVE_FAILED,Speichern fehlgeschlagen,Saving failed
LOADED,Spielstand geladen,Save loaded
LOAD_FAILED,Der Spielstand ließ sich nicht laden,The save could not be loaded
INVENTORY_FULL,Die Tasche ist voll,Your bag is full
PAUSED,Pause,Paused
SIMULATION_STOPPED,Die Simulation wurde angehalten,The simulation stopped
HELP,WASD: laufen · Linke Maustaste: Stein schlagen · F5: speichern · F9: laden · Esc: Pause,WASD: walk · Left mouse button: break rock · F5: save · F9: load · Esc: pause
DEFAULT_NAME,Spieler,Player
MENU_NAME,Dein Name,Your name
MENU_SOLO,Allein spielen,Play alone
MENU_HOST,Welt öffnen,Open your world
MENU_ADDRESS,Adresse des Hosts,Address of the host
MENU_JOIN,Beitreten,Join
CONNECTING,Verbinde mit %s …,Connecting to %s …
CONNECT_FAILED,Keine Verbindung zu %s,Could not reach %s
ADDRESS_INVALID,Das ist keine Adresse: %s,That is not an address: %s
HOST_LEFT,Der Host hat die Welt geschlossen,The host closed the world
PROTOCOL_ERROR,Die Verbindung zum Host ist gestört,The connection to the host is broken
REFUSED_OTHER_PROTOCOL,Der Host hat eine andere Version des Spiels,The host has another version of the game
REFUSED_OTHER_RULES,Der Host hat andere Spielregeln. Ihr braucht denselben Stand des Spiels.,The host has other rules. You need the same version of the game.
REFUSED_WORLD_FULL,Die Welt ist voll: Vier Spieler sind schon da,The world is full: four players are in it
REFUSED_TOO_MANY_PLAYERS,Diese Welt kennt schon 16 Spieler und nimmt keine neuen mehr auf,This world knows 16 players already and takes no new ones
REFUSED_ALREADY_PLAYING,Ein Spieler mit deinem Schlüssel ist schon in der Welt,A player with your key is in the world already
STATUS_HOST,Welt offen auf Port %d · Spieler: %d,World open on port %d · players: %d
STATUS_GUEST,Gast bei %s · Spieler: %d,Guest of %s · players: %d
OPEN_FAILED,Port %d lässt sich nicht öffnen,Port %d cannot be opened
PLAYER_JOINED,%s ist da,%s joined
PLAYER_LEFT,%s ist gegangen,%s left
RESYNCED,Welt mit dem Host abgeglichen,World matched with the host again
PAUSE_ONLY_ALONE,Pause gibt es nur ohne Gäste,Pausing only works without guests
LOAD_ONLY_ALONE,Laden gibt es nur beim Spielen allein,Loading only works when playing alone
HOST_SAVES,Nur der Host speichert,Only the host saves
KEY_UNREADABLE,Dein Spielerschlüssel lässt sich gerade nicht lesen,Your player key cannot be read right now
SAVE_UNREADABLE,Der Spielstand lässt sich gerade nicht lesen,The save cannot be read right now
  • Step 4: Tests ausführen und Erfolg sehen

Run:

godot --headless --path game --import
godot --headless --path game -s res://tests/run_tests.gd

Expected: Exit-Code 0 und 75 passed, 0 failed

  • Step 5: Alles prüfen

Run:

cargo fmt --all -- --check && cargo clippy --workspace --all-targets -- -D warnings && cargo test --workspace
cargo build -p grovewright-bridge

Expected: keine Ausgabe von rustfmt, keine Warnung von Clippy, zusammen 141 bestandene Rust-Tests

  • Step 6: Commit und Hochladen
git add game
git commit -F - <<'EOF'
Startmenü, fremde Figuren und Statuszeile

Das Startmenü fragt nach dem Namen und bietet allein spielen, die Welt
öffnen oder beitreten. Die Figuren der anderen Spieler gleiten zu ihrer
neuesten Position und tragen ihren Namen. Die Anzeige füllt Werte in
Meldungen ein und hat eine Statuszeile. Die Texte für Menü und Netzwerk
gibt es auf Deutsch und Englisch.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
EOF
git push

Task 8: Spielen allein, als Host oder als Gast

Files:

  • Modify: game/scripts/main.gd (ganze Datei ersetzen)
  • Modify: game/tools/screenshot.gd (ganze Datei ersetzen)
  • Test: game/tests/test_main.gd (ganze Datei ersetzen)

Interfaces:

  • Consumes: alles aus den Tasks 4 bis 7

  • Produces, in der Hauptszene (main.gd):

    • play(open: bool) -> bool (allein oder mit offener Welt) und join(address: String)
    • Eigenschaften, die Werkzeuge und Tests vor dem Betreten des Baums setzen: storage_dir, command_line (die Argumente nach --) und listen_address (Standard "*")
    • options: LaunchOptions, store, session, link: SessionLink (null beim Spielen allein), clock, menu, figures, hud, avatar
    • resyncs: int: wie oft die Welt eines Gastes abwich und ersetzt wurde
    • Es bleiben save_game() -> bool (für Gäste immer false), dig_at(tile), AUTOSAVE_TICKS und _run_tick()
    • Neue Konstanten: MAX_GUEST_TICKS_PER_FRAME (20), FIGURE_SECONDS (0.1), REFUSAL_TEXTS
    • Die Konstante PLAYER_NAME entfällt; ohne Namen heißt der Spieler tr("DEFAULT_NAME")
  • Step 1: Fehlschlagende Tests schreiben

Ersetze den gesamten Inhalt von game/tests/test_main.gd durch:

extends TestCase
## Starts the real main scene with its own storage folder. The network tests
## start two of them in this process: a host and a guest.

var _started: Array[Node] = []


func _start_main(directory: String) -> Node:
	var main: Node = load("res://main.tscn").instantiate()
	main.storage_dir = directory
	tree.root.add_child(main)
	_started.append(main)
	return main


func _stop_main(main: Node) -> void:
	_started.erase(main)
	tree.root.remove_child(main)
	if main.link != null:
		main.link.link.close()
	main.free()


## Stops every main scene this test started and removes their folders.
func _clean_up(directories: Array[String]) -> void:
	for main in _started.duplicate():
		_stop_main(main)
	for directory in directories:
		TestFiles.remove_dir(directory)


## A main scene with an open world that listens on the loopback interface.
func _start_host(directory: String) -> Node:
	var host := _start_main(directory)
	host.options.port = 0
	host.listen_address = "127.0.0.1"
	host.menu.name_field.text = "Host"
	check(host.play(true), "the world opens")
	return host


## A main scene that joins `host` as "Grace".
func _start_guest(directory: String, host: Node) -> Node:
	var guest := _start_main(directory)
	guest.menu.name_field.text = "Grace"
	guest.join("127.0.0.1:%d" % host.link.link.local_port())
	return guest


## Lets frames pass until `condition` holds, at most `frames` of them.
func _wait_until(condition: Callable, frames := 600) -> bool:
	for frame in frames:
		if condition.call():
			return true
		await tree.process_frame
	return condition.call()


func _joined(guest: Node) -> bool:
	return guest.session != null and guest.session.local_player() >= 0


func _press(main: Node, action: String) -> void:
	var event := InputEventAction.new()
	event.action = action
	event.pressed = true
	main._unhandled_input(event)


func test_the_menu_comes_first_and_remembers_the_name() -> void:
	var directory := TestFiles.temporary_dir("menu")
	var main := _start_main(directory)
	await tree.process_frame
	check(main.menu.visible, "the menu shows")
	check(main.session == null, "nothing runs yet")
	main.menu.name_field.text = "  Ada "
	check(main.play(false), "playing alone starts")
	check(not main.menu.visible, "the menu is gone")
	check_eq(main.hud.status_text(), "", "no status line when playing alone")
	check_eq(main.session.player_name(main.session.local_player()), "Ada", "the player has the name")
	_stop_main(main)

	var later := _start_main(directory)
	check_eq(later.menu.name_field.text, "Ada", "the name is remembered")
	_clean_up([directory])


func test_the_command_line_can_start_without_the_menu() -> void:
	var directory := TestFiles.temporary_dir("command-line")
	var main: Node = load("res://main.tscn").instantiate()
	main.storage_dir = directory
	main.command_line = PackedStringArray(["--solo", "--name=Linus"])
	tree.root.add_child(main)
	_started.append(main)
	check(main.session != null and main.session.is_running(), "a game runs at once")
	check(not main.menu.visible, "without the menu")
	check_eq(main.session.player_name(main.session.local_player()), "Linus", "with the name from the command line")
	_clean_up([directory])


func test_a_new_game_starts_and_the_player_joins() -> void:
	var directory := TestFiles.temporary_dir("main")
	var main := _start_main(directory)
	main.play(false)
	for frame in 5:
		await tree.process_frame
	var session: GwSession = main.session
	check(session != null and session.is_running(), "a session runs")
	check(not session.is_broken(), "the simulation is healthy")
	check(session.is_host(), "playing alone is hosting without guests")
	check_eq(session.local_player(), 0, "the local player has joined")
	check_eq(main.avatar.position, GridMotion.tile_center(session.spawn_point()), "the figure stands on the spawn")
	check_eq(main.hud.stone_text(), tr("STONE_COUNT") % 0, "the HUD shows the stones")
	_clean_up([directory])


func test_a_saved_game_is_continued_on_the_next_start() -> void:
	var directory := TestFiles.temporary_dir("continue")
	var first := _start_main(directory)
	first.play(false)
	await tree.process_frame
	var tick_at_save: int = first.session.tick()
	check(first.save_game(), "the game was saved")
	_stop_main(first)

	var second := _start_main(directory)
	second.play(false)
	await tree.process_frame
	check(second.session.tick() > tick_at_save, "the saved world continues")
	check_eq(second.session.local_player(), 0, "the same player rejoins")
	_clean_up([directory])


func test_closing_the_window_saves_the_game_and_records_the_session() -> void:
	var directory := TestFiles.temporary_dir("close")
	var main := _start_main(directory)
	main.play(false)
	await tree.process_frame
	main.notification(Node.NOTIFICATION_WM_CLOSE_REQUEST)
	var store := SaveStore.new(directory)
	check(store.has_save(), "the game was saved")
	check_eq(store.recordings().size(), 1, "the session was recorded")
	_clean_up([directory])


func test_the_game_is_saved_automatically_every_two_minutes() -> void:
	var directory := TestFiles.temporary_dir("autosave")
	var main := _start_main(directory)
	main.play(false)
	var store := SaveStore.new(directory)
	check(not store.has_save(), "there is no save at the start")
	# No frames are awaited, so only these ticks run.
	while main.session.tick() < main.AUTOSAVE_TICKS - 1:
		main._run_tick()
	check(not store.has_save(), "still no save one tick before the interval ends")
	main._run_tick()
	check(store.has_save(), "saved when the interval ends")
	_clean_up([directory])


func test_an_unusable_save_is_set_aside_and_a_new_world_starts() -> void:
	var directory := TestFiles.temporary_dir("broken")
	var store := SaveStore.new(directory)
	store.write_save(PackedByteArray([1, 2, 3]))
	var main := _start_main(directory)
	check(main.play(false), "a new world starts")
	check(main.session.tick() <= 1, "it is new, tick %d" % main.session.tick())
	check(FileAccess.file_exists(directory.path_join(SaveStore.SAVE_FILE + ".broken")), "the unusable save is kept aside")
	check_eq(main.hud.message(), tr("LOAD_FAILED"), "the player is told")
	_clean_up([directory])


func test_an_unusable_save_that_cannot_be_set_aside_is_never_overwritten() -> void:
	var directory := TestFiles.temporary_dir("stuck")
	var store := SaveStore.new(directory)
	store.write_save(PackedByteArray([1, 2, 3]))
	# A folder under the set-aside name keeps the unusable save from moving.
	DirAccess.make_dir_absolute(directory.path_join(SaveStore.SAVE_FILE + ".broken"))
	var main := _start_main(directory)
	check(main.play(false), "a new world starts")
	check(not main.save_game(), "saving is refused")
	check_eq(store.read_save(), PackedByteArray([1, 2, 3]), "the unusable save is untouched")
	check_eq(main.hud.message(), tr("LOAD_FAILED"), "the player is told")
	_clean_up([directory])


func test_a_save_that_cannot_be_read_right_now_is_left_alone() -> void:
	var directory := TestFiles.temporary_dir("held")
	SaveStore.new(directory).write_save(PackedByteArray([1, 2, 3]))
	var main := _start_main(directory)
	var held := HeldFileStore.new(directory)
	held.unreadable = SaveStore.SAVE_FILE
	main.store = held
	check(not main.play(false), "nothing starts")
	check_eq(main.menu.status(), tr("SAVE_UNREADABLE"), "the menu says why")
	check(main.session == null, "no session")
	check(not FileAccess.file_exists(directory.path_join(SaveStore.SAVE_FILE + ".broken")), "the save was not set aside")
	_clean_up([directory])


func test_without_a_readable_key_the_game_stays_in_the_menu() -> void:
	var directory := TestFiles.temporary_dir("no-key")
	var main := _start_main(directory)
	main._player_key = PackedByteArray()
	check(not main.play(false), "playing alone does not start")
	main.join("127.0.0.1")
	check(main.session == null, "joining does not start either")
	check_eq(main.menu.status(), tr("KEY_UNREADABLE"), "the menu says why")
	_clean_up([directory])


func test_a_guest_joins_an_open_world_and_both_have_the_same_world() -> void:
	var host_dir := TestFiles.temporary_dir("host")
	var guest_dir := TestFiles.temporary_dir("guest")
	var host := _start_host(host_dir)
	var port: int = host.link.link.local_port()
	check_eq(host.hud.status_text(), tr("STATUS_HOST") % [port, 1], "the host sees the port")
	var guest := _start_guest(guest_dir, host)
	check_eq(guest.menu.status(), tr("CONNECTING") % ("127.0.0.1:%d" % host.link.link.local_port()), "the guest waits")
	check(await _wait_until(_joined.bind(guest)), "the guest is in the world")
	check(not guest.menu.visible, "the guest sees the world")
	check_eq(guest.session.local_player(), 1, "the guest is player 1")
	check(await _wait_until(func() -> bool: return host.hud.message() == tr("PLAYER_JOINED") % "Grace"), "the host is told")
	check_eq(host.hud.status_text(), tr("STATUS_HOST") % [port, 2], "and counts two players")
	check_eq(guest.hud.status_text(), tr("STATUS_GUEST") % ["127.0.0.1:%d" % port, 2], "the guest sees whose guest it is")

	# Stop the host's clock and let the guest catch up.
	host.clock.paused = true
	check(await _wait_until(func() -> bool: return guest.session.tick() == host.session.tick()), "the guest catches up")
	check_eq(guest.session.state_hash_hex(), host.session.state_hash_hex(), "both worlds are the same")
	_clean_up([host_dir, guest_dir])


func test_the_figures_of_host_and_guest_appear_on_the_other_side() -> void:
	var host_dir := TestFiles.temporary_dir("host")
	var guest_dir := TestFiles.temporary_dir("guest")
	var host := _start_host(host_dir)
	var guest := _start_guest(guest_dir, host)
	await _wait_until(_joined.bind(guest))
	host.avatar.position += Vector2(16.0, 0.0)
	var host_figure: Vector2 = host.avatar.position
	check(await _wait_until(func() -> bool: return guest.figures.shown_at(0).distance_to(host_figure) < 0.5), "the guest sees the host's figure")
	check(await _wait_until(func() -> bool: return host.figures.shown_at(1) != Vector2.INF), "the host sees the guest's figure")
	_clean_up([host_dir, guest_dir])


func test_only_the_host_saves_and_only_alone_one_may_pause_or_load() -> void:
	var host_dir := TestFiles.temporary_dir("host")
	var guest_dir := TestFiles.temporary_dir("guest")
	var host := _start_host(host_dir)
	var guest := _start_guest(guest_dir, host)
	await _wait_until(_joined.bind(guest))

	_press(guest, "save_game")
	check_eq(guest.hud.message(), tr("HOST_SAVES"), "a guest does not save")
	_press(guest, "toggle_pause")
	check_eq(guest.hud.message(), tr("PAUSE_ONLY_ALONE"), "a guest cannot pause")
	_press(host, "toggle_pause")
	check_eq(host.hud.message(), tr("PAUSE_ONLY_ALONE"), "nor can a host with a guest")
	_press(host, "load_game")
	check_eq(host.hud.message(), tr("LOAD_ONLY_ALONE"), "loading is for playing alone")

	guest.notification(Node.NOTIFICATION_WM_CLOSE_REQUEST)
	var guest_store := SaveStore.new(guest_dir)
	check(not guest_store.has_save(), "the guest wrote no save")
	check_eq(guest_store.recordings().size(), 1, "but a recording of its session")
	_clean_up([host_dir, guest_dir])


func test_the_host_sees_a_guest_leave() -> void:
	var host_dir := TestFiles.temporary_dir("host")
	var guest_dir := TestFiles.temporary_dir("guest")
	var host := _start_host(host_dir)
	var guest := _start_guest(guest_dir, host)
	await _wait_until(_joined.bind(guest))
	await _wait_until(func() -> bool: return host.figures.shown_at(1) != Vector2.INF)
	guest.notification(Node.NOTIFICATION_WM_CLOSE_REQUEST)
	check(await _wait_until(func() -> bool: return host.hud.message() == tr("PLAYER_LEFT") % "Grace"), "the host is told")
	check_eq(host.figures.shown_at(1), Vector2.INF, "the guest's figure is gone")
	check_eq(host.session.online_players(), PackedInt64Array([0]), "only the host is online")
	_clean_up([host_dir, guest_dir])


func test_a_guest_goes_back_to_the_menu_when_the_host_closes() -> void:
	var host_dir := TestFiles.temporary_dir("host")
	var guest_dir := TestFiles.temporary_dir("guest")
	var host := _start_host(host_dir)
	var guest := _start_guest(guest_dir, host)
	await _wait_until(_joined.bind(guest))
	host.notification(Node.NOTIFICATION_WM_CLOSE_REQUEST)
	check(await _wait_until(func() -> bool: return guest.session == null), "the guest's session ends")
	check(guest.menu.visible, "the menu is back")
	check_eq(guest.menu.status(), tr("HOST_LEFT"), "and says why")
	_clean_up([host_dir, guest_dir])


func test_a_guest_that_is_turned_away_learns_why() -> void:
	var host_dir := TestFiles.temporary_dir("host")
	var host := _start_host(host_dir)
	# A second window on the same profile has the same player key.
	var twin := _start_guest(host_dir, host)
	check(await _wait_until(func() -> bool: return twin.session == null), "the twin is turned away")
	check_eq(twin.menu.status(), tr("REFUSED_ALREADY_PLAYING"), "the menu says why")
	_clean_up([host_dir])


func test_a_mistyped_address_is_reported() -> void:
	var directory := TestFiles.temporary_dir("typo")
	var main := _start_main(directory)
	main.join("host:port")
	check(main.session == null, "nothing starts")
	check_eq(main.menu.status(), tr("ADDRESS_INVALID") % "host:port", "the menu says why")
	_clean_up([directory])
  • Step 2: Tests ausführen und Fehlschlag sehen

Run:

godot --headless --path game --import
godot --headless --path game -s res://tests/run_tests.gd

Expected: Der Testlauf endet mit Exit-Code 1 und 69 passed, 17 failed: Alle 17 Tests in test_main.gd scheitern, etwa mit SCRIPT ERROR: Invalid call. Nonexistent function 'play' in base 'Node2D (main.gd)'. Weil viele Tests auf eine Sitzung warten, die die alte Hauptszene nie startet, dauert dieser Lauf etwa eine Minute und gibt sehr viele Fehlerzeilen aus.

  • Step 3: Hauptszene schreiben

Ersetze den gesamten Inhalt von game/scripts/main.gd durch:

extends Node2D
## Composition root. Shows the start menu, then runs a session as host or as
## guest with the map view, the figures, the HUD and the files on disk.

## Seed of a new world until the slice ships its chosen seed.
const NEW_WORLD_SEED := 2026
## How far the pickaxe reaches, in tiles from the figure to the tile centre.
const REACH_TILES := 2.5
## Ticks between two automatic saves: two minutes.
const AUTOSAVE_TICKS := 2 * 60 * GwSession.TICKS_PER_SECOND
const CAMERA_ZOOM := Vector2(3.0, 3.0)
## Most ticks a guest runs in one frame. A guest that fell behind catches up
## within a few frames without freezing.
const MAX_GUEST_TICKS_PER_FRAME := 20
## Seconds between two position updates of the own figure.
const FIGURE_SECONDS := 0.1
## The text for every reason a host gives for turning a guest away.
const REFUSAL_TEXTS := {
	GwSession.REFUSED_OTHER_PROTOCOL: "REFUSED_OTHER_PROTOCOL",
	GwSession.REFUSED_OTHER_RULES: "REFUSED_OTHER_RULES",
	GwSession.REFUSED_WORLD_FULL: "REFUSED_WORLD_FULL",
	GwSession.REFUSED_TOO_MANY_PLAYERS: "REFUSED_TOO_MANY_PLAYERS",
	GwSession.REFUSED_ALREADY_PLAYING: "REFUSED_ALREADY_PLAYING",
}

## Folder for the player key, the save and the recordings. If it is empty,
## the command line decides; tests set a folder of their own.
@export var storage_dir := ""
## Network interface an open world listens on. Tests use the loopback one.
var listen_address := "*"
## The command line arguments after "--". Tools and tests may set their own
## before the scene enters the tree.
var command_line := OS.get_cmdline_user_args()

var options: LaunchOptions
var store: SaveStore
## The running session, or null in the menu. A guest's session has no world
## until the host sent it.
var session: GwSession
## The network connection of an open world or a guest, or null.
var link: SessionLink
var clock: TickClock
var menu: Menu
var map_view: MapView
var marker: TileMarker
var avatar: Avatar
var figures: Figures
var hud: Hud
## How often a guest's world diverged and was replaced by the host's.
var resyncs := 0

var _player_key: PackedByteArray
var _next_dig_tick := 0
## True while an unusable save could not be set aside. Saving is refused
## then, so that the unusable save is never overwritten.
var _save_blocked := false
var _figure_wait := 0.0
## The address a guest is joining, for messages.
var _joining := ""


func _ready() -> void:
	I18n.install()
	InputSetup.install()
	options = LaunchOptions.parse(command_line)
	for argument in options.ignored:
		push_warning("ignored command line argument: %s" % argument)
	if storage_dir.is_empty():
		storage_dir = options.storage_dir()
	store = SaveStore.new(storage_dir)
	_player_key = store.player_key()
	if _player_key.is_empty():
		push_warning("the player key cannot be read: %s" % error_string(store.last_read_error))
	clock = TickClock.new(GwSession.TICKS_PER_SECOND)
	map_view = MapView.new()
	add_child(map_view)
	marker = TileMarker.new()
	add_child(marker)
	figures = Figures.new()
	add_child(figures)
	avatar = Avatar.new()
	add_child(avatar)
	var camera := Camera2D.new()
	camera.zoom = CAMERA_ZOOM
	avatar.add_child(camera)
	hud = Hud.new()
	add_child(hud)
	menu = Menu.new()
	add_child(menu)
	menu.name_field.text = options.player_name if not options.player_name.is_empty() else store.player_name()
	menu.address_field.text = options.address if not options.address.is_empty() else store.last_address()
	menu.play_chosen.connect(play)
	menu.join_chosen.connect(join)
	_show_menu("")
	match options.start:
		"solo":
			play(false)
		"host":
			play(true)
		"join":
			join(options.address)


## Plays the saved world, or a new one, as its host. With `open`, guests may
## join. Returns false if it could not start; the menu then says why.
func play(open: bool) -> bool:
	if not _can_start():
		return false
	var next := GwSession.new()
	var set_aside := false
	if store.has_save():
		var save := store.read_save()
		if store.last_read_error != OK:
			_show_menu(tr("SAVE_UNREADABLE"))
			return false
		set_aside = not _load(next, save)
	if not next.is_running():
		next.start_new(NEW_WORLD_SEED)
	if open:
		var net := NetLink.new()
		if net.host(options.port, listen_address) != OK:
			_show_menu(tr("OPEN_FAILED") % options.port)
			return false
		link = SessionLink.new(next, net)
	session = next
	session.join(_player_key, _player_name())
	_run_tick()
	_show_world()
	if set_aside:
		hud.flash("LOAD_FAILED")
	return true


## Starts joining the host at `address`, "host" or "host:port". The world
## arrives later; until then the menu says that it is connecting.
func join(address: String) -> void:
	if not _can_start():
		return
	var target := NetLink.split_address(address)
	if target.is_empty():
		_show_menu(tr("ADDRESS_INVALID") % address)
		return
	var net := NetLink.new()
	if net.join(target[0], target[1]) != OK:
		_show_menu(tr("CONNECT_FAILED") % address)
		return
	session = GwSession.new()
	session.start_guest(_player_key, _player_name())
	link = SessionLink.new(session, net)
	_joining = address
	store.set_last_address(address)
	_show_menu(tr("CONNECTING") % address)


func _can_start() -> bool:
	if session != null:
		return false
	if _player_key.is_empty():
		_show_menu(tr("KEY_UNREADABLE"))
		return false
	store.set_player_name(menu.player_name())
	return true


func _player_name() -> String:
	var typed := menu.player_name()
	return typed if not typed.is_empty() else tr("DEFAULT_NAME")


## Starts `next` from `save`. If the save is unusable, it is set aside so
## that the next save does not overwrite it, and false is returned.
func _load(next: GwSession, save: PackedByteArray) -> bool:
	if next.start_loaded(save):
		_save_blocked = false
		return true
	var aside := store.set_aside_save()
	if aside.is_empty():
		_save_blocked = true
		push_warning("save not usable (%s) and could not be set aside; saving is off" % next.last_error())
	else:
		push_warning("save not usable (%s), kept as %s" % [next.last_error(), aside])
	return false


func _show_menu(status: String) -> void:
	menu.show_status(status)
	menu.show()
	hud.hide()
	for node: Node2D in [avatar, marker, figures]:
		node.hide()


## Shows the world of the session and puts the figure on the spawn.
func _show_world() -> void:
	menu.hide()
	hud.show()
	for node: Node2D in [avatar, marker, figures]:
		node.show()
	map_view.session = session
	map_view.queue_redraw()
	figures.session = session
	figures.clear()
	avatar.is_walkable = session.is_walkable
	avatar.position = GridMotion.tile_center(session.spawn_point())
	hud.show_stone(session.item_count(GwSession.ITEM_STONE))
	_update_status()
	_next_dig_tick = 0


## Says in the status line how the game is shared, and with how many.
func _update_status() -> void:
	if link == null:
		hud.show_status("")
	elif session.is_host():
		hud.show_status(tr("STATUS_HOST") % [link.link.local_port(), session.online_players().size()])
	else:
		hud.show_status(tr("STATUS_GUEST") % [_joining, session.online_players().size()])


## Ends a guest's session and goes back to the menu with `message`.
func _leave(message: String) -> void:
	if session == null:
		return
	_write_recording()
	if link != null:
		link.link.close()
	link = null
	session = null
	map_view.session = null
	map_view.queue_redraw()
	avatar.is_walkable = Callable()
	figures.clear()
	_show_menu(message)


func _process(delta: float) -> void:
	if link != null:
		for notice in link.pump(delta):
			_on_notice(notice)
	if session == null or not session.is_running() or session.is_broken():
		return
	_update_marker()
	if Input.is_action_pressed("dig") and not clock.paused:
		_try_dig()
	if session.is_host():
		for i in clock.advance(delta):
			_run_tick()
	else:
		for i in mini(session.pending_ticks(), MAX_GUEST_TICKS_PER_FRAME):
			_apply(session.step())
	if link != null:
		link.flush()
		_figure_wait -= delta
		if _figure_wait <= 0.0:
			_figure_wait = FIGURE_SECONDS
			link.send_figure(avatar.position)


func _on_notice(notice: Dictionary) -> void:
	match notice["type"]:
		"guest_arrived":
			# Pausing is for playing alone; a guest ends the pause.
			if clock.paused:
				_set_paused(false)
		"world_received":
			if notice["resync"]:
				resyncs += 1
				_keep_diverged_recording()
				map_view.queue_redraw()
				hud.show_stone(session.item_count(GwSession.ITEM_STONE))
				hud.flash("RESYNCED")
			else:
				_show_world()
		"refused":
			_leave(tr(REFUSAL_TEXTS.get(notice["reason"], "PROTOCOL_ERROR")))
		"protocol_error":
			push_warning("broken message from the host: %s" % session.last_error())
			_leave(tr("PROTOCOL_ERROR"))
		"host_lost":
			_leave(tr("HOST_LEFT"))
		"failed":
			_leave(tr("CONNECT_FAILED") % _joining)
		"divergence":
			push_warning("guest %d diverged at tick %d with state %s" % [notice["peer"], notice["tick"], notice["hash"]])
		"figure":
			figures.place(notice["player"], notice["position"])


## Keeps the recording of a guest's world that diverged from the host's, so
## that the divergence can be replayed.
func _keep_diverged_recording() -> void:
	var path := store.write_recording(session.take_diverged_recording())
	push_warning("the world diverged from the host's; its recording is %s" % path)


func _unhandled_input(event: InputEvent) -> void:
	if session == null or not session.is_running():
		return
	if event.is_action_pressed("save_game"):
		if session.is_host():
			hud.flash("SAVED" if save_game() else "SAVE_FAILED")
		else:
			hud.flash("HOST_SAVES")
	elif event.is_action_pressed("load_game"):
		_reload()
	elif event.is_action_pressed("toggle_pause"):
		if session.is_host() and session.guest_count() == 0:
			_set_paused(not clock.paused)
		else:
			hud.flash("PAUSE_ONLY_ALONE")


## Goes back to the saved world. Only when playing alone: guests would no
## longer have the host's world.
func _reload() -> void:
	if link != null:
		hud.flash("LOAD_ONLY_ALONE")
		return
	var save := store.read_save()
	if store.last_read_error != OK:
		hud.flash("LOAD_FAILED")
		return
	var next := GwSession.new()
	if not _load(next, save):
		hud.flash("LOAD_FAILED")
		return
	_write_recording()
	session = next
	session.join(_player_key, _player_name())
	_run_tick()
	_show_world()
	hud.flash("LOADED")


func _set_paused(paused: bool) -> void:
	clock.paused = paused
	avatar.set_process(not paused)
	hud.show_paused(paused)


func _notification(what: int) -> void:
	if what != NOTIFICATION_WM_CLOSE_REQUEST or session == null:
		return
	if session.is_running():
		if session.is_host() and not save_game():
			push_warning("the game could not be saved on close")
		_write_recording()
	if link != null:
		link.link.close()


func _run_tick() -> void:
	_apply(session.step())
	if session.tick() % AUTOSAVE_TICKS == 0 and not save_game():
		hud.flash("SAVE_FAILED")
		push_warning("the automatic save failed")


func _apply(events: Array[Dictionary]) -> void:
	var local := session.local_player()
	for event in events:
		match event["type"]:
			"tile_hit", "tile_changed":
				map_view.queue_redraw()
			"item_gained":
				if event["player"] == local:
					hud.show_stone(session.item_count(GwSession.ITEM_STONE))
			"inventory_full":
				if event["player"] == local:
					hud.flash("INVENTORY_FULL")
			"player_joined":
				if event["player"] != local:
					hud.flash("PLAYER_JOINED", [session.player_name(event["player"])])
				_update_status()
			"player_left":
				figures.forget(event["player"])
				hud.flash("PLAYER_LEFT", [session.player_name(event["player"])])
				_update_status()
	if session.is_broken():
		hud.flash("SIMULATION_STOPPED")
		push_error(session.last_error())


func _update_marker() -> void:
	var tile := GridMotion.tile_of(get_global_mouse_position())
	var distance := avatar.position.distance_to(GridMotion.tile_center(tile))
	marker.show_tile(tile, distance <= REACH_TILES * GridMotion.TILE_SIZE)


func _try_dig() -> void:
	if marker.in_reach:
		dig_at(marker.tile)


## Queues a pickaxe hit on `tile`, at most once per cooldown. The caller must
## check the reach: figures move outside the lockstep, so the core does not
## know where the figure is. The core checks the cooldown, the tile, its
## terrain, the tool tier and the free space in the inventory.
func dig_at(tile: Vector2i) -> void:
	if session.tick() < _next_dig_tick:
		return
	session.dig(tile.x, tile.y)
	_next_dig_tick = session.tick() + GwSession.DIG_COOLDOWN_TICKS


## Writes the running world as the save. Returns false if nothing was
## written: for a guest, whose world belongs to the host, and while an
## unusable save could not be set aside.
func save_game() -> bool:
	if _save_blocked or not session.is_host():
		return false
	return store.write_save(session.save())


func _write_recording() -> void:
	store.write_recording(session.recording_bytes())

Ersetze den gesamten Inhalt von game/tools/screenshot.gd durch:

extends SceneTree
## Starts the game in a temporary storage folder, walks the figure toward the
## rock beside the clearing, hits it once and saves a screenshot. Lets a
## change to the look be checked without playing by hand. The player's real
## key and save stay untouched.
##
## From the repository root (opens a window for a moment):
##   godot --path game -s res://tools/screenshot.gd -- <path of the PNG>

const STORAGE := "user://screenshot-run"
const FRAMES_AFTER_HIT := 30


func _initialize() -> void:
	_run.call_deferred()


func _run() -> void:
	var arguments := OS.get_cmdline_user_args()
	var output := arguments[0] if arguments.size() > 0 else "user://screenshot.png"
	DirAccess.make_dir_recursive_absolute(STORAGE)
	var main: Node = load("res://main.tscn").instantiate()
	main.storage_dir = STORAGE
	main.command_line = PackedStringArray()
	root.add_child(main)
	main.play(false)
	await process_frame

	# Four tiles to the right, toward the rock outcrop beside the clearing.
	for frame in 48:
		main.avatar.walk(Vector2.RIGHT, 1.0 / 60.0)
		await process_frame
	var session: GwSession = main.session
	var rock := session.spawn_point() + Vector2i(7, 0)
	main.dig_at(rock)
	for frame in FRAMES_AFTER_HIT:
		await process_frame

	var image := root.get_texture().get_image()
	var error := image.save_png(output)
	print("screenshot %s: %s" % ["saved" if error == OK else "failed", ProjectSettings.globalize_path(output)])
	root.remove_child(main)
	main.free()
	for file in DirAccess.get_files_at(STORAGE):
		DirAccess.remove_absolute(STORAGE.path_join(file))
	for directory in DirAccess.get_directories_at(STORAGE):
		var path := STORAGE.path_join(directory)
		for file in DirAccess.get_files_at(path):
			DirAccess.remove_absolute(path.path_join(file))
		DirAccess.remove_absolute(path)
	DirAccess.remove_absolute(STORAGE)
	quit(0 if error == OK else 1)
  • Step 4: Tests ausführen und Erfolg sehen

Run:

godot --headless --path game --import
godot --headless --path game -s res://tests/run_tests.gd

Expected: Exit-Code 0 und 86 passed, 0 failed. Die einzigen Warnungen sind die beiden bekannten Zeilen mit „save not usable“.

  • Step 5: Bildschirmfoto prüfen

Run: godot --path game -s res://tools/screenshot.gd -- "$TEMP/m1c-screenshot.png" Expected: Ein Fenster öffnet sich kurz. Die Ausgabe endet mit screenshot saved: … und enthält keine Warnung. Auf dem Bild stehen die Figur, die Karte, oben links Stein: 0 und unten die Hilfezeile; das Menü ist nicht zu sehen.

  • Step 6: Alles prüfen

Run:

cargo fmt --all -- --check && cargo clippy --workspace --all-targets -- -D warnings && cargo test --workspace
cargo build -p grovewright-bridge

Expected: keine Ausgabe von rustfmt, keine Warnung von Clippy, zusammen 141 bestandene Rust-Tests

  • Step 7: Commit und Hochladen
git add game
git commit -F - <<'EOF'
Spielen allein, als Host oder als Gast

Die Hauptszene beginnt mit dem Startmenü oder mit dem Direktstart aus
der Befehlszeile. Gäste sehen die Welt des Hosts und die Figuren aller
Spieler. Nur der Host speichert, Pause gibt es nur ohne Gäste, Laden
nur beim Spielen allein. Ablehnungen, Abbrüche und Abgleiche erscheinen
als Meldung, und ein Spielstand, der sich gerade nicht lesen lässt,
bleibt unangetastet.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
EOF
git push

Task 9: Zwei Instanzen im Gleichschritt

Files:

  • Create: game/tools/guest_bot.gd
  • Test: game/tests/test_two_instances.gd

Interfaces:

  • Consumes: die Hauptszene aus Task 8 (play, command_line, listen_address, options, link, clock, session, resyncs, dig_at, _run_tick)

  • Produces: res://tools/guest_bot.gd, startbar mit godot --headless --path game -s res://tools/guest_bot.gd -- --join=ADRESSE --storage=ORDNER --until=TAKT --out=DATEI. Es schreibt {"tick", "hash", "player", "stones", "resyncs"} als JSON oder {"error"} und endet mit Exit-Code 0 beziehungsweise 1.

  • Step 1: Fehlschlagenden Test schreiben

Create game/tests/test_two_instances.gd:

extends TestCase
## What milestone 1 promises: two instances of the game, each in a process of
## its own, stay in lockstep. This process is the host; a second Godot
## process plays a guest with tools/guest_bot.gd.

## The tick at which the host stops and both compare their worlds.
const UNTIL := 150
## Milliseconds the test waits for the second instance.
const PATIENCE_MSEC := 30000


func test_a_guest_in_another_process_stays_in_lockstep() -> void:
	var directory := TestFiles.temporary_dir("two-instances")
	var host: Node = load("res://main.tscn").instantiate()
	host.storage_dir = directory.path_join("host")
	host.command_line = PackedStringArray()
	tree.root.add_child(host)
	host.options.port = 0
	host.listen_address = "127.0.0.1"
	check(host.play(true), "the world opens")

	var report_path := directory.path_join("guest.json")
	var arguments := PackedStringArray([
		"--headless",
		"--path", ProjectSettings.globalize_path("res://"),
		"--log-file", ProjectSettings.globalize_path(directory.path_join("guest.log")),
		"-s", "res://tools/guest_bot.gd",
		"--",
		"--join=127.0.0.1:%d" % host.link.link.local_port(),
		"--storage=" + directory.path_join("guest"),
		"--until=%d" % UNTIL,
		"--out=" + report_path,
	])
	var process := OS.create_process(OS.get_executable_path(), arguments)
	check(process > 0, "the second instance starts")
	var started := Time.get_ticks_msec()
	while OS.is_process_running(process) and Time.get_ticks_msec() - started < PATIENCE_MSEC:
		if host.session.guest_count() > 0:
			# Once the guest is there, the test runs the host's ticks itself,
			# one per frame, instead of twenty a second, and stops at UNTIL.
			# A guest's arrival ends a pause, so the clock is stopped here.
			host.clock.paused = true
			if host.session.tick() < UNTIL:
				host._run_tick()
		await tree.process_frame
	if OS.is_process_running(process):
		OS.kill(process)
		check(false, "the second instance did not finish in time")

	var report: Variant = JSON.parse_string(FileAccess.get_file_as_string(report_path))
	check(report is Dictionary and not report.has("error"), "the guest reports: %s" % [report])
	if report is Dictionary and not report.has("error"):
		check_eq(int(report["tick"]), UNTIL, "the guest stopped where the host stopped")
		check_eq(report["hash"], host.session.state_hash_hex(), "both worlds are the same")
		check_eq(int(report["player"]), 1, "the guest is player 1")
		check_eq(int(report["stones"]), 1, "the guest broke the rock")
		check_eq(int(report["resyncs"]), 0, "without a divergence on the way")
	var rock: Vector2i = host.session.spawn_point() + Vector2i(7, -2)
	check_eq(host.session.terrain_at(rock.x, rock.y), GwSession.TERRAIN_GROUND, "the rock is gone in the host's world too")
	check_eq(host.session.player_name(1), "Bot", "the host knows the guest")

	tree.root.remove_child(host)
	host.link.link.close()
	host.free()
	TestFiles.remove_dir(directory)
  • Step 2: Test ausführen und Fehlschlag sehen

Run:

godot --headless --path game --import
godot --headless --path game -s res://tests/run_tests.gd

Expected: Der Testlauf endet mit Exit-Code 1 und 86 passed, 1 failed. Der zweite Prozess findet res://tools/guest_bot.gd nicht und schreibt keinen Bericht; unter FAIL test_two_instances.gd test_a_guest_in_another_process_stays_in_lockstep stehen the guest reports: <null>, the rock is gone in the host's world too: expected 0, got 1 und the host knows the guest: expected Bot, got .

  • Step 3: Zweite Instanz schreiben

Create game/tools/guest_bot.gd:

extends SceneTree
## A second instance of the game for the test of two instances. It starts the
## real main scene as a guest, hits the rock beside the spawn clearing until
## it has a stone, follows the host until the host stops at a tick, and
## writes what its world looks like then as JSON.
##
## The test of two instances starts it; by hand:
##   godot --headless --path game -s res://tools/guest_bot.gd -- --join=ADDRESS
##     --storage=FOLDER --until=TICK --out=FILE

## Milliseconds the bot waits for everything before it gives up.
const PATIENCE_MSEC := 20000


func _initialize() -> void:
	_run.call_deferred()


func _run() -> void:
	var settings := {}
	for argument in OS.get_cmdline_user_args():
		settings[argument.get_slice("=", 0)] = argument.get_slice("=", 1)
	var until := int(settings.get("--until", "0"))
	var report := {"error": "the host did not answer"}

	var main: Node = load("res://main.tscn").instantiate()
	main.storage_dir = settings.get("--storage", "user://bot")
	main.command_line = PackedStringArray(["--join=" + settings.get("--join", ""), "--name=Bot"])
	root.add_child(main)

	var started := Time.get_ticks_msec()
	while Time.get_ticks_msec() - started < PATIENCE_MSEC:
		await process_frame
		var session: GwSession = main.session
		if session == null:
			report = {"error": "the session ended: %s" % main.menu.status()}
			break
		if session.local_player() < 0:
			continue
		var rock := session.spawn_point() + Vector2i(7, -2)
		if session.item_count(GwSession.ITEM_STONE) == 0:
			main.dig_at(rock)
		if session.tick() == until and session.pending_ticks() == 0:
			report = {
				"tick": session.tick(),
				"hash": session.state_hash_hex(),
				"player": session.local_player(),
				"stones": session.item_count(GwSession.ITEM_STONE),
				"resyncs": main.resyncs,
			}
			break

	var file := FileAccess.open(settings.get("--out", "user://bot.json"), FileAccess.WRITE)
	file.store_string(JSON.stringify(report))
	file.close()
	main.notification(Node.NOTIFICATION_WM_CLOSE_REQUEST)
	root.remove_child(main)
	main.free()
	quit(0 if not report.has("error") else 1)
  • Step 4: Test ausführen und Erfolg sehen

Run:

godot --headless --path game --import
godot --headless --path game -s res://tests/run_tests.gd

Expected: Exit-Code 0 und 87 passed, 0 failed. Der Test test_a_guest_in_another_process_stays_in_lockstep braucht wenige Sekunden, weil er einen zweiten Godot-Prozess startet.

  • Step 5: Alles prüfen

Run:

cargo fmt --all -- --check && cargo clippy --workspace --all-targets -- -D warnings && cargo test --workspace
cargo build -p grovewright-bridge

Expected: keine Ausgabe von rustfmt, keine Warnung von Clippy, zusammen 141 bestandene Rust-Tests

  • Step 6: Commit und Hochladen
git add game
git commit -F - <<'EOF'
Zwei Instanzen im Gleichschritt

Ein Test startet einen zweiten Godot-Prozess als Gast. Der Gast tritt
über das Netzwerk bei und schlägt Stein, beide Welten enden im selben
Zustand, ohne Abgleich unterwegs. Damit ist die Prüffrage von
Meilenstein 1 Teil der automatischen Prüfungen.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
EOF
git push

Task 10: Startskripte, Regeln und Probelauf

Files:

  • Modify: play.bat (ganze Datei ersetzen)
  • Create: play-zweites-fenster.bat
  • Modify: CLAUDE.md (ganze Datei ersetzen)
  • Create: docs/playtests/m1c.md

Interfaces:

  • Consumes: die Befehlszeile aus Task 6 und die Hauptszene aus Task 8

  • Produces: play.bat gibt Argumente an das Spiel weiter; play-zweites-fenster.bat startet mit dem Profil zweites-fenster

  • Step 1: Startskripte schreiben

Ersetze den gesamten Inhalt von play.bat durch:

@echo off
rem Builds the bridge and starts Grovewright from this folder. Arguments go
rem to the game, for example --profile=zweites-fenster; see CLAUDE.md.
cd /d "%~dp0"
cargo build -p grovewright-bridge || goto failed
rem The first import of a fresh checkout crashes while Godot shuts down
rem (Godot 4.7.2 with a Rust extension). The import has happened by then,
rem and a second run succeeds. Batch reports the crash as a negative exit
rem code, so the check compares with zero instead of using errorlevel 1.
godot --headless --path game --import
if %errorlevel% neq 0 godot --headless --path game --import || goto failed
godot --path game -- %*
goto :eof

:failed
echo.
echo Grovewright konnte nicht gestartet werden. Die Meldungen oben nennen den Grund.
pause
exit /b 1

Create play-zweites-fenster.bat:

@echo off
rem Starts a second window with a profile of its own, so that it plays as a
rem second player on this machine, for example as a guest of the first one.
call "%~dp0play.bat" --profile=zweites-fenster

Beide Dateien brauchen Windows-Zeilenenden. Die Regel *.bat text eol=crlf in .gitattributes sorgt beim Auschecken dafür; im Archiv stehen sie mit LF.

  • Step 2: Regeln und Fragebogen schreiben

Ersetze den gesamten Inhalt von CLAUDE.md durch:

# Grovewright

Aufbauspiel für ein bis vier Spieler, in dem die Fabrik lebt. Gebaut wird zuerst eine Vertical Slice mit 2–3 Stunden Spielzeit.

Das verbindliche Konzept steht in [docs/KONZEPT.md](docs/KONZEPT.md). Lies es, bevor du Spielregeln, Architektur oder Umfang änderst. Was dort nicht steht, ist nicht beschlossen.

## Aufbau

| Ordner | Inhalt |
|---|---|
| `core/` | Rust-Kern: die gesamte gemeinsame Welt. Ohne Zugriff auf die Engine, ohne fremde Crates. |
| `bridge/` | Rust-Brücke (GDExtension): macht den Kern für Godot aufrufbar und enthält keine Spielregeln. |
| `game/` | Godot-Projekt mit der GDScript-Hülle: Darstellung, Eingabe, Oberfläche, Dateien. |
| `docs/KONZEPT.md` | Spielkonzept und Entscheidungen |
| `docs/superpowers/plans/` | Umsetzungspläne |
| `docs/playtests/` | Fragebögen für Playtests |

Faustregel: Was im Speicherstand steht oder alle Spieler betrifft, gehört in den Kern. Die Hülle darf Gleitkommazahlen nutzen, weil sie nichts an der gemeinsamen Welt ändert; sie schickt nur Befehle.

## Regeln für den Kern

Alle Spieler simulieren die Welt selbst und tauschen nur Befehle aus (Gleichschritt). Das funktioniert nur, wenn der Kern auf jedem Rechner exakt gleich rechnet. Deshalb gelten für den Quelltext des Kerns in `core/src/` diese Regeln (die Tests in `core/tests/` dürfen zum Beispiel Dateien lesen):

- Nur ganze Zahlen, keine Gleitkommazahlen.
- Keine Uhr, keine Dateien, kein Netzwerk, keine Threads, keine Umgebungsvariablen, kein globaler veränderlicher Zustand.
- Zufall nur über `rng::Rng` mit fester Startzahl.
- Keine Sammlung mit wechselnder Reihenfolge. Statt einer Hash-Tabelle nimmst du `Vec`, `BTreeMap` oder `BTreeSet`.
- Die Welt ändert sich nur durch einen `Command` in `World::step`. Einzige Ausnahme ist das Laden eines Speicherstands mit `World::load`, das alle Spieler abmeldet.
- Wer eine Sitzung aufzeichnet, ruft `Recording::step` statt `World::step` auf. So enthält die Aufzeichnung genau die ausgeführten Befehle.
- Alles, was zum Zustand der Welt gehört, steht im Snapshot. Wer ein Feld ergänzt, ergänzt es im Kodierer und im Dekodierer seiner Struktur (`snapshot.rs` für die Welt, `player.rs` für Spieler, `inventory.rs` für das Inventar, `map.rs` für die Karte), prüft es beim Dekodieren oder in `check_restorable` und erhöht `SNAPSHOT_VERSION`.
- Wo eine Rechnung im Spiel überlaufen kann, steht eine ausdrückliche Wahl: `saturating_`, `wrapping_` oder `checked_`. Taktzähler laufen praktisch nie über und werden normal addiert: Die Welt zählt in `u64`, eine Aufzeichnung in `u32`, was bei 20 Takten pro Sekunde für über sechs Jahre reicht. Takte aus geladenen Dateien begrenzt `MAX_RESTORABLE_TICK`.
- Keine fremden Crates im Kern.

Drei Wächter prüfen das. `core/clippy.toml` sperrt Pfade wie `std::collections::HashMap` oder `std::fs::read`, auch über Aliase und gruppierte Importe. Der Test `core/tests/forbidden_apis.rs` durchsucht zusätzlich den Text nach verbotenen Begriffen; sie dürfen dort auch in Kommentaren nicht vorkommen. Gruppierte Importe aus `std` (`use std::{…}`) sind verboten, weil sie dem Text die Modulnamen verbergen: Jeder Pfad steht in einer eigenen `use`-Zeile. `core/tests/no_dependencies.rs` hält die Abhängigkeiten leer.

## Gemeinsame Welt im Netz

- Ein Spieler ist Host, die anderen sind Gäste. Der Host (`core/src/host.rs`) legt fest, welche Befehle in welchem Takt laufen, und schickt jeden Takt an seine Gäste. Jeder Gast (`core/src/guest.rs`) rechnet jeden Takt selbst nach. Allein spielen heißt: Host ohne Gäste.
- Ein Gast bekommt beim Beitritt die Welt des Hosts als Snapshot. Einmal pro Sekunde liegt einem Takt die Prüfsumme des Hosts bei. Weicht die Welt des Gastes ab, holt er sich die Welt des Hosts neu und hebt die Aufzeichnung der abgewichenen Welt auf.
- Die Nachrichten stehen in `core/src/protocol.rs`. Wer eine Nachricht ändert, erhöht `PROTOCOL_VERSION`.
- Die Regel-Kennung (`core/src/rules.rs`) leitet sich aus den Endzuständen eingefrorener Skripte ab. Rechner mit verschiedener Regel-Kennung spielen nicht zusammen, und Aufzeichnungen anderer Regeln werden abgelehnt. Eine neue Regel braucht deshalb ein neues Skript in `SCRIPTS`, das sie benutzt; bestehende Skripte ändert niemand.
- Gäste schicken nur Aktionen, ohne Spielernummer. Der Host macht daraus Befehle für den Spieler der Verbindung und nimmt höchstens `MAX_ACTIONS_PER_TICK` pro Spieler und Takt an.
- Die Reichweite der Spitzhacke prüft nur die Hülle des eigenen Spielers. Figuren laufen außerhalb des Gleichschritts, also kennt der Kern ihre Position nicht. Ein veränderter Client könnte deshalb aus der Ferne graben. Für ein Koop-Spiel unter Freunden nehmen wir das hin.

## Regeln für Brücke und Hülle

- Die Brücke übersetzt nur. Neue Spielregeln gehören in den Kern, nie in `bridge/` oder `game/`.
- GDScript mit Typangaben schreiben (`var x := …`, Rückgabetypen an jeder Funktion).
- Eingabeaktionen legt `game/scripts/input_setup.gd` an, nicht `project.godot`.
- Alle Texte, die Spieler sehen, stehen in `game/i18n/texts.csv` (Deutsch und Englisch) und werden über `tr()` geholt.
- Tests, die Dateien schreiben, nutzen `TestFiles.temporary_dir()` (Ordner unter `user://tests`), nie die echten Dateien des Spielers.
- Godot erzeugt beim Import zu jedem Skript eine `.uid`-Datei. Sie gehört ins Archiv. Der Ordner `game/.godot/` gehört nicht hinein.
- Das Netzwerk liegt in `game/scripts/net_link.gd` (ENet, Port 24680) und `game/scripts/session_link.gd`. Nachrichten des Kerns gehen zuverlässig und in Reihenfolge über Kanal 0, Positionen der Figuren unzuverlässig über Kanal 1. Eine Verbindung schließt `NetLink.close_peer` mit `peer_disconnect_later`, damit wartende Nachrichten wie eine Ablehnung noch ankommen.
- Nur der Host speichert. Pausieren gibt es nur ohne Gäste, Laden mit F9 nur beim Spielen allein.
- Tests mit Netzwerk lauschen nur auf `127.0.0.1` und lassen das System den Port wählen (Port 0), damit sie nie an einem belegten Port scheitern.

## Prüfen

Vor jedem Commit müssen alle Befehle fehlerfrei laufen:

```bash
cargo fmt --all -- --check
cargo clippy --workspace --all-targets -- -D warnings
cargo test --workspace
cargo build -p grovewright-bridge
godot --headless --path game --import
godot --headless --path game -s res://tests/run_tests.gd
```

Der erste Import in einem frischen Checkout stürzt mit Godot 4.7.2 und der Rust-Erweiterung beim Beenden ab (Exit-Code 139 in der Bash). Der Import ist dann trotzdem erfolgt; ein zweiter Aufruf läuft fehlerfrei durch. `play.bat` wiederholt den Import deshalb einmal.

Der Test `game/tests/test_two_instances.gd` startet einen zweiten Godot-Prozess als Gast (`game/tools/guest_bot.gd`) und prüft, dass beide Welten gleich bleiben. Er braucht deshalb die gebaute Brücke und den Import wie alle anderen Tests der Hülle.

Schlägt `core/tests/golden.rs` fehl, haben sich Regeln oder ein Format geändert. Sind Speicherstände oder Aufzeichnungen betroffen, erhöhst du die Versionskonstante, die die Fehlermeldung nennt. Erst danach passt du den festgeschriebenen Wert bewusst an.

Für Änderungen am Aussehen gibt es ein Werkzeug, das kurz ein Fenster öffnet und ein Bildschirmfoto speichert:

```bash
godot --path game -s res://tools/screenshot.gd -- <Pfad der PNG-Datei>
```

## Spielen

`play.bat` im Hauptordner baut die Brücke und startet das Spiel. Spielstand, Spielerschlüssel und Aufzeichnungen liegen unter `%APPDATA%\Godot\app_userdata\Grovewright`.

Argumente hinter `play.bat` gehen an das Spiel:

| Argument | Wirkung |
|---|---|
| `--profile=NAME` | Eigener Ordner unter `profiles\NAME` für Schlüssel, Spielstand und Aufzeichnungen. Zwei Fenster auf einem Rechner brauchen zwei Profile, sonst sind sie derselbe Spieler. |
| `--name=NAME` | Name des Spielers |
| `--solo`, `--host` | Startet ohne Menü allein beziehungsweise mit offener Welt |
| `--port=PORT` | Port einer offenen Welt |
| `--join=ADRESSE` | Tritt ohne Menü einem Host bei; die Adresse darf auf `:PORT` enden |

`play-zweites-fenster.bat` startet ein zweites Fenster mit dem Profil `zweites-fenster`.

## Sprache

- Code, Bezeichner und Kommentare im Code: Englisch.
- Dokumente, Pläne, Commit-Nachrichten, Issues und Pull Requests: Deutsch.
- Texte im Spiel: Deutsch und Englisch über `game/i18n/texts.csv`.

## Git

- Archiv: `https://git.race-cave.cloud/steven/Grovewright.git` (Gitea). Issues und Pull Requests sind freigegeben.
- Gearbeitet wird auf einem Branch pro Plan, nicht direkt auf `main`.
- Nach jedem abgeschlossenen Arbeitsschritt wird committet und hochgeladen. Das ist freigegeben.
- Commit-Nachrichten enden mit einer Zeile `Co-Authored-By: Claude <Modell> <noreply@anthropic.com>`, die das Claude-Modell nennt, das den Commit erstellt hat.

Create docs/playtests/m1c.md:

# Probelauf M1c: Gleichschritt im Netz

Mit M1c teilen sich bis zu vier Spieler eine Welt. Jeder Rechner rechnet die ganze Welt selbst, übers Netz gehen nur die Befehle. Der Host besitzt die Welt und speichert sie; Gäste kommen und gehen, wann sie wollen. Farben und Formen sind weiter Platzhalter.

## So startest du

1. Doppelklick auf `play.bat`. Im Menü trägst du deinen Namen ein.
2. **Allein:** „Allein spielen“ setzt deinen gespeicherten Stand fort.
3. **Als Host:** „Welt öffnen“ setzt deinen Stand fort und lässt Gäste zu. Oben links steht der Port, normalerweise 24680. Beim ersten Mal fragt Windows, ob Godot im Netzwerk erreichbar sein darf. Erlaube es für private Netzwerke.
4. **Als Gast:** Trag die Adresse des Hosts ein und klick auf „Beitreten“. Im selben Heimnetz ist das die IPv4-Adresse des Host-Rechners, etwa `192.168.178.20`; der Host findet sie mit dem Befehl `ipconfig`. Übers Internet braucht der Host eine Portfreigabe für UDP-Port 24680 in seinem Router oder ein virtuelles Netz wie Tailscale oder ZeroTier; dann gilt die Adresse aus diesem Netz.
5. **Zwei Fenster auf einem Rechner:** Starte mit `play.bat` und klick auf „Welt öffnen“. Dann Doppelklick auf `play-zweites-fenster.bat`, Adresse `127.0.0.1`, „Beitreten“. Das zweite Fenster hat ein eigenes Profil und ist deshalb ein zweiter Spieler.
6. Steuerung wie in M1b: WASD laufen, linke Maustaste Stein schlagen. Nur der Host speichert (F5 und automatisch). Esc pausiert nur, solange keine Gäste da sind; F9 lädt nur beim Spielen allein.

## Fragen

1. Hat der Beitritt geklappt? Wie lange dauerte es vom Klick auf „Beitreten“, bis die Welt zu sehen war?
2. Wirken deine Schläge als Gast spürbar später als beim Host? Stört das?
3. Bewegen sich die Figuren der anderen gleichmäßig, oder ruckeln und springen sie?
4. Erkennst du an den Namen über den Figuren, wer wer ist?
5. Erschien irgendwann die Meldung „Welt mit dem Host abgeglichen“? Wenn ja: Was habt ihr kurz davor getan? Diese Meldung bedeutet, dass zwei Welten auseinandergelaufen sind. Das ist ein Fehler, den ich finden muss.
6. Wenn ein Gast die Welt verlässt und später wiederkommt: Hat er seine Steine noch?
7. Ist dir etwas Merkwürdiges passiert? Schick mir dann die neuesten Dateien aus dem Ordner `recordings`:
   - Beim Host liegt er unter `%APPDATA%\Godot\app_userdata\Grovewright\recordings`.
   - Beim zweiten Fenster liegt er unter `profiles\zweites-fenster\recordings` im selben Ordner.
   - Nach einer Abgleich-Meldung hebt der Gast dort die Aufzeichnung der abgewichenen Welt auf.
   - Nach einem Absturz schick stattdessen den Ordner `logs` daneben, und zwar bevor du das Spiel erneut startest.

Antworte formlos im Chat oder als Kommentar in Gitea.
  • Step 3: Startskripte mit Platzhaltern ausprobieren

Die Skripte sollen Argumente richtig weitergeben, ohne dass das Spiel startet. Dafür lässt du eine Kopie laufen, in der cargo und godot durch echo ersetzt sind:

mkdir -p "$TEMP/bat-check"
sed -e 's/^cargo build .*/echo BUILD/' -e 's/^godot --headless.*/echo IMPORT/' -e 's/^if %errorlevel%.*/rem/' -e 's/^godot --path game -- %\*/echo GAME ARGS: [%*]/' play.bat > "$TEMP/bat-check/play.bat"
cp play-zweites-fenster.bat "$TEMP/bat-check/"
cmd //c "$(cygpath -w "$TEMP/bat-check/play.bat")"
cmd //c "$(cygpath -w "$TEMP/bat-check/play-zweites-fenster.bat")"
rm -rf "$TEMP/bat-check"

Expected: Der erste Aufruf gibt BUILD, IMPORT und GAME ARGS: [] aus. Der zweite Aufruf gibt dasselbe aus, endet aber mit GAME ARGS: [--profile=zweites-fenster].

  • Step 4: Alles prüfen, auch im Release-Modus

Run:

cargo fmt --all -- --check && cargo clippy --workspace --all-targets -- -D warnings && cargo test --workspace
cargo test --workspace --release
cargo build -p grovewright-bridge
godot --headless --path game --import
godot --headless --path game -s res://tests/run_tests.gd

Expected: keine Ausgabe von rustfmt, keine Warnung von Clippy, zusammen 141 bestandene Rust-Tests in beiden Modi; der GDScript-Lauf endet mit Exit-Code 0 und 87 passed, 0 failed

  • Step 5: Commit und Hochladen
git add play.bat play-zweites-fenster.bat CLAUDE.md docs/playtests/m1c.md
git ls-files --eol play.bat play-zweites-fenster.bat
git commit -F - <<'EOF'
Startskripte, Regeln und Probelauf für M1c

play.bat gibt Argumente an das Spiel weiter, und
play-zweites-fenster.bat startet ein zweites Fenster mit eigenem Profil.
CLAUDE.md beschreibt die gemeinsame Welt, das Vertrauensmodell der
Reichweite, die Netzwerkregeln und den Test zweier Instanzen. Ein
Fragebogen begleitet den Probelauf.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
EOF
git push

Expected bei git ls-files --eol: für beide Dateien i/lf w/crlf attr/text eol=crlf.