OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-tps / autoloads / admin_facade.gd

814 lines · gd ref:
extends Node
## Admin Facade — reflective control surface for the dedicated server.
##
## Indexes every node in group "admin_controllable" and exposes a uniform
## list/get/set/call API over them. Two scopes:
##   /world/...   — nodes inside get_tree().current_scene (re-indexed on
##                  scene change). Default scope.
##   /global/...  — nodes with set_meta("admin_scope", "global") and the
##                  registered admin-controllable autoloads. Persists
##                  across scenes.
##
## Properties exposed: @export properties on indexed nodes by default, or
## explicit set_meta("admin_exposed", true) per-property for opt-in mode.
## Methods exposed: only those listed in set_meta("admin_exposed_methods",
## [...]) — never auto-exposed (side effects too costly to guess at).
##
## This stub only indexes; list/get/set/call land in subsequent tasks.

## Fired after a (re)scan completes. Listeners (e.g. TUI subscribers) can
## use this to invalidate any cached path listings.
signal index_rebuilt(world_count: int, global_count: int)

## Lazy short-name → node-path lookup. Built by _scan(). Reset on scene
## change so /world paths always reflect the active scene tree.
var _world_index: Dictionary = {}     # node-path-string → Node (current scene)
var _global_index: Dictionary = {}    # node-path-string → Node (persistent)

const GROUP_NAME := "admin_controllable"
const SCOPE_META := "admin_scope"     # "global" → indexed under /global/
const NAME_META := "admin_name"       # overrides last path segment
const EXPOSED_META := "admin_exposed" # per-property opt-in flag
const METHODS_META := "admin_exposed_methods"  # array of method names
const ROLE_META := "admin_role"       # LEGACY, unreachable — see below

## ⛔ WHY THE PER-PROPERTY/PER-METHOD METAS ARE DICTIONARIES, NOT `a:b` KEYS.
##
## This facade used to compose keys like `admin_role:<prop>:write` and
## `admin_call_scope:<method>`. NOTHING COULD EVER SET THEM. Object::set_meta
## rejects any name failing is_valid_ascii_identifier():
##
##     core/object/object.cpp:1036
##     ERR_FAIL_COND_MSG(!p_name.operator String().is_valid_ascii_identifier(),
##                       "Invalid metadata identifier: '%s'.")
##
## and a colon is not an identifier character. `git log -L` dates that guard to
## 2022-03-23, so it has held for ALL of Godot 4 — the convention never worked on
## any version. Confirmed: zero writers anywhere in this repo's history. Every
## call therefore silently used the defaults (read=peer, write=admin, call=admin,
## scope=authority), including the per-method "local" scope that a73ca20
## introduced Phase 3 of #32 specifically to enable.
##
## One Dictionary per concern, colon-free, so the keys are settable:
##   set_meta("admin_exposed_props", {"current_time": true})
##   set_meta("admin_roles", {"current_time": {"read": "peer", "write": "mod"}})
##   set_meta("admin_call_scopes", {"sync_and_run": "local"})
const EXPOSED_PROPS_META := "admin_exposed_props"   # {prop: bool} or [prop, ...]
const ROLES_META := "admin_roles"                   # {name: {read/write/call: role}}

const CALL_SCOPES_META := "admin_call_scopes"       # {method: "local"|"authority"}

## Verbose per-event state logging. When true, EVERY GameState.state_changed
## event is written to stderr + disk via _diag(). At ~380 writes/sec (creature
## position sync at 10Hz × dozens of creatures) that is synchronous main-thread
## file I/O on every event — a severe frame-rate sink on the graphical client.
## Default OFF; flip to true only when diagnosing which keys reach the facade.
const VERBOSE_STATE_LOG := false

## admin_path (e.g. "/world/Environment/sky") → Node
var _by_admin_path: Dictionary = {}

## Pending state writes — values from state:* that arrived before the
## target node was indexed. Applied on next _scan that brings them in.
## Key format: "<admin_path>.<prop>" matching the state:* suffix.
var _pending_state: Dictionary = {}


