Abschlusskorrekturen M1c: Schläge der Gäste, Figuren und Fragebogen

- Der Host hält einen Schlag eines Gastes, der während der Abklingzeit
  ankommt, bis zum ersten Takt danach. Übers Internet ging sonst etwa
  jeder fünfte Schlag eines Gastes verloren.
- ENet drosselt die Figuren nie mehr; sonst konnten die Figuren eines
  von zwei Gästen dauerhaft stehen bleiben. Figuren gehen geordnet, und
  eine stille Verbindung gilt nach höchstens acht Sekunden als verloren.
- Positionen, die keine Zahlen sind, und Figuren von Spielern, die
  nicht online sind, werden verworfen.
- Ein Host mit angehaltener Simulation sagt das in der Statuszeile.
- Neue Tests: Schlag während der Abklingzeit, Zielauswahl von NetLink,
  verworfene Figuren, Neuabgleich über die Brücke.
- Der Fragebogen sagt, was jeder Rechner braucht, und die Freigabe in
  der Firewall gehört zum Host. CLAUDE.md beschreibt das Halten der
  Schläge, die Drosselung und die Zeitüberschreitung und ergänzt die
  Vertrauensregel um die Schlüssel.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
This commit is contained in:
Steven Wroblewski
2026-10-02 01:22:36 +02:00
parent 7f4bbd9606
commit be22e9f358
13 changed files with 258 additions and 27 deletions
+4 -3
View File
@@ -40,7 +40,8 @@ Drei Wächter prüfen das. `core/clippy.toml` sperrt Pfade wie `std::collections
- Die Nachrichten stehen in `core/src/protocol.rs`. Wer eine Nachricht ändert, erhöht `PROTOCOL_VERSION`. Der Anfang des Grußes und die ganze Ablehnung ändern sich dabei nie, damit Spiele jeder Version einander noch sagen können, dass sie nicht zusammenpassen; `core/tests/golden.rs` hält beides fest.
- Die Regel-Kennung (`core/src/rules.rs`) leitet sich aus den Endzuständen eingefrorener Skripte ab. Rechner mit verschiedener Regel-Kennung spielen nicht zusammen, und Aufzeichnungen anderer Regeln werden abgelehnt. Eine neue Regel braucht deshalb ein neues Skript in `SCRIPTS`, das sie benutzt; bestehende Skripte ändert niemand.
- Gäste schicken nur Aktionen, ohne Spielernummer. Der Host macht daraus Befehle für den Spieler der Verbindung und nimmt höchstens `MAX_ACTIONS_PER_TICK` pro Spieler und Takt an.
- Die Reichweite der Spitzhacke prüft nur die Hülle des eigenen Spielers. Figuren laufen außerhalb des Gleichschritts, also kennt der Kern ihre Position nicht. Ein veränderter Client könnte deshalb aus der Ferne graben. Für ein Koop-Spiel unter Freunden nehmen wir das hin.
- Ein Gast zählt die Abklingzeit der Spitzhacke in seinen eigenen Takten, die denen des Hosts um die Laufzeit nachlaufen. Kommt sein Schlag an, während die Abklingzeit seines Spielers noch läuft, hält ihn der Host bis zum ersten Takt danach; kommen mehrere, gilt der neueste. Sonst ginge übers Internet etwa jeder fünfte Schlag eines Gastes verloren.
- Die Reichweite der Spitzhacke prüft nur die Hülle des eigenen Spielers. Figuren laufen außerhalb des Gleichschritts, also kennt der Kern ihre Position nicht. Ein veränderter Client könnte deshalb aus der Ferne graben. Außerdem stehen die Schlüssel aller Spieler in jedem Snapshot, den ein Gast bekommt; ein veränderter Client könnte sich damit als ein Spieler ausgeben, der gerade nicht online ist. Für ein Koop-Spiel unter Freunden nehmen wir beides hin.
## Regeln für Brücke und Hülle
@@ -50,7 +51,7 @@ Drei Wächter prüfen das. `core/clippy.toml` sperrt Pfade wie `std::collections
- Alle Texte, die Spieler sehen, stehen in `game/i18n/texts.csv` (Deutsch und Englisch) und werden über `tr()` geholt.
- Tests, die Dateien schreiben, nutzen `TestFiles.temporary_dir()` (Ordner unter `user://tests`), nie die echten Dateien des Spielers.
- Godot erzeugt beim Import zu jedem Skript eine `.uid`-Datei. Sie gehört ins Archiv. Der Ordner `game/.godot/` gehört nicht hinein.
- Das Netzwerk liegt in `game/scripts/net_link.gd` (ENet, Port 24680) und `game/scripts/session_link.gd`. Nachrichten des Kerns gehen zuverlässig und in Reihenfolge über Kanal 0, Positionen der Figuren unzuverlässig über Kanal 1. `NetLink.close_peer` schließt eine Verbindung mit `peer_disconnect_later`, damit wartende Nachrichten wie eine Ablehnung noch ankommen.
- Das Netzwerk liegt in `game/scripts/net_link.gd` (ENet, Port 24680) und `game/scripts/session_link.gd`. Nachrichten des Kerns gehen zuverlässig und in Reihenfolge über Kanal 0, Positionen der Figuren unzuverlässig, aber geordnet über Kanal 1; ENet drosselt sie nie. Eine Verbindung, deren Pakete unbeantwortet bleiben, gilt nach höchstens `NetLink.TIMEOUT_MSEC` als verloren. `NetLink.close_peer` schließt eine Verbindung mit `peer_disconnect_later`, damit wartende Nachrichten wie eine Ablehnung noch ankommen.
- Nur der Host speichert. Laden mit F9 geht nur ohne geöffnete Welt („Allein spielen“).
- Eine Pause beginnt nur ohne Gäste und lässt sich immer beenden. Nimmt der Host einen Gast an, endet sie; eine bloße Verbindung ohne Gruß beendet sie nicht.
- Hält der Kern mit einem internen Fehler an, schließt ein Host seine Welt für Gäste, und ein Gast kehrt ins Menü zurück. Eine angehaltene Sitzung hinterlässt keine Aufzeichnung.
@@ -97,7 +98,7 @@ Argumente hinter `play.bat` gehen an das Spiel:
| `--port=PORT` | Port einer offenen Welt |
| `--join=ADRESSE` | Tritt ohne Menü einem Host bei; die Adresse darf auf `:PORT` enden |
`play-zweites-fenster.bat` startet ein zweites Fenster mit dem Profil `zweites-fenster`. Weitere Argumente gibt es ebenfalls an das Spiel weiter, etwa `--name=Zweiter`.
`play-zweites-fenster.bat` startet ein zweites Fenster mit dem Profil `zweites-fenster`. Die Datei reicht weitere Argumente ebenfalls an das Spiel weiter, etwa `--name=Zweiter`.
## Sprache
+53 -4
View File
@@ -39,7 +39,13 @@ enum Link {
/// Accepted. The guest has the world, and its join runs in the next tick.
Joining { key: PlayerKey },
/// In the world as `player`. `actions` counts its actions in this tick.
Playing { player: PlayerId, actions: u32 },
/// `held` is the newest action that arrived while the player's cooldown
/// still ran; it goes into the first tick after the cooldown.
Playing {
player: PlayerId,
actions: u32,
held: Option<Action>,
},
}
/// The host of a shared world.
@@ -139,16 +145,30 @@ impl Host {
(Link::Greeting, Ok(ToHost::ForeignHello { .. })) => {
self.refuse(peer, Refusal::OtherProtocol);
}
(Link::Playing { player, actions }, Ok(ToHost::Act(action))) => {
(
Link::Playing {
player,
actions,
held,
},
Ok(ToHost::Act(action)),
) => {
if actions < MAX_ACTIONS_PER_TICK {
// A guest counts the cooldown in its own ticks, which
// lag the host's by the round trip. An action that
// arrives too early would be lost, so it waits.
let wait = held.is_some() || cooling_down(&self.world, player);
if !wait {
self.inbox.push(action.command(player));
}
self.links.insert(
peer,
Link::Playing {
player,
actions: actions + 1,
held: if wait { Some(action) } else { None },
},
);
self.inbox.push(action.command(player));
}
}
// The guest acted before its join ran. It cannot know its player
@@ -243,6 +263,7 @@ impl Host {
/// Runs the next tick with every queued command, sends it to every
/// accepted guest and returns what happened.
pub fn step(&mut self) -> Vec<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);
@@ -268,7 +289,13 @@ impl Host {
for (peer, link) in self.links.iter_mut() {
match link {
Link::Joining { key } => match online_player(&self.world, key) {
Some(player) => *link = Link::Playing { player, actions: 0 },
Some(player) => {
*link = Link::Playing {
player,
actions: 0,
held: None,
}
}
None => refused.push(*peer),
},
Link::Playing { actions, .. } => *actions = 0,
@@ -283,6 +310,20 @@ impl Host {
events
}
/// Queues the held actions whose player's cooldown is over.
fn release_held_actions(&mut self) {
for link in self.links.values_mut() {
let Link::Playing { player, held, .. } = link else {
continue;
};
if held.is_some() && !cooling_down(&self.world, *player) {
if let Some(action) = held.take() {
self.inbox.push(action.command(*player));
}
}
}
}
/// Number of accepted guests, including those whose join has not run.
pub fn guest_count(&self) -> usize {
self.links
@@ -316,6 +357,14 @@ impl Host {
}
}
/// Whether `player` still waits for its cooldown in the next tick of
/// `world`, so that an action then would be dropped.
fn cooling_down(world: &World, player: PlayerId) -> bool {
world
.player(player)
.is_some_and(|state| world.tick() < state.next_action_tick)
}
/// The player with `key`, if it is online in `world`.
fn online_player(world: &World, key: &PlayerKey) -> Option<PlayerId> {
let id = world.find_player(key)?;
+4 -2
View File
@@ -14,8 +14,10 @@ use crate::snapshot::SnapshotError;
use crate::world::{Event, World, TICKS_PER_SECOND};
const MAGIC: [u8; 4] = *b"GRWR";
/// Version of the recording format. Raise it whenever the encoded recording,
/// including the snapshot inside it, changes.
/// Version of the recording format. Raise it whenever the layout of a
/// recording changes, including the snapshot inside it. Recordings made with
/// other rules are told apart by the rules id that follows this number, not
/// by the number itself.
pub const RECORDING_VERSION: u32 = 3;
/// Most ticks a recording may hold when it is read: thirty days of play.
/// Longer recordings are refused, so that a damaged or crafted file cannot
+56 -1
View File
@@ -10,7 +10,7 @@ use grovewright_core::map::Pos;
use grovewright_core::mapgen::outcrop_origin;
use grovewright_core::player::{PlayerId, PlayerKey};
use grovewright_core::protocol::{Action, Refusal, ToGuest, ToHost, CHECK_INTERVAL_TICKS};
use grovewright_core::world::{Event, World};
use grovewright_core::world::{Event, World, DIG_COOLDOWN_TICKS};
const HOST_KEY: PlayerKey = [100; 16];
@@ -294,3 +294,58 @@ fn a_missing_tick_makes_the_guest_ask_for_the_world() {
Some(host.world().state_hash())
);
}
/// Hands a pickaxe hit of `guest` at `pos` to the host as from `peer`.
fn dig(host: &mut Host, peer: Peer, guest: &mut Guest, pos: Pos) {
guest.act(Action::Dig { pos });
for bytes in guest.take_outbox() {
host.receive(peer, &bytes);
}
}
/// The tiles that pickaxe hits landed on, without breaking them.
fn hits(events: &[Event]) -> Vec<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");
}
}
+13 -3
View File
@@ -2,14 +2,24 @@
Mit M1c teilen sich bis zu vier Spieler eine Welt. Jeder Rechner rechnet die ganze Welt selbst, übers Netz gehen nur die Befehle. Der Host besitzt die Welt und speichert sie; Gäste kommen und gehen, wann sie wollen. Farben und Formen sind weiter Platzhalter.
## Was jeder Rechner braucht
Eine fertig gepackte Fassung des Spiels gibt es noch nicht. Jeder Rechner, der mitspielt, braucht deshalb dasselbe wie deiner:
- Git für Windows und eine Kopie des Archivs auf demselben Stand wie beim Host. Unterscheiden sich die Stände in Regeln oder Nachrichten, lehnt der Host den Gast ab.
- Rust über rustup (https://rustup.rs). rustup bietet an, die Microsoft C++ Build Tools mitzuinstallieren; die braucht es.
- Godot 4.7.2, etwa mit `winget install GodotEngine.GodotEngine --version 4.7.2`, sodass der Befehl `godot` in einer Eingabeaufforderung funktioniert.
Der erste Start mit `play.bat` baut die Brücke zwischen Spiel und Kern und dauert einige Minuten. Danach geht es schnell.
## So startest du
1. Doppelklick auf `play.bat`. Im Menü trägst du deinen Namen ein.
2. **Allein:** „Allein spielen“ setzt deinen gespeicherten Stand fort.
3. **Als Host:** „Welt öffnen“ setzt deinen Stand fort und lässt Gäste zu. Oben rechts steht dann die Statuszeile mit dem Port, etwa „Welt offen auf Port 24680 · Spieler: 1“. Beim ersten Mal fragt Windows, ob Godot im Netzwerk erreichbar sein darf. Erlaube es für private Netzwerke.
4. **Als Gast:** Trag die Adresse des Hosts ein und klick auf „Beitreten“. Alle brauchen denselben Stand des Spiels, sonst lehnt der Host den Gast ab.
3. **Als Host:** „Welt öffnen“ setzt deinen Stand fort und lässt Gäste zu. Oben rechts steht dann die Statuszeile mit dem Port, etwa „Welt offen auf Port 24680 · Spieler: 1“. Beim ersten Mal fragt Windows, ob Godot im Netzwerk erreichbar sein darf. Erlaube es für private und für öffentliche Netzwerke, denn Windows stuft viele Heimnetze und virtuelle Netze als öffentlich ein. Nur der Host braucht diese Freigabe.
4. **Als Gast:** Trag die Adresse des Hosts ein und klick auf „Beitreten“.
- Im selben Heimnetz ist die Adresse die IPv4-Adresse des Host-Rechners, etwa `192.168.178.20`; der Host findet sie mit dem Befehl `ipconfig`.
- Übers Internet geht es am einfachsten mit einem virtuellen Netz wie Tailscale oder ZeroTier; dann gilt die Adresse des Hosts aus diesem Netz. Windows stuft solche Netze oft als öffentlich ein. Kommt keine Verbindung zustande, erlaube Godot in der Windows-Firewall auch für öffentliche Netzwerke.
- Übers Internet geht es am einfachsten mit einem virtuellen Netz wie Tailscale oder ZeroTier; dann gilt die Adresse des Hosts aus diesem Netz.
- Ohne virtuelles Netz braucht der Host eine Portfreigabe für UDP-Port 24680 in seinem Router, und der Gast trägt die öffentliche IP-Adresse des Hosts ein. Bei Anschlüssen ohne eigene öffentliche IPv4-Adresse, etwa mit DS-Lite, geht das nicht.
5. **Zwei Fenster auf einem Rechner:** Starte mit `play.bat` und klick auf „Welt öffnen“. Dann Doppelklick auf `play-zweites-fenster.bat`, einen anderen Namen eintragen, Adresse `127.0.0.1`, „Beitreten“. Das zweite Fenster hat ein eigenes Profil und ist deshalb ein zweiter Spieler. Holst du zwischendurch eine neue Version des Spiels, schließ vorher alle Fenster, sonst scheitert der Start mit „Zugriff verweigert“.
6. Steuerung wie in M1b: WASD laufen, linke Maustaste Stein schlagen. Nur der Host speichert (F5 und automatisch). Esc pausiert nur, solange keine Gäste da sind; kommt ein Gast, endet die Pause. F9 lädt nur nach „Allein spielen“.
+2 -1
View File
@@ -3,7 +3,8 @@ extends RefCounted
## What the command line asks for. Godot passes the arguments after "--":
## --profile=NAME keeps key, save and recordings in a folder of their own,
## so that two windows on one machine are two players
## --name=NAME the player's name for this start
## --name=NAME the player's name; like a name typed in the menu, it is
## remembered for the next start
## --solo starts playing alone at once, without the menu
## --host opens the world for guests at once
## --port=PORT the port an open world listens on
+8 -6
View File
@@ -237,15 +237,17 @@ func _leave(message: String) -> void:
## A broken session runs no more ticks. Instead of leaving the others
## waiting, a host closes its world to guests and a guest leaves.
## waiting, a host closes its world to guests and a guest leaves. The host's
## status line says why its world stands still.
func _stop_sharing() -> void:
if not session.is_host():
_leave(tr("SIMULATION_STOPPED"))
return
link.link.close()
link = null
figures.clear()
_update_status()
if link != null:
link.link.close()
link = null
figures.clear()
hud.show_status(tr("SIMULATION_STOPPED"))
## Ends a pause once the host accepted a guest: the guest joins in the next
@@ -262,7 +264,7 @@ func _process(delta: float) -> void:
if link != null:
for notice in link.pump(delta):
_on_notice(notice)
if session != null and session.is_broken() and link != null:
if session != null and session.is_broken():
_stop_sharing()
if session == null or not session.is_running() or session.is_broken():
return
+15 -3
View File
@@ -2,8 +2,9 @@ class_name NetLink
extends RefCounted
## The network connection of a session, over ENet. A host listens for guests;
## a guest connects to its host. Messages of the core travel on a reliable,
## ordered channel. Positions of figures travel on a second, unreliable
## channel, because a lost one is soon replaced by the next.
## ordered channel. Positions of figures travel on a second channel that is
## unreliable but ordered: a lost position is soon replaced by the next, and
## an older one that arrives after a newer one is dropped.
## Port a host listens on unless told otherwise.
const DEFAULT_PORT := 24680
@@ -14,6 +15,10 @@ const CHANNEL_CORE := 0
const CHANNEL_FIGURES := 1
## Seconds a guest waits for its host to answer before it gives up.
const CONNECT_SECONDS := 8.0
## Milliseconds after which a connection whose packets stay unanswered counts
## as lost. ENet's own limit of 30 seconds would leave a guest whose host
## vanished in a standing world for that long.
const TIMEOUT_MSEC := 8000
## Seconds this guest waits for its host. Tests shorten it.
var connect_seconds := CONNECT_SECONDS
@@ -77,6 +82,13 @@ func _start() -> void:
func _on_connected(peer: int) -> void:
_peers[peer] = true
var connection := _peer.get_peer(peer)
# ENet slows unreliable packets down after a spike in the round trip and
# can keep them at nothing for the rest of a session, so that the figures
# of one guest stop. Positions are a few bytes ten times a second; they
# are never slowed down.
connection.throttle_configure(5000, ENetPacketPeer.PACKET_THROTTLE_SCALE, 0)
connection.set_timeout(32, 5000, TIMEOUT_MSEC)
_events.append({"type": "connected", "peer": peer})
@@ -131,7 +143,7 @@ func send(peer: int, bytes: PackedByteArray) -> void:
## Sends the position of a figure to `peer`, to every connection if `peer`
## is 0, or to every connection but one if `peer` is that one negated.
func send_figure(peer: int, bytes: PackedByteArray) -> void:
_put(peer, bytes, CHANNEL_FIGURES, MultiplayerPeer.TRANSFER_MODE_UNRELIABLE)
_put(peer, bytes, CHANNEL_FIGURES, MultiplayerPeer.TRANSFER_MODE_UNRELIABLE_ORDERED)
func _put(peer: int, bytes: PackedByteArray, channel: int, mode: MultiplayerPeer.TransferMode) -> void:
+9 -3
View File
@@ -22,7 +22,8 @@ func _init(game_session: GwSession, net_link: NetLink) -> void:
## - "world_received" (entry `resync`), "refused" (entry `reason`),
## "protocol_error", "host_lost" and "failed", at a guest
## - "divergence" (entries `peer`, `tick`, `hash`), at the host
## - "figure" (entries `player`, `position`), everywhere
## - "figure" (entries `player`, `position`), everywhere, for another player
## who is online, with a position of real numbers
## `delta` is the time since the last call.
func pump(delta: float) -> Array[Dictionary]:
var notices: Array[Dictionary] = []
@@ -80,12 +81,17 @@ func _receive_figure(peer: int, bytes: PackedByteArray, notices: Array[Dictionar
if player < 0 or bytes.size() != POSITION_BYTES:
return
var position := Vector2(bytes.decode_float(0), bytes.decode_float(4))
# Not a number or infinite, as a broken or changed client may send it:
# such a position would spoil the figure for good.
if not position.is_finite():
return
notices.append({"type": "figure", "player": player, "position": position})
link.send_figure(-peer, _figure_bytes(player, position))
elif bytes.size() == 1 + POSITION_BYTES:
var player := bytes.decode_u8(0)
if player != session.local_player():
var position := Vector2(bytes.decode_float(1), bytes.decode_float(5))
var position := Vector2(bytes.decode_float(1), bytes.decode_float(5))
# A position can arrive after its player left the world.
if player != session.local_player() and session.online_players().has(player) and position.is_finite():
notices.append({"type": "figure", "player": player, "position": position})
+1 -1
View File
@@ -333,7 +333,7 @@ func test_a_host_that_stops_sharing_lets_its_guests_go() -> void:
# session, _process calls what this test calls.
host._stop_sharing()
check(host.link == null, "the host no longer listens")
check_eq(host.hud.status_text(), "", "and no longer says that it does")
check_eq(host.hud.status_text(), tr("SIMULATION_STOPPED"), "its status line says why")
check(await _wait_until(func() -> bool: return guest.session == null), "the guest's session ends")
check_eq(guest.menu.status(), tr("HOST_LEFT"), "and the guest learns why")
_clean_up([host_dir, guest_dir])
+55
View File
@@ -26,6 +26,13 @@ func _first(link: NetLink, type: String) -> Dictionary:
return {}
## The bytes of every event of `type` that `link` got, each as an Array.
func _received(link: NetLink, type: String) -> Array:
return _events.get(link, []).filter(func(event: Dictionary) -> bool: return event["type"] == type).map(
func(event: Dictionary) -> Array: return Array(event["bytes"])
)
## A host and a guest that have connected.
func _connected_pair() -> Array[NetLink]:
var host := NetLink.new()
@@ -139,6 +146,54 @@ func test_nothing_is_sent_to_connections_that_are_gone_or_closing() -> void:
link.close()
## A packet goes to the connection it is addressed to, to every connection
## for peer 0, and to every connection but one for a negative peer.
func test_packets_go_only_where_they_are_addressed() -> void:
var host := NetLink.new()
check_eq(host.host(0, "127.0.0.1"), OK, "the host listens")
var guests: Array[NetLink] = []
var everyone: Array[NetLink] = [host]
for i in 3:
var guest := NetLink.new()
guest.join("127.0.0.1", host.local_port())
guests.append(guest)
everyone.append(guest)
await _pump(everyone, 15)
# Each guest says who it is, so that the host's id for it is known.
for i in 3:
guests[i].send(GwSession.HOST_PEER, PackedByteArray([i]))
await _pump(everyone, 10)
var peer_of: Array[int] = [0, 0, 0]
for event: Dictionary in _events[host]:
if event["type"] == "message":
peer_of[event["bytes"][0]] = event["peer"]
check(not peer_of.has(0), "the host knows all three guests")
_events.clear()
# Repeated, because a position that gets lost is no failure of the link.
for repeat in 5:
host.send(peer_of[0], PackedByteArray([10]))
host.send_figure(peer_of[2], PackedByteArray([20]))
host.send_figure(-peer_of[1], PackedByteArray([30]))
host.send_figure(0, PackedByteArray([40]))
await _pump(everyone, 2)
await _pump(everyone, 10)
check_eq(_received(guests[0], "message").size(), 5, "the first guest got the messages to it")
check_eq(_received(guests[1], "message"), [], "the second guest got none")
check_eq(_received(guests[2], "message"), [], "the third guest got none")
var first := _received(guests[0], "figure")
var second := _received(guests[1], "figure")
var third := _received(guests[2], "figure")
check(not first.has([20]), "the first guest got nothing for the third")
check(first.has([30]) and first.has([40]), "the first guest got 'all but the second' and 'all'")
check(not second.has([20]) and not second.has([30]), "the second guest got neither the one for the third nor 'all but itself'")
check(second.has([40]), "the second guest got 'all'")
check(third.has([20]) and third.has([30]) and third.has([40]), "the third guest got all three kinds")
for link in everyone:
link.close()
func test_a_guest_gives_up_when_nobody_answers() -> void:
# A port that was free a moment ago and has nobody listening now.
var probe := NetLink.new()
+21
View File
@@ -219,6 +219,27 @@ func test_the_hit_of_a_guest_becomes_a_message_to_the_host() -> void:
check_eq(host.damage_codes()[rock.y * host.map_width() + rock.x], 1, "and so does the host")
func test_a_guest_that_missed_a_tick_gets_the_world_again() -> void:
var pair := _host_and_guest()
var host := pair[0]
var guest := pair[1]
host.step()
# The message with this tick gets lost on the way.
host.take_messages()
host.step()
_carry(host, guest)
for i in guest.pending_ticks():
guest.step()
check_eq(_carry(host, guest), [{"type": "world_received", "resync": true}], "the guest gets the host's world again")
check_eq(host.take_divergences().size(), 1, "the host hears of it")
check(guest.take_diverged_recording().size() > 0, "the guest keeps the recording of the world that went astray")
host.step()
_carry(host, guest)
for i in guest.pending_ticks():
guest.step()
check_eq(guest.state_hash_hex(), host.state_hash_hex(), "both worlds are the same again")
func test_a_guest_learns_why_it_is_turned_away() -> void:
var host := _joined_session()
host.connect_peer(5)
+17
View File
@@ -109,6 +109,23 @@ func test_figures_travel_to_the_other_machines() -> void:
_close([host, guest, other])
func test_figures_that_cannot_be_shown_are_dropped() -> void:
_start_host()
var guest := _start_guest(guest_key, "Grace")
var other := _start_guest(PackedByteArray([3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3]), "Linus")
await _play([guest, other], 30)
# A position that is no number, as a broken or changed client may send.
guest.link.send_figure(GwSession.HOST_PEER, guest._position_bytes(Vector2(NAN, 1.0)))
# A position of a player who is not online, as it can arrive late.
host.link.send_figure(0, host._figure_bytes(5, Vector2(1.0, 2.0)))
await _play([guest, other], 10)
check(not _types(host).has("figure"), "the host drops the position that is no number")
check(not _types(other).has("figure"), "passes nothing on, and nobody shows the player who is not online")
check(not _types(guest).has("figure"), "not even the guest that sent it")
_close([host, guest, other])
func test_a_guest_with_the_key_of_a_player_in_the_world_is_turned_away() -> void:
_start_host()
var twin := _start_guest(host_key, "Host")