OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-tps / scenes / test / gate_reconnect_rearm.gd

166 lines · gd ref:
extends SceneTree
## Gate: a room member's stable DHT public key survives in DURABLE presence, so a
## restarted peer can re-arm swarm_join_peer for members it has never met.
##
## THE BUG THIS EXISTS FOR. game.gd learned a peer's public key in exactly one
## place -- _on_peer_connected (:482) -- kept it in the in-memory _peer_keys
## (:61), and wiped it in reset_for_rejoin (:461). swarm_join_peer was therefore
## reachable only from _on_peer_disconnected (:497), and only for a peer we had
## already met THIS session. After a restart the table was empty, so nothing was
## ever marked explicit.
##
## ⛔ THE RETRY WAS NEVER MISSING. hyper-games #77 item 2 reads the one-shot
## swarm_join_peer call as the defect ("nothing retries"). It is not. Read
## hyperswarm-peer/src/swarm_manager.cpp: join_peer() sets is_explicit, and
## _select_timer() sends explicit peers with attempts > 3 to _timer_x while
## NON-explicit peers get nullptr, i.e. give up. update_priority() then pins an
## explicit peer at attempts = 3 for ever (deprioritize()), so an explicit peer
## retries indefinitely. One call is the correct idiom -- it is JS hyperswarm's
## joinPeer. What was missing was the KEY to call it with.
##
## Presence is the right home for it: already DURABLE, already room-global,
## already self-written and memberId-keyed, and already overwritten per identity
## per session, so it does not accumulate the way admin:cmd: does (#77 item 5).
## And the DHT keypair is stable across restarts -- menu_main.gd persists it at
## user://dht_key_<memberId>.hex -- so a stored key is still dialable tomorrow.

var ok := true
var _ran := false


## Minimal stand-in for the GameState autoload. Presence only needs set_state /
## get_state / erase_state / keys_with_prefix and the state_changed signal.
class StubState extends Node:
	signal state_changed(key: String, value: Variant, channel: int)
	var store: Dictionary = {}

	func set_state(key: String, value: Variant) -> void:
		store[key] = value
		state_changed.emit(key, value, 0)

	func get_state(key: String, default: Variant = null) -> Variant:
		return store.get(key, default)

	func erase_state(key: String) -> void:
		store.erase(key)
		state_changed.emit(key, null, 0)

	func keys_with_prefix(prefix: String) -> Array[String]:
		var out: Array[String] = []
		for k: String in store.keys():
			if k.begins_with(prefix):
				out.append(k)
		return out


func _p(c: bool, m: String) -> void:
	print(("ok   " if c else "FAIL: ") + m)
	if not c:
		ok = false


func _process(_delta: float) -> bool:
	if _ran:
		return true
	_ran = true
	_run()
	print("GATE reconnect_rearm: %s" % ("PASS" if ok else "FAIL"))
	# Every other gate in this directory prints its verdict and exits 0, so a FAIL
	# cannot fail the build. This one sets the code as well.
	if not ok:
		quit(1)
	return true