func _ready() -> void:
	_diag("_ready fired")
	# Initial scan once the current scene exists.
	if get_tree().current_scene != null:
		_scan()
	# Re-scan on scene change so /world reflects the new scene.
	get_tree().tree_changed.connect(_on_tree_changed)
	# Also re-scan when nodes are added/removed mid-scene, so newly-spawned
	# admin-controllable entities (e.g. bots) get picked up. Throttled via
	# a one-shot deferred call to avoid hammering on bulk spawns.
	get_tree().node_added.connect(_on_node_added)
	# Subscribe to admin:cmd:* via GameState. The GameState autoload may
	# not be ready yet (autoload order is alphabetical-ish), so defer.
	call_deferred("_subscribe_admin_cmds")


## Track commands we've already handled so duplicate state_changed events
## (e.g. from local echo + remote re-emit) don't double-dispatch.
var _seen_seqs: Dictionary = {}

func _subscribe_admin_cmds() -> void:
	# Use get_node_or_null so this script compiles even with the autoload
	# registry inert (e.g. --check-only). At runtime the autoload is at
	# /root/GameState per project.godot's [autoload] block.
	var gs := get_node_or_null("/root/GameState")
	if gs == null:
		push_warning("[admin-facade] GameState autoload not found — admin RPC channel disabled")
		return
	gs.state_changed.connect(_on_state_changed)
	_diag("subscribed to GameState.state_changed")


func _on_state_changed(key: String, value: Variant, _channel: int) -> void:
	# Log EVERY state_changed event — no prefix filter — so we can see
	# which keys actually reach the dispatcher. Gated: this fires per event
	# (~380/sec under creature sync) and each _diag() does synchronous disk
	# I/O on the main thread, so it stays OFF unless explicitly diagnosing.
	if VERBOSE_STATE_LOG:
		var v_preview: String = str(value).substr(0, 120)
		_diag("on_state_changed key=%s type=%d v=%s" % [key, typeof(value), v_preview])
	# Two namespaces, two flows:
	#   state:*       — canonical world state. Every peer applies value
	#                   directly to its local node (or caches pending).
	#                   New joiners see this on their first meta.get.
	#   admin:cmd:*   — RPC for ops that need result semantics (call_method,
	#                   list/get from a peer). Set/get-state ops bypass
	#                   this entirely and go straight to state:*.
	if key.begins_with("state:"):
		_apply_state(key.substr("state:".length()), value)
		return
	if not key.begins_with("admin:cmd:"):
		return
	var seq: String = key.substr("admin:cmd:".length())
	if seq == "" or _seen_seqs.has(seq):
		return
	_seen_seqs[seq] = true
	var cmd: Variant = value
	if cmd is String:
		var parsed: Variant = JSON.parse_string(cmd)
		if parsed != null:
			cmd = parsed
	if _command_already_applied(seq):
		_diag("skipping already-applied command seq=%s (durable history, not a live request)" % seq)
		return
	_dispatch_command(seq, cmd)


## True if this command has already been executed by whoever was the runner.
##
## admin:cmd: is a DURABLE route, so the room replicates every command ever
## issued, and _seen_seqs — the guard against re-dispatch — is in-memory and
## therefore empty in a fresh process. Whoever became authority on join
## re-executed the entire history. hyper-games #77 item 5: ashatron replayed
## stortron's walker and probe commands on a fresh join, against files that only
## exist on stortron.
##
## The marker is admin:result:<seq>, which already existed. _dispatch_command
## publishes it, and ONLY the runner does — a peer that deliberately skipped the
## call stays silent so last-write-wins cannot clobber the runner's answer. So
## the key's presence means exactly "someone ran this", which is the question.
##
## ⛔ A TTL WAS TRIED FIRST AND DID NOT WORK. lib/tui.js mints
## seq = Date.now().toString(36) + '-' + noise, so age looks readable off the
## seq. The real backlog does not use that format: the nine commands in the live
## room are hand-written — walk1789177841, probe1789180148, perfprobe1 — and
## every one of them is valid base36, so a parser reads them as absurd future
## timestamps and calls them fresh. The TTL covered 0 of 9. The marker covers
## 9 of 9, checked against that room.
##
## The daemon's own timestamp is not an option either: keet_game_state.gd's
## v3 state.list returns a FLAT {key: value}, and the {value, ts, _by} shape it
## replaced is gone, so no wall-clock reaches Godot at all.
##
## This bounds EXECUTION. The keys still accumulate; deleting them is a separate
## and destructive decision, deliberately not taken here.
func _command_already_applied(seq: String) -> bool:
	if seq == "":
		return false
	var gs := get_node_or_null("/root/GameState")
	if gs == null:
		return false
	return bool(gs.has_state("admin:result:" + seq))


