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