From 760bea36fda0b627537d823c860fc6682a57efd3 Mon Sep 17 00:00:00 2001 From: Steven Wroblewski Date: Thu, 1 Oct 2026 14:47:44 +0200 Subject: [PATCH 01/20] Plan M1c: Gleichschritt im Netz MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../2026-10-01-m1c-gleichschritt-im-netz.md | 7209 +++++++++++++++++ 1 file changed, 7209 insertions(+) create mode 100644 docs/superpowers/plans/2026-10-01-m1c-gleichschritt-im-netz.md diff --git a/docs/superpowers/plans/2026-10-01-m1c-gleichschritt-im-netz.md b/docs/superpowers/plans/2026-10-01-m1c-gleichschritt-im-netz.md new file mode 100644 index 0000000..01cb2d3 --- /dev/null +++ b/docs/superpowers/plans/2026-10-01-m1c-gleichschritt-im-netz.md @@ -0,0 +1,7209 @@ +# 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 `, 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>` 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. + +```bash +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: + +```rust +use grovewright_core::codec::DecodeError; +use grovewright_core::command::Command; +use grovewright_core::inventory::ITEM_STONE; +use grovewright_core::mapgen::outcrop_origin; +use grovewright_core::player::PlayerId; +use grovewright_core::replay::{Recording, RecordingError, 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 { + 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 { + 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: + +```rust +//! 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: + +```rust +use grovewright_core::codec::{DecodeError, Reader, Writer}; +use grovewright_core::command::Command; +use grovewright_core::map::Pos; +use grovewright_core::player::{PlayerId, MAX_NAME_BYTES}; + +fn round_trip(command: &Command) -> Command { + let mut w = Writer::new(); + command.encode(&mut w); + let bytes = w.into_bytes(); + let mut r = Reader::new(&bytes); + let decoded = Command::decode(&mut r).expect("command decodes"); + r.finish().expect("no bytes are left over"); + decoded +} + +#[test] +fn every_command_survives_encoding() { + let commands = [ + Command::Join { + key: [7; 16], + name: "Ada".to_owned(), + }, + Command::Leave { + player: PlayerId(3), + }, + Command::Dig { + player: PlayerId(1), + pos: Pos::new(-4, 1_000), + }, + ]; + for command in &commands { + assert_eq!(&round_trip(command), command); + } +} + +#[test] +fn an_overlong_name_is_shortened_when_encoded() { + let command = Command::Join { + key: [1; 16], + name: "x".repeat(MAX_NAME_BYTES + 10), + }; + assert_eq!( + round_trip(&command), + Command::Join { + key: [1; 16], + name: "x".repeat(MAX_NAME_BYTES), + } + ); +} + +#[test] +fn 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`: + +```rust +//! 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>; + +/// 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> { + 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> { + 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: + +```rust +//! Deterministic core of Grovewright. +//! +//! Everything that belongs to the shared world lives here. The core never +//! touches the engine, the clock, files or the network, and it calculates +//! only with whole numbers. Given the same commands it produces the same +//! world on every machine. That is what lets every player simulate the whole +//! world and exchange nothing but commands. + +pub mod codec; +pub mod command; +pub mod hash; +pub mod inventory; +pub mod map; +pub mod mapgen; +pub mod player; +pub mod replay; +pub mod rng; +pub mod rules; +pub mod snapshot; +pub mod world; +``` + +Ersetze den gesamten Inhalt von `core/src/replay.rs` durch: + +```rust +//! 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 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, + start_tick: u64, + tick_count: u32, + /// Ticks that had commands, as (tick index, commands), in rising order. + busy_ticks: Vec<(u32, Vec)>, +} + +impl Recording { + /// Starts a recording at the current state of `world`. + pub fn start(world: &World) -> Self { + Self { + start: world.to_snapshot(), + 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 { + 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 { + 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 { + 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 { + 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: + +```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 +``` + +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** + +```bash +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 +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` + - `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 }`, `Refused(Refusal)` und `Tick { tick: u64, commands: Vec, check: Option }` + - Beide Nachrichtentypen haben `encode(&self) -> Vec` und `decode(bytes: &[u8]) -> Result`. `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`: + +```rust +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: + +```rust +//! 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`: + +```rust +//! 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 { + 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 { + 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 { + 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 { + 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 }, + /// 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, + check: Option, + }, +} + +impl ToGuest { + pub fn encode(&self) -> Vec { + 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 { + 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: + +```rust +//! Deterministic core of Grovewright. +//! +//! Everything that belongs to the shared world lives here. The core never +//! touches the engine, the clock, files or the network, and it calculates +//! only with whole numbers. Given the same commands it produces the same +//! world on every machine. That is what lets every player simulate the whole +//! world and exchange nothing but commands. + +pub mod codec; +pub mod command; +pub mod hash; +pub mod inventory; +pub mod map; +pub mod mapgen; +pub mod player; +pub mod 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: + +```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 +``` + +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** + +```bash +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 +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`, `act_local(action: Action)`, `connect(peer: Peer)`, `disconnect(peer: Peer)`, `receive(peer: Peer, bytes: &[u8])`, `step() -> Vec`, `guest_count() -> usize`, `player_of(peer: Peer) -> Option`, `take_outbox() -> Vec<(Peer, Vec)>`, `take_closing() -> Vec` und `take_divergences() -> Vec` + - `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`, `receive(bytes: &[u8]) -> Result`, `pending_ticks() -> usize`, `step() -> Option>`, `has_diverged() -> bool`, `act(action: Action)`, `take_outbox() -> Vec>` und `take_diverged_recording() -> Option>` + +- [ ] **Step 1: Fehlschlagende Tests schreiben** + +Create `core/tests/session.rs`: + +```rust +//! 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, + /// Guests whose connection the host closed, with the refusal they got. + closed: BTreeMap>, + 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) -> Vec) { + 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 = [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`: + +```rust +//! 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, + local_actions: u32, + /// Commands for the next tick, in the order they arrived. + inbox: Vec, + links: BTreeMap, + outbox: Vec<(Peer, Vec)>, + closing: Vec, + divergences: Vec, +} + +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 { + 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 { + 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 { + 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)> { + 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 { + std::mem::take(&mut self.closing) + } + + /// Divergences the guests reported since the last call. + pub fn take_divergences(&mut self) -> Vec { + std::mem::take(&mut self.divergences) + } +} + +/// The player with `key`, if it is online in `world`. +fn online_player(world: &World, key: &PlayerKey) -> Option { + let id = world.find_player(key)?; + world.player(id)?.online.then_some(id) +} +``` + +Create `core/src/guest.rs`: + +```rust +//! 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, + check: Option, +} + +/// 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, + /// True after a divergence, until the host's world arrives again. + diverged: bool, + pending: VecDeque, + outbox: Vec>, + diverged_recording: Option>, +} + +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 { + 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 { + 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> { + 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> { + 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> { + self.diverged_recording.take() + } +} +``` + +Ersetze den gesamten Inhalt von `core/src/lib.rs` durch: + +```rust +//! Deterministic core of Grovewright. +//! +//! Everything that belongs to the shared world lives here. The core never +//! touches the engine, the clock, files or the network, and it calculates +//! only with whole numbers. Given the same commands it produces the same +//! world on every machine. That is what lets every player simulate the whole +//! world and exchange nothing but commands. + +pub mod codec; +pub mod command; +pub mod 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: + +```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 +``` + +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** + +```bash +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 +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: + +```gdscript +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: + +```bash +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`: + +```rust +//! 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 { + 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(&mut self, f: impl FnOnce(&mut Role) -> R) -> Option { + 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> { + 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 { + 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)> { + 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 { + 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 { + 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> { + 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 { + 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> { + 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> { + 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: + +```rust +//! 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, + 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 { + 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 { + 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 { + 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 = 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 { + 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 = 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 { + 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 = 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 { + 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::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: + +```rust +//! 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: + +```rust +//! 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: + +```bash +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: + +```bash +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** + +```bash +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 +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`: + +```gdscript +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`: + +```gdscript +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: + +```bash +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`: + +```gdscript +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`: + +```gdscript +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: + +```bash +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: + +```bash +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** + +```bash +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 +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`: + +```gdscript +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`: + +```gdscript +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: + +```gdscript +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: + +```bash +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`: + +```gdscript +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: + +```gdscript +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: + +```bash +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: + +```bash +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** + +```bash +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 +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`: + +```gdscript +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`: + +```gdscript +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`: + +```gdscript +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: + +```bash +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`: + +```gdscript +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`: + +```gdscript +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: + +```gdscript +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: + +```text +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: + +```bash +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: + +```bash +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** + +```bash +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 +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: + +```gdscript +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: + +```bash +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: + +```gdscript +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: + +```gdscript +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 -- + +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: + +```bash +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: + +```bash +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** + +```bash +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 +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`: + +```gdscript +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: + +```bash +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: `, `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`: + +```gdscript +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: + +```bash +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: + +```bash +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** + +```bash +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 +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: + +```bat +@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`: + +```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: + +````markdown +# Grovewright + +Aufbauspiel für ein bis vier Spieler, in dem die Fabrik lebt. Gebaut wird zuerst eine Vertical Slice mit 2–3 Stunden Spielzeit. + +Das verbindliche Konzept steht in [docs/KONZEPT.md](docs/KONZEPT.md). Lies es, bevor du Spielregeln, Architektur oder Umfang änderst. Was dort nicht steht, ist nicht beschlossen. + +## Aufbau + +| Ordner | Inhalt | +|---|---| +| `core/` | Rust-Kern: die gesamte gemeinsame Welt. Ohne Zugriff auf die Engine, ohne fremde Crates. | +| `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 -- +``` + +## 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 `, die das Claude-Modell nennt, das den Commit erstellt hat. +```` + +Create `docs/playtests/m1c.md`: + +```markdown +# 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: + +```bash +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: + +```bash +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** + +```bash +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 +EOF +git push +``` + +Expected bei `git ls-files --eol`: für beide Dateien `i/lf w/crlf attr/text eol=crlf`. -- 2.52.0 From f2e4c910822e45e74dac4e5dabbf70e062ec9f96 Mon Sep 17 00:00:00 2001 From: Steven Wroblewski Date: Thu, 1 Oct 2026 18:00:00 +0200 Subject: [PATCH 02/20] Regel-Kennung und Aufzeichnung Version 3 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Haiku 4.5 --- core/src/lib.rs | 1 + core/src/replay.rs | 31 +++++++- core/src/rules.rs | 171 ++++++++++++++++++++++++++++++++++++++++++ core/tests/command.rs | 15 ++++ core/tests/golden.rs | 171 ++++++------------------------------------ core/tests/replay.rs | 61 +++++++++++++-- 6 files changed, 296 insertions(+), 154 deletions(-) create mode 100644 core/src/rules.rs diff --git a/core/src/lib.rs b/core/src/lib.rs index d168edc..ad890d3 100644 --- a/core/src/lib.rs +++ b/core/src/lib.rs @@ -15,5 +15,6 @@ pub mod mapgen; pub mod player; pub mod replay; pub mod rng; +pub mod rules; pub mod snapshot; pub mod world; diff --git a/core/src/replay.rs b/core/src/replay.rs index c5b8477..91569bd 100644 --- a/core/src/replay.rs +++ b/core/src/replay.rs @@ -1,17 +1,26 @@ //! 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}; +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 = 2; +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)] @@ -20,6 +29,9 @@ pub enum RecordingError { 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. @@ -35,6 +47,10 @@ impl fmt::Display for RecordingError { 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!( @@ -139,6 +155,7 @@ impl Recording { 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); @@ -154,6 +171,8 @@ impl Recording { } /// 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 @@ -166,12 +185,20 @@ impl Recording { 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; diff --git a/core/src/rules.rs b/core/src/rules.rs new file mode 100644 index 0000000..a11dfcf --- /dev/null +++ b/core/src/rules.rs @@ -0,0 +1,171 @@ +//! 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>; + +/// 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> { + 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> { + 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()) +} diff --git a/core/tests/command.rs b/core/tests/command.rs index c86f27d..58aa1fa 100644 --- a/core/tests/command.rs +++ b/core/tests/command.rs @@ -48,6 +48,21 @@ fn an_overlong_name_is_shortened_when_encoded() { ); } +#[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!( diff --git a/core/tests/golden.rs b/core/tests/golden.rs index a2a3632..a38fc45 100644 --- a/core/tests/golden.rs +++ b/core/tests/golden.rs @@ -8,145 +8,19 @@ //! 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::mapgen::outcrop_origin; -use grovewright_core::player::PlayerId; 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; -const SEED: u64 = 2026; - -/// The commands of a session of 60 ticks. It is built from literal values -/// and the outcrop origin of the map, not from constants such as -/// `DIG_COOLDOWN_TICKS`, so that changing a rule changes the result and not -/// the script. -/// -/// Never change or extend this script. To pin more, add a new script and a -/// new pinned value instead. -fn frozen_script(world: &World) -> Vec> { - let origin = outcrop_origin(world.map()); - let right_of_origin = Pos::new(origin.x + 1, origin.y); - // Pairs of (tick, command). Commands of the same tick are applied in the - // order listed here. - let scripted = [ - // Two players join in the same tick. - ( - 0, - Command::Join { - key: [1; 16], - name: "Ada".to_owned(), - }, - ), - ( - 0, - Command::Join { - key: [2; 16], - name: "Grace".to_owned(), - }, - ), - // Player 0 hits the outcrop origin three times, five ticks apart. - // The third hit breaks the rock and gives a stone. - ( - 2, - Command::Dig { - player: PlayerId(0), - pos: origin, - }, - ), - ( - 7, - Command::Dig { - player: PlayerId(0), - pos: origin, - }, - ), - ( - 12, - Command::Dig { - player: PlayerId(0), - pos: origin, - }, - ), - // Player 1 hits the tile to the right of the origin once. - ( - 20, - Command::Dig { - player: PlayerId(1), - pos: right_of_origin, - }, - ), - // Player 1 leaves and returns with a name of 40 bytes, which the - // world shortens to 32. - ( - 30, - Command::Leave { - player: PlayerId(1), - }, - ), - ( - 40, - Command::Join { - key: [2; 16], - name: "ä".repeat(20), - }, - ), - ]; - let mut ticks = vec![Vec::new(); 60]; - for (tick, command) in scripted { - ticks[tick].push(command); - } - ticks -} - -/// The commands of a second session of 30 ticks that ends with one player -/// offline, so that the encoding of offline players is pinned as well. -/// -/// Never change or extend this script either. -fn frozen_offline_script(world: &World) -> Vec> { - 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 -} - /// 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(SEED).to_snapshot()); + 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 \ @@ -159,15 +33,11 @@ fn the_starting_snapshot_is_unchanged() { /// and the encoding of players. #[test] fn the_frozen_session_ends_in_the_same_state() { - let mut world = World::new(SEED); - for commands in frozen_script(&world) { - world.step(&commands); - } assert_eq!( - world.state_hash(), + end_hash(frozen_script), 0xc304_43df_fc74_dcfc, - "the rules or the snapshot format changed; old recordings no longer \ - verify; if saves are affected, raise SNAPSHOT_VERSION in \ + "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" ); } @@ -175,30 +45,39 @@ fn the_frozen_session_ends_in_the_same_state() { /// Covers the encoding of a player who is offline. #[test] fn the_frozen_offline_session_ends_in_the_same_state() { - let mut world = World::new(SEED); - for commands in frozen_offline_script(&world) { - world.step(&commands); - } assert_eq!( - world.state_hash(), + end_hash(frozen_offline_script), 0x904f_10e1_7c52_8be4, - "the rules or the snapshot format changed; old recordings no longer \ - verify; if saves are affected, raise SNAPSHOT_VERSION in \ + "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(SEED); + 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)), - 0x1c24_8cc7_6bc8_846a, + 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 \ diff --git a/core/tests/replay.rs b/core/tests/replay.rs index 53f4e1e..c1cfb34 100644 --- a/core/tests/replay.rs +++ b/core/tests/replay.rs @@ -3,11 +3,15 @@ use grovewright_core::command::Command; use grovewright_core::inventory::ITEM_STONE; use grovewright_core::mapgen::outcrop_origin; use grovewright_core::player::PlayerId; -use grovewright_core::replay::{Recording, RecordingError, RECORDING_VERSION}; +use grovewright_core::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 { @@ -81,8 +85,9 @@ fn verify_accepts_a_faithful_recording() { 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 and the version. - bytes[8] ^= 0x01; + // 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()); @@ -179,11 +184,55 @@ fn damaged_recordings_are_rejected() { 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, expected hash and its own - // length: 4 + 4 + 8 + 4 bytes. - bytes[20 + 10] ^= 0x01; + // 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 { + 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" + ))) + ); +} -- 2.52.0 From a0cb6b12b7b9a135aef8bb1502a8cff7d20f27a2 Mon Sep 17 00:00:00 2001 From: Steven Wroblewski Date: Thu, 1 Oct 2026 18:14:04 +0200 Subject: [PATCH 03/20] =?UTF-8?q?Nachrichten=20zwischen=20Host=20und=20G?= =?UTF-8?q?=C3=A4sten?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Haiku 4.5 --- core/src/lib.rs | 1 + core/src/protocol.rs | 276 +++++++++++++++++++++++++++++++++++++++++ core/tests/golden.rs | 51 ++++++++ core/tests/protocol.rs | 177 ++++++++++++++++++++++++++ 4 files changed, 505 insertions(+) create mode 100644 core/src/protocol.rs create mode 100644 core/tests/protocol.rs diff --git a/core/src/lib.rs b/core/src/lib.rs index ad890d3..f1fd938 100644 --- a/core/src/lib.rs +++ b/core/src/lib.rs @@ -13,6 +13,7 @@ pub mod inventory; pub mod map; pub mod mapgen; pub mod player; +pub mod protocol; pub mod replay; pub mod rng; pub mod rules; diff --git a/core/src/protocol.rs b/core/src/protocol.rs new file mode 100644 index 0000000..f8b855e --- /dev/null +++ b/core/src/protocol.rs @@ -0,0 +1,276 @@ +//! 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 { + 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 { + 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 { + 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 { + 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 }, + /// 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, + check: Option, + }, +} + +impl ToGuest { + pub fn encode(&self) -> Vec { + 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 { + 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) + } +} diff --git a/core/tests/golden.rs b/core/tests/golden.rs index a38fc45..f87f09e 100644 --- a/core/tests/golden.rs +++ b/core/tests/golden.rs @@ -8,8 +8,11 @@ //! 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, @@ -114,3 +117,51 @@ fn the_map_encoding_is_unchanged() { 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" + ); +} diff --git a/core/tests/protocol.rs b/core/tests/protocol.rs new file mode 100644 index 0000000..373be18 --- /dev/null +++ b/core/tests/protocol.rs @@ -0,0 +1,177 @@ +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) + ); +} -- 2.52.0 From b7d538365031b1547f7ea45f2d4b47974472a7bf Mon Sep 17 00:00:00 2001 From: Steven Wroblewski Date: Thu, 1 Oct 2026 18:29:32 +0200 Subject: [PATCH 04/20] =?UTF-8?q?Gru=C3=9F-Anfang=20und=20Ablehnung=20fest?= =?UTF-8?q?schreiben?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ein Test hält fest, dass ein Gruß mit Kennung und Version beginnt und eine Ablehnung aus Kennung und Grund besteht. Beides bleibt auch bei einer neuen Protokollversion gleich, damit sich Spiele verschiedener Versionen weiter sagen können, dass sie nicht zusammenpassen. Die Meldung des festgeschriebenen Nachrichtenformats weist darauf hin. Co-Authored-By: Claude Haiku 4.5 --- core/src/protocol.rs | 9 +++++++-- core/tests/golden.rs | 43 +++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 48 insertions(+), 4 deletions(-) diff --git a/core/src/protocol.rs b/core/src/protocol.rs index f8b855e..ec97936 100644 --- a/core/src/protocol.rs +++ b/core/src/protocol.rs @@ -19,16 +19,20 @@ 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. +/// with a name of full length, which needs 65 bytes. Greetings of every +/// future version must fit as well: a longer one is rejected unread instead +/// of being recognised as another version. 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; +// Never changes, see the module documentation. const TO_HOST_HELLO: u8 = 1; const TO_HOST_ACT: u8 = 2; const TO_HOST_DIVERGED: u8 = 3; const TO_GUEST_WORLD: u8 = 1; +// Never changes, see the module documentation. const TO_GUEST_REFUSED: u8 = 2; const TO_GUEST_TICK: u8 = 3; @@ -75,7 +79,8 @@ impl Action { } } -/// Why a host turned a guest away. +/// Why a host turned a guest away. The codes never change, because games of +/// other versions read them. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum Refusal { /// The guest's game speaks another version of these messages. diff --git a/core/tests/golden.rs b/core/tests/golden.rs index f87f09e..684c376 100644 --- a/core/tests/golden.rs +++ b/core/tests/golden.rs @@ -12,7 +12,7 @@ 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::protocol::{Action, Refusal, ToGuest, ToHost, PROTOCOL_VERSION}; use grovewright_core::replay::Recording; use grovewright_core::rules::{ end_hash, frozen_offline_script, frozen_script, rules_id, RULES_SEED, @@ -162,6 +162,45 @@ fn the_protocol_format_is_unchanged() { 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" + PROTOCOL_VERSION in core/src/protocol.rs; then update this value. \ + The start of a greeting and the refusal must never change, see \ + the_greeting_start_and_the_refusal_never_change" ); } + +/// Covers the promise that games of every version can still tell each other +/// that they do not fit: a greeting starts with its tag and the version, and +/// a refusal is its tag and the code of its reason. Unlike every other part +/// of the messages, these stay the same when PROTOCOL_VERSION is raised. +#[test] +fn the_greeting_start_and_the_refusal_never_change() { + let hello = ToHost::Hello { + rules: 1, + key: [3; 16], + name: "Ada".to_owned(), + } + .encode(); + let mut start = vec![1]; + start.extend(PROTOCOL_VERSION.to_le_bytes()); + assert_eq!( + hello[..start.len()], + start[..], + "a greeting starts with the tag 1 and the version; this never \ + changes, not even with a new PROTOCOL_VERSION" + ); + let refusals = [ + (Refusal::OtherProtocol, 1), + (Refusal::OtherRules, 2), + (Refusal::WorldFull, 3), + (Refusal::TooManyPlayers, 4), + (Refusal::AlreadyPlaying, 5), + ]; + for (refusal, code) in refusals { + assert_eq!( + ToGuest::Refused(refusal).encode(), + vec![2, code], + "a refusal is the tag 2 and the code of its reason; this never \ + changes, not even with a new PROTOCOL_VERSION" + ); + } +} -- 2.52.0 From b48556d1f2a9078941bdf5e5683f1fdc9f01a766 Mon Sep 17 00:00:00 2001 From: Steven Wroblewski Date: Thu, 1 Oct 2026 18:38:10 +0200 Subject: [PATCH 05/20] Host und Gast im Kern MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Haiku 4.5 --- core/src/guest.rs | 231 +++++++++++++++++++ core/src/host.rs | 323 ++++++++++++++++++++++++++ core/src/lib.rs | 2 + core/tests/session.rs | 516 ++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 1072 insertions(+) create mode 100644 core/src/guest.rs create mode 100644 core/src/host.rs create mode 100644 core/tests/session.rs diff --git a/core/src/guest.rs b/core/src/guest.rs new file mode 100644 index 0000000..a3b87bb --- /dev/null +++ b/core/src/guest.rs @@ -0,0 +1,231 @@ +//! 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, + check: Option, +} + +/// 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, + /// True after a divergence, until the host's world arrives again. + diverged: bool, + pending: VecDeque, + outbox: Vec>, + diverged_recording: Option>, +} + +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 { + 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 { + 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> { + 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> { + 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> { + self.diverged_recording.take() + } +} diff --git a/core/src/host.rs b/core/src/host.rs new file mode 100644 index 0000000..6fbb1aa --- /dev/null +++ b/core/src/host.rs @@ -0,0 +1,323 @@ +//! 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, + local_actions: u32, + /// Commands for the next tick, in the order they arrived. + inbox: Vec, + links: BTreeMap, + outbox: Vec<(Peer, Vec)>, + closing: Vec, + divergences: Vec, +} + +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 { + 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 { + 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 { + 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)> { + 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 { + std::mem::take(&mut self.closing) + } + + /// Divergences the guests reported since the last call. + pub fn take_divergences(&mut self) -> Vec { + std::mem::take(&mut self.divergences) + } +} + +/// The player with `key`, if it is online in `world`. +fn online_player(world: &World, key: &PlayerKey) -> Option { + let id = world.find_player(key)?; + world.player(id)?.online.then_some(id) +} diff --git a/core/src/lib.rs b/core/src/lib.rs index f1fd938..c1e3abb 100644 --- a/core/src/lib.rs +++ b/core/src/lib.rs @@ -8,7 +8,9 @@ pub mod codec; pub mod command; +pub mod guest; pub mod hash; +pub mod host; pub mod inventory; pub mod map; pub mod mapgen; diff --git a/core/tests/session.rs b/core/tests/session.rs new file mode 100644 index 0000000..1486a3b --- /dev/null +++ b/core/tests/session.rs @@ -0,0 +1,516 @@ +//! 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, + /// Guests whose connection the host closed, with the refusal they got. + closed: BTreeMap>, + 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) -> Vec) { + 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 = [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()); + } +} -- 2.52.0 From 155d90f3a5e4ac2037a50c9de15085eea06586f6 Mon Sep 17 00:00:00 2001 From: Steven Wroblewski Date: Thu, 1 Oct 2026 19:05:07 +0200 Subject: [PATCH 06/20] Host und Gast von Hand getrieben testen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Neue Tests treiben Host und Gast Nachricht für Nachricht und erzeugen, was das Netz im Speicher nie herstellt: einen Gast, der hinterherhängt, einen verlorenen Takt und Beitritte, die an den Grenzen warten. Sie halten fest, dass ein abgewichener Gast nichts rechnet, bis die Welt des Hosts kommt, dass er dann ältere Takte verwirft, dass die Zahl der Aktionen eines Gastes in jedem Takt neu beginnt und dass wartende Beitritte bei beiden Spielergrenzen zählen. Co-Authored-By: Claude Haiku 4.5 --- core/tests/host_guest.rs | 296 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 296 insertions(+) create mode 100644 core/tests/host_guest.rs diff --git a/core/tests/host_guest.rs b/core/tests/host_guest.rs new file mode 100644 index 0000000..64c8e47 --- /dev/null +++ b/core/tests/host_guest.rs @@ -0,0 +1,296 @@ +//! Host and guest driven by hand, message by message. These tests produce +//! what the in-memory network of `session.rs` never does, because it lets +//! every guest run each tick before the next one arrives: a guest that lags +//! behind, a tick that gets lost, and joins that wait at the limits. + +use grovewright_core::command::Command; +use grovewright_core::guest::{Arrival, Guest}; +use grovewright_core::host::{Host, Peer, MAX_ACTIONS_PER_TICK}; +use grovewright_core::map::Pos; +use grovewright_core::mapgen::outcrop_origin; +use grovewright_core::player::{PlayerId, PlayerKey}; +use grovewright_core::protocol::{Action, Refusal, ToGuest, ToHost, CHECK_INTERVAL_TICKS}; +use grovewright_core::world::{Event, World}; + +const HOST_KEY: PlayerKey = [100; 16]; + +/// A host whose own player is in a new world. +fn started_host() -> Host { + let mut host = Host::new(World::new(5)); + host.join_local(HOST_KEY, "Host"); + host.step(); + host +} + +/// Connects a guest with a key made of `key` as `peer` and hands its +/// greeting to the host. +fn greet(host: &mut Host, peer: Peer, key: u8) -> Guest { + host.connect(peer); + let mut guest = Guest::new([key; 16], "Guest"); + for bytes in guest.take_outbox() { + host.receive(peer, &bytes); + } + guest +} + +/// Takes the host's whole outbox and keeps what is for `peer`. +fn messages_for(host: &mut Host, peer: Peer) -> Vec> { + host.take_outbox() + .into_iter() + .filter(|(to, _)| *to == peer) + .map(|(_, bytes)| bytes) + .collect() +} + +/// The host's answer to a greeting from `peer`: `None` for its world, or +/// the reason it turned the guest away. +fn answer_to(host: &mut Host, peer: Peer) -> Option { + let messages = messages_for(host, peer); + match ToGuest::decode(&messages[0]) { + Ok(ToGuest::World { .. }) => None, + Ok(ToGuest::Refused(refusal)) => Some(refusal), + other => panic!("unexpected answer {other:?}"), + } +} + +/// A host and the guest Ada at peer 2, in the same state. +fn host_with_ada() -> (Host, Guest) { + let mut host = started_host(); + let mut ada = greet(&mut host, 2, 1); + for bytes in messages_for(&mut host, 2) { + ada.receive(&bytes).unwrap(); + } + host.step(); + for bytes in messages_for(&mut host, 2) { + ada.receive(&bytes).unwrap(); + } + while ada.step().is_some() {} + assert!(ada.local_player().is_some()); + assert_eq!( + ada.world().map(World::state_hash), + Some(host.world().state_hash()) + ); + (host, ada) +} + +/// A world that knows players with keys made of 1 to `count`, all offline. +fn world_with_offline_players(count: u8) -> World { + let mut world = World::new(5); + for key in 1..=count { + world.step(&[Command::Join { + key: [key; 16], + name: "Visitor".to_owned(), + }]); + world.step(&[Command::Leave { + player: PlayerId(key - 1), + }]); + } + world +} + +#[test] +fn the_hosts_own_queued_join_counts_for_the_online_limit() { + let mut host = Host::new(World::new(5)); + host.join_local(HOST_KEY, "Host"); + // No tick has run: the host's join is still queued. + let mut answers = Vec::new(); + for key in 1..=4 { + let peer = Peer::from(key) + 1; + greet(&mut host, peer, key); + answers.push(answer_to(&mut host, peer)); + } + assert_eq!(answers, [None, None, None, Some(Refusal::WorldFull)]); +} + +#[test] +fn queued_new_keys_count_for_the_limit_of_known_players_but_known_ones_do_not() { + let mut host = Host::new(world_with_offline_players(15)); + greet(&mut host, 2, 3); + assert_eq!(answer_to(&mut host, 2), None, "a returning player"); + greet(&mut host, 3, 200); + assert_eq!( + answer_to(&mut host, 3), + None, + "a new player fits as the 16th" + ); + greet(&mut host, 4, 201); + assert_eq!( + answer_to(&mut host, 4), + Some(Refusal::TooManyPlayers), + "a second new player does not" + ); + + let events = host.step(); + assert!(!events + .iter() + .any(|event| matches!(event, Event::JoinRefused { .. }))); + assert_eq!(host.world().players().len(), 16); +} + +#[test] +fn a_queued_join_of_a_player_who_is_online_already_counts_once() { + let mut world = World::new(5); + let joins = [HOST_KEY, [1; 16], [2; 16]].map(|key| Command::Join { + key, + name: "Online".to_owned(), + }); + world.step(&joins); + let mut host = Host::new(world); + // The host's player is online already; its queued join changes nothing. + host.join_local(HOST_KEY, "Host"); + greet(&mut host, 2, 3); + assert_eq!(answer_to(&mut host, 2), None, "the fourth player fits"); +} + +#[test] +fn the_count_of_a_guests_actions_starts_again_in_every_tick() { + let (mut host, mut ada) = host_with_ada(); + for tick in 0..3 { + for x in 0..10 { + ada.act(Action::Dig { + pos: Pos::new(x, 1), + }); + } + for bytes in ada.take_outbox() { + host.receive(2, &bytes); + } + host.step(); + let counts: Vec = messages_for(&mut host, 2) + .iter() + .filter_map(|bytes| match ToGuest::decode(bytes) { + Ok(ToGuest::Tick { commands, .. }) => Some(commands.len()), + _ => None, + }) + .collect(); + assert_eq!(counts, [MAX_ACTIONS_PER_TICK as usize], "tick {tick}"); + } +} + +#[test] +fn a_tick_carries_the_number_of_the_tick_before_it_runs() { + let (mut host, mut ada) = host_with_ada(); + let before = host.world().tick(); + host.step(); + let messages = messages_for(&mut host, 2); + let Ok(ToGuest::Tick { tick, .. }) = ToGuest::decode(&messages[0]) else { + panic!("not a tick"); + }; + assert_eq!(tick, before); + for bytes in messages { + ada.receive(&bytes).unwrap(); + } + ada.step().unwrap(); + assert_eq!(ada.world().map(World::tick), Some(before + 1)); +} + +#[test] +fn a_lagging_guest_that_diverges_runs_nothing_until_the_world_comes_and_drops_older_ticks() { + let (mut host, mut ada) = host_with_ada(); + + // 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. + host.act_local(Action::Dig { + pos: outcrop_origin(host.world().map()), + }); + host.step(); + for bytes in messages_for(&mut host, 2) { + let bytes = match ToGuest::decode(&bytes) { + Ok(ToGuest::Tick { tick, check, .. }) => ToGuest::Tick { + tick, + commands: vec![], + check, + } + .encode(), + _ => bytes, + }; + ada.receive(&bytes).unwrap(); + } + while ada.step().is_some() {} + assert!(!ada.has_diverged()); + + // Thirty more ticks arrive before Ada runs any of them. + for _ in 0..30 { + host.step(); + for bytes in messages_for(&mut host, 2) { + ada.receive(&bytes).unwrap(); + } + } + assert!(ada.pending_ticks() >= 30); + + // She catches up and trips over the next checksum. + while ada.step().is_some() && !ada.has_diverged() {} + assert!(ada.has_diverged()); + let waiting = ada.pending_ticks(); + assert!(waiting > 0, "newer ticks are still waiting"); + assert!(ada.step().is_none(), "no tick runs after a divergence"); + assert_eq!(ada.pending_ticks(), waiting, "and none is used up"); + + // Her report names her tick after the step that showed the divergence. + let reports = ada.take_outbox(); + assert_eq!(reports.len(), 1); + let Ok(ToHost::Diverged { tick, hash }) = ToHost::decode(&reports[0]) else { + panic!("not a divergence report"); + }; + assert_eq!(Some(tick), ada.world().map(World::tick)); + assert_eq!(tick % CHECK_INTERVAL_TICKS, 0); + host.receive(2, &reports[0]); + let divergences = host.take_divergences(); + assert_eq!(divergences.len(), 1); + assert_eq!((divergences[0].tick, divergences[0].hash), (tick, hash)); + + // The host has gone on meanwhile. Its world comes first, then later ticks. + let world_tick = host.world().tick(); + let answer = messages_for(&mut host, 2); + assert_eq!(answer.len(), 1); + host.step(); + host.step(); + let later = messages_for(&mut host, 2); + assert_eq!(later.len(), 2); + + assert_eq!( + ada.receive(&answer[0]), + Ok(Arrival::World { replaced: true }) + ); + assert_eq!(ada.pending_ticks(), 0, "the older ticks are dropped"); + assert_eq!(ada.world().map(World::tick), Some(world_tick)); + assert!(!ada.has_diverged()); + for bytes in later { + ada.receive(&bytes).unwrap(); + } + while ada.step().is_some() {} + assert_eq!( + ada.world().map(World::state_hash), + Some(host.world().state_hash()) + ); + assert!(ada.take_diverged_recording().is_some()); +} + +#[test] +fn a_missing_tick_makes_the_guest_ask_for_the_world() { + let (mut host, mut ada) = host_with_ada(); + host.step(); + // This tick never arrives. + messages_for(&mut host, 2); + host.step(); + for bytes in messages_for(&mut host, 2) { + ada.receive(&bytes).unwrap(); + } + assert!(ada.step().is_none()); + assert!(ada.has_diverged()); + let reports = ada.take_outbox(); + assert_eq!(reports.len(), 1); + + host.receive(2, &reports[0]); + for bytes in messages_for(&mut host, 2) { + assert_eq!(ada.receive(&bytes), Ok(Arrival::World { replaced: true })); + } + host.step(); + for bytes in messages_for(&mut host, 2) { + ada.receive(&bytes).unwrap(); + } + while ada.step().is_some() {} + assert_eq!( + ada.world().map(World::state_hash), + Some(host.world().state_hash()) + ); +} -- 2.52.0 From 2faa3ccce34bab036946ab30ed055d461b2797d6 Mon Sep 17 00:00:00 2001 From: Steven Wroblewski Date: Thu, 1 Oct 2026 19:20:34 +0200 Subject: [PATCH 07/20] =?UTF-8?q?Br=C3=BCcke=20f=C3=BCr=20Host=20und=20Gas?= =?UTF-8?q?t?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Haiku 4.5 --- bridge/src/driver.rs | 272 +++++++++++++++++++++++ bridge/src/events.rs | 3 +- bridge/src/lib.rs | 1 + bridge/src/session.rs | 427 ++++++++++++++++++++++++++----------- game/tests/test_session.gd | 96 +++++++++ 5 files changed, 671 insertions(+), 128 deletions(-) create mode 100644 bridge/src/driver.rs diff --git a/bridge/src/driver.rs b/bridge/src/driver.rs new file mode 100644 index 0000000..2ea095d --- /dev/null +++ b/bridge/src/driver.rs @@ -0,0 +1,272 @@ +//! 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 { + 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(&mut self, f: impl FnOnce(&mut Role) -> R) -> Option { + 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> { + 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 { + 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)> { + 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 { + 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 { + 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> { + 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 { + 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> { + 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> { + 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"); + } +} diff --git a/bridge/src/events.rs b/bridge/src/events.rs index 25c055a..4de6741 100644 --- a/bridge/src/events.rs +++ b/bridge/src/events.rs @@ -14,8 +14,9 @@ pub fn to_dictionary(event: &Event) -> VarDictionary { "type" => "player_left", "player" => i64::from(player.0), }, - Event::JoinRefused { .. } => vdict! { + Event::JoinRefused { key } => vdict! { "type" => "join_refused", + "key" => &PackedByteArray::from(key.as_slice()), }, Event::TileHit { player, pos } => vdict! { "type" => "tile_hit", diff --git a/bridge/src/lib.rs b/bridge/src/lib.rs index 9075ffb..31e66a0 100644 --- a/bridge/src/lib.rs +++ b/bridge/src/lib.rs @@ -3,6 +3,7 @@ //! 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; diff --git a/bridge/src/session.rs b/bridge/src/session.rs index 908a68c..94bdc5f 100644 --- a/bridge/src/session.rs +++ b/bridge/src/session.rs @@ -1,56 +1,26 @@ -//! A solo game session for GDScript: the shared world, its recording and the -//! commands waiting for the next tick. - -use std::panic::{catch_unwind, AssertUnwindSafe}; +//! A game session for GDScript, as host or as guest of a shared world. use godot::prelude::*; -use grovewright_core::command::Command; +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::replay::Recording; +use grovewright_core::protocol::{Action, Refusal}; use grovewright_core::world::World; +use crate::driver::{Driver, HOST_PEER}; use crate::events; -/// Everything a running session holds. -struct Game { - world: World, - recording: Recording, - /// Commands for the next tick, in the order they were queued. - pending: Vec, - local_key: Option, - local: Option, - /// Set when the core panicked. The world may be half-updated, so the - /// session neither steps nor saves any more. - broken: bool, -} - -impl Game { - fn new(world: World) -> Self { - let recording = Recording::start(&world); - Self { - world, - recording, - pending: Vec::new(), - local_key: None, - local: None, - broken: false, - } - } - - fn local_player(&self) -> Option<&grovewright_core::player::Player> { - self.local.and_then(|id| self.world.player(id)) - } -} - -/// A solo session. GDScript queues commands, calls [`GwSession::step`] once -/// per tick and reads the world back for display. +/// 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 { - game: Option, + driver: Option, last_error: GString, } @@ -72,133 +42,277 @@ impl GwSession { 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; - /// Starts a fresh world from `seed`, replacing any running game. + /// Hosts a fresh world from `seed`, replacing any running session. #[func] fn start_new(&mut self, seed: i64) { - self.game = Some(Game::new(World::new(seed as u64))); + self.driver = Some(Driver::host(World::new(seed as u64))); self.last_error = GString::new(); } - /// Starts from a save. Returns false and keeps any running game if the - /// save cannot be used; [`GwSession::last_error`] then says why. + /// 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.game = Some(Game::new(world)); + self.driver = Some(Driver::host(world)); self.last_error = GString::new(); true } Err(error) => { - self.last_error = GString::from(error.to_string().as_str()); + 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.game.is_some() + 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.game.as_ref().is_some_and(|game| game.broken) + self.driver.as_ref().is_some_and(Driver::is_broken) } - /// Queues the entry of the local player for the next tick. `key` - /// identifies the player across sessions and must have 16 bytes. + /// 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(game) = self.game.as_mut() else { + let Some(key) = self.player_key(&key) else { return false; }; - let Ok(key) = PlayerKey::try_from(key.as_slice()) else { - self.last_error = GString::from("a player key has 16 bytes"); + let Some(driver) = self.driver.as_mut() else { return false; }; - game.local_key = Some(key); - game.local = None; - game.pending.push(Command::Join { - key, - name: name.to_string(), - }); - true + driver.join_local(key, &name.to_string()) } - /// Queues a pickaxe hit of the local player on tile (`x`, `y`) for the - /// next tick. Does nothing before the local player has joined. + /// 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(game) = self.game.as_mut() else { + let (Some(driver), Some(pos)) = (self.driver.as_mut(), to_pos(x, y)) else { return; }; - let (Some(player), Some(pos)) = (game.local, to_pos(x, y)) else { - return; - }; - game.pending.push(Command::Dig { player, pos }); + driver.act(Action::Dig { pos }); } - /// Runs one tick with the queued commands and returns what happened, as - /// dictionaries with a `type` entry. + /// 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 { let mut happened = Array::new(); - let Some(game) = self.game.as_mut() else { + let Some(driver) = self.driver.as_mut() else { return happened; }; - if game.broken { - return happened; + if let Some(events) = driver.step() { + for event in &events { + happened.push(&events::to_dictionary(event)); + } } - let commands = std::mem::take(&mut game.pending); - let Game { - world, recording, .. - } = game; - match catch_unwind(AssertUnwindSafe(|| recording.step(world, &commands))) { - Ok(events) => { - for event in &events { - happened.push(&events::to_dictionary(event)); - } - if game.local.is_none() { - game.local = game.local_key.and_then(|key| game.world.find_player(&key)); - } - } - Err(_) => { - game.broken = true; - self.last_error = GString::from("the simulation stopped after an internal error"); - } + 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 { + 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 { + 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 = 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 { + 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.game - .as_ref() - .map_or(0, |game| game.world.tick() as i64) + self.world().map_or(0, |world| world.tick() as i64) } #[func] fn map_width(&self) -> i64 { - self.game - .as_ref() - .map_or(0, |game| i64::from(game.world.map().width())) + self.world() + .map_or(0, |world| i64::from(world.map().width())) } #[func] fn map_height(&self) -> i64 { - self.game - .as_ref() - .map_or(0, |game| i64::from(game.world.map().height())) + self.world() + .map_or(0, |world| i64::from(world.map().height())) } /// The terrain of every tile, row by row (`TERRAIN_*` constants). @@ -229,76 +343,123 @@ impl GwSession { /// The tile where figures appear. #[func] fn spawn_point(&self) -> Vector2i { - self.game.as_ref().map_or(Vector2i::ZERO, |game| { - let spawn = spawn_pos(game.world.map()); + self.world().map_or(Vector2i::ZERO, |world| { + let spawn = spawn_pos(world.map()); Vector2i::new(spawn.x, spawn.y) }) } - /// Index of the local player in the world, or -1 before joining. + /// Index of this machine's player in the world, or -1 before it is in. #[func] fn local_player(&self) -> i64 { - self.game + self.driver .as_ref() - .and_then(|game| game.local) + .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 = 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(game), Ok(item)) = (self.game.as_ref(), u16::try_from(item)) else { + 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; }; - game.local_player() + world + .player(player) .map_or(0, |player| i64::from(player.inventory.count(ItemId(item)))) } - /// The world as a save, or empty if there is no healthy session. + /// The world as a save, or empty. Only a healthy host saves. #[func] fn save(&self) -> PackedByteArray { - match self.game.as_ref() { - Some(game) if !game.broken => { - PackedByteArray::from(game.world.to_snapshot().as_slice()) - } - _ => PackedByteArray::new(), - } + self.driver + .as_ref() + .and_then(Driver::save) + .map_or_else(PackedByteArray::new, |bytes| { + PackedByteArray::from(bytes.as_slice()) + }) } - /// The recording of this session, or empty if there is no healthy - /// session. + /// The recording of this machine's session, or empty if there is no + /// healthy session with a world. #[func] fn recording_bytes(&self) -> PackedByteArray { - match self.game.as_ref() { - Some(game) if !game.broken => { - PackedByteArray::from(game.recording.to_bytes(&game.world).as_slice()) - } - _ => PackedByteArray::new(), - } + 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.game.as_ref().map_or_else(GString::new, |game| { - GString::from(format!("{:016x}", game.world.state_hash()).as_str()) + 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 { + 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 game = self.game.as_ref()?; - let tile = game.world.map().get(to_pos(x, y)?)?; + 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(game) = self.game.as_ref() else { + let Some(world) = self.world() else { return PackedByteArray::new(); }; - let bytes: Vec = game - .world + let bytes: Vec = world .map() .tiles() .iter() @@ -313,6 +474,11 @@ fn to_pos(x: i64, y: i64) -> Option { 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::try_from(peer).ok() +} + #[cfg(test)] mod tests { use super::*; @@ -323,4 +489,11 @@ mod tests { 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); + } } diff --git a/game/tests/test_session.gd b/game/tests/test_session.gd index 0f798e7..ec44158 100644 --- a/game/tests/test_session.gd +++ b/game/tests/test_session.gd @@ -138,3 +138,99 @@ func test_the_state_hash_changes_with_every_tick() -> void: 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") -- 2.52.0 From 6c7c5bffc2c1da9fbec4d941cf855be89221359f Mon Sep 17 00:00:00 2001 From: Steven Wroblewski Date: Thu, 1 Oct 2026 19:41:38 +0200 Subject: [PATCH 08/20] Panik-Test ohne globalen Hook, begrenzte Gast-Schleifen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Der Test einer Panik im Kern tauscht den globalen Panik-Hook nicht mehr aus. Das war unnötig, weil der Testläufer die Meldung einer erwarteten Panik ohnehin zurückhält, und es konnte Meldungen anderer, gleichzeitig scheiternder Tests verschlucken. Die Gast-Schleifen in den GDScript-Tests der Brücke laufen höchstens so oft, wie Takte warten, damit ein abgewichener Gast den Testlauf nicht anhält. Co-Authored-By: Claude Haiku 4.5 --- bridge/src/driver.rs | 7 ++----- game/tests/test_session.gd | 6 +++--- 2 files changed, 5 insertions(+), 8 deletions(-) diff --git a/bridge/src/driver.rs b/bridge/src/driver.rs index 2ea095d..7656466 100644 --- a/bridge/src/driver.rs +++ b/bridge/src/driver.rs @@ -229,7 +229,6 @@ impl Driver { #[cfg(test)] mod tests { use super::*; - use std::panic; #[test] fn a_host_saves_and_records_its_world() { @@ -254,12 +253,10 @@ mod tests { #[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(|_| {})); + // The test harness keeps the message of this expected panic to itself + // and shows it only if the test fails. 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()); diff --git a/game/tests/test_session.gd b/game/tests/test_session.gd index ec44158..c727b23 100644 --- a/game/tests/test_session.gd +++ b/game/tests/test_session.gd @@ -172,7 +172,7 @@ func _host_and_guest() -> Array[GwSession]: _carry(host, guest) host.step() _carry(host, guest) - while guest.pending_ticks() > 0: + for i in guest.pending_ticks(): guest.step() return [host, guest] @@ -191,7 +191,7 @@ func test_a_guest_follows_its_host_through_the_bridge() -> void: host.step() _carry(host, guest) check_eq(guest.pending_ticks(), 25, "the ticks wait until the guest runs them") - while guest.pending_ticks() > 0: + for i in guest.pending_ticks(): 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") @@ -213,7 +213,7 @@ func test_the_hit_of_a_guest_becomes_a_message_to_the_host() -> void: host.step() _carry(host, guest) var events: Array[Dictionary] = [] - while guest.pending_ticks() > 0: + for i in guest.pending_ticks(): 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") -- 2.52.0 From 0917450014f0f8a6759cf7a5ed056c7006338dc1 Mon Sep 17 00:00:00 2001 From: Steven Wroblewski Date: Thu, 1 Oct 2026 19:53:04 +0200 Subject: [PATCH 09/20] =?UTF-8?q?Verbindung=20=C3=BCber=20ENet?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Haiku 4.5 --- game/scripts/net_link.gd | 154 ++++++++++++++++++++++++++++ game/scripts/net_link.gd.uid | 1 + game/scripts/session_link.gd | 103 +++++++++++++++++++ game/scripts/session_link.gd.uid | 1 + game/tests/test_net_link.gd | 131 +++++++++++++++++++++++ game/tests/test_net_link.gd.uid | 1 + game/tests/test_session_link.gd | 141 +++++++++++++++++++++++++ game/tests/test_session_link.gd.uid | 1 + 8 files changed, 533 insertions(+) create mode 100644 game/scripts/net_link.gd create mode 100644 game/scripts/net_link.gd.uid create mode 100644 game/scripts/session_link.gd create mode 100644 game/scripts/session_link.gd.uid create mode 100644 game/tests/test_net_link.gd create mode 100644 game/tests/test_net_link.gd.uid create mode 100644 game/tests/test_session_link.gd create mode 100644 game/tests/test_session_link.gd.uid diff --git a/game/scripts/net_link.gd b/game/scripts/net_link.gd new file mode 100644 index 0000000..1e5ed2c --- /dev/null +++ b/game/scripts/net_link.gd @@ -0,0 +1,154 @@ +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 diff --git a/game/scripts/net_link.gd.uid b/game/scripts/net_link.gd.uid new file mode 100644 index 0000000..4aac885 --- /dev/null +++ b/game/scripts/net_link.gd.uid @@ -0,0 +1 @@ +uid://dmyy8qbtuhhdh diff --git a/game/scripts/session_link.gd b/game/scripts/session_link.gd new file mode 100644 index 0000000..474d854 --- /dev/null +++ b/game/scripts/session_link.gd @@ -0,0 +1,103 @@ +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 diff --git a/game/scripts/session_link.gd.uid b/game/scripts/session_link.gd.uid new file mode 100644 index 0000000..32aac12 --- /dev/null +++ b/game/scripts/session_link.gd.uid @@ -0,0 +1 @@ +uid://dpau4ls365tsj diff --git a/game/tests/test_net_link.gd b/game/tests/test_net_link.gd new file mode 100644 index 0000000..4cf5a85 --- /dev/null +++ b/game/tests/test_net_link.gd @@ -0,0 +1,131 @@ +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) diff --git a/game/tests/test_net_link.gd.uid b/game/tests/test_net_link.gd.uid new file mode 100644 index 0000000..c99d6ee --- /dev/null +++ b/game/tests/test_net_link.gd.uid @@ -0,0 +1 @@ +uid://bmma6gn72s36v diff --git a/game/tests/test_session_link.gd b/game/tests/test_session_link.gd new file mode 100644 index 0000000..90ca2d9 --- /dev/null +++ b/game/tests/test_session_link.gd @@ -0,0 +1,141 @@ +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)) + for i in guest.session.pending_ticks(): + 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]) diff --git a/game/tests/test_session_link.gd.uid b/game/tests/test_session_link.gd.uid new file mode 100644 index 0000000..1ccdbae --- /dev/null +++ b/game/tests/test_session_link.gd.uid @@ -0,0 +1 @@ +uid://c6tn270be6rbu -- 2.52.0 From a43ce66a89d74b3a9d155b95437c21bee89960a5 Mon Sep 17 00:00:00 2001 From: Steven Wroblewski Date: Thu, 1 Oct 2026 20:27:07 +0200 Subject: [PATCH 10/20] =?UTF-8?q?Nichts=20an=20schlie=C3=9Fende=20oder=20f?= =?UTF-8?q?ehlende=20Verbindungen=20senden?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit NetLink merkt sich die Verbindungen, die es gerade schließt, und schickt ihnen nichts mehr. Pakete an alle oder an alle außer einem gehen einzeln an die offenen Verbindungen, statt Godot ein Ziel zu nennen, das es nicht mehr gibt. Beides löste sonst Fehler in Godot aus, etwa wenn der Host Figuren weiterreicht, während er einen abgelehnten Gast hinauswirft. Ein neuer Test mit zwei Gästen deckt das ab. Co-Authored-By: Claude Haiku 4.5 --- game/scripts/net_link.gd | 35 ++++++++++++++++++++++++++++------- game/tests/test_net_link.gd | 35 ++++++++++++++++++++++++++++++++--- 2 files changed, 60 insertions(+), 10 deletions(-) diff --git a/game/scripts/net_link.gd b/game/scripts/net_link.gd index 1e5ed2c..65aad40 100644 --- a/game/scripts/net_link.gd +++ b/game/scripts/net_link.gd @@ -20,6 +20,9 @@ var connect_seconds := CONNECT_SECONDS var _peer: ENetMultiplayerPeer var _peers: Dictionary[int, bool] = {} +## Connections that close_peer is closing. Nothing more goes to them: Godot +## reports an error for every packet to a connection in that state. +var _closing: Dictionary[int, bool] = {} var _events: Array[Dictionary] = [] var _connecting := false var _waited := 0.0 @@ -67,6 +70,7 @@ func _start() -> void: close() _peer = ENetMultiplayerPeer.new() _peers.clear() + _closing.clear() _peer.peer_connected.connect(_on_connected) _peer.peer_disconnected.connect(_on_disconnected) @@ -78,6 +82,7 @@ func _on_connected(peer: int) -> void: func _on_disconnected(peer: int) -> void: _peers.erase(peer) + _closing.erase(peer) _events.append({"type": "disconnected", "peer": peer}) @@ -118,7 +123,7 @@ func poll(delta: float) -> Array[Dictionary]: ## Sends a message of the core to `peer`, reliably and in order. A message -## to a connection that is gone is dropped. +## to a connection that is gone or being closed is dropped. func send(peer: int, bytes: PackedByteArray) -> void: _put(peer, bytes, CHANNEL_CORE, MultiplayerPeer.TRANSFER_MODE_RELIABLE) @@ -130,19 +135,34 @@ func send_figure(peer: int, bytes: PackedByteArray) -> void: func _put(peer: int, bytes: PackedByteArray, channel: int, mode: MultiplayerPeer.TransferMode) -> void: - if not is_connected_to_peers() or _peers.is_empty(): + if not is_connected_to_peers(): 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) + # One packet per connection, so that no packet goes to a connection that + # is gone or being closed; Godot's own sending to all would. + for target in _targets(peer): + _peer.set_target_peer(target) + _peer.put_packet(bytes) + + +## The connections a packet for `peer` goes to: that one, every one if +## `peer` is 0, or every one but -`peer` if `peer` is negative. Connections +## that are being closed get nothing. +func _targets(peer: int) -> Array[int]: + var targets: Array[int] = [] + for open: int in _peers: + if _closing.has(open): + continue + if peer == 0 or open == peer or (peer < 0 and open != -peer): + targets.append(open) + return targets ## Closes the connection to `peer` once the messages waiting for it are out. func close_peer(peer: int) -> void: - if _peers.has(peer): + if _peers.has(peer) and not _closing.has(peer): + _closing[peer] = true _peer.get_peer(peer).peer_disconnect_later() @@ -151,4 +171,5 @@ func close() -> void: if is_open(): _peer.close() _peers.clear() + _closing.clear() _connecting = false diff --git a/game/tests/test_net_link.gd b/game/tests/test_net_link.gd index 4cf5a85..68daffa 100644 --- a/game/tests/test_net_link.gd +++ b/game/tests/test_net_link.gd @@ -100,14 +100,43 @@ func test_a_guest_that_quits_is_seen_leaving() -> void: host.close() -func test_messages_to_a_connection_that_is_gone_are_dropped() -> void: +## Godot reports an error for every packet to a connection that is gone or +## being closed; the test runner turns each such error into a failure. +func test_nothing_is_sent_to_connections_that_are_gone_or_closing() -> void: var host := NetLink.new() check_eq(host.host(0, "127.0.0.1"), OK, "the host listens") + var first := NetLink.new() + var second := NetLink.new() + first.join("127.0.0.1", host.local_port()) + second.join("127.0.0.1", host.local_port()) + await _pump([host, first, second], 10) + var peers: Array = _events[host].map(func(event: Dictionary) -> int: return event["peer"]) + check_eq(peers.size(), 2, "two guests connected") + var leaving: int = peers[0] + var staying: int = peers[1] + + # Connections that never existed. host.send(12345, PackedByteArray([1])) host.send_figure(12345, PackedByteArray([1])) + host.send_figure(-12345, PackedByteArray([1])) host.close_peer(12345) - check_eq(host.poll(0.0), [], "nothing happened") - host.close() + # A connection that is being closed, and everybody while it closes. + host.close_peer(leaving) + for frame in 10: + host.send(leaving, PackedByteArray([2])) + host.send_figure(leaving, PackedByteArray([2])) + host.send_figure(-staying, PackedByteArray([2])) + host.send_figure(0, PackedByteArray([2])) + await _pump([host, first, second], 1) + # The connection is gone now. + host.send(leaving, PackedByteArray([3])) + host.send_figure(-leaving, PackedByteArray([3])) + await _pump([host, first, second], 5) + + check(_types(first).has("disconnected"), "the first guest was closed") + check(not _types(second).has("disconnected"), "the second guest stays") + for link in [host, first, second]: + link.close() func test_a_guest_gives_up_when_nobody_answers() -> void: -- 2.52.0 From d5debc5bcba34cf324b89e6cb62703a77e17e711 Mon Sep 17 00:00:00 2001 From: Steven Wroblewski Date: Thu, 1 Oct 2026 20:57:03 +0200 Subject: [PATCH 11/20] Profile, Startoptionen und vorsichtiges Lesen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Haiku 4.5 --- game/scripts/launch_options.gd | 73 +++++++++++++++++++++++ game/scripts/launch_options.gd.uid | 1 + game/scripts/save_store.gd | 71 +++++++++++++++++++--- game/tests/support/held_file_store.gd | 13 ++++ game/tests/support/held_file_store.gd.uid | 1 + game/tests/test_launch_options.gd | 30 ++++++++++ game/tests/test_launch_options.gd.uid | 1 + game/tests/test_save_store.gd | 48 +++++++++++++++ 8 files changed, 229 insertions(+), 9 deletions(-) create mode 100644 game/scripts/launch_options.gd create mode 100644 game/scripts/launch_options.gd.uid create mode 100644 game/tests/support/held_file_store.gd create mode 100644 game/tests/support/held_file_store.gd.uid create mode 100644 game/tests/test_launch_options.gd create mode 100644 game/tests/test_launch_options.gd.uid diff --git a/game/scripts/launch_options.gd b/game/scripts/launch_options.gd new file mode 100644 index 0000000..8fe9470 --- /dev/null +++ b/game/scripts/launch_options.gd @@ -0,0 +1,73 @@ +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) diff --git a/game/scripts/launch_options.gd.uid b/game/scripts/launch_options.gd.uid new file mode 100644 index 0000000..f046f56 --- /dev/null +++ b/game/scripts/launch_options.gd.uid @@ -0,0 +1 @@ +uid://dkgt2ownscxuj diff --git a/game/scripts/save_store.gd b/game/scripts/save_store.gd index 7736ca5..7a507a0 100644 --- a/game/scripts/save_store.gd +++ b/game/scripts/save_store.gd @@ -1,18 +1,23 @@ class_name SaveStore extends RefCounted -## The game's files on the player's machine: the player key, the save and -## the session recordings for bug reports. +## 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 @@ -25,14 +30,19 @@ func _init(directory: String = "user://") -> void: ## The key that identifies this installation in every world. It is created -## on first use and kept from then on. +## 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) - var key := FileAccess.get_file_as_bytes(path) - if key.size() != KEY_BYTES: - key = Crypto.new().generate_random_bytes(KEY_BYTES) - if not _write(path, key): - push_error("player key could not be stored at %s" % path) + 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 @@ -40,8 +50,10 @@ 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 FileAccess.get_file_as_bytes(base_dir.path_join(SAVE_FILE)) + return _read_file(base_dir.path_join(SAVE_FILE)) ## Writes the save through a temporary file, so that a crash or a failed @@ -115,6 +127,40 @@ func recordings() -> PackedStringArray: 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) @@ -125,6 +171,13 @@ func _restore_backup() -> void: 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: diff --git a/game/tests/support/held_file_store.gd b/game/tests/support/held_file_store.gd new file mode 100644 index 0000000..41be9f9 --- /dev/null +++ b/game/tests/support/held_file_store.gd @@ -0,0 +1,13 @@ +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) diff --git a/game/tests/support/held_file_store.gd.uid b/game/tests/support/held_file_store.gd.uid new file mode 100644 index 0000000..c7894aa --- /dev/null +++ b/game/tests/support/held_file_store.gd.uid @@ -0,0 +1 @@ +uid://bprgpcfabdtn4 diff --git a/game/tests/test_launch_options.gd b/game/tests/test_launch_options.gd new file mode 100644 index 0000000..2ac91ba --- /dev/null +++ b/game/tests/test_launch_options.gd @@ -0,0 +1,30 @@ +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") diff --git a/game/tests/test_launch_options.gd.uid b/game/tests/test_launch_options.gd.uid new file mode 100644 index 0000000..4da3e81 --- /dev/null +++ b/game/tests/test_launch_options.gd.uid @@ -0,0 +1 @@ +uid://xe7vqgyxi2wm diff --git a/game/tests/test_save_store.gd b/game/tests/test_save_store.gd index 319ff21..47c64e3 100644 --- a/game/tests/test_save_store.gd +++ b/game/tests/test_save_store.gd @@ -126,3 +126,51 @@ func test_only_the_newest_recordings_are_kept() -> void: 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) -- 2.52.0 From 51cd4572a85233d86e874808386ba88daefef237 Mon Sep 17 00:00:00 2001 From: Steven Wroblewski Date: Thu, 1 Oct 2026 21:23:49 +0200 Subject: [PATCH 12/20] =?UTF-8?q?Gesperrte=20Einstellungen=20nicht=20?= =?UTF-8?q?=C3=BCberschreiben?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wer eine Einstellung ändert, liest die Einstellungsdatei jetzt an derselben Stelle wie Schlüssel und Speicherstand. Ist sie gerade nicht lesbar, etwa weil ein anderes Programm sie hält, bleibt sie unverändert, statt nur mit dem neuen Wert überschrieben zu werden. Von einer beschädigten Datei bleibt, was Godot lesen konnte. Ein neuer Test deckt die gehaltene Datei ab. Der Parameter von set_player_name heißt jetzt new_name, damit er die Methode player_name nicht verdeckt. Co-Authored-By: Claude Haiku 4.5 --- game/scripts/save_store.gd | 38 ++++++++++++++++++++++++++--------- game/tests/test_save_store.gd | 15 ++++++++++++++ 2 files changed, 44 insertions(+), 9 deletions(-) diff --git a/game/scripts/save_store.gd b/game/scripts/save_store.gd index 7a507a0..985fc09 100644 --- a/game/scripts/save_store.gd +++ b/game/scripts/save_store.gd @@ -14,9 +14,10 @@ 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. +## The result of the last attempt to read the key, the save or the settings: +## 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 @@ -132,8 +133,8 @@ func player_name() -> String: return _setting("player", "name") -func set_player_name(player_name: String) -> void: - _set_setting("player", "name", player_name) +func set_player_name(new_name: String) -> void: + _set_setting("player", "name", new_name) ## The address of the host the player joined last time, or an empty text. @@ -146,21 +147,40 @@ func set_last_address(address: String) -> void: func _setting(section: String, key: String) -> String: - var settings := ConfigFile.new() - if settings.load(base_dir.path_join(SETTINGS_FILE)) != OK: + var settings := _read_settings(base_dir.path_join(SETTINGS_FILE)) + if settings == null: return "" return str(settings.get_value(section, key, "")) +## Changes one setting and keeps the others. Settings that cannot be read +## right now stay as they are, because writing them would lose the others. 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) + var settings := _read_settings(path) + if settings == null: + push_warning("settings could not be read at %s and stay as they are: %s" % [path, error_string(last_read_error)]) + return settings.set_value(section, key, value) if settings.save(path) != OK: push_warning("settings could not be stored at %s" % path) +## The settings on disk, or null if the file exists but cannot be read right +## now; last_read_error then says why. A missing file gives empty settings. +## Of a damaged file, Godot reports the error and keeps what it read before +## it, and the next change of a setting writes the file anew. +func _read_settings(path: String) -> ConfigFile: + var settings := ConfigFile.new() + if not FileAccess.file_exists(path): + return settings + var text := _read_file(path).get_string_from_utf8() + if last_read_error != OK: + return null + settings.parse(text) + return settings + + ## 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) diff --git a/game/tests/test_save_store.gd b/game/tests/test_save_store.gd index 47c64e3..7f88387 100644 --- a/game/tests/test_save_store.gd +++ b/game/tests/test_save_store.gd @@ -174,3 +174,18 @@ func test_the_settings_are_kept() -> void: check_eq(later.player_name(), "Ada", "name") check_eq(later.last_address(), "192.168.1.20", "address") TestFiles.remove_dir(directory) + + +func test_settings_that_cannot_be_read_right_now_are_not_overwritten() -> void: + var directory := TestFiles.temporary_dir("held-settings") + var store := SaveStore.new(directory) + store.set_player_name("Ada") + store.set_last_address("192.168.1.20") + var held := HeldFileStore.new(directory) + held.unreadable = SaveStore.SETTINGS_FILE + check_eq(held.player_name(), "", "nothing read for now") + held.set_player_name("Grace") + var later := SaveStore.new(directory) + check_eq(later.player_name(), "Ada", "the name on disk is the old one") + check_eq(later.last_address(), "192.168.1.20", "the address is kept") + TestFiles.remove_dir(directory) -- 2.52.0 From d2da661452996bb2593c145fafa181b70db744b0 Mon Sep 17 00:00:00 2001 From: Steven Wroblewski Date: Thu, 1 Oct 2026 21:40:57 +0200 Subject: [PATCH 13/20] =?UTF-8?q?Startmen=C3=BC,=20fremde=20Figuren=20und?= =?UTF-8?q?=20Statuszeile?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Haiku 4.5 --- game/i18n/texts.csv | 27 ++++++++++++++ game/scripts/figures.gd | 63 ++++++++++++++++++++++++++++++++ game/scripts/figures.gd.uid | 1 + game/scripts/hud.gd | 26 +++++++++++--- game/scripts/menu.gd | 65 ++++++++++++++++++++++++++++++++++ game/scripts/menu.gd.uid | 1 + game/tests/test_figures.gd | 34 ++++++++++++++++++ game/tests/test_figures.gd.uid | 1 + game/tests/test_hud.gd | 32 +++++++++++++++++ game/tests/test_hud.gd.uid | 1 + game/tests/test_menu.gd | 37 +++++++++++++++++++ game/tests/test_menu.gd.uid | 1 + 12 files changed, 284 insertions(+), 5 deletions(-) create mode 100644 game/scripts/figures.gd create mode 100644 game/scripts/figures.gd.uid create mode 100644 game/scripts/menu.gd create mode 100644 game/scripts/menu.gd.uid create mode 100644 game/tests/test_figures.gd create mode 100644 game/tests/test_figures.gd.uid create mode 100644 game/tests/test_hud.gd create mode 100644 game/tests/test_hud.gd.uid create mode 100644 game/tests/test_menu.gd create mode 100644 game/tests/test_menu.gd.uid diff --git a/game/i18n/texts.csv b/game/i18n/texts.csv index 6df10eb..984664a 100644 --- a/game/i18n/texts.csv +++ b/game/i18n/texts.csv @@ -8,3 +8,30 @@ 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 diff --git a/game/scripts/figures.gd b/game/scripts/figures.gd new file mode 100644 index 0000000..0809836 --- /dev/null +++ b/game/scripts/figures.gd @@ -0,0 +1,63 @@ +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) diff --git a/game/scripts/figures.gd.uid b/game/scripts/figures.gd.uid new file mode 100644 index 0000000..f3220f0 --- /dev/null +++ b/game/scripts/figures.gd.uid @@ -0,0 +1 @@ +uid://0krucvyguufx diff --git a/game/scripts/hud.gd b/game/scripts/hud.gd index e81b625..e46352e 100644 --- a/game/scripts/hud.gd +++ b/game/scripts/hud.gd @@ -1,11 +1,13 @@ class_name Hud extends CanvasLayer -## Text over the game: the stone count, short messages and a help line. +## 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 @@ -15,9 +17,12 @@ func _ready() -> void: _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, _help]: + 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) @@ -36,9 +41,10 @@ func show_stone(count: int) -> void: _stone.text = tr("STONE_COUNT") % count -## Shows the text for `key` for a few seconds. -func flash(key: String) -> void: - _message.text = tr(key) +## 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 @@ -48,6 +54,16 @@ func show_paused(paused: bool) -> void: _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 diff --git a/game/scripts/menu.gd b/game/scripts/menu.gd new file mode 100644 index 0000000..2c8e515 --- /dev/null +++ b/game/scripts/menu.gd @@ -0,0 +1,65 @@ +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() diff --git a/game/scripts/menu.gd.uid b/game/scripts/menu.gd.uid new file mode 100644 index 0000000..adfd865 --- /dev/null +++ b/game/scripts/menu.gd.uid @@ -0,0 +1 @@ +uid://dppcwwh67rtu6 diff --git a/game/tests/test_figures.gd b/game/tests/test_figures.gd new file mode 100644 index 0000000..31cf682 --- /dev/null +++ b/game/tests/test_figures.gd @@ -0,0 +1,34 @@ +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() diff --git a/game/tests/test_figures.gd.uid b/game/tests/test_figures.gd.uid new file mode 100644 index 0000000..693c9a2 --- /dev/null +++ b/game/tests/test_figures.gd.uid @@ -0,0 +1 @@ +uid://x5pqgp2p1hku diff --git a/game/tests/test_hud.gd b/game/tests/test_hud.gd new file mode 100644 index 0000000..6d1019d --- /dev/null +++ b/game/tests/test_hud.gd @@ -0,0 +1,32 @@ +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) diff --git a/game/tests/test_hud.gd.uid b/game/tests/test_hud.gd.uid new file mode 100644 index 0000000..8e0b698 --- /dev/null +++ b/game/tests/test_hud.gd.uid @@ -0,0 +1 @@ +uid://8e4vlt0gnipa diff --git a/game/tests/test_menu.gd b/game/tests/test_menu.gd new file mode 100644 index 0000000..7238019 --- /dev/null +++ b/game/tests/test_menu.gd @@ -0,0 +1,37 @@ +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) diff --git a/game/tests/test_menu.gd.uid b/game/tests/test_menu.gd.uid new file mode 100644 index 0000000..f64ffb9 --- /dev/null +++ b/game/tests/test_menu.gd.uid @@ -0,0 +1 @@ +uid://dh6uogkq28wa4 -- 2.52.0 From 53ef02ae75ce0b24c7a86de38f9bebceb5cc277e Mon Sep 17 00:00:00 2001 From: Steven Wroblewski Date: Thu, 1 Oct 2026 22:03:42 +0200 Subject: [PATCH 14/20] Spielen allein, als Host oder als Gast MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Haiku 4.5 --- game/scripts/main.gd | 361 ++++++++++++++++++++++++++++++++------- game/tests/test_main.gd | 253 ++++++++++++++++++++++++--- game/tools/screenshot.gd | 2 + 3 files changed, 534 insertions(+), 82 deletions(-) diff --git a/game/scripts/main.gd b/game/scripts/main.gd index ea5dbb1..c270348 100644 --- a/game/scripts/main.gd +++ b/game/scripts/main.gd @@ -1,45 +1,83 @@ extends Node2D -## Composition root of a solo session: connects the simulation core with the -## map view, the figure, the HUD and the files on disk. +## 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 -const PLAYER_NAME := "Spieler" ## 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. Tests use their -## own folder. -@export var storage_dir := "user://" +## 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 session: GwSession +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() @@ -47,76 +85,268 @@ func _ready() -> void: avatar.add_child(camera) hud = Hud.new() add_child(hud) - if not _start_from_save(): - _start_new_world() + 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 session == null or session.is_broken(): + 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() - for i in clock.advance(delta): - _run_tick() + 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"): - hud.flash("SAVED" if save_game() else "SAVE_FAILED") + 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"): - _write_recording() - hud.flash("LOADED" if _start_from_save() else "LOAD_FAILED") + _reload() elif event.is_action_pressed("toggle_pause"): - clock.paused = not clock.paused - avatar.set_process(not clock.paused) - hud.show_paused(clock.paused) + 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 and session != null: - if not save_game(): + 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() - - -func _start_new_world() -> void: - var fresh := GwSession.new() - fresh.start_new(NEW_WORLD_SEED) - _enter(fresh) - - -## Continues the saved world. Returns false if there is no usable save. An -## unusable save is set aside, so that the next save does not overwrite it. -func _start_from_save() -> bool: - if not store.has_save(): - return false - var loaded := GwSession.new() - if not loaded.start_loaded(store.read_save()): - 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" % loaded.last_error()) - else: - push_warning("save not usable (%s), kept as %s" % [loaded.last_error(), aside]) - hud.flash("LOAD_FAILED") - return false - _save_blocked = false - _enter(loaded) - return true - - -## Makes `next` the running session and lets the local player join it. -func _enter(next: GwSession) -> void: - session = next - session.join(_player_key, PLAYER_NAME) - _run_tick() - _next_dig_tick = 0 - map_view.session = session - map_view.queue_redraw() - avatar.is_walkable = session.is_walkable - avatar.position = GridMotion.tile_center(session.spawn_point()) - hud.show_stone(session.item_count(GwSession.ITEM_STONE)) + if link != null: + link.link.close() func _run_tick() -> void: @@ -138,6 +368,14 @@ func _apply(events: Array[Dictionary]) -> void: "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()) @@ -166,9 +404,10 @@ func dig_at(tile: Vector2i) -> void: ## Writes the running world as the save. Returns false if nothing was -## written, also while an unusable save could not be set aside. +## 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: + if _save_blocked or not session.is_host(): return false return store.write_save(session.save()) diff --git a/game/tests/test_main.gd b/game/tests/test_main.gd index ff63a94..44b0c75 100644 --- a/game/tests/test_main.gd +++ b/game/tests/test_main.gd @@ -1,76 +1,162 @@ extends TestCase -## Starts the real main scene with its own storage folder and runs a few -## frames. +## 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") - _stop_main(main) - TestFiles.remove_dir(directory) + _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") - _stop_main(second) - TestFiles.remove_dir(directory) + _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") - _stop_main(main) - TestFiles.remove_dir(directory) + _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: + # No frames are awaited, so only these ticks run. The loop is counted, so + # that a session that stops ticking fails the test instead of hanging it. + for i in main.AUTOSAVE_TICKS - 1 - main.session.tick(): 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") - _stop_main(main) - TestFiles.remove_dir(directory) + _clean_up([directory]) func test_an_unusable_save_is_set_aside_and_a_new_world_starts() -> void: @@ -78,13 +164,11 @@ func test_an_unusable_save_is_set_aside_and_a_new_world_starts() -> void: var store := SaveStore.new(directory) store.write_save(PackedByteArray([1, 2, 3])) var main := _start_main(directory) - await tree.process_frame - check(main.session.is_running(), "a new world runs") - check(main.session.tick() <= 5, "it is new, tick %d" % main.session.tick()) + 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") - _stop_main(main) - TestFiles.remove_dir(directory) + _clean_up([directory]) func test_an_unusable_save_that_cannot_be_set_aside_is_never_overwritten() -> void: @@ -94,10 +178,137 @@ func test_an_unusable_save_that_cannot_be_set_aside_is_never_overwritten() -> vo # 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) - await tree.process_frame - check(main.session.is_running(), "a new world runs") + 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") - _stop_main(main) - TestFiles.remove_dir(directory) + _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]) diff --git a/game/tools/screenshot.gd b/game/tools/screenshot.gd index b417a71..85e012f 100644 --- a/game/tools/screenshot.gd +++ b/game/tools/screenshot.gd @@ -21,7 +21,9 @@ func _run() -> void: 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. -- 2.52.0 From bfd237fd5cd70163b9d6d27ac55ada4b9e2d3425 Mon Sep 17 00:00:00 2001 From: Steven Wroblewski Date: Thu, 1 Oct 2026 22:33:10 +0200 Subject: [PATCH 15/20] =?UTF-8?q?Defekte=20Sitzung=20gibt=20Mitspieler=20f?= =?UTF-8?q?rei,=20nur=20echte=20G=C3=A4ste=20beenden=20die=20Pause?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hält der Kern mit einem internen Fehler an, schließt ein Host seine Welt für Gäste, und ein Gast kehrt ins Menü zurück, statt dass die anderen ohne Nachricht warten. Eine Pause endet erst, wenn der Host einen Gast angenommen hat, nicht schon bei einer bloßen Verbindung. So kann ein Gast nicht mehr in einer pausierten Welt festhängen, wenn der Host zwischen Verbindung und Gruß pausiert. Eine Pause lässt sich immer beenden. Meldungen, die nach dem Ende einer Sitzung im selben Stapel folgen, bleiben unbeachtet. Vier neue Tests decken das ab. Co-Authored-By: Claude Haiku 4.5 --- game/scripts/main.gd | 38 ++++++++++++++++++++--- game/tests/test_main.gd | 69 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 102 insertions(+), 5 deletions(-) diff --git a/game/scripts/main.gd b/game/scripts/main.gd index c270348..b945d18 100644 --- a/game/scripts/main.gd +++ b/game/scripts/main.gd @@ -57,6 +57,9 @@ var _save_blocked := false var _figure_wait := 0.0 ## The address a guest is joining, for messages. var _joining := "" +## The host's accepted guests when _process last looked. A new one ends a +## pause. +var _known_guests := 0 func _ready() -> void: @@ -233,12 +236,37 @@ func _leave(message: String) -> void: _show_menu(message) +## A broken session runs no more ticks. Instead of leaving the others +## waiting, a host closes its world to guests and a guest leaves. +func _stop_sharing() -> void: + if not session.is_host(): + _leave(tr("SIMULATION_STOPPED")) + return + link.link.close() + link = null + figures.clear() + _update_status() + + +## Ends a pause once the host accepted a guest: the guest joins in the next +## tick, and a paused world would keep it waiting. A connection that has not +## greeted yet is no guest. +func _end_pause_for_new_guests() -> void: + var guests := session.guest_count() + if guests > _known_guests and clock.paused: + _set_paused(false) + _known_guests = guests + + func _process(delta: float) -> void: if link != null: for notice in link.pump(delta): _on_notice(notice) + if session != null and session.is_broken() and link != null: + _stop_sharing() if session == null or not session.is_running() or session.is_broken(): return + _end_pause_for_new_guests() _update_marker() if Input.is_action_pressed("dig") and not clock.paused: _try_dig() @@ -257,11 +285,10 @@ func _process(delta: float) -> void: func _on_notice(notice: Dictionary) -> void: + # An earlier notice of the same batch may have ended the session. + if session == null: + return 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 @@ -304,7 +331,8 @@ func _unhandled_input(event: InputEvent) -> void: elif event.is_action_pressed("load_game"): _reload() elif event.is_action_pressed("toggle_pause"): - if session.is_host() and session.guest_count() == 0: + # Ending a pause is always allowed, starting one only alone. + if clock.paused or (session.is_host() and session.guest_count() == 0): _set_paused(not clock.paused) else: hud.flash("PAUSE_ONLY_ALONE") diff --git a/game/tests/test_main.gd b/game/tests/test_main.gd index 44b0c75..d40fae0 100644 --- a/game/tests/test_main.gd +++ b/game/tests/test_main.gd @@ -268,6 +268,34 @@ func test_only_the_host_saves_and_only_alone_one_may_pause_or_load() -> void: _clean_up([host_dir, guest_dir]) +func test_an_accepted_guest_ends_a_pause_and_a_bare_connection_does_not() -> void: + var host_dir := TestFiles.temporary_dir("host") + var guest_dir := TestFiles.temporary_dir("guest") + var host := _start_host(host_dir) + _press(host, "toggle_pause") + check(host.clock.paused, "the host pauses while alone") + + # A connection that never greets is no guest. + var stranger := NetLink.new() + stranger.join("127.0.0.1", host.link.link.local_port()) + var seen := func() -> bool: + stranger.poll(1.0 / 60.0) + return host.link.link._peers.size() == 1 + check(await _wait_until(seen), "the host sees a bare connection") + check(host.clock.paused, "which leaves the pause alone") + + var guest := _start_guest(guest_dir, host) + check(await _wait_until(_joined.bind(guest)), "a guest joins the paused world") + check(not host.clock.paused, "and ends the pause") + + # Should a pause ever meet a guest, the host can still end it. + host._set_paused(true) + _press(host, "toggle_pause") + check(not host.clock.paused, "the host can always end a pause") + stranger.close() + _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") @@ -295,6 +323,36 @@ func test_a_guest_goes_back_to_the_menu_when_the_host_closes() -> void: _clean_up([host_dir, guest_dir]) +func test_a_host_that_stops_sharing_lets_its_guests_go() -> 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)) + # An internal error of the core cannot be caused from here. For a broken + # session, _process calls what this test calls. + host._stop_sharing() + check(host.link == null, "the host no longer listens") + check_eq(host.hud.status_text(), "", "and no longer says that it does") + check(await _wait_until(func() -> bool: return guest.session == null), "the guest's session ends") + check_eq(guest.menu.status(), tr("HOST_LEFT"), "and the guest learns why") + _clean_up([host_dir, guest_dir]) + + +func test_a_guest_that_stops_sharing_leaves() -> 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)) + # As above, for a guest whose own core stopped. + guest._stop_sharing() + check(guest.session == null, "the guest's session ends") + check_eq(guest.menu.status(), tr("SIMULATION_STOPPED"), "the menu says why") + check(await _wait_until(func() -> bool: return host.hud.message() == tr("PLAYER_LEFT") % "Grace"), "the host sees the guest go") + _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) @@ -305,6 +363,17 @@ func test_a_guest_that_is_turned_away_learns_why() -> void: _clean_up([host_dir]) +func test_notices_after_the_end_of_a_session_are_ignored() -> void: + var directory := TestFiles.temporary_dir("late") + var main := _start_main(directory) + # In one batch of notices, an earlier one may have ended the session. + main._on_notice({"type": "world_received", "resync": true}) + main._on_notice({"type": "protocol_error"}) + check(main.session == null, "no session") + check(main.menu.visible, "the menu stays") + _clean_up([directory]) + + func test_a_mistyped_address_is_reported() -> void: var directory := TestFiles.temporary_dir("typo") var main := _start_main(directory) -- 2.52.0 From acce1ed435078899f6319d1efdc6b7bd8714fd9a Mon Sep 17 00:00:00 2001 From: Steven Wroblewski Date: Thu, 1 Oct 2026 22:49:41 +0200 Subject: [PATCH 16/20] Zwei Instanzen im Gleichschritt MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Haiku 4.5 --- game/tests/test_two_instances.gd | 65 ++++++++++++++++++++++++++++ game/tests/test_two_instances.gd.uid | 1 + game/tools/guest_bot.gd | 59 +++++++++++++++++++++++++ game/tools/guest_bot.gd.uid | 1 + 4 files changed, 126 insertions(+) create mode 100644 game/tests/test_two_instances.gd create mode 100644 game/tests/test_two_instances.gd.uid create mode 100644 game/tools/guest_bot.gd create mode 100644 game/tools/guest_bot.gd.uid diff --git a/game/tests/test_two_instances.gd b/game/tests/test_two_instances.gd new file mode 100644 index 0000000..965dd2d --- /dev/null +++ b/game/tests/test_two_instances.gd @@ -0,0 +1,65 @@ +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) diff --git a/game/tests/test_two_instances.gd.uid b/game/tests/test_two_instances.gd.uid new file mode 100644 index 0000000..7483170 --- /dev/null +++ b/game/tests/test_two_instances.gd.uid @@ -0,0 +1 @@ +uid://c2vju7aa5pgqm diff --git a/game/tools/guest_bot.gd b/game/tools/guest_bot.gd new file mode 100644 index 0000000..55df730 --- /dev/null +++ b/game/tools/guest_bot.gd @@ -0,0 +1,59 @@ +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) diff --git a/game/tools/guest_bot.gd.uid b/game/tools/guest_bot.gd.uid new file mode 100644 index 0000000..78344e7 --- /dev/null +++ b/game/tools/guest_bot.gd.uid @@ -0,0 +1 @@ +uid://hvhqt2viwbjx -- 2.52.0 From 84e786e5319bf289f9492bf1cdf2e51109c87f3e Mon Sep 17 00:00:00 2001 From: Steven Wroblewski Date: Thu, 1 Oct 2026 23:24:10 +0200 Subject: [PATCH 17/20] =?UTF-8?q?Zwei-Instanzen-Test=20unabh=C3=A4ngig=20v?= =?UTF-8?q?on=20der=20Startzeit,=20Bot=20endet=20immer?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Die Uhr des Hosts wartet jetzt auf den Gast. So kann ein langsamer Start des zweiten Prozesses die Takte bis zum Vergleich nicht mehr verbrauchen. Der Bot endet auch dann, wenn er seinen Bericht nicht schreiben kann, und überlebt den Test nie. Ein leeres --storage= führt ihn nicht mehr in den Ordner des Spielers. Ein neuer Test prüft, dass der Bot ohne schreibbaren Bericht mit Exit-Code 1 endet. Co-Authored-By: Claude Haiku 4.5 --- game/tests/test_two_instances.gd | 30 ++++++++++++++++++++++++++++++ game/tools/guest_bot.gd | 18 +++++++++++++----- 2 files changed, 43 insertions(+), 5 deletions(-) diff --git a/game/tests/test_two_instances.gd b/game/tests/test_two_instances.gd index 965dd2d..2d9b897 100644 --- a/game/tests/test_two_instances.gd +++ b/game/tests/test_two_instances.gd @@ -18,6 +18,9 @@ func test_a_guest_in_another_process_stays_in_lockstep() -> void: host.options.port = 0 host.listen_address = "127.0.0.1" check(host.play(true), "the world opens") + # The host's clock waits for the guest, so that a slow start of the second + # process cannot use up the ticks before UNTIL. + host.clock.paused = true var report_path := directory.path_join("guest.json") var arguments := PackedStringArray([ @@ -63,3 +66,30 @@ func test_a_guest_in_another_process_stays_in_lockstep() -> void: host.link.link.close() host.free() TestFiles.remove_dir(directory) + + +func test_a_guest_bot_that_cannot_write_its_report_still_ends() -> void: + var directory := TestFiles.temporary_dir("bot-report") + var arguments := PackedStringArray([ + "--headless", + "--path", ProjectSettings.globalize_path("res://"), + "--log-file", ProjectSettings.globalize_path(directory.path_join("bot.log")), + "-s", "res://tools/guest_bot.gd", + "--", + # An address that cannot be read ends the session at once. + "--join=host:port", + "--storage=" + directory.path_join("bot"), + # A folder cannot be opened as the report file. + "--out=" + ProjectSettings.globalize_path(directory), + ]) + var process := OS.create_process(OS.get_executable_path(), arguments) + check(process > 0, "the bot starts") + var started := Time.get_ticks_msec() + while OS.is_process_running(process) and Time.get_ticks_msec() - started < PATIENCE_MSEC: + await tree.process_frame + var ended := not OS.is_process_running(process) + if not ended: + OS.kill(process) + check(ended, "the bot ends") + check_eq(OS.get_process_exit_code(process), 1, "and reports a failure") + TestFiles.remove_dir(directory) diff --git a/game/tools/guest_bot.gd b/game/tools/guest_bot.gd index 55df730..786459d 100644 --- a/game/tools/guest_bot.gd +++ b/game/tools/guest_bot.gd @@ -24,7 +24,9 @@ func _run() -> void: var report := {"error": "the host did not answer"} var main: Node = load("res://main.tscn").instantiate() - main.storage_dir = settings.get("--storage", "user://bot") + # Never the player's own folder, even for an empty --storage=. + var storage: String = settings.get("--storage", "") + main.storage_dir = storage if not storage.is_empty() else "user://bot" main.command_line = PackedStringArray(["--join=" + settings.get("--join", ""), "--name=Bot"]) root.add_child(main) @@ -50,10 +52,16 @@ func _run() -> void: } break - var file := FileAccess.open(settings.get("--out", "user://bot.json"), FileAccess.WRITE) - file.store_string(JSON.stringify(report)) - file.close() + # The bot ends in every case, even if the report cannot be written, so that + # it never outlives the test that started it. + var path: String = settings.get("--out", "user://bot.json") + var file := FileAccess.open(path, FileAccess.WRITE) + if file == null: + push_error("the report cannot be written to %s: %s" % [path, error_string(FileAccess.get_open_error())]) + else: + 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) + quit(0 if file != null and not report.has("error") else 1) -- 2.52.0 From 56101b15a5a65c05ae4440fc871f5688c9bc51f9 Mon Sep 17 00:00:00 2001 From: Steven Wroblewski Date: Thu, 1 Oct 2026 23:39:39 +0200 Subject: [PATCH 18/20] =?UTF-8?q?Startskripte,=20Regeln=20und=20Probelauf?= =?UTF-8?q?=20f=C3=BCr=20M1c?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Haiku 4.5 --- CLAUDE.md | 26 ++++++++++++++++++++++++++ docs/playtests/m1c.md | 28 ++++++++++++++++++++++++++++ play-zweites-fenster.bat | 4 ++++ play.bat | 5 +++-- 4 files changed, 61 insertions(+), 2 deletions(-) create mode 100644 docs/playtests/m1c.md create mode 100644 play-zweites-fenster.bat diff --git a/CLAUDE.md b/CLAUDE.md index 7a7b913..6f8014f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -33,6 +33,15 @@ Alle Spieler simulieren die Welt selbst und tauschen nur Befehle aus (Gleichschr 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/`. @@ -41,6 +50,9 @@ Drei Wächter prüfen das. `core/clippy.toml` sperrt Pfade wie `std::collections - 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 @@ -57,6 +69,8 @@ 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: @@ -69,6 +83,18 @@ godot --path game -s res://tools/screenshot.gd -- `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. diff --git a/docs/playtests/m1c.md b/docs/playtests/m1c.md new file mode 100644 index 0000000..333a128 --- /dev/null +++ b/docs/playtests/m1c.md @@ -0,0 +1,28 @@ +# 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. diff --git a/play-zweites-fenster.bat b/play-zweites-fenster.bat new file mode 100644 index 0000000..77a2c1d --- /dev/null +++ b/play-zweites-fenster.bat @@ -0,0 +1,4 @@ +@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 diff --git a/play.bat b/play.bat index e1e9f4d..4ffa20f 100644 --- a/play.bat +++ b/play.bat @@ -1,5 +1,6 @@ @echo off -rem Builds the bridge and starts Grovewright from this folder. +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 @@ -8,7 +9,7 @@ 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 +godot --path game -- %* goto :eof :failed -- 2.52.0 From 7f4bbd96065fa609a648617197669e0e3a43f5c3 Mon Sep 17 00:00:00 2001 From: Steven Wroblewski Date: Fri, 2 Oct 2026 00:07:20 +0200 Subject: [PATCH 19/20] Fragebogen und Regeln an das Spiel angeglichen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Der Fragebogen nennt die Statuszeile oben rechts. Er sagt wieder, wann eine Aufzeichnung entsteht, dass eine angehaltene Sitzung keine hinterlässt und wo die Logs liegen, und er gibt bessere Hinweise für Verbindungen übers Internet. CLAUDE.md beschreibt jetzt auch: - die Übernahme der Host-Welt durch Gäste, - den festen Anfang von Gruß und Ablehnung, - die Pausenregel und das Anhalten bei einem Kernfehler, - die erlaubten Profilnamen, - die gesperrte Brücken-DLL bei laufendem Spiel. play-zweites-fenster.bat gibt weitere Argumente an das Spiel weiter. Co-Authored-By: Claude Haiku 4.5 --- CLAUDE.md | 20 ++++++++++++-------- docs/playtests/m1c.md | 23 +++++++++++++---------- play-zweites-fenster.bat | 3 ++- 3 files changed, 27 insertions(+), 19 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 6f8014f..b53a896 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -25,7 +25,7 @@ Alle Spieler simulieren die Welt selbst und tauschen nur Befehle aus (Gleichschr - 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. +- Die Welt ändert sich nur durch einen `Command` in `World::step`. Ausnahmen sind das Laden eines Speicherstands mit `World::load`, das alle Spieler abmeldet, und die Übernahme der Welt des Hosts mit `World::from_snapshot`, die ein Gast beim Beitritt und nach einer Abweichung macht. - 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`. @@ -36,8 +36,8 @@ Drei Wächter prüfen das. `core/clippy.toml` sperrt Pfade wie `std::collections ## 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`. +- 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 die Hülle speichert die Aufzeichnung der abgewichenen Welt. +- Die Nachrichten stehen in `core/src/protocol.rs`. Wer eine Nachricht ändert, erhöht `PROTOCOL_VERSION`. Der Anfang des Grußes und die ganze Ablehnung ändern sich dabei nie, damit Spiele jeder Version einander noch sagen können, dass sie nicht zusammenpassen; `core/tests/golden.rs` hält beides fest. - 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. @@ -50,8 +50,10 @@ Drei Wächter prüfen das. `core/clippy.toml` sperrt Pfade wie `std::collections - 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. +- 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. `NetLink.close_peer` schließt eine Verbindung mit `peer_disconnect_later`, damit wartende Nachrichten wie eine Ablehnung noch ankommen. +- Nur der Host speichert. Laden mit F9 geht nur ohne geöffnete Welt („Allein spielen“). +- Eine Pause beginnt nur ohne Gäste und lässt sich immer beenden. Nimmt der Host einen Gast an, endet sie; eine bloße Verbindung ohne Gruß beendet sie nicht. +- Hält der Kern mit einem internen Fehler an, schließt ein Host seine Welt für Gäste, und ein Gast kehrt ins Menü zurück. Eine angehaltene Sitzung hinterlässt keine Aufzeichnung. - 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 @@ -71,7 +73,7 @@ Der erste Import in einem frischen Checkout stürzt mit Godot 4.7.2 und der Rust 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. +Schlägt `core/tests/golden.rs` fehl, haben sich Regeln oder ein Format geändert. Sind Speicherstände, Aufzeichnungen oder Nachrichten betroffen, erhöhst du die Versionskonstante, die die Fehlermeldung nennt (`SNAPSHOT_VERSION`, `RECORDING_VERSION` oder `PROTOCOL_VERSION`). 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: @@ -83,17 +85,19 @@ godot --path game -s res://tools/screenshot.gd -- `play.bat` im Hauptordner baut die Brücke und startet das Spiel. Spielstand, Spielerschlüssel und Aufzeichnungen liegen unter `%APPDATA%\Godot\app_userdata\Grovewright`. +Ein laufendes Spiel hält `target\debug\grovewright_bridge.dll` offen. Ändern sich danach Brücke oder Kern, scheitert `cargo build` mit „Zugriff verweigert (os error 5)“, bis alle Spielfenster geschlossen sind. + 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. | +| `--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. Erlaubt sind bis zu 32 Zeichen aus A bis Z, a bis z, Ziffern, `-` und `_`; einen anderen Namen übergeht das Spiel mit einer Warnung im Log und nimmt das Standardprofil. | | `--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`. +`play-zweites-fenster.bat` startet ein zweites Fenster mit dem Profil `zweites-fenster`. Weitere Argumente gibt es ebenfalls an das Spiel weiter, etwa `--name=Zweiter`. ## Sprache diff --git a/docs/playtests/m1c.md b/docs/playtests/m1c.md index 333a128..8b20df4 100644 --- a/docs/playtests/m1c.md +++ b/docs/playtests/m1c.md @@ -6,10 +6,13 @@ Mit M1c teilen sich bis zu vier Spieler eine Welt. Jeder Rechner rechnet die gan 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. +3. **Als Host:** „Welt öffnen“ setzt deinen Stand fort und lässt Gäste zu. Oben rechts steht dann die Statuszeile mit dem Port, etwa „Welt offen auf Port 24680 · Spieler: 1“. 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“. Alle brauchen denselben Stand des Spiels, sonst lehnt der Host den Gast ab. + - Im selben Heimnetz ist die Adresse die IPv4-Adresse des Host-Rechners, etwa `192.168.178.20`; der Host findet sie mit dem Befehl `ipconfig`. + - Übers Internet geht es am einfachsten mit einem virtuellen Netz wie Tailscale oder ZeroTier; dann gilt die Adresse des Hosts aus diesem Netz. Windows stuft solche Netze oft als öffentlich ein. Kommt keine Verbindung zustande, erlaube Godot in der Windows-Firewall auch für öffentliche Netzwerke. + - Ohne virtuelles Netz braucht der Host eine Portfreigabe für UDP-Port 24680 in seinem Router, und der Gast trägt die öffentliche IP-Adresse des Hosts ein. Bei Anschlüssen ohne eigene öffentliche IPv4-Adresse, etwa mit DS-Lite, geht das nicht. +5. **Zwei Fenster auf einem Rechner:** Starte mit `play.bat` und klick auf „Welt öffnen“. Dann Doppelklick auf `play-zweites-fenster.bat`, einen anderen Namen eintragen, Adresse `127.0.0.1`, „Beitreten“. Das zweite Fenster hat ein eigenes Profil und ist deshalb ein zweiter Spieler. Holst du zwischendurch eine neue Version des Spiels, schließ vorher alle Fenster, sonst scheitert der Start mit „Zugriff verweigert“. +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; kommt ein Gast, endet die Pause. F9 lädt nur nach „Allein spielen“. ## Fragen @@ -17,12 +20,12 @@ Mit M1c teilen sich bis zu vier Spieler eine Welt. Jeder Rechner rechnet die gan 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. +5. Erschien bei einem Gast irgendwann die Meldung „Welt mit dem Host abgeglichen“? Nur Gäste sehen sie. 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. +7. Ist dir etwas Merkwürdiges passiert? Dann schick mir die neuesten Dateien aus dem Ordner `recordings`; mit ihnen spiele ich die Sitzung exakt nach. + - Für ein Fenster aus `play.bat` liegt er unter `%APPDATA%\Godot\app_userdata\Grovewright\recordings`, für das zweite Fenster unter `%APPDATA%\Godot\app_userdata\Grovewright\profiles\zweites-fenster\recordings`. + - Eine Aufzeichnung entsteht erst, wenn du das Fenster schließt oder mit F9 einen Stand lädst, beim Gast auch, wenn seine Sitzung endet, etwa weil der Host die Welt schließt. Schließ das Fenster also, bevor du die Datei suchst. + - Nach der Meldung „Welt mit dem Host abgeglichen“ legt der Gast dort zusätzlich die Aufzeichnung der abgewichenen Welt ab. + - Nach einem Absturz oder nach der Meldung „Die Simulation wurde angehalten“ gibt es von dieser Sitzung keine Aufzeichnung. Schick mir dann den Ordner `%APPDATA%\Godot\app_userdata\Grovewright\logs`, und zwar sofort, bevor du das Spiel erneut startest, denn jeder Start verdrängt ältere Logdateien. Alle Fenster eines Rechners schreiben in diesen einen Ordner. Antworte formlos im Chat oder als Kommentar in Gitea. diff --git a/play-zweites-fenster.bat b/play-zweites-fenster.bat index 77a2c1d..577109d 100644 --- a/play-zweites-fenster.bat +++ b/play-zweites-fenster.bat @@ -1,4 +1,5 @@ @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 +rem Further arguments go to the game as well, for example --name=Zweiter. +call "%~dp0play.bat" --profile=zweites-fenster %* -- 2.52.0 From be22e9f358cc5340697ae694079de3d17194889f Mon Sep 17 00:00:00 2001 From: Steven Wroblewski Date: Fri, 2 Oct 2026 01:22:36 +0200 Subject: [PATCH 20/20] =?UTF-8?q?Abschlusskorrekturen=20M1c:=20Schl=C3=A4g?= =?UTF-8?q?e=20der=20G=C3=A4ste,=20Figuren=20und=20Fragebogen?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Der Host hält einen Schlag eines Gastes, der während der Abklingzeit ankommt, bis zum ersten Takt danach. Übers Internet ging sonst etwa jeder fünfte Schlag eines Gastes verloren. - ENet drosselt die Figuren nie mehr; sonst konnten die Figuren eines von zwei Gästen dauerhaft stehen bleiben. Figuren gehen geordnet, und eine stille Verbindung gilt nach höchstens acht Sekunden als verloren. - Positionen, die keine Zahlen sind, und Figuren von Spielern, die nicht online sind, werden verworfen. - Ein Host mit angehaltener Simulation sagt das in der Statuszeile. - Neue Tests: Schlag während der Abklingzeit, Zielauswahl von NetLink, verworfene Figuren, Neuabgleich über die Brücke. - Der Fragebogen sagt, was jeder Rechner braucht, und die Freigabe in der Firewall gehört zum Host. CLAUDE.md beschreibt das Halten der Schläge, die Drosselung und die Zeitüberschreitung und ergänzt die Vertrauensregel um die Schlüssel. Co-Authored-By: Claude Haiku 4.5 --- CLAUDE.md | 7 ++-- core/src/host.rs | 57 ++++++++++++++++++++++++++++++--- core/src/replay.rs | 6 ++-- core/tests/host_guest.rs | 57 ++++++++++++++++++++++++++++++++- docs/playtests/m1c.md | 16 +++++++-- game/scripts/launch_options.gd | 3 +- game/scripts/main.gd | 14 ++++---- game/scripts/net_link.gd | 18 +++++++++-- game/scripts/session_link.gd | 12 +++++-- game/tests/test_main.gd | 2 +- game/tests/test_net_link.gd | 55 +++++++++++++++++++++++++++++++ game/tests/test_session.gd | 21 ++++++++++++ game/tests/test_session_link.gd | 17 ++++++++++ 13 files changed, 258 insertions(+), 27 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index b53a896..b2c9b78 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -40,7 +40,8 @@ Drei Wächter prüfen das. `core/clippy.toml` sperrt Pfade wie `std::collections - Die Nachrichten stehen in `core/src/protocol.rs`. Wer eine Nachricht ändert, erhöht `PROTOCOL_VERSION`. Der Anfang des Grußes und die ganze Ablehnung ändern sich dabei nie, damit Spiele jeder Version einander noch sagen können, dass sie nicht zusammenpassen; `core/tests/golden.rs` hält beides fest. - 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. +- Ein Gast zählt die Abklingzeit der Spitzhacke in seinen eigenen Takten, die denen des Hosts um die Laufzeit nachlaufen. Kommt sein Schlag an, während die Abklingzeit seines Spielers noch läuft, hält ihn der Host bis zum ersten Takt danach; kommen mehrere, gilt der neueste. Sonst ginge übers Internet etwa jeder fünfte Schlag eines Gastes verloren. +- 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. Außerdem stehen die Schlüssel aller Spieler in jedem Snapshot, den ein Gast bekommt; ein veränderter Client könnte sich damit als ein Spieler ausgeben, der gerade nicht online ist. Für ein Koop-Spiel unter Freunden nehmen wir beides hin. ## Regeln für Brücke und Hülle @@ -50,7 +51,7 @@ Drei Wächter prüfen das. `core/clippy.toml` sperrt Pfade wie `std::collections - 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. `NetLink.close_peer` schließt eine Verbindung mit `peer_disconnect_later`, damit wartende Nachrichten wie eine Ablehnung noch ankommen. +- 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, aber geordnet über Kanal 1; ENet drosselt sie nie. Eine Verbindung, deren Pakete unbeantwortet bleiben, gilt nach höchstens `NetLink.TIMEOUT_MSEC` als verloren. `NetLink.close_peer` schließt eine Verbindung mit `peer_disconnect_later`, damit wartende Nachrichten wie eine Ablehnung noch ankommen. - Nur der Host speichert. Laden mit F9 geht nur ohne geöffnete Welt („Allein spielen“). - Eine Pause beginnt nur ohne Gäste und lässt sich immer beenden. Nimmt der Host einen Gast an, endet sie; eine bloße Verbindung ohne Gruß beendet sie nicht. - Hält der Kern mit einem internen Fehler an, schließt ein Host seine Welt für Gäste, und ein Gast kehrt ins Menü zurück. Eine angehaltene Sitzung hinterlässt keine Aufzeichnung. @@ -97,7 +98,7 @@ Argumente hinter `play.bat` gehen an das Spiel: | `--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`. Weitere Argumente gibt es ebenfalls an das Spiel weiter, etwa `--name=Zweiter`. +`play-zweites-fenster.bat` startet ein zweites Fenster mit dem Profil `zweites-fenster`. Die Datei reicht weitere Argumente ebenfalls an das Spiel weiter, etwa `--name=Zweiter`. ## Sprache diff --git a/core/src/host.rs b/core/src/host.rs index 6fbb1aa..9ac1e4b 100644 --- a/core/src/host.rs +++ b/core/src/host.rs @@ -39,7 +39,13 @@ enum Link { /// 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 }, + /// `held` is the newest action that arrived while the player's cooldown + /// still ran; it goes into the first tick after the cooldown. + Playing { + player: PlayerId, + actions: u32, + held: Option, + }, } /// The host of a shared world. @@ -139,16 +145,30 @@ impl Host { (Link::Greeting, Ok(ToHost::ForeignHello { .. })) => { self.refuse(peer, Refusal::OtherProtocol); } - (Link::Playing { player, actions }, Ok(ToHost::Act(action))) => { + ( + Link::Playing { + player, + actions, + held, + }, + Ok(ToHost::Act(action)), + ) => { if actions < MAX_ACTIONS_PER_TICK { + // A guest counts the cooldown in its own ticks, which + // lag the host's by the round trip. An action that + // arrives too early would be lost, so it waits. + let wait = held.is_some() || cooling_down(&self.world, player); + if !wait { + self.inbox.push(action.command(player)); + } self.links.insert( peer, Link::Playing { player, actions: actions + 1, + held: if wait { Some(action) } else { None }, }, ); - self.inbox.push(action.command(player)); } } // The guest acted before its join ran. It cannot know its player @@ -243,6 +263,7 @@ impl Host { /// Runs the next tick with every queued command, sends it to every /// accepted guest and returns what happened. pub fn step(&mut self) -> Vec { + self.release_held_actions(); let commands = std::mem::take(&mut self.inbox); let tick = self.world.tick(); let events = self.recording.step(&mut self.world, &commands); @@ -268,7 +289,13 @@ impl Host { 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 }, + Some(player) => { + *link = Link::Playing { + player, + actions: 0, + held: None, + } + } None => refused.push(*peer), }, Link::Playing { actions, .. } => *actions = 0, @@ -283,6 +310,20 @@ impl Host { events } + /// Queues the held actions whose player's cooldown is over. + fn release_held_actions(&mut self) { + for link in self.links.values_mut() { + let Link::Playing { player, held, .. } = link else { + continue; + }; + if held.is_some() && !cooling_down(&self.world, *player) { + if let Some(action) = held.take() { + self.inbox.push(action.command(*player)); + } + } + } + } + /// Number of accepted guests, including those whose join has not run. pub fn guest_count(&self) -> usize { self.links @@ -316,6 +357,14 @@ impl Host { } } +/// Whether `player` still waits for its cooldown in the next tick of +/// `world`, so that an action then would be dropped. +fn cooling_down(world: &World, player: PlayerId) -> bool { + world + .player(player) + .is_some_and(|state| world.tick() < state.next_action_tick) +} + /// The player with `key`, if it is online in `world`. fn online_player(world: &World, key: &PlayerKey) -> Option { let id = world.find_player(key)?; diff --git a/core/src/replay.rs b/core/src/replay.rs index 91569bd..6f3ca53 100644 --- a/core/src/replay.rs +++ b/core/src/replay.rs @@ -14,8 +14,10 @@ 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. +/// Version of the recording format. Raise it whenever the layout of a +/// recording changes, including the snapshot inside it. Recordings made with +/// other rules are told apart by the rules id that follows this number, not +/// by the number itself. 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 diff --git a/core/tests/host_guest.rs b/core/tests/host_guest.rs index 64c8e47..13300c7 100644 --- a/core/tests/host_guest.rs +++ b/core/tests/host_guest.rs @@ -10,7 +10,7 @@ use grovewright_core::map::Pos; use grovewright_core::mapgen::outcrop_origin; use grovewright_core::player::{PlayerId, PlayerKey}; use grovewright_core::protocol::{Action, Refusal, ToGuest, ToHost, CHECK_INTERVAL_TICKS}; -use grovewright_core::world::{Event, World}; +use grovewright_core::world::{Event, World, DIG_COOLDOWN_TICKS}; const HOST_KEY: PlayerKey = [100; 16]; @@ -294,3 +294,58 @@ fn a_missing_tick_makes_the_guest_ask_for_the_world() { Some(host.world().state_hash()) ); } + +/// Hands a pickaxe hit of `guest` at `pos` to the host as from `peer`. +fn dig(host: &mut Host, peer: Peer, guest: &mut Guest, pos: Pos) { + guest.act(Action::Dig { pos }); + for bytes in guest.take_outbox() { + host.receive(peer, &bytes); + } +} + +/// The tiles that pickaxe hits landed on, without breaking them. +fn hits(events: &[Event]) -> Vec { + events + .iter() + .filter_map(|event| match event { + Event::TileHit { pos, .. } => Some(*pos), + _ => None, + }) + .collect() +} + +#[test] +fn a_guests_hit_that_comes_during_the_cooldown_waits_for_it() { + let (mut host, mut ada) = host_with_ada(); + let rock = outcrop_origin(host.world().map()); + let next = Pos::new(rock.x + 1, rock.y); + dig(&mut host, 2, &mut ada, rock); + let first = host.world().tick(); + assert_eq!(hits(&host.step()), [rock], "the first hit lands at once"); + + // Two more hits come during the cooldown, as if the round trip had + // become shorter. The newer one waits for the end of the cooldown. + dig(&mut host, 2, &mut ada, rock); + dig(&mut host, 2, &mut ada, next); + let mut landed = Vec::new(); + for _ in 0..DIG_COOLDOWN_TICKS { + let tick = host.world().tick(); + for pos in hits(&host.step()) { + landed.push((tick, pos)); + } + } + assert_eq!(landed, [(first + DIG_COOLDOWN_TICKS, next)]); +} + +#[test] +fn a_waiting_hit_is_dropped_when_its_guest_leaves() { + let (mut host, mut ada) = host_with_ada(); + let rock = outcrop_origin(host.world().map()); + dig(&mut host, 2, &mut ada, rock); + host.step(); + dig(&mut host, 2, &mut ada, rock); + host.disconnect(2); + for _ in 0..DIG_COOLDOWN_TICKS { + assert_eq!(hits(&host.step()), [], "nobody hits"); + } +} diff --git a/docs/playtests/m1c.md b/docs/playtests/m1c.md index 8b20df4..f285025 100644 --- a/docs/playtests/m1c.md +++ b/docs/playtests/m1c.md @@ -2,14 +2,24 @@ 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. +## Was jeder Rechner braucht + +Eine fertig gepackte Fassung des Spiels gibt es noch nicht. Jeder Rechner, der mitspielt, braucht deshalb dasselbe wie deiner: + +- Git für Windows und eine Kopie des Archivs auf demselben Stand wie beim Host. Unterscheiden sich die Stände in Regeln oder Nachrichten, lehnt der Host den Gast ab. +- Rust über rustup (https://rustup.rs). rustup bietet an, die Microsoft C++ Build Tools mitzuinstallieren; die braucht es. +- Godot 4.7.2, etwa mit `winget install GodotEngine.GodotEngine --version 4.7.2`, sodass der Befehl `godot` in einer Eingabeaufforderung funktioniert. + +Der erste Start mit `play.bat` baut die Brücke zwischen Spiel und Kern und dauert einige Minuten. Danach geht es schnell. + ## 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 rechts steht dann die Statuszeile mit dem Port, etwa „Welt offen auf Port 24680 · Spieler: 1“. 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“. Alle brauchen denselben Stand des Spiels, sonst lehnt der Host den Gast ab. +3. **Als Host:** „Welt öffnen“ setzt deinen Stand fort und lässt Gäste zu. Oben rechts steht dann die Statuszeile mit dem Port, etwa „Welt offen auf Port 24680 · Spieler: 1“. Beim ersten Mal fragt Windows, ob Godot im Netzwerk erreichbar sein darf. Erlaube es für private und für öffentliche Netzwerke, denn Windows stuft viele Heimnetze und virtuelle Netze als öffentlich ein. Nur der Host braucht diese Freigabe. +4. **Als Gast:** Trag die Adresse des Hosts ein und klick auf „Beitreten“. - Im selben Heimnetz ist die Adresse die IPv4-Adresse des Host-Rechners, etwa `192.168.178.20`; der Host findet sie mit dem Befehl `ipconfig`. - - Übers Internet geht es am einfachsten mit einem virtuellen Netz wie Tailscale oder ZeroTier; dann gilt die Adresse des Hosts aus diesem Netz. Windows stuft solche Netze oft als öffentlich ein. Kommt keine Verbindung zustande, erlaube Godot in der Windows-Firewall auch für öffentliche Netzwerke. + - Übers Internet geht es am einfachsten mit einem virtuellen Netz wie Tailscale oder ZeroTier; dann gilt die Adresse des Hosts aus diesem Netz. - Ohne virtuelles Netz braucht der Host eine Portfreigabe für UDP-Port 24680 in seinem Router, und der Gast trägt die öffentliche IP-Adresse des Hosts ein. Bei Anschlüssen ohne eigene öffentliche IPv4-Adresse, etwa mit DS-Lite, geht das nicht. 5. **Zwei Fenster auf einem Rechner:** Starte mit `play.bat` und klick auf „Welt öffnen“. Dann Doppelklick auf `play-zweites-fenster.bat`, einen anderen Namen eintragen, Adresse `127.0.0.1`, „Beitreten“. Das zweite Fenster hat ein eigenes Profil und ist deshalb ein zweiter Spieler. Holst du zwischendurch eine neue Version des Spiels, schließ vorher alle Fenster, sonst scheitert der Start mit „Zugriff verweigert“. 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; kommt ein Gast, endet die Pause. F9 lädt nur nach „Allein spielen“. diff --git a/game/scripts/launch_options.gd b/game/scripts/launch_options.gd index 8fe9470..8a469c6 100644 --- a/game/scripts/launch_options.gd +++ b/game/scripts/launch_options.gd @@ -3,7 +3,8 @@ 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 +## --name=NAME the player's name; like a name typed in the menu, it is +## remembered for the next 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 diff --git a/game/scripts/main.gd b/game/scripts/main.gd index b945d18..d8c8b85 100644 --- a/game/scripts/main.gd +++ b/game/scripts/main.gd @@ -237,15 +237,17 @@ func _leave(message: String) -> void: ## A broken session runs no more ticks. Instead of leaving the others -## waiting, a host closes its world to guests and a guest leaves. +## waiting, a host closes its world to guests and a guest leaves. The host's +## status line says why its world stands still. func _stop_sharing() -> void: if not session.is_host(): _leave(tr("SIMULATION_STOPPED")) return - link.link.close() - link = null - figures.clear() - _update_status() + if link != null: + link.link.close() + link = null + figures.clear() + hud.show_status(tr("SIMULATION_STOPPED")) ## Ends a pause once the host accepted a guest: the guest joins in the next @@ -262,7 +264,7 @@ func _process(delta: float) -> void: if link != null: for notice in link.pump(delta): _on_notice(notice) - if session != null and session.is_broken() and link != null: + if session != null and session.is_broken(): _stop_sharing() if session == null or not session.is_running() or session.is_broken(): return diff --git a/game/scripts/net_link.gd b/game/scripts/net_link.gd index 65aad40..193a299 100644 --- a/game/scripts/net_link.gd +++ b/game/scripts/net_link.gd @@ -2,8 +2,9 @@ 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. +## ordered channel. Positions of figures travel on a second channel that is +## unreliable but ordered: a lost position is soon replaced by the next, and +## an older one that arrives after a newer one is dropped. ## Port a host listens on unless told otherwise. const DEFAULT_PORT := 24680 @@ -14,6 +15,10 @@ 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 +## Milliseconds after which a connection whose packets stay unanswered counts +## as lost. ENet's own limit of 30 seconds would leave a guest whose host +## vanished in a standing world for that long. +const TIMEOUT_MSEC := 8000 ## Seconds this guest waits for its host. Tests shorten it. var connect_seconds := CONNECT_SECONDS @@ -77,6 +82,13 @@ func _start() -> void: func _on_connected(peer: int) -> void: _peers[peer] = true + var connection := _peer.get_peer(peer) + # ENet slows unreliable packets down after a spike in the round trip and + # can keep them at nothing for the rest of a session, so that the figures + # of one guest stop. Positions are a few bytes ten times a second; they + # are never slowed down. + connection.throttle_configure(5000, ENetPacketPeer.PACKET_THROTTLE_SCALE, 0) + connection.set_timeout(32, 5000, TIMEOUT_MSEC) _events.append({"type": "connected", "peer": peer}) @@ -131,7 +143,7 @@ func send(peer: int, bytes: PackedByteArray) -> void: ## 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) + _put(peer, bytes, CHANNEL_FIGURES, MultiplayerPeer.TRANSFER_MODE_UNRELIABLE_ORDERED) func _put(peer: int, bytes: PackedByteArray, channel: int, mode: MultiplayerPeer.TransferMode) -> void: diff --git a/game/scripts/session_link.gd b/game/scripts/session_link.gd index 474d854..6e5de34 100644 --- a/game/scripts/session_link.gd +++ b/game/scripts/session_link.gd @@ -22,7 +22,8 @@ func _init(game_session: GwSession, net_link: NetLink) -> void: ## - "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 +## - "figure" (entries `player`, `position`), everywhere, for another player +## who is online, with a position of real numbers ## `delta` is the time since the last call. func pump(delta: float) -> Array[Dictionary]: var notices: Array[Dictionary] = [] @@ -80,12 +81,17 @@ func _receive_figure(peer: int, bytes: PackedByteArray, notices: Array[Dictionar if player < 0 or bytes.size() != POSITION_BYTES: return var position := Vector2(bytes.decode_float(0), bytes.decode_float(4)) + # Not a number or infinite, as a broken or changed client may send it: + # such a position would spoil the figure for good. + if not position.is_finite(): + return 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)) + var position := Vector2(bytes.decode_float(1), bytes.decode_float(5)) + # A position can arrive after its player left the world. + if player != session.local_player() and session.online_players().has(player) and position.is_finite(): notices.append({"type": "figure", "player": player, "position": position}) diff --git a/game/tests/test_main.gd b/game/tests/test_main.gd index d40fae0..58653f6 100644 --- a/game/tests/test_main.gd +++ b/game/tests/test_main.gd @@ -333,7 +333,7 @@ func test_a_host_that_stops_sharing_lets_its_guests_go() -> void: # session, _process calls what this test calls. host._stop_sharing() check(host.link == null, "the host no longer listens") - check_eq(host.hud.status_text(), "", "and no longer says that it does") + check_eq(host.hud.status_text(), tr("SIMULATION_STOPPED"), "its status line says why") check(await _wait_until(func() -> bool: return guest.session == null), "the guest's session ends") check_eq(guest.menu.status(), tr("HOST_LEFT"), "and the guest learns why") _clean_up([host_dir, guest_dir]) diff --git a/game/tests/test_net_link.gd b/game/tests/test_net_link.gd index 68daffa..8c328d1 100644 --- a/game/tests/test_net_link.gd +++ b/game/tests/test_net_link.gd @@ -26,6 +26,13 @@ func _first(link: NetLink, type: String) -> Dictionary: return {} +## The bytes of every event of `type` that `link` got, each as an Array. +func _received(link: NetLink, type: String) -> Array: + return _events.get(link, []).filter(func(event: Dictionary) -> bool: return event["type"] == type).map( + func(event: Dictionary) -> Array: return Array(event["bytes"]) + ) + + ## A host and a guest that have connected. func _connected_pair() -> Array[NetLink]: var host := NetLink.new() @@ -139,6 +146,54 @@ func test_nothing_is_sent_to_connections_that_are_gone_or_closing() -> void: link.close() +## A packet goes to the connection it is addressed to, to every connection +## for peer 0, and to every connection but one for a negative peer. +func test_packets_go_only_where_they_are_addressed() -> void: + var host := NetLink.new() + check_eq(host.host(0, "127.0.0.1"), OK, "the host listens") + var guests: Array[NetLink] = [] + var everyone: Array[NetLink] = [host] + for i in 3: + var guest := NetLink.new() + guest.join("127.0.0.1", host.local_port()) + guests.append(guest) + everyone.append(guest) + await _pump(everyone, 15) + # Each guest says who it is, so that the host's id for it is known. + for i in 3: + guests[i].send(GwSession.HOST_PEER, PackedByteArray([i])) + await _pump(everyone, 10) + var peer_of: Array[int] = [0, 0, 0] + for event: Dictionary in _events[host]: + if event["type"] == "message": + peer_of[event["bytes"][0]] = event["peer"] + check(not peer_of.has(0), "the host knows all three guests") + _events.clear() + + # Repeated, because a position that gets lost is no failure of the link. + for repeat in 5: + host.send(peer_of[0], PackedByteArray([10])) + host.send_figure(peer_of[2], PackedByteArray([20])) + host.send_figure(-peer_of[1], PackedByteArray([30])) + host.send_figure(0, PackedByteArray([40])) + await _pump(everyone, 2) + await _pump(everyone, 10) + + check_eq(_received(guests[0], "message").size(), 5, "the first guest got the messages to it") + check_eq(_received(guests[1], "message"), [], "the second guest got none") + check_eq(_received(guests[2], "message"), [], "the third guest got none") + var first := _received(guests[0], "figure") + var second := _received(guests[1], "figure") + var third := _received(guests[2], "figure") + check(not first.has([20]), "the first guest got nothing for the third") + check(first.has([30]) and first.has([40]), "the first guest got 'all but the second' and 'all'") + check(not second.has([20]) and not second.has([30]), "the second guest got neither the one for the third nor 'all but itself'") + check(second.has([40]), "the second guest got 'all'") + check(third.has([20]) and third.has([30]) and third.has([40]), "the third guest got all three kinds") + for link in everyone: + link.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() diff --git a/game/tests/test_session.gd b/game/tests/test_session.gd index c727b23..d9b8bb4 100644 --- a/game/tests/test_session.gd +++ b/game/tests/test_session.gd @@ -219,6 +219,27 @@ func test_the_hit_of_a_guest_becomes_a_message_to_the_host() -> void: check_eq(host.damage_codes()[rock.y * host.map_width() + rock.x], 1, "and so does the host") +func test_a_guest_that_missed_a_tick_gets_the_world_again() -> void: + var pair := _host_and_guest() + var host := pair[0] + var guest := pair[1] + host.step() + # The message with this tick gets lost on the way. + host.take_messages() + host.step() + _carry(host, guest) + for i in guest.pending_ticks(): + guest.step() + check_eq(_carry(host, guest), [{"type": "world_received", "resync": true}], "the guest gets the host's world again") + check_eq(host.take_divergences().size(), 1, "the host hears of it") + check(guest.take_diverged_recording().size() > 0, "the guest keeps the recording of the world that went astray") + host.step() + _carry(host, guest) + for i in guest.pending_ticks(): + guest.step() + check_eq(guest.state_hash_hex(), host.state_hash_hex(), "both worlds are the same again") + + func test_a_guest_learns_why_it_is_turned_away() -> void: var host := _joined_session() host.connect_peer(5) diff --git a/game/tests/test_session_link.gd b/game/tests/test_session_link.gd index 90ca2d9..6e8ddad 100644 --- a/game/tests/test_session_link.gd +++ b/game/tests/test_session_link.gd @@ -109,6 +109,23 @@ func test_figures_travel_to_the_other_machines() -> void: _close([host, guest, other]) +func test_figures_that_cannot_be_shown_are_dropped() -> 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) + # A position that is no number, as a broken or changed client may send. + guest.link.send_figure(GwSession.HOST_PEER, guest._position_bytes(Vector2(NAN, 1.0))) + # A position of a player who is not online, as it can arrive late. + host.link.send_figure(0, host._figure_bytes(5, Vector2(1.0, 2.0))) + await _play([guest, other], 10) + + check(not _types(host).has("figure"), "the host drops the position that is no number") + check(not _types(other).has("figure"), "passes nothing on, and nobody shows the player who is not online") + check(not _types(guest).has("figure"), "not even the guest that sent it") + _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") -- 2.52.0