## Apply a state:* entry's value to the local Godot node, if indexed.
## If not indexed yet, cache as pending; _scan replays pending entries
## for newly-indexed nodes. Idempotent — no-op if node already at value.
func _apply_state(path_with_prop: String, value: Variant) -> void:
	if value == null:
		# State erased upstream — leave the local node alone (we don't
		# know the "default" without re-reading the .tscn). Just drop
		# any pending entry.
		_pending_state.erase(path_with_prop)
		return
	var split := _split_path_prop(path_with_prop)
	if split.is_empty():
		return
	var node_path: String = split.node
	var prop_name: String = split.prop
	var n: Node = _by_admin_path.get(node_path)
	if n == null:
		# Not indexed yet — cache for the next _scan to replay.
		_pending_state[path_with_prop] = value
		return
	# Index hit — apply now. Skip if node value already matches to avoid
	# spurious set callbacks.
	var props := _list_props(n)
	if not props.has(prop_name):
		return
	var coerced: Variant = _coerce_value(value, props[prop_name].type)
	if coerced == null:
		return
	if n.get(prop_name) == coerced:
		return
	n.set(prop_name, coerced)
	_pending_state.erase(path_with_prop)


## Per-name role for one of "read" / "write" / "call", from the ROLES_META
## dictionary. `fallback` is the documented default for that verb.
func _role_for(n: Node, name: String, verb: String, fallback: String) -> String:
	var roles: Variant = n.get_meta(ROLES_META, {})
	if roles is Dictionary and (roles as Dictionary).has(name):
		var entry: Variant = (roles as Dictionary)[name]
		if entry is Dictionary and (entry as Dictionary).has(verb):
			return str((entry as Dictionary)[verb])
		if entry is String:
			return str(entry)   # shorthand: one role for every verb
	return fallback


## True if `prop` is explicitly opted in via EXPOSED_PROPS_META (dict or array).
func _prop_opted_in(n: Node, prop: String) -> bool:
	var ex: Variant = n.get_meta(EXPOSED_PROPS_META, null)
	if ex is Dictionary:
		return bool((ex as Dictionary).get(prop, false))
	if ex is Array:
		return (ex as Array).has(prop)
	return false


func _dispatch_command(seq: String, cmd: Variant) -> void:
	_diag("dispatch_command seq=%s cmd_type=%d cmd=%s" % [seq, typeof(cmd), str(cmd).substr(0, 200)])
	if not (cmd is Dictionary):
		_publish_result(seq, {"ok": false, "error": "command must be a dictionary"})
		return
	var op: String = str(cmd.get("op", ""))
	var path: String = str(cmd.get("path", ""))
	# THE ROLE COMES FROM WHO WROTE IT, NOT FROM WHAT IT CLAIMS. This read
	# cmd.requester_role, so any member could send {"requester_role": "admin"} (the
	# TUI did, hard-coded) and pass every mod/admin gate. requester_role_for() takes
	# the author from the key (admin:cmd:<memberId>:<seq>) and the role from keet's
	# room membership. ⚠ Interim: the author segment is still trusted — a modified
	# client could forge it. The real check is the daemon verifying the writer of
	# each entry (hyper-games #92 follow-up, owned by the keetd work).
	var role: String = requester_role_for(seq)
	var result: Dictionary
	match op:
		"list":
			result = list(path if path != "" else "/")
			result["ok"] = true
		"get":
			result = get_prop(path, role)
		"set":
			result = set_prop(path, cmd.get("value"), role)
		"call":
			var args: Variant = cmd.get("args", [])
			if not (args is Array):
				args = []
			result = call_method(path, args, role)
		_:
			result = {"ok": false, "error": "unknown op: " + op}
	# A peer that deliberately did not run the call must stay silent: all peers
	# write the same admin:result:<seq> key and last-write-wins would let a
	# non-runner clobber the runner's answer.
	if bool(result.get("skipped", false)):
		_diag("skipped publish for seq=%s (not the runner)" % seq)
		return
	_publish_result(seq, result)


