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")