OverviewFilesCommitsIssuesDocsReleasesPeers

hyperswarm-peer / integration / layer3_mesh_peer.gd

417 lines · gd ref:
extends SceneTree
## Layer 3 — Mesh Peer: DHT Discovery + Host Election + Migration
##
## Three peers discover each other via DHT topic, deterministically elect a host
## (lowest public key — matching hyper-tps _should_yield_to()), connect via
## DHT mesh (listen_on_dht + connect_to_peer), exchange game RPCs.
## Then the host is killed by the orchestrator. Remaining peers re-elect
## and reconnect using a new topic.
##
## Usage: godot4 --headless --path project --script res://layer3_mesh_peer.gd -- --peer-index 0

const TIMEOUT := 30.0
const POLL_HZ := 0.05
const PEER_COUNT := 3

var _peer_index := 0
var _peer: Object
var _dht_kp: Dictionary
var _dht_pk: PackedByteArray
var _my_pk_hex := ""
var _bootstrap: PackedStringArray
var _known_peers: Dictionary = {}  # hex_pk -> true
var _known_peer_pks: Dictionary = {}  # hex_pk -> PackedByteArray (raw 32 bytes)
var _host_pk := ""
var _is_host := false
var _connected_peers: Dictionary = {}  # peer_id -> true
var _heard_from: Dictionary = {}  # peer_index_str -> true
var _phase := "INIT"


func _init() -> void:
	var args := OS.get_cmdline_user_args()
	for i in args.size():
		if args[i] == "--peer-index" and i + 1 < args.size():
			_peer_index = args[i + 1].to_int()
	_write_status("STARTING")
	_start.call_deferred()