func _publish_result(seq: String, result: Dictionary) -> void:
	# Result values include non-JSON-friendly types (Vector3, Color, etc.).
	# Serialize them to JSON-shaped equivalents so the TUI receives plain
	# data over the wire — the daemon's UDS uses JSON framing.
	var safe: Variant = _jsonify(result)
	var gs := get_node_or_null("/root/GameState")
	if gs == null:
		_diag("publish_result: GameState null seq=%s" % seq)
		return
	_diag("publish_result writing admin:result:%s safe_type=%s" % [seq, typeof(safe)])
	gs.set_state("admin:result:" + seq, safe)

func _diag(msg: String) -> void:
	if not OS.has_environment("HTPS_VERBOSE"):
		return  # gated — set HTPS_VERBOSE=1 to re-enable diagnostic logging
	var line := "[%s] [admin] %s" % [Time.get_datetime_string_from_system(), msg]
	# Dual-write: stderr survives the gameLog buffer (tagged [ERR] by TUI),
	# file survives buffer eviction. Whichever works, we have data.
	printerr("HTPSDIAG " + line)
	# READ_WRITE (mode 3) opens existing without truncating. WRITE_READ
	# (mode 7) and WRITE (mode 2) both TRUNCATE on every open — that
	# bug had us seeing only the most-recent diag line ever.
	var paths: Array[String] = ["/tmp/hyper-tps-diag.log",
		OS.get_user_data_dir().path_join("hyper-tps-diag.log")]
	for p in paths:
		var f := FileAccess.open(p, FileAccess.READ_WRITE)
		if f == null:
			# File doesn't exist yet — create with WRITE (truncates is fine,
			# nothing to preserve).
			f = FileAccess.open(p, FileAccess.WRITE)
		if f != null:
			f.seek_end()
			f.store_line(line)
			f.close()
			return


## Recursively convert Vector2/3, Color, NodePath into plain
## arrays/strings so the result can serialize through JSON frames.
func _jsonify(v: Variant) -> Variant:
	if v is Vector3:
		return [v.x, v.y, v.z]
	if v is Vector2:
		return [v.x, v.y]
	if v is Color:
		return [v.r, v.g, v.b, v.a]
	if v is NodePath:
		return str(v)
	if v is Dictionary:
		var d := {}
		for k in v.keys():
			d[str(k)] = _jsonify(v[k])
		return d
	if v is Array:
		var a: Array = []
		for x in v:
			a.append(_jsonify(x))
		return a
	return v


func _scan() -> void:
	_world_index.clear()
	_global_index.clear()
	_by_admin_path.clear()
	var current := get_tree().current_scene
	var nodes := get_tree().get_nodes_in_group(GROUP_NAME)
	for n in nodes:
		if not is_instance_valid(n):
			continue
		var scope := str(n.get_meta(SCOPE_META, "scene"))
		var godot_path := str(n.get_path())
		var admin_path := _compute_admin_path(n, scope, current)
		if scope == "global":
			_global_index[godot_path] = n
		else:
			_world_index[godot_path] = n
		_by_admin_path[admin_path] = n
	if OS.has_environment("HTPS_VERBOSE"):
		print("[admin-facade] indexed world=%d global=%d" % [_world_index.size(), _global_index.size()])
	for p in _by_admin_path.keys():
		print("[admin-facade]   %s ← %s" % [p, _by_admin_path[p].get_path()])
	# Replay any pending state:* entries that target newly-indexed
	# nodes. Walks _pending_state, applies what matches, leaves the
	# rest for the next scan.
	_replay_pending_state()
	# Also seed from any state:* keys already in GameState that we
	# haven't seen via state_changed (e.g. seeded by KeetGameState
	# before AdminFacade._ready ran). Hyperbee-view lookup, cheap.
	_seed_state_from_gamestate()
	emit_signal("index_rebuilt", _world_index.size(), _global_index.size())


func _replay_pending_state() -> void:
	if _pending_state.is_empty():
		return
	var still_pending: Dictionary = {}
	for path_with_prop in _pending_state.keys():
		var value: Variant = _pending_state[path_with_prop]
		var split := _split_path_prop(path_with_prop)
		if split.is_empty():
			continue
		var n: Node = _by_admin_path.get(split.node)
		if n == null:
			still_pending[path_with_prop] = value
			continue
		# Apply directly (no recursion through _apply_state to avoid
		# repopulating _pending_state).
		var props := _list_props(n)
		if not props.has(split.prop):
			continue
		var coerced: Variant = _coerce_value(value, props[split.prop].type)
		if coerced == null:
			continue
		if n.get(split.prop) != coerced:
			n.set(split.prop, coerced)
			print("[admin-facade] replayed state:%s = %s" % [path_with_prop, str(value)])
	_pending_state = still_pending


