extends Node
## Presence — autobase-backed live peer list (memberId-keyed).
##
## Phases 1 + 2.5 of hyper-fleeky-dev#32.
##
## Model: each peer writes only its OWN presence entry, keyed on the
## stable Keet memberId (NOT per-session DHT pk). New sessions for the
## same identity overwrite the previous entry naturally, so stale data
## doesn't accumulate. Remote peers' presence comes in via autobase
## replication of their own writes — we never write on their behalf.
##
## Graceful exit erases our own entry. Ungraceful crash: next session
## of the same identity overwrites; until then the stale entry persists
## in autobase (no heartbeat for now). /who displays everything; if
## that becomes confusing, we add a freshness filter or heartbeat.
##
## API:
## Presence.get_presence() — pk_or_memberId → entry map (all)
## Presence.get_count() — total entries
## Presence.has_member(id) — true if id is in the map
## Presence.presence_changed — signal fires on any change
## Presence.announce_self(...) — write our entry (auto-called)
## Presence.depart_self() — erase our entry (auto-called on quit)
signal presence_changed(presence_map: Dictionary)
const PREFIX := "presence:"
const KeetNames := preload("res://addons/keet_social/keet_names.gd")
# Authoritative local mirror, built from autobase state_changed events.
# Keyed by stable identity (memberId for new entries; legacy pk_hex
# entries from pre-2.5 builds may also appear until they're overwritten
# or aged out by GameState's clear).
var _presence: Dictionary = {}
# Our own stable identity key (memberId). Set when KeetIdentity loads.
var _my_member_id: String = ""
var _my_display_name: String = ""
var _my_role: String = ""
var _announced: bool = false
## Our stable DHT public key, hex. Published in our own presence entry so other
## members can dial us directly after a restart.
##
## This is per-SESSION only in the sense that menu_main.gd hands it over once per
## launch -- the key itself is stable, persisted at user://dht_key_<memberId>.hex,
## precisely so restarts reuse one announce instead of littering the room topic
## with ephemeral keys. That stability is what makes storing it worth anything: a
## key recorded today is still dialable tomorrow.
var _my_dht_pk_hex: String = ""
func _ready() -> void:
# Wait one frame so other autoloads (GameState, KeetService) finish
# their _ready, then subscribe.
call_deferred("_wire_up")
func _wire_up() -> void:
var gs := get_node_or_null("/root/GameState")
if gs != null:
gs.state_changed.connect(_on_state_changed)
# Seed from any state already loaded.
var existing: Array[String] = gs.keys_with_prefix(PREFIX)
for key in existing:
var member_id: String = key.substr(PREFIX.length())
var value: Variant = gs.get_state(key)
if value != null:
_presence[member_id] = value
if _presence.size() > 0:
print("[presence] seeded %d entries from existing state" % _presence.size())
emit_signal("presence_changed", _presence.duplicate())
# Subscribe to identity + role + room signals so we know when to announce.
var ks := get_node_or_null("/root/KeetService")
if ks == null:
push_warning("[presence] KeetService not present; presence will not auto-announce")
return
if ks.identity != null:
ks.identity.identity_loaded.connect(_on_identity_loaded)
ks.role_changed.connect(_on_role_changed)
ks.room_selected.connect(_on_room_selected)
# In case identity already loaded before we got here, prompt a refresh.
if ks.identity != null:
ks.identity.get_identity()
func _on_identity_loaded(identity: Dictionary) -> void:
# NOT str(): str(null) is "<null>". A fresh keet identity has no displayName,
# and "<null>" would be written into presence as a name -- and a null memberId
# would pass _maybe_announce's `== ""` guard and announce under presence:<null>.
_my_member_id = KeetNames.clean(identity.get("memberId"))
var name := KeetNames.clean(identity.get("displayName"))
if name.is_empty():
name = KeetNames.clean(identity.get("username"))
_my_display_name = name
_maybe_announce()
func _on_role_changed(_room_id: String, role: String) -> void:
_my_role = role
_maybe_announce()
func _on_room_selected(_room_id: String, _title: String) -> void:
# Force a re-announce when the user enters a room — our entry's
# ts updates, signaling "I'm freshly present in THIS room session."
_announced = false
_maybe_announce()
# Announce self if we have enough info (identity + room).
func _maybe_announce() -> void:
if _my_member_id == "":
return
var ks := get_node_or_null("/root/KeetService")
if ks == null or ks.get_current_room() == "":
return
announce_self(_my_member_id, _my_role, _my_display_name)
## Write our own presence to the autobase. Idempotent; safe to call repeatedly.
## member_id is the stable Keet memberId — overwrites any prior entry for
## the same identity, including stale entries from crashed sessions.
func announce_self(member_id: String, role: String = "peer", display_name: String = "") -> void:
if member_id == "":
return
var gs := get_node_or_null("/root/GameState")
if gs == null:
return
var entry := {
"memberId": member_id,
"ts": Time.get_unix_time_from_system(),
"role": role if role != "" else "peer"
}
if display_name != "":
entry["name"] = display_name
# The dialable address. Omitted rather than written empty when we don't have a
# key yet: get_peer_dht_keys() skips entries without one, and an empty string
# there would be a key that fails to dial rather than a member to skip.
if _my_dht_pk_hex != "":
entry["dhtKey"] = _my_dht_pk_hex
gs.set_state(PREFIX + member_id, entry)
_announced = true
print("[presence] announce_self %s role=%s" % [member_id.left(12), role])
## Erase our own entry. Called on shutdown.
func depart_self() -> void:
if _my_member_id == "":
return
var gs := get_node_or_null("/root/GameState")
if gs == null:
return
gs.erase_state(PREFIX + _my_member_id)
_announced = false
print("[presence] depart_self %s" % _my_member_id.left(12))
func _on_state_changed(key: String, value: Variant, _channel: int) -> void:
if not key.begins_with(PREFIX):
return
var id_key: String = key.substr(PREFIX.length())
var changed := false
if value == null:
if _presence.has(id_key):
_presence.erase(id_key)
changed = true
else:
var prior: Variant = _presence.get(id_key, null)
if prior != value:
_presence[id_key] = value
changed = true
if changed:
emit_signal("presence_changed", _presence.duplicate())
# ── Public accessors ────────────────────────────────────────────────
## Publish our stable DHT public key. Called by menu_main.gd once the keypair is
## loaded. Re-announces if we have already written an entry, so the order in
## which identity, role and the DHT key arrive does not matter -- whichever lands
## last rewrites the entry with everything known so far.
func set_dht_public_key(pk_hex: String) -> void:
var clean: String = pk_hex.strip_edges().to_lower()
if clean == _my_dht_pk_hex:
return
_my_dht_pk_hex = clean
if _announced:
announce_self(_my_member_id, _my_role, _my_display_name)
else:
_maybe_announce()
## Every OTHER member's stable DHT public key, hex.
##
## This is the durable room membership hyper-games #77 asks reconnect to be
## seeded from. It answers a strictly larger question than "who have I met":
## presence replicates, so this returns members this peer has NEVER connected to,
## which is exactly the case a restarted peer is in.
##
## Entries without a dhtKey are skipped, not returned empty -- a member running a
## build that does not publish one is not dialable, and a blank key would turn
## that into a failing dial instead of a member we cannot reach yet.
func get_peer_dht_keys() -> Array[String]:
var out: Array[String] = []
for id_key: String in _presence:
var entry: Variant = _presence[id_key]
if not (entry is Dictionary):
continue
var pk: String = str((entry as Dictionary).get("dhtKey", "")).strip_edges().to_lower()
if pk.length() != 64:
continue
# Never dial ourselves. Matched on the KEY, not the memberId: the key is
# what swarm_join_peer acts on, and it is the one field guaranteed present
# here. A second device on the same identity is a different key and a
# genuine peer, so memberId is the wrong thing to match on anyway.
if pk == _my_dht_pk_hex:
continue
if not out.has(pk):
out.append(pk)
return out
func get_presence() -> Dictionary:
return _presence.duplicate()
func get_count() -> int:
return _presence.size()
func has_member(member_id: String) -> bool:
return _presence.has(member_id)