M1c: Gleichschritt im Netz #9

Merged
steven merged 20 commits from m1c-gleichschritt-im-netz into main 2026-10-02 06:41:02 +00:00
8 changed files with 533 additions and 0 deletions
Showing only changes of commit 0917450014 - Show all commits
+154
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
uid://dmyy8qbtuhhdh
+103
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
uid://dpau4ls365tsj
+131
View File
@@ -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)
+1
View File
@@ -0,0 +1 @@
uid://bmma6gn72s36v
+141
View File
@@ -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])
+1
View File
@@ -0,0 +1 @@
uid://c6tn270be6rbu