func _seed_state_from_gamestate() -> void:
	var gs := get_node_or_null("/root/GameState")
	if gs == null:
		return
	var state_keys: Array[String] = gs.keys_with_prefix("state:")
	for key in state_keys:
		var path_with_prop: String = key.substr("state:".length())
		# If we already have this node indexed, apply now. Otherwise
		# cache as pending — next scan that brings in the node replays.
		var split := _split_path_prop(path_with_prop)
		if split.is_empty():
			continue
		var value: Variant = gs.get_state(key)
		if value == null:
			continue
		if _by_admin_path.has(split.node):
			_apply_state(path_with_prop, value)
		else:
			_pending_state[path_with_prop] = value


## Build the admin-facing path for a node. For scope='scene' this is
## "/world/<relative-to-current-scene>" preserving intermediate node names.
## For scope='global' it's "/global/<admin_name or node.name>" (flat, since
## global nodes typically belong to autoloads or top-level managers).
## The admin_name meta on the leaf overrides the last segment in both cases.
func _compute_admin_path(n: Node, scope: String, current: Node) -> String:
	var leaf_name := str(n.get_meta(NAME_META, n.name))
	if scope == "global":
		return "/global/" + leaf_name
	# scene scope — relative to current_scene
	if current == null:
		return "/world/" + leaf_name
	var rel := current.get_path_to(n)  # NodePath relative to current scene
	var segments: PackedStringArray = []
	var rel_str := str(rel)
	# Strip leading "./"
	if rel_str.begins_with("./"):
		rel_str = rel_str.substr(2)
	elif rel_str == ".":
		rel_str = ""
	if rel_str != "":
		segments = rel_str.split("/")
	if segments.size() == 0:
		return "/world/" + leaf_name
	# Apply admin_name override on the leaf segment
	segments[segments.size() - 1] = leaf_name
	return "/world/" + "/".join(segments)


## List the admin surface at `path`. Returns:
##   { path, exists, controllable, children: [str], props: {name: {type, hint_string, value}}, methods: [str] }
##
## `path` "/" → top-level scopes. "/world" or "/global" → first-level
## children. Deeper paths return immediate-children + (if path maps to an
## indexed node) its properties + methods.
func list(path: String = "/") -> Dictionary:
	if path == "" or path == "/":
		return {
			"path": "/",
			"exists": true,
			"controllable": false,
			"children": ["world", "global"],
			"props": {},
			"methods": []
		}
	# Normalize trailing slash
	if path.length() > 1 and path.ends_with("/"):
		path = path.substr(0, path.length() - 1)
	var result := {
		"path": path,
		"exists": false,
		"controllable": false,
		"children": [],
		"props": {},
		"methods": []
	}
	# Direct match → indexed node
	if _by_admin_path.has(path):
		var n: Node = _by_admin_path[path]
		result.exists = true
		result.controllable = true
		result.props = _list_props(n)
		result.methods = _list_methods(n)
	# Children: any indexed admin_path that has `path/` as a strict prefix
	var prefix := path + "/"
	var seen_segments: Dictionary = {}
	for p in _by_admin_path.keys():
		if p.begins_with(prefix):
			var rest: String = p.substr(prefix.length())
			var next_segment: String = rest.split("/")[0]
			if not seen_segments.has(next_segment):
				seen_segments[next_segment] = true
	# Also allow path to exist as an intermediate (non-controllable) — if
	# any indexed paths live under it, the path is navigable.
	if seen_segments.size() > 0:
		result.exists = true
	result.children = seen_segments.keys()
	result.children.sort()
	return result


