OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-tps / scenes / test / gate_bot_call_scope.gd

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