func _run() -> void:
	# --path . loads project.godot, so the REAL GameState autoload already owns
	# /root/GameState and a second child of that name is silently renamed on
	# collision -- which left the stub inert and two checks below failing for the
	# wrong reason on the first run of this gate. Move the autoload aside so the
	# stub actually answers Presence's get_node_or_null("/root/GameState").
	var real_gs: Node = root.get_node_or_null("GameState")
	if real_gs != null:
		real_gs.name = "GameStateAutoload"

	var stub := StubState.new()
	stub.name = "GameState"
	root.add_child(stub)
	_p(root.get_node_or_null("GameState") == stub,
		"the stub owns /root/GameState (not renamed by an autoload collision)")

	var presence_script: GDScript = load("res://autoloads/presence.gd")
	_p(presence_script != null, "presence.gd loads")
	if presence_script == null:
		return

	var p: Node = presence_script.new()
	p.name = "PresenceUnderTest"
	root.add_child(p)

	# ---- A. the key reaches the durable entry --------------------------------
	var my_id := "member-aaa"
	var my_pk := "11223344556677889900aabbccddeeff11223344556677889900aabbccddeeff"

	_p(p.has_method("set_dht_public_key"),
		"Presence exposes set_dht_public_key() -- without it nothing can publish a dialable key")
	if p.has_method("set_dht_public_key"):
		p.set_dht_public_key(my_pk)

	p.announce_self(my_id, "peer", "tester")
	var entry: Variant = stub.get_state("presence:" + my_id)
	_p(entry is Dictionary, "announce_self wrote presence:%s" % my_id)
	if entry is Dictionary:
		var e: Dictionary = entry
		_p(e.has("dhtKey"), "the presence entry carries dhtKey")
		_p(str(e.get("dhtKey", "")) == my_pk, "dhtKey is the key we published")

	# ---- B. SELF-CHECK: the assertion above must be able to fail --------------
	# A presence entry built WITHOUT a published key is the pre-fix shape. If this
	# check cannot see the difference, test A passes for the wrong reason.
	var bare_id := "member-bare"
	var p2: Node = presence_script.new()
	p2.name = "PresenceNoKey"
	root.add_child(p2)
	p2.announce_self(bare_id, "peer", "keyless")
	var bare: Variant = stub.get_state("presence:" + bare_id)
	_p(bare is Dictionary and not (bare as Dictionary).has("dhtKey"),
		"self-check: an entry with no published key carries no dhtKey")

	# ---- C. the room's keys come back, minus our own -------------------------
	_p(p.has_method("get_peer_dht_keys"),
		"Presence exposes get_peer_dht_keys() -- the seed for re-arming")
	if p.has_method("get_peer_dht_keys"):
		# Exactly what autobase replication does when a remote member's entry lands.
		# 64 hex chars exactly. The first draft of this line had 65 and the gate
		# failed -- correctly: get_peer_dht_keys() drops a malformed key rather
		# than hand a 33-byte "public key" to swarm_join_peer.
		var other_pk := "aabbccddeeff00112233445566778899aabbccddeeff00112233445566778899"
		p._on_state_changed("presence:member-bbb",
			{"memberId": "member-bbb", "ts": 1.0, "role": "peer", "dhtKey": other_pk}, 0)
		p._on_state_changed("presence:member-ccc",
			{"memberId": "member-ccc", "ts": 1.0, "role": "peer"}, 0)
		p._on_state_changed("presence:" + my_id,
			{"memberId": my_id, "ts": 1.0, "role": "peer", "dhtKey": my_pk}, 0)

		var keys: Array = p.get_peer_dht_keys()
		_p(keys.has(other_pk), "a remote member's dhtKey is returned")
		_p(not keys.has(my_pk), "our OWN key is excluded -- dialing yourself is not a reconnect")
		_p(keys.size() == 1, "a member with no dhtKey is skipped, not returned empty (got %d)" % keys.size())

	# ---- D. game.gd actually re-arms from presence ----------------------------
	# Structural: the wiring is a connect() plus a call, and instancing Game here
	# would pull in maps, a spawner and the HUD to verify one signal.
	var src: String = FileAccess.get_file_as_string("res://scenes/game/game.gd")
	_p(src != "", "game.gd readable")
	if src != "":
		_p(src.contains("presence_changed"),
			"game.gd subscribes to Presence.presence_changed")
		_p(src.contains("_rearm_known_peers"),
			"game.gd has a re-arm path")
		# The re-arm must not be reachable ONLY from the disconnect handler, which
		# is the shape that shipped.
		var idx: int = src.find("func _on_peer_disconnected")
		var before: String = src.substr(0, idx) if idx > 0 else src
		_p(before.contains("_rearm_known_peers"),
			"the re-arm is wired somewhere OTHER than _on_peer_disconnected")