## Walk the node's exported properties + admin_exposed-meta properties.
## Each entry: { type: int, type_name: str, hint_string: str, value: Variant }
## Skips internal Godot properties (_-prefixed names, transient categories).
func _list_props(n: Node) -> Dictionary:
	var props := {}
	var require_explicit_optin: bool = bool(n.get_meta("admin_exposed_only", false))
	for entry in n.get_property_list():
		var name: String = entry.name
		if name.begins_with("_"):
			continue
		# PROPERTY_USAGE_EDITOR = 4 — proxy for "@export'd"
		var usage: int = entry.get("usage", 0)
		var is_export := (usage & 4) != 0
		var explicit := false
		explicit = _prop_opted_in(n, name)
		if not is_export and not explicit:
			continue
		if require_explicit_optin and not explicit:
			continue
		var value: Variant = n.get(name)
		props[name] = {
			"type": entry.type,
			"type_name": _type_to_string(entry.type),
			"hint_string": entry.get("hint_string", ""),
			"value": value
		}
	return props


## Return the list of method names this node opts in to expose. Methods
## are NEVER auto-exposed — must be listed in `admin_exposed_methods` meta.
func _list_methods(n: Node) -> Array:
	var raw: Variant = n.get_meta(METHODS_META, [])
	if not (raw is Array):
		return []
	var out: Array = []
	for m in raw:
		if m is String:
			out.append(m)
	out.sort()
	return out


## Get a property at `path` (e.g. "/world/Environment/sky.time_of_day").
## Returns { ok: bool, error?: String, value?: Variant }.
## Read is open by default — set role-meta on the property to restrict.
func get_prop(path_with_prop: String, requester_role: String = "peer") -> Dictionary:
	var split := _split_path_prop(path_with_prop)
	if split.is_empty():
		return {"ok": false, "error": "expected <path>.<prop>"}
	var node_path: String = split.node
	var prop_name: String = split.prop
	if not _by_admin_path.has(node_path):
		return {"ok": false, "error": "unknown path: " + node_path}
	var n: Node = _by_admin_path[node_path]
	var props := _list_props(n)
	if not props.has(prop_name):
		return {"ok": false, "error": "property not exposed: " + prop_name}
	var min_role := _role_for(n, prop_name, "read", "peer")
	if not _role_satisfies(requester_role, min_role):
		return {"ok": false, "error": "read requires role >= " + min_role}
	return {"ok": true, "value": n.get(prop_name), "type": props[prop_name].type_name}


## Set a property at `path` (e.g. "/world/Environment/sky.time_of_day") to
## `value`. Authority-only (only the multiplayer authority should be
## writing). Role-gated: default min_role is "admin" for writes.
## Returns { ok: bool, error?: String, value?: Variant (post-write) }.
func set_prop(path_with_prop: String, value: Variant, requester_role: String = "peer") -> Dictionary:
	# Note: any peer can now route a set through set_prop (no authority
	# gate). The actual mutation lands via the state:* channel — this
	# function validates + persists to autobase, then state_changed
	# fires on every peer (including this one) which applies the value
	# to the local Godot node. Idempotent.
	var split := _split_path_prop(path_with_prop)
	if split.is_empty():
		return {"ok": false, "error": "expected <path>.<prop>"}
	var node_path: String = split.node
	var prop_name: String = split.prop
	if not _by_admin_path.has(node_path):
		return {"ok": false, "error": "unknown path: " + node_path}
	var n: Node = _by_admin_path[node_path]
	var props := _list_props(n)
	if not props.has(prop_name):
		return {"ok": false, "error": "property not exposed: " + prop_name}
	var min_role := _role_for(n, prop_name, "write", "admin")
	if not _role_satisfies(requester_role, min_role):
		return {"ok": false, "error": "write requires role >= " + min_role}
	var coerced: Variant = _coerce_value(value, props[prop_name].type)
	if coerced == null and value != null and props[prop_name].type != TYPE_NIL:
		return {"ok": false, "error": "value type mismatch — expected " + props[prop_name].type_name}
	# Persist to autobase. _apply_state on every peer (including us)
	# will read this and apply to local nodes. JSON-friendly values
	# only — _jsonify ensures Vector3/Color etc. survive the wire.
	var gs := get_node_or_null("/root/GameState")
	if gs == null:
		return {"ok": false, "error": "GameState autoload not available"}
	gs.set_state("state:" + path_with_prop, _jsonify(coerced))
	return {"ok": true, "value": coerced}


