From 0917450014f0f8a6759cf7a5ed056c7006338dc1 Mon Sep 17 00:00:00 2001 From: Steven Wroblewski Date: Thu, 1 Oct 2026 19:53:04 +0200 Subject: [PATCH] =?UTF-8?q?Verbindung=20=C3=BCber=20ENet?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit NetLink verbindet Host und Gäste über ENet: Nachrichten des Kerns zuverlässig und in Reihenfolge, Positionen der Figuren unzuverlässig. Eine Verbindung schließt erst, wenn wartende Nachrichten draußen sind. SessionLink trägt die Nachrichten einer Sitzung über die Verbindung und reicht Positionen weiter. Die Tests verbinden zwei Seiten über die Loopback-Schnittstelle. Co-Authored-By: Claude Haiku 4.5 --- game/scripts/net_link.gd | 154 ++++++++++++++++++++++++++++ game/scripts/net_link.gd.uid | 1 + game/scripts/session_link.gd | 103 +++++++++++++++++++ game/scripts/session_link.gd.uid | 1 + game/tests/test_net_link.gd | 131 +++++++++++++++++++++++ game/tests/test_net_link.gd.uid | 1 + game/tests/test_session_link.gd | 141 +++++++++++++++++++++++++ game/tests/test_session_link.gd.uid | 1 + 8 files changed, 533 insertions(+) create mode 100644 game/scripts/net_link.gd create mode 100644 game/scripts/net_link.gd.uid create mode 100644 game/scripts/session_link.gd create mode 100644 game/scripts/session_link.gd.uid create mode 100644 game/tests/test_net_link.gd create mode 100644 game/tests/test_net_link.gd.uid create mode 100644 game/tests/test_session_link.gd create mode 100644 game/tests/test_session_link.gd.uid diff --git a/game/scripts/net_link.gd b/game/scripts/net_link.gd new file mode 100644 index 0000000..1e5ed2c --- /dev/null +++ b/game/scripts/net_link.gd @@ -0,0 +1,154 @@ +class_name NetLink +extends RefCounted +## The network connection of a session, over ENet. A host listens for guests; +## a guest connects to its host. Messages of the core travel on a reliable, +## ordered channel. Positions of figures travel on a second, unreliable +## channel, because a lost one is soon replaced by the next. + +## Port a host listens on unless told otherwise. +const DEFAULT_PORT := 24680 +## Most guests that may connect to a host. More than can play, so that the +## core can tell the extra ones why it turns them away. +const MAX_CONNECTIONS := 8 +const CHANNEL_CORE := 0 +const CHANNEL_FIGURES := 1 +## Seconds a guest waits for its host to answer before it gives up. +const CONNECT_SECONDS := 8.0 + +## Seconds this guest waits for its host. Tests shorten it. +var connect_seconds := CONNECT_SECONDS + +var _peer: ENetMultiplayerPeer +var _peers: Dictionary[int, bool] = {} +var _events: Array[Dictionary] = [] +var _connecting := false +var _waited := 0.0 + + +## Splits "host" or "host:port" into [host, port], with DEFAULT_PORT if the +## text names none. Returns an empty array if `text` is no such address. +static func split_address(text: String) -> Array: + var parts := text.strip_edges().split(":") + if parts[0].is_empty() or parts.size() > 2: + return [] + if parts.size() == 1: + return [parts[0], DEFAULT_PORT] + if not parts[1].is_valid_int() or parts[1].to_int() < 1 or parts[1].to_int() > 65535: + return [] + return [parts[0], parts[1].to_int()] + + +## Starts listening for guests on `port`, or on a free port that the system +## chooses if `port` is 0; see local_port(). `address` limits listening to +## one network interface, for example "127.0.0.1"; "*" listens on all. +func host(port: int, address := "*") -> Error: + _start() + _peer.set_bind_ip(address) + return _peer.create_server(port, MAX_CONNECTIONS, 2) + + +## The port a host listens on, or 0. +func local_port() -> int: + if not is_open() or _peer.host == null: + return 0 + return _peer.host.get_local_port() + + +## Starts connecting to the host at `address` and `port`. +func join(address: String, port: int) -> Error: + _start() + var error := _peer.create_client(address, port, 2) + _connecting = error == OK + _waited = 0.0 + return error + + +func _start() -> void: + close() + _peer = ENetMultiplayerPeer.new() + _peers.clear() + _peer.peer_connected.connect(_on_connected) + _peer.peer_disconnected.connect(_on_disconnected) + + +func _on_connected(peer: int) -> void: + _peers[peer] = true + _events.append({"type": "connected", "peer": peer}) + + +func _on_disconnected(peer: int) -> void: + _peers.erase(peer) + _events.append({"type": "disconnected", "peer": peer}) + + +## True while the connection is up or being made. +func is_open() -> bool: + return _peer != null and _peer.get_connection_status() != MultiplayerPeer.CONNECTION_DISCONNECTED + + +## True when a guest has reached its host, or a host is listening. +func is_connected_to_peers() -> bool: + return _peer != null and _peer.get_connection_status() == MultiplayerPeer.CONNECTION_CONNECTED + + +## Returns what happened since the last call, oldest first, as dictionaries +## with a `type` entry: "connected" and "disconnected" (entry `peer`), +## "message" and "figure" (entries `peer` and `bytes`), and "failed" when a +## guest could not reach its host. `delta` is the time since the last call. +func poll(delta: float) -> Array[Dictionary]: + if is_open(): + _peer.poll() + while _peer.get_available_packet_count() > 0: + var from := _peer.get_packet_peer() + var channel := _peer.get_packet_channel() + var bytes := _peer.get_packet() + var type := "message" if channel == CHANNEL_CORE else "figure" + _events.append({"type": type, "peer": from, "bytes": bytes}) + if _connecting: + _waited += delta + if is_connected_to_peers(): + _connecting = false + elif not is_open() or _waited > connect_seconds: + _connecting = false + close() + _events.append({"type": "failed"}) + var events := _events + _events = [] + return events + + +## Sends a message of the core to `peer`, reliably and in order. A message +## to a connection that is gone is dropped. +func send(peer: int, bytes: PackedByteArray) -> void: + _put(peer, bytes, CHANNEL_CORE, MultiplayerPeer.TRANSFER_MODE_RELIABLE) + + +## Sends the position of a figure to `peer`, to every connection if `peer` +## is 0, or to every connection but one if `peer` is that one negated. +func send_figure(peer: int, bytes: PackedByteArray) -> void: + _put(peer, bytes, CHANNEL_FIGURES, MultiplayerPeer.TRANSFER_MODE_UNRELIABLE) + + +func _put(peer: int, bytes: PackedByteArray, channel: int, mode: MultiplayerPeer.TransferMode) -> void: + if not is_connected_to_peers() or _peers.is_empty(): + return + if peer > 0 and not _peers.has(peer): + return + _peer.set_target_peer(peer) + _peer.set_transfer_channel(channel) + _peer.set_transfer_mode(mode) + _peer.put_packet(bytes) + + +## Closes the connection to `peer` once the messages waiting for it are out. +func close_peer(peer: int) -> void: + if _peers.has(peer): + _peer.get_peer(peer).peer_disconnect_later() + + +## Closes everything at once. +func close() -> void: + if is_open(): + _peer.close() + _peers.clear() + _connecting = false diff --git a/game/scripts/net_link.gd.uid b/game/scripts/net_link.gd.uid new file mode 100644 index 0000000..4aac885 --- /dev/null +++ b/game/scripts/net_link.gd.uid @@ -0,0 +1 @@ +uid://dmyy8qbtuhhdh diff --git a/game/scripts/session_link.gd b/game/scripts/session_link.gd new file mode 100644 index 0000000..474d854 --- /dev/null +++ b/game/scripts/session_link.gd @@ -0,0 +1,103 @@ +class_name SessionLink +extends RefCounted +## Carries the messages of a GwSession over a NetLink, and the positions of +## the figures, which are not part of the shared world. Tells the game what +## happened on the way. + +## Bytes of a position: x and y as 32-bit floats. +const POSITION_BYTES := 8 + +var session: GwSession +var link: NetLink + + +func _init(game_session: GwSession, net_link: NetLink) -> void: + session = game_session + link = net_link + + +## Moves messages in both directions and returns notices, oldest first, as +## dictionaries with a `type` entry: +## - "guest_arrived" and "guest_left" (entry `peer`), at the host +## - "world_received" (entry `resync`), "refused" (entry `reason`), +## "protocol_error", "host_lost" and "failed", at a guest +## - "divergence" (entries `peer`, `tick`, `hash`), at the host +## - "figure" (entries `player`, `position`), everywhere +## `delta` is the time since the last call. +func pump(delta: float) -> Array[Dictionary]: + var notices: Array[Dictionary] = [] + for event in link.poll(delta): + match event["type"]: + "connected": + if session.is_host(): + session.connect_peer(event["peer"]) + notices.append({"type": "guest_arrived", "peer": event["peer"]}) + "disconnected": + if session.is_host(): + session.disconnect_peer(event["peer"]) + notices.append({"type": "guest_left", "peer": event["peer"]}) + else: + notices.append({"type": "host_lost"}) + "failed": + notices.append({"type": "failed"}) + "message": + notices.append_array(session.receive(event["peer"], event["bytes"])) + "figure": + _receive_figure(event["peer"], event["bytes"], notices) + for divergence in session.take_divergences(): + divergence["type"] = "divergence" + notices.append(divergence) + flush() + return notices + + +## Sends what the session has for the network and closes the connections +## the host gave up. A guest's messages wait until its host is reached. +func flush() -> void: + if not link.is_connected_to_peers(): + return + for message in session.take_messages(): + link.send(message["peer"], message["bytes"]) + for peer in session.take_closing(): + link.close_peer(peer) + + +## Tells the other machines where this machine's figure stands. +func send_figure(position: Vector2) -> void: + if session.is_host(): + var player := session.local_player() + if player >= 0: + link.send_figure(0, _figure_bytes(player, position)) + elif session.local_player() >= 0: + link.send_figure(GwSession.HOST_PEER, _position_bytes(position)) + + +func _receive_figure(peer: int, bytes: PackedByteArray, notices: Array[Dictionary]) -> void: + if session.is_host(): + # A guest sends only a position; the host knows whose it is and + # passes it on to every other guest. + var player := session.player_of_peer(peer) + if player < 0 or bytes.size() != POSITION_BYTES: + return + var position := Vector2(bytes.decode_float(0), bytes.decode_float(4)) + notices.append({"type": "figure", "player": player, "position": position}) + link.send_figure(-peer, _figure_bytes(player, position)) + elif bytes.size() == 1 + POSITION_BYTES: + var player := bytes.decode_u8(0) + if player != session.local_player(): + var position := Vector2(bytes.decode_float(1), bytes.decode_float(5)) + notices.append({"type": "figure", "player": player, "position": position}) + + +func _position_bytes(position: Vector2) -> PackedByteArray: + var bytes := PackedByteArray() + bytes.resize(POSITION_BYTES) + bytes.encode_float(0, position.x) + bytes.encode_float(4, position.y) + return bytes + + +func _figure_bytes(player: int, position: Vector2) -> PackedByteArray: + var bytes := PackedByteArray([player]) + bytes.append_array(_position_bytes(position)) + return bytes diff --git a/game/scripts/session_link.gd.uid b/game/scripts/session_link.gd.uid new file mode 100644 index 0000000..32aac12 --- /dev/null +++ b/game/scripts/session_link.gd.uid @@ -0,0 +1 @@ +uid://dpau4ls365tsj diff --git a/game/tests/test_net_link.gd b/game/tests/test_net_link.gd new file mode 100644 index 0000000..4cf5a85 --- /dev/null +++ b/game/tests/test_net_link.gd @@ -0,0 +1,131 @@ +extends TestCase +## Two NetLinks in one process, connected over the loopback interface. + +## Events per link, collected while frames pass. +var _events: Dictionary[NetLink, Array] = {} + + +## Polls `links` for `frames` frames and collects their events. +func _pump(links: Array[NetLink], frames: int) -> void: + for frame in frames: + for link in links: + if not _events.has(link): + _events[link] = [] + _events[link].append_array(link.poll(1.0 / 60.0)) + await tree.process_frame + + +func _types(link: NetLink) -> Array: + return _events.get(link, []).map(func(event: Dictionary) -> String: return event["type"]) + + +func _first(link: NetLink, type: String) -> Dictionary: + for event: Dictionary in _events.get(link, []): + if event["type"] == type: + return event + return {} + + +## A host and a guest that have connected. +func _connected_pair() -> Array[NetLink]: + var host := NetLink.new() + check_eq(host.host(0, "127.0.0.1"), OK, "the host listens") + check(host.local_port() > 0, "on a port the system chose") + var guest := NetLink.new() + check_eq(guest.join("127.0.0.1", host.local_port()), OK, "the guest starts connecting") + await _pump([host, guest], 10) + return [host, guest] + + +func test_a_guest_connects_and_both_sides_hear_of_it() -> void: + var links := await _connected_pair() + var host := links[0] + var guest := links[1] + check(guest.is_connected_to_peers(), "the guest is connected") + check_eq(_first(guest, "connected").get("peer"), GwSession.HOST_PEER, "the guest sees the host as peer 1") + check(_first(host, "connected").get("peer", 0) > 1, "the host sees the guest") + host.close() + guest.close() + + +func test_messages_arrive_in_order_on_both_channels() -> void: + var links := await _connected_pair() + var host := links[0] + var guest := links[1] + var guest_peer: int = _first(host, "connected")["peer"] + _events.clear() + var big := PackedByteArray() + big.resize(50000) + big.fill(7) + host.send(guest_peer, big) + host.send(guest_peer, PackedByteArray([1])) + guest.send(GwSession.HOST_PEER, PackedByteArray([2, 3])) + guest.send_figure(GwSession.HOST_PEER, PackedByteArray([4])) + await _pump([host, guest], 20) + + var received := _events[guest].filter(func(event: Dictionary) -> bool: return event["type"] == "message") + check_eq(received.size(), 2, "two messages for the guest") + if received.size() == 2: + check_eq(received[0]["bytes"].size(), 50000, "the big one first") + check_eq(received[1]["bytes"], PackedByteArray([1]), "then the small one") + check_eq(_first(host, "message").get("bytes"), PackedByteArray([2, 3]), "the host got the guest's message") + check_eq(_first(host, "figure").get("bytes"), PackedByteArray([4]), "and the position on the other channel") + host.close() + guest.close() + + +func test_closing_a_guest_delivers_what_was_sent_before() -> void: + var links := await _connected_pair() + var host := links[0] + var guest := links[1] + var guest_peer: int = _first(host, "connected")["peer"] + _events.clear() + host.send(guest_peer, PackedByteArray([9, 9])) + host.close_peer(guest_peer) + await _pump([host, guest], 30) + check_eq(_types(guest), ["message", "disconnected"], "the message comes before the end") + check_eq(_types(host), ["disconnected"], "the host sees the guest go") + check(not guest.is_open(), "the guest's connection is closed") + host.close() + + +func test_a_guest_that_quits_is_seen_leaving() -> void: + var links := await _connected_pair() + var host := links[0] + var guest := links[1] + _events.clear() + guest.close() + await _pump([host, guest], 10) + check_eq(_types(host), ["disconnected"], "the host sees the guest go") + host.close() + + +func test_messages_to_a_connection_that_is_gone_are_dropped() -> void: + var host := NetLink.new() + check_eq(host.host(0, "127.0.0.1"), OK, "the host listens") + host.send(12345, PackedByteArray([1])) + host.send_figure(12345, PackedByteArray([1])) + host.close_peer(12345) + check_eq(host.poll(0.0), [], "nothing happened") + host.close() + + +func test_a_guest_gives_up_when_nobody_answers() -> void: + # A port that was free a moment ago and has nobody listening now. + var probe := NetLink.new() + probe.host(0, "127.0.0.1") + var port := probe.local_port() + probe.close() + var guest := NetLink.new() + guest.connect_seconds = 0.2 + check_eq(guest.join("127.0.0.1", port), OK, "connecting starts") + await _pump([guest], 30) + check_eq(_types(guest), ["failed"], "the guest gives up") + check(not guest.is_open(), "and closes") + + +func test_addresses_are_split_into_host_and_port() -> void: + check_eq(NetLink.split_address("192.168.1.20"), ["192.168.1.20", NetLink.DEFAULT_PORT], "default port") + check_eq(NetLink.split_address(" example.net:25000 "), ["example.net", 25000], "with port") + for text in ["", ":25000", "host:", "host:0", "host:70000", "host:port", "a:1:2"]: + check_eq(NetLink.split_address(text), [], "refused: '%s'" % text) diff --git a/game/tests/test_net_link.gd.uid b/game/tests/test_net_link.gd.uid new file mode 100644 index 0000000..c99d6ee --- /dev/null +++ b/game/tests/test_net_link.gd.uid @@ -0,0 +1 @@ +uid://bmma6gn72s36v diff --git a/game/tests/test_session_link.gd b/game/tests/test_session_link.gd new file mode 100644 index 0000000..90ca2d9 --- /dev/null +++ b/game/tests/test_session_link.gd @@ -0,0 +1,141 @@ +extends TestCase +## A host and guests in one process, each with its own GwSession and +## NetLink, connected over the loopback interface. + +var host_key := PackedByteArray([1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1]) +var guest_key := PackedByteArray([2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2]) + +var host: SessionLink +## Notices per SessionLink, collected while frames pass. +var notices: Dictionary[SessionLink, Array] = {} + + +func _start_host() -> void: + var session := GwSession.new() + session.start_new(7) + session.join(host_key, "Host") + session.step() + var link := NetLink.new() + check_eq(link.host(0, "127.0.0.1"), OK, "the host listens") + host = SessionLink.new(session, link) + notices[host] = [] + + +func _start_guest(key: PackedByteArray, name: String) -> SessionLink: + var session := GwSession.new() + session.start_guest(key, name) + var link := NetLink.new() + link.join("127.0.0.1", host.link.local_port()) + var guest := SessionLink.new(session, link) + notices[guest] = [] + return guest + + +## Lets `frames` frames pass. The host runs a tick per frame if `ticking`; +## every guest runs what arrived. +func _play(guests: Array[SessionLink], frames: int, ticking := true) -> void: + for frame in frames: + notices[host].append_array(host.pump(1.0 / 60.0)) + if ticking: + host.session.step() + host.flush() + for guest in guests: + notices[guest].append_array(guest.pump(1.0 / 60.0)) + for i in guest.session.pending_ticks(): + guest.session.step() + await tree.process_frame + + +func _types(link: SessionLink) -> Array: + return notices[link].map(func(notice: Dictionary) -> String: return notice["type"]) + + +func _first(link: SessionLink, type: String) -> Dictionary: + for notice: Dictionary in notices[link]: + if notice["type"] == type: + return notice + return {} + + +func _close(links: Array[SessionLink]) -> void: + for link in links: + link.link.close() + + +func test_a_guest_joins_over_the_network_and_follows_the_host() -> void: + _start_host() + var guest := _start_guest(guest_key, "Grace") + await _play([guest], 40) + await _play([guest], 10, false) + + check(_types(host).has("guest_arrived"), "the host sees the guest arrive") + check_eq(_first(guest, "world_received").get("resync"), false, "the guest got the host's world") + check_eq(guest.session.local_player(), 1, "the guest is player 1") + check_eq(guest.session.player_name(0), "Host", "the guest knows the host's name") + check_eq(guest.session.tick(), host.session.tick(), "the guest is at the host's tick") + check_eq(guest.session.state_hash_hex(), host.session.state_hash_hex(), "both worlds are the same") + check_eq(guest.session.save().size(), 0, "a guest does not save the host's world") + _close([host, guest]) + + +func test_a_hit_by_the_guest_reaches_the_host() -> void: + _start_host() + var guest := _start_guest(guest_key, "Grace") + await _play([guest], 30) + var rock := host.session.spawn_point() + Vector2i(7, -2) + guest.session.dig(rock.x, rock.y) + await _play([guest], 10) + await _play([guest], 10, false) + var index := rock.y * host.session.map_width() + rock.x + check_eq(host.session.damage_codes()[index], 1, "the host's rock took the hit") + check_eq(guest.session.state_hash_hex(), host.session.state_hash_hex(), "both worlds are the same") + _close([host, guest]) + + +func test_figures_travel_to_the_other_machines() -> void: + _start_host() + var guest := _start_guest(guest_key, "Grace") + var other := _start_guest(PackedByteArray([3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3]), "Linus") + await _play([guest, other], 30) + host.send_figure(Vector2(10.0, 20.0)) + guest.send_figure(Vector2(5.5, 6.5)) + await _play([guest, other], 10) + + check_eq(_first(host, "figure"), {"type": "figure", "player": 1, "position": Vector2(5.5, 6.5)}, "the host sees the guest") + var seen := notices[other].filter(func(notice: Dictionary) -> bool: return notice["type"] == "figure") + check(seen.has({"type": "figure", "player": 0, "position": Vector2(10.0, 20.0)}), "the other guest sees the host") + check(seen.has({"type": "figure", "player": 1, "position": Vector2(5.5, 6.5)}), "and the first guest, passed on by the host") + check(not notices[guest].any(func(notice: Dictionary) -> bool: return notice["type"] == "figure" and notice["player"] == 1), "nobody gets their own figure back") + _close([host, guest, other]) + + +func test_a_guest_with_the_key_of_a_player_in_the_world_is_turned_away() -> void: + _start_host() + var twin := _start_guest(host_key, "Host") + await _play([twin], 30) + check_eq(_first(twin, "refused").get("reason"), GwSession.REFUSED_ALREADY_PLAYING, "the reason is given") + check(_types(twin).has("host_lost"), "then the host closes the connection") + check(not twin.session.is_running(), "the guest never got a world") + _close([host, twin]) + + +func test_a_guest_that_quits_leaves_the_world() -> void: + _start_host() + var guest := _start_guest(guest_key, "Grace") + await _play([guest], 30) + check_eq(host.session.online_players(), PackedInt64Array([0, 1]), "two players online") + guest.link.close() + await _play([], 10) + check(_types(host).has("guest_left"), "the host sees the guest leave") + check_eq(host.session.online_players(), PackedInt64Array([0]), "only the host is online") + _close([host]) + + +func test_a_guest_learns_when_the_host_is_gone() -> void: + _start_host() + var guest := _start_guest(guest_key, "Grace") + await _play([guest], 30) + host.link.close() + await _play([guest], 10, false) + check(_types(guest).has("host_lost"), "the guest notices") + _close([guest]) diff --git a/game/tests/test_session_link.gd.uid b/game/tests/test_session_link.gd.uid new file mode 100644 index 0000000..1ccdbae --- /dev/null +++ b/game/tests/test_session_link.gd.uid @@ -0,0 +1 @@ +uid://c6tn270be6rbu