extends SceneTree
## Gate: registering an owned bot runs on the AUTHORITY, not on every peer.
##
## THE BUG THIS EXISTS FOR. BotRegistry declared every admin method with call
## scope "local", so every peer that saw the admin:cmd: ran it. For a
## body=none brain that drives the local player that means EVERY peer's avatar
## starts walking and every player loses their keyboard, because the brain sets
## InputSync.driven = true on whatever body it finds. hyper-games #77 item 6.
## It was worked around by calling /global/scripts.run_file instead, which
## defaults to "authority".
##
## "local" was never right for a mutator here, in either body mode:
##
## body=position — register() writes the durable world:bots:registry: row and
## the realtime world:bots:pos: row. Every OTHER peer already materialises
## the marker and follows the position from those rows alone
## (_on_state_changed), so running register locally makes every peer write
## the same durable row and start a second simulator of the same bot.
## BotRegistry.move() says it outright: owned bots are room-authoritative,
## the authority moves them and everyone else renders the result.
##
## body=none — there is no row and no node, only a brain. A brain is a
## simulation, and a simulation belongs to one peer.
##
## ⚠ THE READ METHODS ARE LEFT "local" AND THAT IS NOT OBVIOUSLY RIGHT. list,
## bindings_of and is_registered return per-peer answers, and under "local"
## EVERY peer publishes to the same admin:result:<seq>, which is LWW in
## StateBaseV3 -- so the caller gets an arbitrary peer's roster. That is the
## same data race admin_facade.gd documents at its scope check. It is left alone
## here because making them "authority" would remove the ability to ask a
## specific non-authority peer what it thinks it is running, which is a real
## debugging need, and the choice deserves its own decision rather than being
## swept in with a bug fix.
var ok := true
var _ran := false
## Anything that CHANGES the room's bots. These must not run per-peer.
const MUTATORS := ["register", "rebind", "despawn"]
## Queries. Deliberately not asserted to be "authority" -- see the header.
const QUERIES := ["list", "bindings_of", "is_registered"]
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 bot_call_scope: %s" % ("PASS" if ok else "FAIL"))
if not ok:
quit(1)
return true
func _run() -> void:
var br: Node = root.get_node_or_null("BotRegistry")
_p(br != null, "BotRegistry autoload present")
if br == null:
return
# Read it exactly as admin_facade.gd does, so this tests the value that
# actually decides, not a copy of it.
var scopes: Dictionary = br.get_meta("admin_call_scopes", {}) as Dictionary
_p(not scopes.is_empty(), "BotRegistry declares admin_call_scopes")
if scopes.is_empty():
return
for m: String in MUTATORS:
var s: String = str(scopes.get(m, "authority"))
_p(s == "authority", "%s() is scope authority, not per-peer (got %s)" % [m, s])
# SELF-CHECK: the assertions above must be reading the real dictionary. If
# every method were missing, .get() would return the "authority" default and
# they would all pass against a node declaring nothing at all.
var declared := 0
for m: String in MUTATORS + QUERIES:
if scopes.has(m):
declared += 1
_p(declared == MUTATORS.size() + QUERIES.size(),
"self-check: all %d methods are present in the dictionary, so the " %
(MUTATORS.size() + QUERIES.size()) +
"assertions above read real values rather than the .get() default (got %d)" % declared)
# The exposed list and the scope list must not drift apart: a method exposed
# with no scope entry silently inherits "authority", which is the safe answer
# but not a declared one.
var exposed: Array = br.get_meta("admin_exposed_methods", []) as Array
for m: Variant in exposed:
_p(scopes.has(String(m)), "exposed method %s has a declared scope" % m)
# And the roles table must still gate the mutators at mod+, or scope is moot.
var roles: Dictionary = br.get_meta("admin_roles", {}) as Dictionary
for m: String in MUTATORS:
var r: Dictionary = roles.get(m, {}) as Dictionary
_p(str(r.get("call", "")) == "mod", "%s() still requires role mod" % m)