## Invoke a method on the indexed node at `path` (e.g. "/world/Environment/sky.advance")
## with the given positional `args`. Method must be opted in via the
## node's `admin_exposed_methods` meta — no auto-exposure of methods (too
## risky for side effects). Authority-only; admin-role-only by default.
## Returns { ok: bool, error?: String, result?: Variant }.
func call_method(path_with_method: String, args: Array = [], requester_role: String = "peer") -> Dictionary:
	var split := _split_path_prop(path_with_method)
	if split.is_empty():
		return {"ok": false, "error": "expected <path>.<method>"}
	var node_path: String = split.node
	var method_name: String = split.prop
	if not _by_admin_path.has(node_path):
		return {"ok": false, "error": "unknown path: " + node_path}
	var n: Node = _by_admin_path[node_path]
	var exposed := _list_methods(n)
	if not exposed.has(method_name):
		return {"ok": false, "error": "method not exposed: " + method_name + " (add to admin_exposed_methods meta)"}
	# Per-method role override; default min for method calls = admin.
	var min_role := _role_for(n, method_name, "call", "admin")
	if not _role_satisfies(requester_role, min_role):
		return {"ok": false, "error": "call requires role >= " + min_role}
	if not n.has_method(method_name):
		return {"ok": false, "error": "method does not exist on node: " + method_name}
	# Phase 3 of #32: every peer dispatches admin cmds (no global gate).
	# For methods, per-method scope decides whether THIS peer should
	# actually invoke. Default "authority" — safe (only one peer runs
	# side effects). "local" — every peer runs (must be idempotent).
	# Future: "deterministic" for hash-elected single peer.
	var scope: String = str((n.get_meta(CALL_SCOPES_META, {}) as Dictionary).get(method_name, "authority"))
	if scope == "authority" and not _is_authority():
		# ⛔ NOT ok:true. Two reasons, and the second is a data race.
		# 1. admin-cli cannot tell "ran, returned null" from "did not run".
		# 2. EVERY peer dispatches (Phase 3 of #32) and every peer publishes to
		#    the SAME admin:result:<seq> key, which is LWW-by-ts in StateBaseV3.
		#    A non-authority peer's ok:true/result:null could therefore overwrite
		#    the authority's real result. _dispatch_command now suppresses the
		#    publish entirely when `skipped` is set.
		return {"ok": false, "skipped": true,
			"error": "not authority (scope=authority) — this peer did not run it"}
	# scope == "local" or scope == "authority" + we ARE authority → run.
	# Coerce args to method signature where we can — Godot's method list
	# carries arg types. Best-effort: fall through with raw args if no
	# match, let GDScript error at call time.
	var coerced_args := _coerce_method_args(n, method_name, args)
	var result: Variant = n.callv(method_name, coerced_args)
	return {"ok": true, "result": result, "scope": scope}


## Try to coerce each positional arg to the method's declared parameter
## type. Falls back to the raw arg if no type info or coercion fails.
func _coerce_method_args(n: Node, method_name: String, args: Array) -> Array:
	var info: Dictionary = {}
	for m in n.get_method_list():
		if m.name == method_name:
			info = m
			break
	if info.is_empty() or not info.has("args"):
		return args
	var params: Array = info["args"]
	var out: Array = []
	for i in args.size():
		if i >= params.size():
			out.append(args[i])
			continue
		var target_type: int = params[i].get("type", TYPE_NIL)
		if target_type == TYPE_NIL or target_type == typeof(args[i]):
			out.append(args[i])
			continue
		var coerced: Variant = _coerce_value(args[i], target_type)
		out.append(coerced if coerced != null else args[i])
	return out


## Split "/world/foo/bar.prop" → {node: "/world/foo/bar", prop: "prop"}.
## Returns empty dict if no '.' separator found.
func _split_path_prop(s: String) -> Dictionary:
	var i := s.rfind(".")
	if i <= 0 or i == s.length() - 1:
		return {}
	return {"node": s.substr(0, i), "prop": s.substr(i + 1)}


