M1c: Gleichschritt im Netz (#9)
Koop für bis zu vier Spieler im Gleichschritt: Der Host bestimmt jeden Takt, Gäste rechnen ihn nach, Prüfsummen decken Abweichungen auf, und ein abgewichener Gast bekommt die Welt neu. Dazu Verbindung über ENet, Startmenü, Profile, die Figuren der anderen und eine Statuszeile. 151 Rust-Tests in Debug und Release, 96 GDScript-Tests, ein Test mit zwei Godot-Prozessen; jede Task und der ganze Branch geprüft. Closes #7 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit was merged in pull request #9.
This commit is contained in:
@@ -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`.
|
||||
@@ -33,6 +33,16 @@ 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 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.
|
||||
- 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
|
||||
|
||||
- Die Brücke übersetzt nur. Neue Spielregeln gehören in den Kern, nie in `bridge/` oder `game/`.
|
||||
@@ -41,6 +51,11 @@ 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, 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.
|
||||
- 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,7 +72,9 @@ 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.
|
||||
|
||||
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.
|
||||
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, 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:
|
||||
|
||||
@@ -69,6 +86,20 @@ godot --path game -s res://tools/screenshot.gd -- <Pfad der PNG-Datei>
|
||||
|
||||
`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. 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`. Die Datei reicht weitere Argumente ebenfalls an das Spiel weiter, etwa `--name=Zweiter`.
|
||||
|
||||
## Sprache
|
||||
|
||||
- Code, Bezeichner und Kommentare im Code: Englisch.
|
||||
|
||||
@@ -0,0 +1,269 @@
|
||||
//! The running session behind `GwSession`, without Godot types, so that its
|
||||
//! handling of internal errors can be tested in plain Rust.
|
||||
|
||||
use std::panic::{catch_unwind, AssertUnwindSafe};
|
||||
|
||||
use grovewright_core::guest::{Arrival, Guest, GuestError};
|
||||
use grovewright_core::host::{Divergence, Host, Peer};
|
||||
use grovewright_core::player::{PlayerId, PlayerKey};
|
||||
use grovewright_core::protocol::Action;
|
||||
use grovewright_core::world::{Event, World};
|
||||
|
||||
/// The connection number under which a guest reaches its host. The shell's
|
||||
/// network layer gives the host this number.
|
||||
pub const HOST_PEER: Peer = 1;
|
||||
|
||||
/// The machine's part in the session.
|
||||
pub enum Role {
|
||||
Host(Host),
|
||||
Guest(Guest),
|
||||
}
|
||||
|
||||
/// A session in either role. After an internal error of the core it is
|
||||
/// broken: the world may be half-updated, so nothing runs, saves or records
|
||||
/// any more.
|
||||
pub struct Driver {
|
||||
role: Role,
|
||||
broken: bool,
|
||||
}
|
||||
|
||||
impl Driver {
|
||||
/// Hosts `world`, new or loaded.
|
||||
pub fn host(world: World) -> Self {
|
||||
Self {
|
||||
role: Role::Host(Host::new(world)),
|
||||
broken: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Joins a host as the player `key` named `name`. The greeting waits in
|
||||
/// the outbox.
|
||||
pub fn guest(key: PlayerKey, name: &str) -> Self {
|
||||
Self {
|
||||
role: Role::Guest(Guest::new(key, name)),
|
||||
broken: false,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_host(&self) -> bool {
|
||||
matches!(self.role, Role::Host(_))
|
||||
}
|
||||
|
||||
pub fn is_broken(&self) -> bool {
|
||||
self.broken
|
||||
}
|
||||
|
||||
/// The world, once there is one. A guest has it after the host sent it.
|
||||
pub fn world(&self) -> Option<&World> {
|
||||
match &self.role {
|
||||
Role::Host(host) => Some(host.world()),
|
||||
Role::Guest(guest) => guest.world(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The player of this machine, once it is in the world.
|
||||
pub fn local_player(&self) -> Option<PlayerId> {
|
||||
match &self.role {
|
||||
Role::Host(host) => host.local_player(),
|
||||
Role::Guest(guest) => guest.local_player(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Runs `f` on the role unless the session is broken. A panic inside
|
||||
/// marks the session as broken and gives `None`.
|
||||
pub fn guarded<R>(&mut self, f: impl FnOnce(&mut Role) -> R) -> Option<R> {
|
||||
if self.broken {
|
||||
return None;
|
||||
}
|
||||
let role = &mut self.role;
|
||||
match catch_unwind(AssertUnwindSafe(|| f(role))) {
|
||||
Ok(result) => Some(result),
|
||||
Err(_) => {
|
||||
self.broken = true;
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Queues the entry of the host's own player. Returns false for a guest,
|
||||
/// which enters through its greeting.
|
||||
pub fn join_local(&mut self, key: PlayerKey, name: &str) -> bool {
|
||||
self.guarded(|role| match role {
|
||||
Role::Host(host) => {
|
||||
host.join_local(key, name);
|
||||
true
|
||||
}
|
||||
Role::Guest(_) => false,
|
||||
})
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Carries out an action of this machine's player: the host queues it
|
||||
/// for the next tick, a guest sends it to the host.
|
||||
pub fn act(&mut self, action: Action) {
|
||||
self.guarded(|role| match role {
|
||||
Role::Host(host) => host.act_local(action),
|
||||
Role::Guest(guest) => guest.act(action),
|
||||
});
|
||||
}
|
||||
|
||||
/// Runs one tick: the host its next tick, a guest the oldest tick that
|
||||
/// arrived. `None` if a guest has no tick to run or the session broke.
|
||||
pub fn step(&mut self) -> Option<Vec<Event>> {
|
||||
self.guarded(|role| match role {
|
||||
Role::Host(host) => Some(host.step()),
|
||||
Role::Guest(guest) => guest.step(),
|
||||
})
|
||||
.flatten()
|
||||
}
|
||||
|
||||
/// Ticks that arrived at a guest and have not run yet.
|
||||
pub fn pending_ticks(&self) -> usize {
|
||||
match &self.role {
|
||||
Role::Host(_) => 0,
|
||||
Role::Guest(guest) => guest.pending_ticks(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Hands a message to the session. A host learns from `peer` who sent
|
||||
/// it; a guest only hears from its host.
|
||||
pub fn receive(&mut self, peer: Peer, bytes: &[u8]) -> Result<Arrival, GuestError> {
|
||||
self.guarded(|role| match role {
|
||||
Role::Host(host) => {
|
||||
host.receive(peer, bytes);
|
||||
Ok(Arrival::Nothing)
|
||||
}
|
||||
Role::Guest(guest) => guest.receive(bytes),
|
||||
})
|
||||
.unwrap_or(Ok(Arrival::Nothing))
|
||||
}
|
||||
|
||||
/// A guest connected to the host as `peer`.
|
||||
pub fn connect(&mut self, peer: Peer) {
|
||||
if let Role::Host(host) = &mut self.role {
|
||||
host.connect(peer);
|
||||
}
|
||||
}
|
||||
|
||||
/// The connection `peer` of the host closed.
|
||||
pub fn disconnect(&mut self, peer: Peer) {
|
||||
if let Role::Host(host) = &mut self.role {
|
||||
host.disconnect(peer);
|
||||
}
|
||||
}
|
||||
|
||||
/// Messages to send, as (peer, bytes). A guest's messages go to
|
||||
/// [`HOST_PEER`].
|
||||
pub fn take_messages(&mut self) -> Vec<(Peer, Vec<u8>)> {
|
||||
match &mut self.role {
|
||||
Role::Host(host) => host.take_outbox(),
|
||||
Role::Guest(guest) => guest
|
||||
.take_outbox()
|
||||
.into_iter()
|
||||
.map(|bytes| (HOST_PEER, bytes))
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Connections the host has given up.
|
||||
pub fn take_closing(&mut self) -> Vec<Peer> {
|
||||
match &mut self.role {
|
||||
Role::Host(host) => host.take_closing(),
|
||||
Role::Guest(_) => Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Divergences the host's guests reported.
|
||||
pub fn take_divergences(&mut self) -> Vec<Divergence> {
|
||||
match &mut self.role {
|
||||
Role::Host(host) => host.take_divergences(),
|
||||
Role::Guest(_) => Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The encoded recording of a guest's world that diverged.
|
||||
pub fn take_diverged_recording(&mut self) -> Option<Vec<u8>> {
|
||||
match &mut self.role {
|
||||
Role::Host(_) => None,
|
||||
Role::Guest(guest) => guest.take_diverged_recording(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Number of the host's accepted guests.
|
||||
pub fn guest_count(&self) -> usize {
|
||||
match &self.role {
|
||||
Role::Host(host) => host.guest_count(),
|
||||
Role::Guest(_) => 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// The player of the host's guest at `peer`.
|
||||
pub fn player_of(&self, peer: Peer) -> Option<PlayerId> {
|
||||
match &self.role {
|
||||
Role::Host(host) => host.player_of(peer),
|
||||
Role::Guest(_) => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The world as a save. Only the host saves: the world belongs to the
|
||||
/// host, and a guest must never overwrite its own save with it.
|
||||
pub fn save(&self) -> Option<Vec<u8>> {
|
||||
match &self.role {
|
||||
Role::Host(host) if !self.broken => Some(host.world().to_snapshot()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The recording of this machine's session.
|
||||
pub fn recording_bytes(&self) -> Option<Vec<u8>> {
|
||||
if self.broken {
|
||||
return None;
|
||||
}
|
||||
match &self.role {
|
||||
Role::Host(host) => Some(host.recording().to_bytes(host.world())),
|
||||
Role::Guest(guest) => Some(guest.recording()?.to_bytes(guest.world()?)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[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() {
|
||||
// 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") });
|
||||
|
||||
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");
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
+300
-127
@@ -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<Command>,
|
||||
local_key: Option<PlayerKey>,
|
||||
local: Option<PlayerId>,
|
||||
/// 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<Game>,
|
||||
driver: Option<Driver>,
|
||||
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<VarDictionary> {
|
||||
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<VarDictionary> {
|
||||
let mut notices = Array::new();
|
||||
let (Some(driver), Some(peer)) = (self.driver.as_mut(), to_peer(peer)) else {
|
||||
return notices;
|
||||
};
|
||||
match driver.receive(peer, bytes.as_slice()) {
|
||||
Ok(Arrival::Nothing) => {}
|
||||
Ok(Arrival::World { replaced }) => notices.push(&vdict! {
|
||||
"type" => "world_received",
|
||||
"resync" => replaced,
|
||||
}),
|
||||
Ok(Arrival::Refused(refusal)) => notices.push(&vdict! {
|
||||
"type" => "refused",
|
||||
"reason" => i64::from(refusal.code()),
|
||||
}),
|
||||
Err(error) => {
|
||||
self.fail(&error.to_string());
|
||||
notices.push(&vdict! { "type" => "protocol_error" });
|
||||
}
|
||||
}
|
||||
if self.is_broken() {
|
||||
self.fail("the simulation stopped after an internal error");
|
||||
}
|
||||
notices
|
||||
}
|
||||
|
||||
/// The host learned that a guest connected as `peer`.
|
||||
#[func]
|
||||
fn connect_peer(&mut self, peer: i64) {
|
||||
if let (Some(driver), Some(peer)) = (self.driver.as_mut(), to_peer(peer)) {
|
||||
driver.connect(peer);
|
||||
}
|
||||
}
|
||||
|
||||
/// The host learned that the connection `peer` closed.
|
||||
#[func]
|
||||
fn disconnect_peer(&mut self, peer: i64) {
|
||||
if let (Some(driver), Some(peer)) = (self.driver.as_mut(), to_peer(peer)) {
|
||||
driver.disconnect(peer);
|
||||
}
|
||||
}
|
||||
|
||||
/// Messages to send, oldest first, as dictionaries with the entries
|
||||
/// `peer` and `bytes`. They must arrive reliably and in this order.
|
||||
#[func]
|
||||
fn take_messages(&mut self) -> Array<VarDictionary> {
|
||||
let mut messages = Array::new();
|
||||
if let Some(driver) = self.driver.as_mut() {
|
||||
for (peer, bytes) in driver.take_messages() {
|
||||
messages.push(&vdict! {
|
||||
"peer" => i64::from(peer),
|
||||
"bytes" => &PackedByteArray::from(bytes.as_slice()),
|
||||
});
|
||||
}
|
||||
}
|
||||
messages
|
||||
}
|
||||
|
||||
/// Connections the host has given up. Close them after sending the
|
||||
/// messages that are waiting for them.
|
||||
#[func]
|
||||
fn take_closing(&mut self) -> PackedInt64Array {
|
||||
let peers: Vec<i64> = self
|
||||
.driver
|
||||
.as_mut()
|
||||
.map(|driver| driver.take_closing().into_iter().map(i64::from).collect())
|
||||
.unwrap_or_default();
|
||||
PackedInt64Array::from(peers.as_slice())
|
||||
}
|
||||
|
||||
/// Divergences the host's guests reported, as dictionaries with the
|
||||
/// entries `peer`, `tick` and `hash` (16 hexadecimal digits).
|
||||
#[func]
|
||||
fn take_divergences(&mut self) -> Array<VarDictionary> {
|
||||
let mut reports = Array::new();
|
||||
if let Some(driver) = self.driver.as_mut() {
|
||||
for divergence in driver.take_divergences() {
|
||||
reports.push(&vdict! {
|
||||
"peer" => i64::from(divergence.peer),
|
||||
"tick" => divergence.tick as i64,
|
||||
"hash" => format!("{:016x}", divergence.hash),
|
||||
});
|
||||
}
|
||||
}
|
||||
reports
|
||||
}
|
||||
|
||||
/// The recording of a guest's world that diverged from the host's, or
|
||||
/// empty. Taking it empties it.
|
||||
#[func]
|
||||
fn take_diverged_recording(&mut self) -> PackedByteArray {
|
||||
self.driver
|
||||
.as_mut()
|
||||
.and_then(Driver::take_diverged_recording)
|
||||
.map_or_else(PackedByteArray::new, |bytes| {
|
||||
PackedByteArray::from(bytes.as_slice())
|
||||
})
|
||||
}
|
||||
|
||||
/// Number of the host's guests.
|
||||
#[func]
|
||||
fn guest_count(&self) -> i64 {
|
||||
self.driver
|
||||
.as_ref()
|
||||
.map_or(0, |driver| driver.guest_count() as i64)
|
||||
}
|
||||
|
||||
/// The player of the host's guest at `peer`, or -1.
|
||||
#[func]
|
||||
fn player_of_peer(&self, peer: i64) -> i64 {
|
||||
let (Some(driver), Some(peer)) = (self.driver.as_ref(), to_peer(peer)) else {
|
||||
return -1;
|
||||
};
|
||||
driver
|
||||
.player_of(peer)
|
||||
.map_or(-1, |player| i64::from(player.0))
|
||||
}
|
||||
|
||||
/// Number of ticks the world has run.
|
||||
#[func]
|
||||
fn tick(&self) -> i64 {
|
||||
self.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<i64> = self.world().map_or_else(Vec::new, |world| {
|
||||
world
|
||||
.players()
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, player)| player.online)
|
||||
.map(|(index, _)| index as i64)
|
||||
.collect()
|
||||
});
|
||||
PackedInt64Array::from(online.as_slice())
|
||||
}
|
||||
|
||||
/// The name of player `player`, or an empty text.
|
||||
#[func]
|
||||
fn player_name(&self, player: i64) -> GString {
|
||||
let name = u8::try_from(player)
|
||||
.ok()
|
||||
.and_then(|index| self.world()?.player(PlayerId(index)))
|
||||
.map_or("", |player| player.name.as_str());
|
||||
GString::from(name)
|
||||
}
|
||||
|
||||
/// How many of `item` the local player carries.
|
||||
#[func]
|
||||
fn item_count(&self, item: i64) -> i64 {
|
||||
let (Some(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<PlayerKey> {
|
||||
match PlayerKey::try_from(bytes.as_slice()) {
|
||||
Ok(key) => Some(key),
|
||||
Err(_) => {
|
||||
self.fail("a player key has 16 bytes");
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn tile(&self, x: i64, y: i64) -> Option<(Terrain, u8)> {
|
||||
let 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<u8> = game
|
||||
.world
|
||||
let bytes: Vec<u8> = world
|
||||
.map()
|
||||
.tiles()
|
||||
.iter()
|
||||
@@ -313,6 +474,11 @@ fn to_pos(x: i64, y: i64) -> Option<Pos> {
|
||||
Some(Pos::new(i32::try_from(x).ok()?, i32::try_from(y).ok()?))
|
||||
}
|
||||
|
||||
/// A connection number from GDScript, or `None` if it cannot exist.
|
||||
fn to_peer(peer: i64) -> Option<Peer> {
|
||||
Peer::try_from(peer).ok()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<Command>,
|
||||
check: Option<u64>,
|
||||
}
|
||||
|
||||
/// The world the guest follows, with the recording of everything it ran.
|
||||
struct Following {
|
||||
world: World,
|
||||
recording: Recording,
|
||||
}
|
||||
|
||||
/// The guest side of a shared world.
|
||||
pub struct Guest {
|
||||
key: PlayerKey,
|
||||
following: Option<Following>,
|
||||
/// True after a divergence, until the host's world arrives again.
|
||||
diverged: bool,
|
||||
pending: VecDeque<PendingTick>,
|
||||
outbox: Vec<Vec<u8>>,
|
||||
diverged_recording: Option<Vec<u8>>,
|
||||
}
|
||||
|
||||
impl Guest {
|
||||
/// A guest that greets the host as the player `key` named `name`. The
|
||||
/// greeting waits in the outbox.
|
||||
pub fn new(key: PlayerKey, name: &str) -> Self {
|
||||
let hello = ToHost::Hello {
|
||||
rules: rules_id(),
|
||||
key,
|
||||
name: clamp_name(name),
|
||||
};
|
||||
Self {
|
||||
key,
|
||||
following: None,
|
||||
diverged: false,
|
||||
pending: VecDeque::new(),
|
||||
outbox: vec![hello.encode()],
|
||||
diverged_recording: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The world, once the host has sent it.
|
||||
pub fn world(&self) -> Option<&World> {
|
||||
self.following.as_ref().map(|following| &following.world)
|
||||
}
|
||||
|
||||
/// The recording of every tick this guest ran since the host's world
|
||||
/// last arrived.
|
||||
pub fn recording(&self) -> Option<&Recording> {
|
||||
self.following
|
||||
.as_ref()
|
||||
.map(|following| &following.recording)
|
||||
}
|
||||
|
||||
/// The guest's player, once its join has run.
|
||||
pub fn local_player(&self) -> Option<PlayerId> {
|
||||
let world = self.world()?;
|
||||
let id = world.find_player(&self.key)?;
|
||||
world.player(id)?.online.then_some(id)
|
||||
}
|
||||
|
||||
/// Handles a message from the host.
|
||||
pub fn receive(&mut self, bytes: &[u8]) -> Result<Arrival, GuestError> {
|
||||
match ToGuest::decode(bytes).map_err(GuestError::Unreadable)? {
|
||||
ToGuest::World { snapshot } => {
|
||||
let world = World::from_snapshot(&snapshot).map_err(GuestError::BadWorld)?;
|
||||
let replaced = self.diverged;
|
||||
if let Some(old) = self.following.take() {
|
||||
if replaced {
|
||||
self.diverged_recording = Some(old.recording.to_bytes(&old.world));
|
||||
}
|
||||
}
|
||||
// Ticks sent before this world are part of it already.
|
||||
let tick = world.tick();
|
||||
self.pending.retain(|pending| pending.tick >= tick);
|
||||
self.following = Some(Following {
|
||||
recording: Recording::start(&world),
|
||||
world,
|
||||
});
|
||||
self.diverged = false;
|
||||
Ok(Arrival::World { replaced })
|
||||
}
|
||||
ToGuest::Refused(refusal) => Ok(Arrival::Refused(refusal)),
|
||||
ToGuest::Tick {
|
||||
tick,
|
||||
commands,
|
||||
check,
|
||||
} => {
|
||||
if self.following.is_none() {
|
||||
return Err(GuestError::TickBeforeWorld);
|
||||
}
|
||||
self.pending.push_back(PendingTick {
|
||||
tick,
|
||||
commands,
|
||||
check,
|
||||
});
|
||||
Ok(Arrival::Nothing)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Ticks that arrived and have not run yet.
|
||||
pub fn pending_ticks(&self) -> usize {
|
||||
self.pending.len()
|
||||
}
|
||||
|
||||
/// Runs the oldest tick that arrived and returns what happened, or
|
||||
/// `None` if there is none to run. After a divergence no tick runs until
|
||||
/// the host's world arrives again.
|
||||
pub fn step(&mut self) -> Option<Vec<Event>> {
|
||||
if self.diverged {
|
||||
return None;
|
||||
}
|
||||
let following = self.following.as_mut()?;
|
||||
while let Some(next) = self.pending.pop_front() {
|
||||
let expected = following.world.tick();
|
||||
if next.tick < expected {
|
||||
continue;
|
||||
}
|
||||
if next.tick > expected {
|
||||
// A tick is missing: this world can no longer follow.
|
||||
self.diverge();
|
||||
return None;
|
||||
}
|
||||
let events = following
|
||||
.recording
|
||||
.step(&mut following.world, &next.commands);
|
||||
if next
|
||||
.check
|
||||
.is_some_and(|check| check != following.world.state_hash())
|
||||
{
|
||||
self.diverge();
|
||||
}
|
||||
return Some(events);
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Reports the current state to the host and stops running ticks until
|
||||
/// the host's world arrives again.
|
||||
fn diverge(&mut self) {
|
||||
let Some(world) = self.world() else {
|
||||
return;
|
||||
};
|
||||
let report = ToHost::Diverged {
|
||||
tick: world.tick(),
|
||||
hash: world.state_hash(),
|
||||
};
|
||||
self.outbox.push(report.encode());
|
||||
self.diverged = true;
|
||||
}
|
||||
|
||||
/// True after a divergence, until the host's world arrives again.
|
||||
pub fn has_diverged(&self) -> bool {
|
||||
self.diverged
|
||||
}
|
||||
|
||||
/// Asks the host to carry out an action of the guest's player. Does
|
||||
/// nothing before the player is in the world or while the world is
|
||||
/// diverged.
|
||||
pub fn act(&mut self, action: Action) {
|
||||
if self.diverged || self.local_player().is_none() {
|
||||
return;
|
||||
}
|
||||
self.outbox.push(ToHost::Act(action).encode());
|
||||
}
|
||||
|
||||
/// Messages for the host, in the order they were made.
|
||||
pub fn take_outbox(&mut self) -> Vec<Vec<u8>> {
|
||||
std::mem::take(&mut self.outbox)
|
||||
}
|
||||
|
||||
/// The encoded recording of the world that diverged last, kept so that
|
||||
/// the divergence can be replayed. Empty after it has been taken.
|
||||
pub fn take_diverged_recording(&mut self) -> Option<Vec<u8>> {
|
||||
self.diverged_recording.take()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,372 @@
|
||||
//! 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.
|
||||
/// `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<Action>,
|
||||
},
|
||||
}
|
||||
|
||||
/// The host of a shared world.
|
||||
pub struct Host {
|
||||
world: World,
|
||||
recording: Recording,
|
||||
rules: u64,
|
||||
local_key: Option<PlayerKey>,
|
||||
local_actions: u32,
|
||||
/// Commands for the next tick, in the order they arrived.
|
||||
inbox: Vec<Command>,
|
||||
links: BTreeMap<Peer, Link>,
|
||||
outbox: Vec<(Peer, Vec<u8>)>,
|
||||
closing: Vec<Peer>,
|
||||
divergences: Vec<Divergence>,
|
||||
}
|
||||
|
||||
impl Host {
|
||||
/// Hosts `world`, new or loaded. Nobody is in it yet.
|
||||
pub fn new(world: World) -> Self {
|
||||
Self {
|
||||
recording: Recording::start(&world),
|
||||
world,
|
||||
rules: rules_id(),
|
||||
local_key: None,
|
||||
local_actions: 0,
|
||||
inbox: Vec::new(),
|
||||
links: BTreeMap::new(),
|
||||
outbox: Vec::new(),
|
||||
closing: Vec::new(),
|
||||
divergences: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn world(&self) -> &World {
|
||||
&self.world
|
||||
}
|
||||
|
||||
/// The recording of everything that happened since the world was hosted.
|
||||
pub fn recording(&self) -> &Recording {
|
||||
&self.recording
|
||||
}
|
||||
|
||||
/// Queues the entry of the host's own player for the next tick.
|
||||
pub fn join_local(&mut self, key: PlayerKey, name: &str) {
|
||||
self.local_key = Some(key);
|
||||
self.inbox.push(Command::Join {
|
||||
key,
|
||||
name: clamp_name(name),
|
||||
});
|
||||
}
|
||||
|
||||
/// The host's own player, once it is in the world.
|
||||
pub fn local_player(&self) -> Option<PlayerId> {
|
||||
online_player(&self.world, &self.local_key?)
|
||||
}
|
||||
|
||||
/// Queues an action of the host's own player for the next tick. Does
|
||||
/// nothing before the player is in the world.
|
||||
pub fn act_local(&mut self, action: Action) {
|
||||
let Some(player) = self.local_player() else {
|
||||
return;
|
||||
};
|
||||
if self.local_actions < MAX_ACTIONS_PER_TICK {
|
||||
self.local_actions += 1;
|
||||
self.inbox.push(action.command(player));
|
||||
}
|
||||
}
|
||||
|
||||
/// A guest connected as `peer`. Its first message must be a greeting.
|
||||
pub fn connect(&mut self, peer: Peer) {
|
||||
self.links.entry(peer).or_insert(Link::Greeting);
|
||||
}
|
||||
|
||||
/// The connection `peer` closed. A guest in the world leaves it in the
|
||||
/// next tick; an accepted guest whose join has not run yet never enters.
|
||||
pub fn disconnect(&mut self, peer: Peer) {
|
||||
match self.links.remove(&peer) {
|
||||
Some(Link::Joining { key }) => self.inbox.retain(
|
||||
|command| !matches!(command, Command::Join { key: joining, .. } if *joining == key),
|
||||
),
|
||||
Some(Link::Playing { player, .. }) => self.inbox.push(Command::Leave { player }),
|
||||
Some(Link::Greeting) | None => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// Handles a message that arrived from `peer`. A guest that breaks the
|
||||
/// protocol is closed; see [`Host::take_closing`].
|
||||
pub fn receive(&mut self, peer: Peer, bytes: &[u8]) {
|
||||
let Some(link) = self.links.get(&peer).cloned() else {
|
||||
return;
|
||||
};
|
||||
match (link, ToHost::decode(bytes)) {
|
||||
(Link::Greeting, Ok(ToHost::Hello { rules, key, name })) => {
|
||||
self.greet(peer, rules, key, &name);
|
||||
}
|
||||
(Link::Greeting, Ok(ToHost::ForeignHello { .. })) => {
|
||||
self.refuse(peer, Refusal::OtherProtocol);
|
||||
}
|
||||
(
|
||||
Link::Playing {
|
||||
player,
|
||||
actions,
|
||||
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 },
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
// The guest acted before its join ran. It cannot know its player
|
||||
// yet, so the action is dropped.
|
||||
(Link::Joining { .. }, Ok(ToHost::Act(_))) => {}
|
||||
(Link::Joining { .. } | Link::Playing { .. }, Ok(ToHost::Diverged { tick, hash })) => {
|
||||
self.divergences.push(Divergence { peer, tick, hash });
|
||||
self.send(
|
||||
peer,
|
||||
&ToGuest::World {
|
||||
snapshot: self.world.to_snapshot(),
|
||||
},
|
||||
);
|
||||
}
|
||||
_ => {
|
||||
self.disconnect(peer);
|
||||
self.closing.push(peer);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn greet(&mut self, peer: Peer, rules: u64, key: PlayerKey, name: &str) {
|
||||
if let Err(refusal) = self.admission(rules, &key) {
|
||||
self.refuse(peer, refusal);
|
||||
return;
|
||||
}
|
||||
self.send(
|
||||
peer,
|
||||
&ToGuest::World {
|
||||
snapshot: self.world.to_snapshot(),
|
||||
},
|
||||
);
|
||||
self.inbox.push(Command::Join {
|
||||
key,
|
||||
name: clamp_name(name),
|
||||
});
|
||||
self.links.insert(peer, Link::Joining { key });
|
||||
}
|
||||
|
||||
/// Whether a guest with `rules` and `key` may join now. Joins that are
|
||||
/// queued for the next tick count as if they had run.
|
||||
fn admission(&self, rules: u64, key: &PlayerKey) -> Result<(), Refusal> {
|
||||
if rules != self.rules {
|
||||
return Err(Refusal::OtherRules);
|
||||
}
|
||||
let queued: Vec<&PlayerKey> = self
|
||||
.inbox
|
||||
.iter()
|
||||
.filter_map(|command| match command {
|
||||
Command::Join { key, .. } => Some(key),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
if online_player(&self.world, key).is_some() || queued.contains(&key) {
|
||||
return Err(Refusal::AlreadyPlaying);
|
||||
}
|
||||
let online = self
|
||||
.world
|
||||
.players()
|
||||
.iter()
|
||||
.filter(|player| player.online)
|
||||
.count();
|
||||
let entering = queued
|
||||
.iter()
|
||||
.filter(|queued| online_player(&self.world, queued).is_none())
|
||||
.count();
|
||||
if online + entering >= MAX_ONLINE_PLAYERS {
|
||||
return Err(Refusal::WorldFull);
|
||||
}
|
||||
let new = queued
|
||||
.iter()
|
||||
.filter(|queued| self.world.find_player(queued).is_none())
|
||||
.count();
|
||||
if self.world.find_player(key).is_none()
|
||||
&& self.world.players().len() + new >= MAX_KNOWN_PLAYERS
|
||||
{
|
||||
return Err(Refusal::TooManyPlayers);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn refuse(&mut self, peer: Peer, refusal: Refusal) {
|
||||
self.send(peer, &ToGuest::Refused(refusal));
|
||||
self.links.remove(&peer);
|
||||
self.closing.push(peer);
|
||||
}
|
||||
|
||||
fn send(&mut self, peer: Peer, message: &ToGuest) {
|
||||
self.outbox.push((peer, message.encode()));
|
||||
}
|
||||
|
||||
/// Runs the next tick with every queued command, sends it to every
|
||||
/// accepted guest and returns what happened.
|
||||
pub fn step(&mut self) -> Vec<Event> {
|
||||
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);
|
||||
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,
|
||||
held: None,
|
||||
}
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
/// 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
|
||||
.values()
|
||||
.filter(|link| **link != Link::Greeting)
|
||||
.count()
|
||||
}
|
||||
|
||||
/// The player of the guest connected as `peer`, once it is in the world.
|
||||
pub fn player_of(&self, peer: Peer) -> Option<PlayerId> {
|
||||
match self.links.get(&peer) {
|
||||
Some(Link::Playing { player, .. }) => Some(*player),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Messages to send, as (peer, bytes), in the order they were made.
|
||||
pub fn take_outbox(&mut self) -> Vec<(Peer, Vec<u8>)> {
|
||||
std::mem::take(&mut self.outbox)
|
||||
}
|
||||
|
||||
/// Connections the host has given up. The shell closes them after it
|
||||
/// has sent the messages for them.
|
||||
pub fn take_closing(&mut self) -> Vec<Peer> {
|
||||
std::mem::take(&mut self.closing)
|
||||
}
|
||||
|
||||
/// Divergences the guests reported since the last call.
|
||||
pub fn take_divergences(&mut self) -> Vec<Divergence> {
|
||||
std::mem::take(&mut self.divergences)
|
||||
}
|
||||
}
|
||||
|
||||
/// 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<PlayerId> {
|
||||
let id = world.find_player(key)?;
|
||||
world.player(id)?.online.then_some(id)
|
||||
}
|
||||
@@ -8,12 +8,16 @@
|
||||
|
||||
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;
|
||||
|
||||
@@ -0,0 +1,281 @@
|
||||
//! 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. 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;
|
||||
|
||||
const ACTION_DIG: u8 = 1;
|
||||
|
||||
/// Something a player wants to do. The host turns it into a command for the
|
||||
/// player of the connection it came from, so that no guest can act for
|
||||
/// another player.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Action {
|
||||
/// Hit the tile at `pos` with the pickaxe.
|
||||
Dig { pos: Pos },
|
||||
}
|
||||
|
||||
impl Action {
|
||||
/// The command that carries out this action for `player`.
|
||||
pub fn command(self, player: PlayerId) -> Command {
|
||||
match self {
|
||||
Action::Dig { pos } => Command::Dig { player, pos },
|
||||
}
|
||||
}
|
||||
|
||||
fn encode(&self, w: &mut Writer) {
|
||||
match self {
|
||||
Action::Dig { pos } => {
|
||||
w.u8(ACTION_DIG);
|
||||
w.i32(pos.x);
|
||||
w.i32(pos.y);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn decode(r: &mut Reader<'_>) -> Result<Self, DecodeError> {
|
||||
match r.u8()? {
|
||||
ACTION_DIG => {
|
||||
let x = r.i32()?;
|
||||
let y = r.i32()?;
|
||||
Ok(Action::Dig {
|
||||
pos: Pos::new(x, y),
|
||||
})
|
||||
}
|
||||
_ => Err(DecodeError::InvalidValue("action tag")),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Why a host turned a guest away. 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.
|
||||
OtherProtocol = 1,
|
||||
/// The guest's game has other rules than the host's.
|
||||
OtherRules = 2,
|
||||
/// Four players are online already.
|
||||
WorldFull = 3,
|
||||
/// The world remembers as many players as it can and does not know this
|
||||
/// one.
|
||||
TooManyPlayers = 4,
|
||||
/// A player with the same key is in the world already.
|
||||
AlreadyPlaying = 5,
|
||||
}
|
||||
|
||||
impl Refusal {
|
||||
/// The number that stands for this refusal in a message.
|
||||
pub fn code(self) -> u8 {
|
||||
self as u8
|
||||
}
|
||||
|
||||
pub fn from_code(code: u8) -> Option<Self> {
|
||||
match code {
|
||||
1 => Some(Refusal::OtherProtocol),
|
||||
2 => Some(Refusal::OtherRules),
|
||||
3 => Some(Refusal::WorldFull),
|
||||
4 => Some(Refusal::TooManyPlayers),
|
||||
5 => Some(Refusal::AlreadyPlaying),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A message from a guest to the host.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum ToHost {
|
||||
/// The first message of every guest.
|
||||
Hello {
|
||||
rules: u64,
|
||||
key: PlayerKey,
|
||||
name: String,
|
||||
},
|
||||
/// A greeting in another version of the messages. Nothing after the
|
||||
/// version can be read.
|
||||
ForeignHello { protocol: u32 },
|
||||
/// The guest's player wants to do something.
|
||||
Act(Action),
|
||||
/// After `tick` ticks the guest's state had the hash `hash`, which
|
||||
/// differs from the host's checksum. The host answers with its world.
|
||||
Diverged { tick: u64, hash: u64 },
|
||||
}
|
||||
|
||||
impl ToHost {
|
||||
pub fn encode(&self) -> Vec<u8> {
|
||||
let mut w = Writer::new();
|
||||
match self {
|
||||
ToHost::Hello { rules, key, name } => {
|
||||
w.u8(TO_HOST_HELLO);
|
||||
w.u32(PROTOCOL_VERSION);
|
||||
w.u64(*rules);
|
||||
w.raw(key);
|
||||
w.str(&clamp_name(name));
|
||||
}
|
||||
ToHost::ForeignHello { protocol } => {
|
||||
w.u8(TO_HOST_HELLO);
|
||||
w.u32(*protocol);
|
||||
}
|
||||
ToHost::Act(action) => {
|
||||
w.u8(TO_HOST_ACT);
|
||||
action.encode(&mut w);
|
||||
}
|
||||
ToHost::Diverged { tick, hash } => {
|
||||
w.u8(TO_HOST_DIVERGED);
|
||||
w.u64(*tick);
|
||||
w.u64(*hash);
|
||||
}
|
||||
}
|
||||
w.into_bytes()
|
||||
}
|
||||
|
||||
/// Decodes a whole message. Messages longer than
|
||||
/// [`MAX_GUEST_MESSAGE_BYTES`] are rejected unread.
|
||||
pub fn decode(bytes: &[u8]) -> Result<Self, DecodeError> {
|
||||
if bytes.len() > MAX_GUEST_MESSAGE_BYTES {
|
||||
return Err(DecodeError::InvalidValue("message length"));
|
||||
}
|
||||
let mut r = Reader::new(bytes);
|
||||
let message = match r.u8()? {
|
||||
TO_HOST_HELLO => {
|
||||
let protocol = r.u32()?;
|
||||
if protocol != PROTOCOL_VERSION {
|
||||
return Ok(ToHost::ForeignHello { protocol });
|
||||
}
|
||||
let rules = r.u64()?;
|
||||
let mut key = [0u8; 16];
|
||||
key.copy_from_slice(r.raw(16)?);
|
||||
let name = r.string()?;
|
||||
if name.len() > MAX_NAME_BYTES {
|
||||
return Err(DecodeError::InvalidValue("player name"));
|
||||
}
|
||||
ToHost::Hello { rules, key, name }
|
||||
}
|
||||
TO_HOST_ACT => ToHost::Act(Action::decode(&mut r)?),
|
||||
TO_HOST_DIVERGED => ToHost::Diverged {
|
||||
tick: r.u64()?,
|
||||
hash: r.u64()?,
|
||||
},
|
||||
_ => return Err(DecodeError::InvalidValue("message tag")),
|
||||
};
|
||||
r.finish()?;
|
||||
Ok(message)
|
||||
}
|
||||
}
|
||||
|
||||
/// A message from the host to a guest.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum ToGuest {
|
||||
/// The world as it is now. The host sends it when it accepts a guest and
|
||||
/// again after the guest reported a divergence. The ticks that follow
|
||||
/// start at this world's tick.
|
||||
World { snapshot: Vec<u8> },
|
||||
/// The host turned the guest away and closes the connection.
|
||||
Refused(Refusal),
|
||||
/// The commands of tick `tick` in the order the host ran them. Every
|
||||
/// [`CHECK_INTERVAL_TICKS`] ticks, `check` holds the hash of the host's
|
||||
/// state after the tick.
|
||||
Tick {
|
||||
tick: u64,
|
||||
commands: Vec<Command>,
|
||||
check: Option<u64>,
|
||||
},
|
||||
}
|
||||
|
||||
impl ToGuest {
|
||||
pub fn encode(&self) -> Vec<u8> {
|
||||
let mut w = Writer::new();
|
||||
match self {
|
||||
ToGuest::World { snapshot } => {
|
||||
w.u8(TO_GUEST_WORLD);
|
||||
w.bytes(snapshot);
|
||||
}
|
||||
ToGuest::Refused(refusal) => {
|
||||
w.u8(TO_GUEST_REFUSED);
|
||||
w.u8(refusal.code());
|
||||
}
|
||||
ToGuest::Tick {
|
||||
tick,
|
||||
commands,
|
||||
check,
|
||||
} => {
|
||||
w.u8(TO_GUEST_TICK);
|
||||
w.u64(*tick);
|
||||
w.u16(u16::try_from(commands.len()).expect("command count fits in u16"));
|
||||
for command in commands {
|
||||
command.encode(&mut w);
|
||||
}
|
||||
match check {
|
||||
Some(hash) => {
|
||||
w.bool(true);
|
||||
w.u64(*hash);
|
||||
}
|
||||
None => w.bool(false),
|
||||
}
|
||||
}
|
||||
}
|
||||
w.into_bytes()
|
||||
}
|
||||
|
||||
/// Decodes a whole message.
|
||||
pub fn decode(bytes: &[u8]) -> Result<Self, DecodeError> {
|
||||
let mut r = Reader::new(bytes);
|
||||
let message = match r.u8()? {
|
||||
TO_GUEST_WORLD => ToGuest::World {
|
||||
snapshot: r.bytes()?.to_vec(),
|
||||
},
|
||||
TO_GUEST_REFUSED => ToGuest::Refused(
|
||||
Refusal::from_code(r.u8()?).ok_or(DecodeError::InvalidValue("refusal"))?,
|
||||
),
|
||||
TO_GUEST_TICK => {
|
||||
let tick = r.u64()?;
|
||||
let count = r.u16()?;
|
||||
let mut commands = Vec::new();
|
||||
for _ in 0..count {
|
||||
commands.push(Command::decode(&mut r)?);
|
||||
}
|
||||
let check = if r.bool()? { Some(r.u64()?) } else { None };
|
||||
ToGuest::Tick {
|
||||
tick,
|
||||
commands,
|
||||
check,
|
||||
}
|
||||
}
|
||||
_ => return Err(DecodeError::InvalidValue("message tag")),
|
||||
};
|
||||
r.finish()?;
|
||||
Ok(message)
|
||||
}
|
||||
}
|
||||
+33
-4
@@ -1,17 +1,28 @@
|
||||
//! 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;
|
||||
/// 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
|
||||
/// 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 +31,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 +49,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 +157,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 +173,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 +187,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;
|
||||
|
||||
@@ -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<Vec<Command>>;
|
||||
|
||||
/// Every frozen script the rules identity covers, in a fixed order.
|
||||
pub const SCRIPTS: [Script; 2] = [frozen_script, frozen_offline_script];
|
||||
|
||||
/// The commands of a session of 60 ticks. It is built from literal values
|
||||
/// and the outcrop origin of the map, not from constants such as
|
||||
/// `DIG_COOLDOWN_TICKS`, so that changing a rule changes the result and not
|
||||
/// the script.
|
||||
///
|
||||
/// Never change or extend this script. To cover more, add a new script to
|
||||
/// [`SCRIPTS`] instead.
|
||||
pub fn frozen_script(world: &World) -> Vec<Vec<Command>> {
|
||||
let origin = outcrop_origin(world.map());
|
||||
let right_of_origin = Pos::new(origin.x + 1, origin.y);
|
||||
// Pairs of (tick, command). Commands of the same tick are applied in the
|
||||
// order listed here.
|
||||
let scripted = [
|
||||
// Two players join in the same tick.
|
||||
(
|
||||
0,
|
||||
Command::Join {
|
||||
key: [1; 16],
|
||||
name: "Ada".to_owned(),
|
||||
},
|
||||
),
|
||||
(
|
||||
0,
|
||||
Command::Join {
|
||||
key: [2; 16],
|
||||
name: "Grace".to_owned(),
|
||||
},
|
||||
),
|
||||
// Player 0 hits the outcrop origin three times, five ticks apart.
|
||||
// The third hit breaks the rock and gives a stone.
|
||||
(
|
||||
2,
|
||||
Command::Dig {
|
||||
player: PlayerId(0),
|
||||
pos: origin,
|
||||
},
|
||||
),
|
||||
(
|
||||
7,
|
||||
Command::Dig {
|
||||
player: PlayerId(0),
|
||||
pos: origin,
|
||||
},
|
||||
),
|
||||
(
|
||||
12,
|
||||
Command::Dig {
|
||||
player: PlayerId(0),
|
||||
pos: origin,
|
||||
},
|
||||
),
|
||||
// Player 1 hits the tile to the right of the origin once.
|
||||
(
|
||||
20,
|
||||
Command::Dig {
|
||||
player: PlayerId(1),
|
||||
pos: right_of_origin,
|
||||
},
|
||||
),
|
||||
// Player 1 leaves and returns with a name of 40 bytes, which the
|
||||
// world shortens to 32.
|
||||
(
|
||||
30,
|
||||
Command::Leave {
|
||||
player: PlayerId(1),
|
||||
},
|
||||
),
|
||||
(
|
||||
40,
|
||||
Command::Join {
|
||||
key: [2; 16],
|
||||
name: "ä".repeat(20),
|
||||
},
|
||||
),
|
||||
];
|
||||
let mut ticks = vec![Vec::new(); 60];
|
||||
for (tick, command) in scripted {
|
||||
ticks[tick].push(command);
|
||||
}
|
||||
ticks
|
||||
}
|
||||
|
||||
/// The commands of a second session of 30 ticks that ends with one player
|
||||
/// offline, so that the encoding of offline players is covered as well.
|
||||
///
|
||||
/// Never change or extend this script either.
|
||||
pub fn frozen_offline_script(world: &World) -> Vec<Vec<Command>> {
|
||||
let origin = outcrop_origin(world.map());
|
||||
let scripted = [
|
||||
(
|
||||
0,
|
||||
Command::Join {
|
||||
key: [7; 16],
|
||||
name: "Linus".to_owned(),
|
||||
},
|
||||
),
|
||||
(
|
||||
1,
|
||||
Command::Join {
|
||||
key: [8; 16],
|
||||
name: "Margaret".to_owned(),
|
||||
},
|
||||
),
|
||||
(
|
||||
3,
|
||||
Command::Dig {
|
||||
player: PlayerId(1),
|
||||
pos: origin,
|
||||
},
|
||||
),
|
||||
(
|
||||
10,
|
||||
Command::Leave {
|
||||
player: PlayerId(0),
|
||||
},
|
||||
),
|
||||
];
|
||||
let mut ticks = vec![Vec::new(); 30];
|
||||
for (tick, command) in scripted {
|
||||
ticks[tick].push(command);
|
||||
}
|
||||
ticks
|
||||
}
|
||||
|
||||
/// Runs `script` on a fresh world from [`RULES_SEED`] and returns the hash
|
||||
/// of the state it ends in.
|
||||
pub fn end_hash(script: Script) -> u64 {
|
||||
let mut world = World::new(RULES_SEED);
|
||||
for commands in script(&world) {
|
||||
world.step(&commands);
|
||||
}
|
||||
world.state_hash()
|
||||
}
|
||||
|
||||
/// The identity of the rules of this build: one hash over the end states of
|
||||
/// all [`SCRIPTS`]. Computing it runs every script, which takes about a
|
||||
/// millisecond, so callers that need it often keep the value.
|
||||
pub fn rules_id() -> u64 {
|
||||
let mut w = Writer::new();
|
||||
for script in SCRIPTS {
|
||||
w.u64(end_hash(script));
|
||||
}
|
||||
fnv1a64(&w.into_bytes())
|
||||
}
|
||||
@@ -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!(
|
||||
|
||||
+113
-144
@@ -11,142 +11,19 @@ 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::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,
|
||||
};
|
||||
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<Vec<Command>> {
|
||||
let origin = outcrop_origin(world.map());
|
||||
let right_of_origin = Pos::new(origin.x + 1, origin.y);
|
||||
// Pairs of (tick, command). Commands of the same tick are applied in the
|
||||
// order listed here.
|
||||
let scripted = [
|
||||
// Two players join in the same tick.
|
||||
(
|
||||
0,
|
||||
Command::Join {
|
||||
key: [1; 16],
|
||||
name: "Ada".to_owned(),
|
||||
},
|
||||
),
|
||||
(
|
||||
0,
|
||||
Command::Join {
|
||||
key: [2; 16],
|
||||
name: "Grace".to_owned(),
|
||||
},
|
||||
),
|
||||
// Player 0 hits the outcrop origin three times, five ticks apart.
|
||||
// The third hit breaks the rock and gives a stone.
|
||||
(
|
||||
2,
|
||||
Command::Dig {
|
||||
player: PlayerId(0),
|
||||
pos: origin,
|
||||
},
|
||||
),
|
||||
(
|
||||
7,
|
||||
Command::Dig {
|
||||
player: PlayerId(0),
|
||||
pos: origin,
|
||||
},
|
||||
),
|
||||
(
|
||||
12,
|
||||
Command::Dig {
|
||||
player: PlayerId(0),
|
||||
pos: origin,
|
||||
},
|
||||
),
|
||||
// Player 1 hits the tile to the right of the origin once.
|
||||
(
|
||||
20,
|
||||
Command::Dig {
|
||||
player: PlayerId(1),
|
||||
pos: right_of_origin,
|
||||
},
|
||||
),
|
||||
// Player 1 leaves and returns with a name of 40 bytes, which the
|
||||
// world shortens to 32.
|
||||
(
|
||||
30,
|
||||
Command::Leave {
|
||||
player: PlayerId(1),
|
||||
},
|
||||
),
|
||||
(
|
||||
40,
|
||||
Command::Join {
|
||||
key: [2; 16],
|
||||
name: "ä".repeat(20),
|
||||
},
|
||||
),
|
||||
];
|
||||
let mut ticks = vec![Vec::new(); 60];
|
||||
for (tick, command) in scripted {
|
||||
ticks[tick].push(command);
|
||||
}
|
||||
ticks
|
||||
}
|
||||
|
||||
/// The commands of a second session of 30 ticks that ends with one player
|
||||
/// offline, so that the encoding of offline players is pinned as well.
|
||||
///
|
||||
/// Never change or extend this script either.
|
||||
fn frozen_offline_script(world: &World) -> Vec<Vec<Command>> {
|
||||
let origin = outcrop_origin(world.map());
|
||||
let scripted = [
|
||||
(
|
||||
0,
|
||||
Command::Join {
|
||||
key: [7; 16],
|
||||
name: "Linus".to_owned(),
|
||||
},
|
||||
),
|
||||
(
|
||||
1,
|
||||
Command::Join {
|
||||
key: [8; 16],
|
||||
name: "Margaret".to_owned(),
|
||||
},
|
||||
),
|
||||
(
|
||||
3,
|
||||
Command::Dig {
|
||||
player: PlayerId(1),
|
||||
pos: origin,
|
||||
},
|
||||
),
|
||||
(
|
||||
10,
|
||||
Command::Leave {
|
||||
player: PlayerId(0),
|
||||
},
|
||||
),
|
||||
];
|
||||
let mut ticks = vec![Vec::new(); 30];
|
||||
for (tick, command) in scripted {
|
||||
ticks[tick].push(command);
|
||||
}
|
||||
ticks
|
||||
}
|
||||
|
||||
/// 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 +36,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 +48,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 \
|
||||
@@ -235,3 +117,90 @@ 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. \
|
||||
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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,351 @@
|
||||
//! 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, DIG_COOLDOWN_TICKS};
|
||||
|
||||
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<Vec<u8>> {
|
||||
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<Refusal> {
|
||||
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<usize> = 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())
|
||||
);
|
||||
}
|
||||
|
||||
/// 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<Pos> {
|
||||
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");
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
);
|
||||
}
|
||||
+55
-6
@@ -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<Command> {
|
||||
@@ -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<u8> {
|
||||
let mut bytes = recording.to_bytes(world);
|
||||
let length = u32::from_le_bytes(bytes[START - 4..START].try_into().unwrap()) as usize;
|
||||
let at = START + length;
|
||||
bytes[at..at + 4].copy_from_slice(&ticks.to_le_bytes());
|
||||
bytes
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_recording_longer_than_thirty_days_is_refused() {
|
||||
let world = World::new(3);
|
||||
let recording = Recording::start(&world);
|
||||
let (longest, _) =
|
||||
Recording::from_bytes(&with_tick_count(&recording, &world, MAX_RECORDING_TICKS)).unwrap();
|
||||
assert_eq!(longest.tick_count(), MAX_RECORDING_TICKS);
|
||||
assert_eq!(
|
||||
Recording::from_bytes(&with_tick_count(
|
||||
&recording,
|
||||
&world,
|
||||
MAX_RECORDING_TICKS + 1
|
||||
)),
|
||||
Err(RecordingError::Corrupt(DecodeError::InvalidValue(
|
||||
"tick count"
|
||||
)))
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<Peer, Guest>,
|
||||
/// Guests whose connection the host closed, with the refusal they got.
|
||||
closed: BTreeMap<Peer, Option<Refusal>>,
|
||||
next_peer: Peer,
|
||||
}
|
||||
|
||||
impl Lan {
|
||||
/// A host whose own player is in a new world from `seed`.
|
||||
fn new(seed: u64) -> Self {
|
||||
let mut host = Host::new(World::new(seed));
|
||||
host.join_local(HOST_KEY, "Host");
|
||||
host.step();
|
||||
Self {
|
||||
host,
|
||||
guests: BTreeMap::new(),
|
||||
closed: BTreeMap::new(),
|
||||
next_peer: 2,
|
||||
}
|
||||
}
|
||||
|
||||
/// Connects a guest with a key made of `key` and lets it greet.
|
||||
fn connect(&mut self, key: u8, name: &str) -> Peer {
|
||||
let peer = self.next_peer;
|
||||
self.next_peer += 1;
|
||||
self.host.connect(peer);
|
||||
self.guests.insert(peer, Guest::new([key; 16], name));
|
||||
self.deliver();
|
||||
peer
|
||||
}
|
||||
|
||||
/// Closes the connection of `peer`, as a guest that quits does.
|
||||
fn disconnect(&mut self, peer: Peer) {
|
||||
self.guests.remove(&peer);
|
||||
self.host.disconnect(peer);
|
||||
}
|
||||
|
||||
/// Carries messages both ways until none are left.
|
||||
fn deliver(&mut self) {
|
||||
self.deliver_changed(|_, bytes| bytes);
|
||||
}
|
||||
|
||||
/// Like [`Lan::deliver`], but `change` may alter what the host sends.
|
||||
fn deliver_changed(&mut self, mut change: impl FnMut(Peer, Vec<u8>) -> Vec<u8>) {
|
||||
loop {
|
||||
let mut moved = false;
|
||||
for (peer, guest) in self.guests.iter_mut() {
|
||||
for bytes in guest.take_outbox() {
|
||||
self.host.receive(*peer, &bytes);
|
||||
moved = true;
|
||||
}
|
||||
}
|
||||
for (peer, bytes) in self.host.take_outbox() {
|
||||
let bytes = change(peer, bytes);
|
||||
if let Some(guest) = self.guests.get_mut(&peer) {
|
||||
let arrival = guest
|
||||
.receive(&bytes)
|
||||
.expect("the host's message is readable");
|
||||
if let Arrival::Refused(refusal) = arrival {
|
||||
self.closed.insert(peer, Some(refusal));
|
||||
}
|
||||
}
|
||||
moved = true;
|
||||
}
|
||||
for peer in self.host.take_closing() {
|
||||
self.guests.remove(&peer);
|
||||
self.closed.entry(peer).or_insert(None);
|
||||
// The connection closes on both ends, and the host hears it.
|
||||
self.host.disconnect(peer);
|
||||
}
|
||||
if !moved {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Runs one tick on the host and lets every guest run what arrived.
|
||||
fn tick(&mut self) {
|
||||
self.host.step();
|
||||
self.deliver();
|
||||
for guest in self.guests.values_mut() {
|
||||
while guest.step().is_some() {}
|
||||
}
|
||||
self.deliver();
|
||||
}
|
||||
|
||||
fn ticks(&mut self, count: u64) {
|
||||
for _ in 0..count {
|
||||
self.tick();
|
||||
}
|
||||
}
|
||||
|
||||
fn guest(&mut self, peer: Peer) -> &mut Guest {
|
||||
self.guests.get_mut(&peer).expect("the guest is connected")
|
||||
}
|
||||
|
||||
/// Asserts that every guest is in exactly the state of the host.
|
||||
fn assert_in_lockstep(&self) {
|
||||
let expected = self.host.world().state_hash();
|
||||
for (peer, guest) in &self.guests {
|
||||
assert_eq!(
|
||||
guest.world().map(World::state_hash),
|
||||
Some(expected),
|
||||
"guest {peer}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The rock right beside the spawn clearing.
|
||||
fn rock(&self) -> Pos {
|
||||
outcrop_origin(self.host.world().map())
|
||||
}
|
||||
}
|
||||
|
||||
/// The commands of the next tick as one guest receives them. Takes the
|
||||
/// host's outbox, so the guests miss this tick.
|
||||
fn commands_sent_to(host: &mut Host, peer: Peer) -> usize {
|
||||
host.take_outbox()
|
||||
.into_iter()
|
||||
.filter(|(to, _)| *to == peer)
|
||||
.find_map(|(_, bytes)| match ToGuest::decode(&bytes) {
|
||||
Ok(ToGuest::Tick { commands, .. }) => Some(commands.len()),
|
||||
_ => None,
|
||||
})
|
||||
.expect("a tick was sent")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_guest_gets_the_world_and_follows_every_tick() {
|
||||
let mut lan = Lan::new(5);
|
||||
let ada = lan.connect(1, "Ada");
|
||||
lan.ticks(3 * CHECK_INTERVAL_TICKS);
|
||||
|
||||
lan.assert_in_lockstep();
|
||||
let guest = lan.guest(ada);
|
||||
assert!(!guest.has_diverged());
|
||||
let me = guest.local_player().expect("the guest is in the world");
|
||||
let world = guest.world().unwrap();
|
||||
assert_eq!(world.player(me).unwrap().name, "Ada");
|
||||
assert_eq!(
|
||||
world
|
||||
.players()
|
||||
.iter()
|
||||
.filter(|player| player.online)
|
||||
.count(),
|
||||
2
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_guest_joins_a_running_world_at_its_current_tick() {
|
||||
let mut lan = Lan::new(5);
|
||||
lan.ticks(100);
|
||||
let ada = lan.connect(1, "Ada");
|
||||
let tick = lan.host.world().tick();
|
||||
assert_eq!(lan.guest(ada).world().unwrap().tick(), tick);
|
||||
assert_eq!(
|
||||
lan.guest(ada).local_player(),
|
||||
None,
|
||||
"its join has not run yet"
|
||||
);
|
||||
|
||||
lan.tick();
|
||||
assert!(lan.guest(ada).local_player().is_some());
|
||||
assert_eq!(lan.host.player_of(ada), lan.guest(ada).local_player());
|
||||
lan.assert_in_lockstep();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_actions_of_a_guest_change_the_world_of_everyone() {
|
||||
let mut lan = Lan::new(5);
|
||||
let ada = lan.connect(1, "Ada");
|
||||
let grace = lan.connect(2, "Grace");
|
||||
lan.tick();
|
||||
let rock = lan.rock();
|
||||
for _ in 0..3 {
|
||||
lan.guest(ada).act(Action::Dig { pos: rock });
|
||||
lan.ticks(DIG_COOLDOWN_TICKS + 1);
|
||||
}
|
||||
|
||||
lan.assert_in_lockstep();
|
||||
let ada_player = lan.host.player_of(ada).unwrap();
|
||||
let grace_player = lan.host.player_of(grace).unwrap();
|
||||
let seen_by_grace = lan.guest(grace).world().unwrap().clone();
|
||||
assert_eq!(
|
||||
seen_by_grace.map().get(rock).unwrap().terrain,
|
||||
Terrain::Ground
|
||||
);
|
||||
assert_eq!(
|
||||
seen_by_grace
|
||||
.player(ada_player)
|
||||
.unwrap()
|
||||
.inventory
|
||||
.count(ITEM_STONE),
|
||||
1
|
||||
);
|
||||
assert_eq!(
|
||||
seen_by_grace
|
||||
.player(grace_player)
|
||||
.unwrap()
|
||||
.inventory
|
||||
.count(ITEM_STONE),
|
||||
0
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_host_takes_at_most_four_actions_per_player_and_tick() {
|
||||
let mut lan = Lan::new(5);
|
||||
let ada = lan.connect(1, "Ada");
|
||||
lan.tick();
|
||||
for x in 0..10 {
|
||||
lan.guest(ada).act(Action::Dig {
|
||||
pos: Pos::new(x, 1),
|
||||
});
|
||||
lan.host.act_local(Action::Dig {
|
||||
pos: Pos::new(x, 2),
|
||||
});
|
||||
}
|
||||
lan.deliver();
|
||||
lan.host.step();
|
||||
assert_eq!(
|
||||
commands_sent_to(&mut lan.host, ada),
|
||||
2 * MAX_ACTIONS_PER_TICK as usize
|
||||
);
|
||||
|
||||
// The count starts again in the next tick.
|
||||
lan.host.act_local(Action::Dig {
|
||||
pos: Pos::new(1, 3),
|
||||
});
|
||||
lan.host.step();
|
||||
assert_eq!(commands_sent_to(&mut lan.host, ada), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_guest_of_another_protocol_version_is_told_so() {
|
||||
let mut lan = Lan::new(5);
|
||||
let peer = 9;
|
||||
lan.host.connect(peer);
|
||||
let foreign = ToHost::ForeignHello {
|
||||
protocol: PROTOCOL_VERSION + 1,
|
||||
};
|
||||
lan.host.receive(peer, &foreign.encode());
|
||||
assert_eq!(
|
||||
lan.host.take_outbox(),
|
||||
vec![(peer, ToGuest::Refused(Refusal::OtherProtocol).encode())]
|
||||
);
|
||||
assert_eq!(lan.host.take_closing(), vec![peer]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_guest_with_other_rules_is_refused() {
|
||||
let mut lan = Lan::new(5);
|
||||
let peer = 9;
|
||||
lan.host.connect(peer);
|
||||
let hello = ToHost::Hello {
|
||||
rules: rules_id() ^ 1,
|
||||
key: [1; 16],
|
||||
name: "Ada".to_owned(),
|
||||
};
|
||||
lan.host.receive(peer, &hello.encode());
|
||||
assert_eq!(
|
||||
lan.host.take_outbox(),
|
||||
vec![(peer, ToGuest::Refused(Refusal::OtherRules).encode())]
|
||||
);
|
||||
assert_eq!(lan.host.take_closing(), vec![peer]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_fifth_player_is_refused_even_before_the_others_have_joined() {
|
||||
let mut lan = Lan::new(5);
|
||||
let guests = [1, 2, 3].map(|key| lan.connect(key, "Guest"));
|
||||
let fifth = lan.connect(4, "Fifth");
|
||||
assert_eq!(lan.closed.get(&fifth), Some(&Some(Refusal::WorldFull)));
|
||||
|
||||
lan.tick();
|
||||
let late = lan.connect(5, "Late");
|
||||
assert_eq!(lan.closed.get(&late), Some(&Some(Refusal::WorldFull)));
|
||||
assert_eq!(lan.host.guest_count(), 3);
|
||||
|
||||
// Once a guest has left, there is room again.
|
||||
lan.disconnect(guests[0]);
|
||||
lan.tick();
|
||||
let next = lan.connect(6, "Next");
|
||||
lan.tick();
|
||||
assert!(lan.host.player_of(next).is_some());
|
||||
lan.assert_in_lockstep();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_world_that_knows_sixteen_players_refuses_a_new_one_but_not_an_old_one() {
|
||||
let mut lan = Lan::new(5);
|
||||
// The host and fifteen guests who come and go.
|
||||
for key in 1..MAX_KNOWN_PLAYERS as u8 {
|
||||
let peer = lan.connect(key, "Visitor");
|
||||
lan.tick();
|
||||
lan.disconnect(peer);
|
||||
lan.tick();
|
||||
}
|
||||
assert_eq!(lan.host.world().players().len(), MAX_KNOWN_PLAYERS);
|
||||
|
||||
let stranger = lan.connect(200, "Stranger");
|
||||
assert_eq!(
|
||||
lan.closed.get(&stranger),
|
||||
Some(&Some(Refusal::TooManyPlayers))
|
||||
);
|
||||
let regular = lan.connect(3, "Visitor");
|
||||
lan.tick();
|
||||
assert!(lan.host.player_of(regular).is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_key_that_is_in_the_world_already_is_refused() {
|
||||
let mut lan = Lan::new(5);
|
||||
let ada = lan.connect(1, "Ada");
|
||||
let twin = lan.connect(1, "Ada");
|
||||
assert_eq!(lan.closed.get(&twin), Some(&Some(Refusal::AlreadyPlaying)));
|
||||
lan.tick();
|
||||
let impostor = lan.connect(HOST_KEY[0], "Host");
|
||||
assert_eq!(
|
||||
lan.closed.get(&impostor),
|
||||
Some(&Some(Refusal::AlreadyPlaying))
|
||||
);
|
||||
assert!(lan.host.player_of(ada).is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_guest_that_leaves_is_offline_everywhere_and_keeps_its_bag() {
|
||||
let mut lan = Lan::new(5);
|
||||
let ada = lan.connect(1, "Ada");
|
||||
let grace = lan.connect(2, "Grace");
|
||||
lan.tick();
|
||||
let rock = lan.rock();
|
||||
for _ in 0..3 {
|
||||
lan.guest(ada).act(Action::Dig { pos: rock });
|
||||
lan.ticks(DIG_COOLDOWN_TICKS + 1);
|
||||
}
|
||||
let player = lan.host.player_of(ada).unwrap();
|
||||
|
||||
lan.disconnect(ada);
|
||||
lan.tick();
|
||||
assert!(!lan.host.world().player(player).unwrap().online);
|
||||
assert!(
|
||||
!lan.guest(grace)
|
||||
.world()
|
||||
.unwrap()
|
||||
.player(player)
|
||||
.unwrap()
|
||||
.online
|
||||
);
|
||||
lan.assert_in_lockstep();
|
||||
|
||||
let back = lan.connect(1, "Ada");
|
||||
lan.tick();
|
||||
assert_eq!(lan.host.player_of(back), Some(player));
|
||||
assert_eq!(lan.guest(back).local_player(), Some(player));
|
||||
assert_eq!(
|
||||
lan.host
|
||||
.world()
|
||||
.player(player)
|
||||
.unwrap()
|
||||
.inventory
|
||||
.count(ITEM_STONE),
|
||||
1
|
||||
);
|
||||
lan.assert_in_lockstep();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_guest_that_goes_before_its_join_ran_never_enters() {
|
||||
let mut lan = Lan::new(5);
|
||||
let ada = lan.connect(1, "Ada");
|
||||
lan.disconnect(ada);
|
||||
lan.tick();
|
||||
assert_eq!(lan.host.world().find_player(&[1; 16]), None);
|
||||
assert_eq!(lan.host.guest_count(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_guest_that_breaks_the_protocol_is_closed() {
|
||||
let mut lan = Lan::new(5);
|
||||
let act = ToHost::Act(Action::Dig {
|
||||
pos: Pos::new(1, 1),
|
||||
})
|
||||
.encode();
|
||||
let broken = [vec![], vec![0xEE], vec![2; 200], act.clone()];
|
||||
for (index, bytes) in broken.iter().enumerate() {
|
||||
let peer = 50 + index as Peer;
|
||||
lan.host.connect(peer);
|
||||
lan.host.receive(peer, bytes);
|
||||
assert_eq!(lan.host.take_closing(), vec![peer], "message {index}");
|
||||
}
|
||||
|
||||
// Messages from a connection the host does not know are ignored.
|
||||
lan.host.receive(77, &act);
|
||||
assert!(lan.host.take_outbox().is_empty());
|
||||
assert!(lan.host.take_closing().is_empty());
|
||||
|
||||
// A second greeting from a guest in the world closes it, and its
|
||||
// player leaves.
|
||||
let ada = lan.connect(1, "Ada");
|
||||
lan.tick();
|
||||
let player = lan.host.player_of(ada).unwrap();
|
||||
let hello = ToHost::Hello {
|
||||
rules: rules_id(),
|
||||
key: [1; 16],
|
||||
name: "Ada".to_owned(),
|
||||
};
|
||||
lan.host.receive(ada, &hello.encode());
|
||||
assert_eq!(lan.host.take_closing(), vec![ada]);
|
||||
lan.host.step();
|
||||
assert!(!lan.host.world().player(player).unwrap().online);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_guest_that_diverges_gets_the_world_again_and_keeps_the_evidence() {
|
||||
let mut lan = Lan::new(5);
|
||||
let ada = lan.connect(1, "Ada");
|
||||
lan.tick();
|
||||
// The host's player hits the rock, but Ada's copy of that tick loses the
|
||||
// command, as if her machine had run it differently.
|
||||
lan.host.act_local(Action::Dig { pos: lan.rock() });
|
||||
lan.host.step();
|
||||
lan.deliver_changed(|_, bytes| match ToGuest::decode(&bytes) {
|
||||
Ok(ToGuest::Tick { tick, check, .. }) => ToGuest::Tick {
|
||||
tick,
|
||||
commands: vec![],
|
||||
check,
|
||||
}
|
||||
.encode(),
|
||||
_ => bytes,
|
||||
});
|
||||
while lan.guest(ada).step().is_some() {}
|
||||
assert!(
|
||||
!lan.guest(ada).has_diverged(),
|
||||
"nobody notices before the next checksum"
|
||||
);
|
||||
|
||||
lan.ticks(CHECK_INTERVAL_TICKS);
|
||||
lan.assert_in_lockstep();
|
||||
assert!(!lan.guest(ada).has_diverged());
|
||||
let divergences = lan.host.take_divergences();
|
||||
assert_eq!(divergences.len(), 1);
|
||||
assert_eq!(divergences[0].peer, ada);
|
||||
|
||||
let evidence = lan
|
||||
.guest(ada)
|
||||
.take_diverged_recording()
|
||||
.expect("the recording of the diverged world is kept");
|
||||
let replayed = Recording::verify(&evidence).expect("the evidence replays");
|
||||
assert_eq!(replayed.state_hash(), divergences[0].hash);
|
||||
assert_eq!(lan.guest(ada).take_diverged_recording(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_long_busy_session_stays_in_lockstep() {
|
||||
let mut lan = Lan::new(77);
|
||||
let mut rng = Rng::new(9);
|
||||
let mut peers: Vec<Peer> = [1, 2, 3].map(|key| lan.connect(key, "Busy")).to_vec();
|
||||
let area = TEST_MAP_SIZE / 3;
|
||||
let random_pos = |rng: &mut Rng| {
|
||||
Pos::new(
|
||||
(area + rng.below(area)) as i32,
|
||||
(area + rng.below(area)) as i32,
|
||||
)
|
||||
};
|
||||
for tick in 0..2_000 {
|
||||
for &peer in &peers {
|
||||
if rng.below(3) == 0 {
|
||||
let pos = random_pos(&mut rng);
|
||||
lan.guest(peer).act(Action::Dig { pos });
|
||||
}
|
||||
}
|
||||
if rng.below(2) == 0 {
|
||||
let pos = random_pos(&mut rng);
|
||||
lan.host.act_local(Action::Dig { pos });
|
||||
}
|
||||
if tick == 700 {
|
||||
lan.disconnect(peers.remove(1));
|
||||
}
|
||||
if tick == 1_100 {
|
||||
peers.push(lan.connect(2, "Busy"));
|
||||
}
|
||||
lan.tick();
|
||||
}
|
||||
|
||||
lan.assert_in_lockstep();
|
||||
assert!(lan.host.take_divergences().is_empty());
|
||||
for guest in lan.guests.values() {
|
||||
let replayed = guest.recording().unwrap().replay().unwrap();
|
||||
assert_eq!(&replayed, guest.world().unwrap());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
# 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.
|
||||
|
||||
## 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 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.
|
||||
- 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
|
||||
|
||||
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 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? 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.
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
|
||||
|
@@ -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)
|
||||
@@ -0,0 +1 @@
|
||||
uid://0krucvyguufx
|
||||
+21
-5
@@ -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
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
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; 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
|
||||
## --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)
|
||||
@@ -0,0 +1 @@
|
||||
uid://dkgt2ownscxuj
|
||||
+336
-67
@@ -1,45 +1,86 @@
|
||||
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 := ""
|
||||
## The host's accepted guests when _process last looked. A new one ends a
|
||||
## pause.
|
||||
var _known_guests := 0
|
||||
|
||||
|
||||
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 +88,295 @@ 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)
|
||||
|
||||
|
||||
func _process(delta: float) -> void:
|
||||
if session == null 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()
|
||||
|
||||
|
||||
func _unhandled_input(event: InputEvent) -> void:
|
||||
if event.is_action_pressed("save_game"):
|
||||
hud.flash("SAVED" if save_game() else "SAVE_FAILED")
|
||||
elif event.is_action_pressed("load_game"):
|
||||
_write_recording()
|
||||
hud.flash("LOADED" if _start_from_save() else "LOAD_FAILED")
|
||||
elif event.is_action_pressed("toggle_pause"):
|
||||
clock.paused = not clock.paused
|
||||
avatar.set_process(not clock.paused)
|
||||
hud.show_paused(clock.paused)
|
||||
|
||||
|
||||
func _notification(what: int) -> void:
|
||||
if what == NOTIFICATION_WM_CLOSE_REQUEST and session != null:
|
||||
if 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():
|
||||
## 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 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])
|
||||
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 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
|
||||
## 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)
|
||||
|
||||
|
||||
## A broken session runs no more ticks. Instead of leaving the others
|
||||
## 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
|
||||
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
|
||||
## 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():
|
||||
_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()
|
||||
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:
|
||||
# An earlier notice of the same batch may have ended the session.
|
||||
if session == null:
|
||||
return
|
||||
match notice["type"]:
|
||||
"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"):
|
||||
# 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")
|
||||
|
||||
|
||||
## 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:
|
||||
@@ -138,6 +398,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 +434,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())
|
||||
|
||||
|
||||
@@ -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()
|
||||
@@ -0,0 +1 @@
|
||||
uid://dppcwwh67rtu6
|
||||
@@ -0,0 +1,187 @@
|
||||
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 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
|
||||
## 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
|
||||
## 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
|
||||
|
||||
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
|
||||
|
||||
|
||||
## 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()
|
||||
_closing.clear()
|
||||
_peer.peer_connected.connect(_on_connected)
|
||||
_peer.peer_disconnected.connect(_on_disconnected)
|
||||
|
||||
|
||||
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})
|
||||
|
||||
|
||||
func _on_disconnected(peer: int) -> void:
|
||||
_peers.erase(peer)
|
||||
_closing.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 or being closed 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_ORDERED)
|
||||
|
||||
|
||||
func _put(peer: int, bytes: PackedByteArray, channel: int, mode: MultiplayerPeer.TransferMode) -> void:
|
||||
if not is_connected_to_peers():
|
||||
return
|
||||
_peer.set_transfer_channel(channel)
|
||||
_peer.set_transfer_mode(mode)
|
||||
# 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) and not _closing.has(peer):
|
||||
_closing[peer] = true
|
||||
_peer.get_peer(peer).peer_disconnect_later()
|
||||
|
||||
|
||||
## Closes everything at once.
|
||||
func close() -> void:
|
||||
if is_open():
|
||||
_peer.close()
|
||||
_peers.clear()
|
||||
_closing.clear()
|
||||
_connecting = false
|
||||
@@ -0,0 +1 @@
|
||||
uid://dmyy8qbtuhhdh
|
||||
@@ -1,18 +1,24 @@
|
||||
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, 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
|
||||
|
||||
|
||||
## Opens the folder and creates it if it is missing. If a crash interrupted a
|
||||
@@ -25,14 +31,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 +51,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 +128,59 @@ 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(new_name: String) -> void:
|
||||
_set_setting("player", "name", new_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 := _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 := _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)
|
||||
@@ -125,6 +191,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:
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
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, 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] = []
|
||||
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))
|
||||
# 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)
|
||||
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})
|
||||
|
||||
|
||||
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
|
||||
@@ -0,0 +1 @@
|
||||
uid://dpau4ls365tsj
|
||||
@@ -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)
|
||||
@@ -0,0 +1 @@
|
||||
uid://bprgpcfabdtn4
|
||||
@@ -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()
|
||||
@@ -0,0 +1 @@
|
||||
uid://x5pqgp2p1hku
|
||||
@@ -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)
|
||||
@@ -0,0 +1 @@
|
||||
uid://8e4vlt0gnipa
|
||||
@@ -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")
|
||||
@@ -0,0 +1 @@
|
||||
uid://xe7vqgyxi2wm
|
||||
+301
-21
@@ -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,206 @@ 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_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")
|
||||
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_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(), 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])
|
||||
|
||||
|
||||
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)
|
||||
# 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_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)
|
||||
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])
|
||||
|
||||
@@ -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)
|
||||
@@ -0,0 +1 @@
|
||||
uid://dh6uogkq28wa4
|
||||
@@ -0,0 +1,215 @@
|
||||
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 {}
|
||||
|
||||
|
||||
## 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()
|
||||
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()
|
||||
|
||||
|
||||
## 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)
|
||||
# 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()
|
||||
|
||||
|
||||
## 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()
|
||||
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)
|
||||
@@ -0,0 +1 @@
|
||||
uid://bmma6gn72s36v
|
||||
@@ -126,3 +126,66 @@ 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)
|
||||
|
||||
|
||||
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)
|
||||
|
||||
@@ -138,3 +138,120 @@ 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)
|
||||
for i in guest.pending_ticks():
|
||||
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")
|
||||
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")
|
||||
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] = []
|
||||
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")
|
||||
|
||||
|
||||
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)
|
||||
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")
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
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_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")
|
||||
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])
|
||||
@@ -0,0 +1 @@
|
||||
uid://c6tn270be6rbu
|
||||
@@ -0,0 +1,95 @@
|
||||
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")
|
||||
# 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([
|
||||
"--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)
|
||||
|
||||
|
||||
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)
|
||||
@@ -0,0 +1 @@
|
||||
uid://c2vju7aa5pgqm
|
||||
@@ -0,0 +1,67 @@
|
||||
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()
|
||||
# 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)
|
||||
|
||||
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
|
||||
|
||||
# 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 file != null and not report.has("error") else 1)
|
||||
@@ -0,0 +1 @@
|
||||
uid://hvhqt2viwbjx
|
||||
@@ -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.
|
||||
|
||||
@@ -0,0 +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.
|
||||
rem Further arguments go to the game as well, for example --name=Zweiter.
|
||||
call "%~dp0play.bat" --profile=zweites-fenster %*
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user