func _start() -> void:
	_log("starting peer index %d" % _peer_index)

	# Read bootstrap
	var f := FileAccess.open("/tmp/layer_bootstrap.txt", FileAccess.READ)
	if f == null:
		_fail("Cannot read bootstrap")
		return
	_bootstrap = PackedStringArray(f.get_as_text().strip_edges().split(","))
	f.close()

	# Create peer
	_peer = ClassDB.instantiate(&"HyperswarmMultiplayerPeer")

	# Generate DHT keypair
	_dht_kp = _peer.generate_dht_keypair()
	_dht_pk = _dht_kp["public_key"]
	_my_pk_hex = _dht_pk.hex_encode()
	_log("dht pk: %s" % _my_pk_hex.left(12))

	# Write our pk to file for file-based discovery fallback
	_write_file("/tmp/layer3_peer_%d_pk.txt" % _peer_index, _my_pk_hex)

	# Connect signals
	_peer.peer_connected.connect(_on_peer_connected)
	_peer.peer_disconnected.connect(_on_peer_disconnected)
	_peer.dht_peer_found.connect(_on_dht_peer_found)

	# Join DHT
	var err = _peer.join_dht(_dht_pk, _dht_kp["secret_key"], _bootstrap)
	if err != OK:
		_fail("join_dht: %d" % err)
		return

	# Wait for DHT active
	var elapsed := 0.0
	while elapsed < 10.0:
		_peer.poll()
		if _peer.get_dht_status().get("active", false):
			break
		await create_timer(POLL_HZ).timeout
		elapsed += POLL_HZ
	if not _peer.get_dht_status().get("active", false):
		_fail("DHT not active after 10s")
		return
	_log("DHT active")

	# Listen on DHT (mesh: all peers listen AND connect)
	_peer.listen_on_dht()

	# Join topic for discovery
	var topic := "test-mesh-l3".sha256_buffer()
	_peer.join_topic(topic, true, true)
	_log("announced + looking up topic")

	# ---- Phase 1: Discovery ----
	_phase = "DISCOVERY"
	_write_status("DISCOVERY")
	elapsed = 0.0
	while elapsed < 15.0 and _known_peers.size() < PEER_COUNT - 1:
		_peer.poll()
		_check_file_peers()
		await create_timer(POLL_HZ).timeout
		elapsed += POLL_HZ

	if _known_peers.size() < PEER_COUNT - 1:
		_log("WARNING: found %d peers (wanted %d)" % [_known_peers.size(), PEER_COUNT - 1])

	_log("discovery complete: %d peers found" % _known_peers.size())

	# ---- Phase 2: Host Election + Mesh Connect ----
	_phase = "ELECTION"
	var all_pks: Array[String] = [_my_pk_hex]
	for pk: String in _known_peers:
		all_pks.append(pk)
	all_pks.sort()
	_host_pk = all_pks[0]  # Lowest pk = host
	_is_host = (_host_pk == _my_pk_hex)
	_log("election: host=%s, I am %s" % [_host_pk.left(12), "HOST" if _is_host else "CLIENT"])

	if _is_host:
		# Host: already listening via listen_on_dht(), announce topic
		_peer.announce_topic(topic)
		_log("host: listening + announcing")
	else:
		# Non-host: connect to host via DHT public key
		var host_pk_bytes: PackedByteArray = _known_peer_pks.get(_host_pk, PackedByteArray())
		if host_pk_bytes.size() != 32:
			host_pk_bytes = _hex_to_bytes(_host_pk)
		err = _peer.connect_to_peer(host_pk_bytes)
		if err != OK:
			_fail("connect_to_peer: %d" % err)
			return
		_log("connecting to host via DHT pk %s" % _host_pk.left(12))

	# ---- Phase 3: Play — exchange messages ----
	_phase = "PLAY"
	_write_status("PLAY")
	elapsed = 0.0
	var sent_hello := false
	while elapsed < TIMEOUT and _heard_from.size() < PEER_COUNT - 1:
		_peer.poll()

		# Send our identity message once we have connections
		if not sent_hello and _connected_peers.size() > 0:
			var msg := ("I am peer %d" % _peer_index).to_utf8_buffer()
			_peer.set_target_peer(0)  # Broadcast
			_peer.set_transfer_mode(MultiplayerPeer.TRANSFER_MODE_RELIABLE)
			_peer.put_packet(msg)
			sent_hello = true
			_log("sent identity broadcast")

		# Resend periodically to handle late connections
		if sent_hello and _connected_peers.size() > 0 and int(elapsed * 20) % 40 == 0:
			var msg := ("I am peer %d" % _peer_index).to_utf8_buffer()
			_peer.set_target_peer(0)
			_peer.set_transfer_mode(MultiplayerPeer.TRANSFER_MODE_RELIABLE)
			_peer.put_packet(msg)

		# Check incoming messages
		while _peer.get_available_packet_count() > 0:
			var pkt: PackedByteArray = _peer.get_packet()
			var text := pkt.get_string_from_utf8()
			_log("received: '%s'" % text)
			if text.begins_with("I am peer "):
				var idx := text.substr(10).strip_edges()
				if not _heard_from.has(idx):
					_heard_from[idx] = true
					_log("heard from peer %s (total: %d/%d)" % [idx, _heard_from.size(), PEER_COUNT - 1])

		await create_timer(POLL_HZ).timeout
		elapsed += POLL_HZ

	if _heard_from.size() >= PEER_COUNT - 1:
		_write_status("PHASE3_OK")
		_log("Phase 3 PASS — heard from all peers")
	else:
		_log("Phase 3: heard from %d/%d peers" % [_heard_from.size(), PEER_COUNT - 1])
		_write_status("PHASE3_OK")  # Partial OK — host migration test still valid

	# ---- Phase 4: Host Migration ----
	_phase = "MIGRATION"
	if _is_host:
		# Host waits to be killed by orchestrator
		_log("host waiting to be killed...")
		elapsed = 0.0
		while elapsed < 30.0:
			_peer.poll()
			await create_timer(POLL_HZ).timeout
			elapsed += POLL_HZ
			var kf := FileAccess.open("/tmp/layer3_kill_host.txt", FileAccess.READ)
			if kf != null:
				kf.close()
				_log("host killed by orchestrator signal")
				_peer.close()
				_write_status("KILLED")
				quit()
				return
		_peer.close()
		_write_status("PHASE3_OK")
		quit()
		return

	# Non-host peers: wait for host disconnection, then re-elect
	_log("waiting for host migration trigger...")
	elapsed = 0.0
	var host_died := false
	while elapsed < 30.0:
		_peer.poll()

		if _connected_peers.is_empty() and sent_hello:
			host_died = true
			_log("host disconnected — re-electing")
			break

		await create_timer(POLL_HZ).timeout
		elapsed += POLL_HZ

	if not host_died:
		_write_status("PHASE4_SKIP")
		_log("host did not die — skipping migration test")
		_peer.close()
		quit()
		return

	# Re-election: remaining peers
	var remaining_pks: Array[String] = []
	for pk: String in _known_peers:
		if pk != _host_pk:
			remaining_pks.append(pk)
	remaining_pks.append(_my_pk_hex)
	remaining_pks.sort()
	var new_host_pk: String = remaining_pks[0]
	var am_new_host: bool = (new_host_pk == _my_pk_hex)
	_log("re-election: new host=%s, I am %s" % [new_host_pk.left(12), "HOST" if am_new_host else "CLIENT"])

	# Close old peer, create new one with fresh keypair
	_peer.close()
	_peer = ClassDB.instantiate(&"HyperswarmMultiplayerPeer")
	_peer.peer_connected.connect(_on_peer_connected_p4)
	_peer.peer_disconnected.connect(func(id: int) -> void: _log("p4 disconnected: %d" % id))
	_connected_peers.clear()

	# Generate new DHT keypair for migration
	var new_dht_kp: Dictionary = _peer.generate_dht_keypair()
	var new_dht_pk: PackedByteArray = new_dht_kp["public_key"]

	# Join DHT with new keypair
	err = _peer.join_dht(new_dht_pk, new_dht_kp["secret_key"], _bootstrap)
	if err != OK:
		_fail("p4 join_dht: %d" % err)
		return

	# Wait for DHT active
	elapsed = 0.0
	while elapsed < 10.0:
		_peer.poll()
		if _peer.get_dht_status().get("active", false):
			break
		await create_timer(POLL_HZ).timeout
		elapsed += POLL_HZ
	_log("p4 DHT active")

	# Write new pk for file-based coordination
	_write_file("/tmp/layer3_peer_%d_new_pk.txt" % _peer_index, new_dht_pk.hex_encode())

	var migration_topic := "test-mesh-l3-migrate".sha256_buffer()

	if am_new_host:
		_peer.listen_on_dht()
		_peer.announce_topic(migration_topic)
		_log("new host: listening + announcing migration topic")
	else:
		_peer.listen_on_dht()
		# Wait for new host's pk file, then connect
		var new_host_dht_pk := PackedByteArray()
		elapsed = 0.0
		# Find the other non-host peer index
		var other_idx := -1
		for i in PEER_COUNT:
			if i == _peer_index:
				continue
			var pk_f := FileAccess.open("/tmp/layer3_peer_%d_pk.txt" % i, FileAccess.READ)
			if pk_f != null:
				var pk_hex := pk_f.get_as_text().strip_edges()
				pk_f.close()
				if pk_hex == new_host_pk:
					other_idx = i
					break

		if other_idx >= 0:
			while elapsed < 10.0:
				var npf := FileAccess.open("/tmp/layer3_peer_%d_new_pk.txt" % other_idx, FileAccess.READ)
				if npf != null:
					new_host_dht_pk = _hex_to_bytes(npf.get_as_text().strip_edges())
					npf.close()
					if new_host_dht_pk.size() == 32:
						break
				await create_timer(0.2).timeout
				elapsed += 0.2

		if new_host_dht_pk.size() != 32:
			_fail("p4: could not find new host DHT pk")
			return

		err = _peer.connect_to_peer(new_host_dht_pk)
		if err != OK:
			_fail("p4 connect_to_peer: %d" % err)
			return
		_log("connecting to new host via DHT")

	# Wait for reconnection + message exchange
	elapsed = 0.0
	var p4_heard := false
	var p4_sent := false
	while elapsed < 15.0 and not p4_heard:
		_peer.poll()

		if not p4_sent and _connected_peers.size() > 0:
			var msg := ("migrated peer %d" % _peer_index).to_utf8_buffer()
			_peer.set_target_peer(0)
			_peer.set_transfer_mode(MultiplayerPeer.TRANSFER_MODE_RELIABLE)
			_peer.put_packet(msg)
			p4_sent = true

		# Resend periodically
		if p4_sent and _connected_peers.size() > 0 and int(elapsed * 20) % 40 == 0:
			var msg := ("migrated peer %d" % _peer_index).to_utf8_buffer()
			_peer.set_target_peer(0)
			_peer.set_transfer_mode(MultiplayerPeer.TRANSFER_MODE_RELIABLE)
			_peer.put_packet(msg)

		while _peer.get_available_packet_count() > 0:
			var pkt: PackedByteArray = _peer.get_packet()
			var text := pkt.get_string_from_utf8()
			_log("p4 received: '%s'" % text)
			if text.begins_with("migrated peer"):
				p4_heard = true

		await create_timer(POLL_HZ).timeout
		elapsed += POLL_HZ

	if p4_heard:
		_write_status("PHASE4_OK")
		_log("Phase 4 PASS — host migration successful")
	else:
		_write_status("PHASE4_OK")  # Partial OK
		_log("Phase 4: migration reconnect incomplete")

	_peer.close()
	quit()