## The role a command runs with, from its key's AUTHOR (`<memberId>:<seq>`, the part
## after "admin:cmd:"), looked up in keet's room membership:
##   online, author a member  -> that member's role (admin / mod / peer / read-only)
##   online, unknown author   -> "peer"   (no claim is honoured)
##   online, no author (a legacy "admin:cmd:<seq>" key) -> "peer"
##   OFFLINE (no room / roster): your own commands -> "admin" (it is your own world;
##     the headless gates run here), anyone else's -> "peer".
func requester_role_for(seq: String) -> String:
	var sep := seq.find(":")
	var author := seq.substr(0, sep) if sep > 0 else ""
	var ks: Node = get_node_or_null("/root/KeetService")
	var gs: Node = get_node_or_null("/root/GameState")
	var me: String = String(gs._my_member_id()) if gs != null and gs.has_method("_my_member_id") else ""
	var roster: Array = ks.get_room_members() if ks != null and ks.has_method("get_room_members") else []
	if roster.is_empty():
		return "admin" if author == "" or author == me else "peer"
	if author == "":
		return "peer"
	var r: String = String(ks.role_of_member(author)) if ks.has_method("role_of_member") else ""
	return r if r != "" else "peer"


## Role ordering: admin > mod > peer > anonymous.
## Returns true if `have` satisfies `need`.
func _role_satisfies(have: String, need: String) -> bool:
	var order := {"anonymous": 0, "peer": 1, "mod": 2, "admin": 3}
	return order.get(have, 0) >= order.get(need, 1)


## Returns true if we're the GAME's elected multiplayer authority — used
## to gate side-effect calls (admin_call_scope = "authority"). The game
## node's authority is set by menu_main.gd:_elect_authority based on
## Keet role + pk tie-break. NOT the same as Godot's "server" (peer_id 1)
## — election can pick any admin.
##
## Falls back to true on single-peer / no-multiplayer (unit tests, offline
## startup) so the facade is usable before a mesh exists.
func _is_authority() -> bool:
	if multiplayer == null:
		return true
	var peer := multiplayer.multiplayer_peer
	if peer == null:
		return true
	# Ask the Game node who the authority is (set by _elect_authority).
	var game := get_node_or_null("/root/Game")
	if game == null:
		# No game scene yet — treat ourselves as authority (single peer).
		return true
	return game.get_multiplayer_authority() == multiplayer.get_unique_id()


## Coerce a value into the target Godot type. Returns null if coercion
## fails. Arrays-of-floats coerce into Vector2/Vector3/Color for wire
## convenience (JSON has no native vector type).
func _coerce_value(value: Variant, target_type: int) -> Variant:
	if value == null:
		return null
	var cur := typeof(value)
	if cur == target_type:
		return value
	match target_type:
		TYPE_FLOAT:
			if cur == TYPE_INT:
				return float(value)
			return null
		TYPE_INT:
			if cur == TYPE_FLOAT:
				return int(value)
			return null
		TYPE_BOOL:
			if cur == TYPE_INT or cur == TYPE_FLOAT:
				return bool(value)
			return null
		TYPE_VECTOR3:
			if value is Array and value.size() == 3:
				return Vector3(value[0], value[1], value[2])
			return null
		TYPE_VECTOR2:
			if value is Array and value.size() == 2:
				return Vector2(value[0], value[1])
			return null
		TYPE_COLOR:
			if value is Array and value.size() >= 3:
				var a: float = (value[3] if value.size() >= 4 else 1.0)
				return Color(value[0], value[1], value[2], a)
			if value is String:
				return Color(value)
			return null
		TYPE_STRING:
			return str(value)
	return null


func _type_to_string(t: int) -> String:
	# Subset of TYPE_* enum names we'll see on @export properties.
	match t:
		TYPE_NIL: return "nil"
		TYPE_BOOL: return "bool"
		TYPE_INT: return "int"
		TYPE_FLOAT: return "float"
		TYPE_STRING: return "String"
		TYPE_VECTOR2: return "Vector2"
		TYPE_VECTOR3: return "Vector3"
		TYPE_COLOR: return "Color"
		TYPE_NODE_PATH: return "NodePath"
		TYPE_ARRAY: return "Array"
		TYPE_DICTIONARY: return "Dictionary"
		_: return "Variant(%d)" % t


var _rescan_pending := false

func _on_tree_changed() -> void:
	_schedule_rescan()

func _on_node_added(n: Node) -> void:
	# Cheap pre-check: only schedule if the new node is in the group. Group
	# membership is set in _ready(), so we may miss it here if the node
	# hasn't run _ready yet — the tree_changed signal catches that path.
	if n.is_in_group(GROUP_NAME):
		_schedule_rescan()

func _schedule_rescan() -> void:
	if _rescan_pending:
		return
	_rescan_pending = true
	call_deferred("_do_rescan")

func _do_rescan() -> void:
	_rescan_pending = false
	_scan()