OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-tps / autoloads / presence.gd

234 lines · gd ref:
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)