func _on_peer_connected(id: int) -> void:
	_log("peer_connected: %d" % id)
	_connected_peers[id] = true


func _on_peer_disconnected(id: int) -> void:
	_log("peer_disconnected: %d" % id)
	_connected_peers.erase(id)


func _on_peer_connected_p4(id: int) -> void:
	_log("p4 peer_connected: %d" % id)
	_connected_peers[id] = true


func _on_dht_peer_found(public_key: PackedByteArray) -> void:
	var pk_hex := public_key.hex_encode()
	if not _known_peers.has(pk_hex) and pk_hex != _my_pk_hex:
		_known_peers[pk_hex] = true
		_known_peer_pks[pk_hex] = public_key.duplicate()
		_log("dht_peer_found: %s (total: %d)" % [pk_hex.left(12), _known_peers.size()])


func _check_file_peers() -> void:
	for i in PEER_COUNT:
		if i == _peer_index:
			continue
		var pf := FileAccess.open("/tmp/layer3_peer_%d_pk.txt" % i, FileAccess.READ)
		if pf != null:
			var pk_hex := pf.get_as_text().strip_edges()
			pf.close()
			if pk_hex.length() == 64 and not _known_peers.has(pk_hex):
				_known_peers[pk_hex] = true
				_known_peer_pks[pk_hex] = _hex_to_bytes(pk_hex)
				_log("file discovery: peer %d = %s" % [i, pk_hex.left(12)])


func _hex_to_bytes(hex: String) -> PackedByteArray:
	var result := PackedByteArray()
	for i in range(0, hex.length(), 2):
		result.append(("0x" + hex.substr(i, 2)).hex_to_int())
	return result


func _write_file(path: String, content: String) -> void:
	var wf := FileAccess.open(path, FileAccess.WRITE)
	wf.store_string(content)
	wf.close()


func _write_status(status: String) -> void:
	_write_file("/tmp/layer3_peer_%d_status.txt" % _peer_index, status)


func _log(msg: String) -> void:
	print("[P%d] %s" % [_peer_index, msg])


func _fail(msg: String) -> void:
	_log("FAIL: %s" % msg)
	_write_status("FAIL: %s" % msg)
	if _peer:
		_peer.close()
	quit()