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