OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-tps / autoloads / script_host.gd

382 lines · gd ref:
extends Node
## Compile and run a GDScript file at runtime. The execution half of
## spec/bot-testbed.md (d); AssetSync already does the sync half.
##
## ⚠ NO `class_name`: registered as the autoload singleton `ScriptHost`, and a
## global class of the same name makes Godot refuse to compile it. Tests
## instantiate the script directly with load(...).new(), as asset_sync.gd does.
##
## WHAT THIS IS FOR. A behaviour -- a bot brain, a world tweak, a probe -- should
## be a file you can edit and re-run without a rebuild or a redeploy, and that
## every peer in the room picks up. The sync is not this file's job: a script is
## an asset of kind "script", it is NOT in AssetSync.IMMUTABLE_KINDS, so it gets
## the MUTABLE rule (world:assets:script:<name> -> newest publish wins), which is
## exactly "edit the file and it propagates".
##
## ⛔ TRUST. There is NO sandbox here. A compiled script has the full engine:
## OS, FileAccess, DirAccess, every singleton. GDScript is for TRUSTED peers only.
## The trust decision is NOT made in this file and must not be: it belongs at the
## POINTER, because a content hash is an address, not a proof
## (keet_tile_index.gd:11-13). StateBase writes are already role-gated by room
## membership, so the room roles ARE the gate -- whoever could publish the pointer
## was already allowed to. This file only asks: did the caller say this source is
## trusted? If a sandboxed VM ever lands, it replaces `_compile` and nothing else.
##
## ⚠ KNOWN GDScript RUNTIME-COMPILE LIMITS (docs/07-metaverse-runtime-scripting.md):
##   - inner classes fail to resolve (godot#70518) -- keep scripts flat
##   - a spurious "Can't open file" can be printed even on success (godot#66593)
##   - exported builds are documented as needing GDScript export mode "Text";
##     export_presets.cfg sets no script_export_mode, so this is UNVERIFIED for
##     an exported build. The editor binary (scripts/dev-run.sh) is unaffected.

## A script compiled and instantiated successfully.
signal script_loaded(name: String)
## Compile or instantiation failed. `reason` is engine text, already logged.
signal script_failed(name: String, reason: String)
## The script called finish() on itself, or was unloaded.
signal script_unloaded(name: String)
## A published script's bytes arrived and can be run. It is NOT running.
signal script_available(name: String, path: String)

## AssetSync kind. Not in IMMUTABLE_KINDS, so mutable: newest publish wins.
const KIND := "script"

## Meta names owned by AdminFacade. Referenced rather than retyped so a rename
## there is a compile error here instead of a silently ignored meta.
class AdminFacadeScopes:
	const CALL_SCOPES := "admin_call_scopes"
	const ROLES := "admin_roles"

## Entry points, all optional. A script implementing none still compiles and is
## held -- useful for a one-shot that does its work in _init.
const ENTRY_INIT := "_bot_init"
const ENTRY_TICK := "_bot_tick"
const ENTRY_EXIT := "_bot_exit"

## Containment bindings, from spec/bot-testbed.md (e2). A script is a bot with
## body = none, so it obeys the same rule as any other bot: it dies with its
## container. The full registry takes a SET of bindings (map / room / peer:<id> /
## session); until it exists, ScriptHost honours the one that matters here.
##
## ⚠ THE DEFAULT IS "map", AND THAT IS A BEHAVIOUR CHANGE. Shipped 0.1123.0 held
## every script at session scope with no way to say otherwise, so a _bot_tick kept
## running after a map change -- the same defect as the creature leak, in the
## execution layer instead of the scene tree. Leaking must stop being the default:
## a script that genuinely wants to outlive the map now says so.
const BINDING_MAP := "map"
const BINDING_SESSION := "session"
const DEFAULT_BINDINGS: Array[String] = [BINDING_MAP]

## name -> {script: GDScript, instance: Object, ticks: bool, bindings: Array}
var _loaded: Dictionary = {}

## name -> local path of a script whose bytes have arrived but which is NOT
## running. Arrival does not execute: see _on_asset_ready.
var _available: Dictionary = {}

## Names sync_and_run() is waiting on. A fetch that has no holder yet is not an
## error -- it stays open and completes when a peer with the bytes joins -- so
## this can outlive the call that created it.
var _pending_run: Dictionary = {}

## Injected so the arrival path is testable with no overlay, room or mesh
## (scenes/test/gate_script_host.gd). Defaults to the autoload at runtime, the
## same shape asset_sync.gd uses for its own dependencies.
var sync: Object = null


func _ready() -> void:
	_register_admin()
	# EVENT-DRIVEN, no polling: the map change announces itself. GameState may be
	# absent in a gate that instantiates this script directly, so this is guarded
	# rather than assumed.
	var gs: Node = get_node_or_null(^"/root/GameState")
	if gs != null and gs.has_signal("map_changing"):
		if not gs.map_changing.is_connected(_on_map_changing):
			gs.map_changing.connect(_on_map_changing)
	if sync == null:
		sync = get_node_or_null(^"/root/AssetSync")
	if sync != null and sync.has_signal("asset_ready"):
		if not sync.asset_ready.is_connected(_on_asset_ready):
			sync.asset_ready.connect(_on_asset_ready)


## Reachable from `admin-cli.cjs`, which is the project's existing agent surface
## (spec/daemon-session.md calls --daemon-only the canonical agent interface).
## Without this the whole autoload is unreachable dead code.
##
##   node scripts/admin-cli.cjs raw state.set \
##     '{"roomId":"<room>","key":"admin:cmd:1","value":"{\"op\":\"call\",
##       \"path\":\"/global/scripts.sync_and_run\",\"args\":[\"mybot\"],
##       \"requester_role\":\"admin\"}"}'
##
## Methods are opt-in (admin_facade.gd:516) and default to role >= admin.
##
## ⚠ admin_call_scope MATTERS HERE. An admin:cmd travels over StateBase to EVERY
## peer, and the default scope "authority" means only the authority actually runs
## it. That is right for publish (one publisher) and wrong for sync_and_run --
## the entire point of which is that every peer fetches the script and runs it.
## sync_and_run is idempotent by construction (run() unloads any previous holder
## of the name first), which is the precondition "local" requires.
func _register_admin() -> void:
	add_to_group("admin_controllable")
	set_meta("admin_scope", "global")     # persists across map changes
	set_meta("admin_name", "scripts")     # -> /global/scripts
	set_meta("admin_exposed_methods", [
		"run_file", "sync_and_run", "unload", "publish",
		"loaded_names", "available_names", "available_path", "is_loaded",
		"bindings_of",
	])
	# sync_and_run MUST be "local": an admin:cmd reaches every peer and the
	# default scope "authority" would mean only ONE peer ever runs the script --
	# the opposite of the feature. Safe because run() unloads any previous holder
	# of the name first, so a repeat is idempotent.
	#
	# Dictionary metas, not `admin_call_scope:<method>`: Object::set_meta rejects
	# a colon (core/object/object.cpp:1036, is_valid_ascii_identifier), which is
	# why the old per-method convention never worked on any Godot 4.
	set_meta(AdminFacadeScopes.CALL_SCOPES, {
		"sync_and_run": "local",
		"unload": "local",
		"loaded_names": "local",
		"available_names": "local",
		"available_path": "local",
		"is_loaded": "local",
		"bindings_of": "local",
	})
	set_meta(AdminFacadeScopes.ROLES, {
		"loaded_names": {"call": "peer"},
		"available_names": {"call": "peer"},
		"available_path": {"call": "peer"},
		"is_loaded": {"call": "peer"},
		"bindings_of": {"call": "peer"},
	})


## Bytes for some asset landed. If it is a script, record it as AVAILABLE.
##
## ⛔ ARRIVAL DOES NOT RUN IT. A file that executes itself on arrival turns "sync
## a script" into "remote code execution by default", and that is a different
## feature with a different blast radius -- even inside a trusted fleet, and even
## though the publisher had to pass the room's role gate to write the pointer.
## Running is an explicit act: run_file(), or sync_and_run() where the CALLER has
## already said it wants this name executed when it turns up.
func _on_asset_ready(kind: String, name: String, path: String) -> void:
	if kind != KIND:
		return
	_available[name] = path
	script_available.emit(name, path)
	if _pending_run.has(name):
		var pending_bindings: Array = _pending_run[name] as Array
		_pending_run.erase(name)
		run_file(path, name, pending_bindings)
	else:
		print("[ScriptHost] script '%s' available at %s (not running -- explicit by design)"
				% [name, path])


## The map changed: unload every script bound to it. A script bound to "session"
## survives, which is the ONLY way to get the old behaviour and now has to be asked
## for explicitly.
##
## Availability is NOT cleared -- the bytes are still fetched and still valid, and
## _available is a cache of what could be run, not of what is running. Only the
## running instance is map-bound.
func _on_map_changing(_map_id: String) -> void:
	var doomed: Array[String] = []
	for name: String in _loaded.keys():
		var b: Array = (_loaded[name] as Dictionary).get("bindings", DEFAULT_BINDINGS) as Array
		if b.has(BINDING_MAP):
			doomed.append(name)
	for name: String in doomed:
		print("[ScriptHost] map change: unloading map-bound script '%s'" % name)
		unload(name)
	_pending_run.clear()


## Which containment bindings a running script holds, or [] if it is not running.
func bindings_of(name: String) -> Array:
	if not _loaded.has(name):
		return []
	return (_loaded[name] as Dictionary).get("bindings", DEFAULT_BINDINGS) as Array


## Bytes for a script arrived and it is ready to run. NOT running yet.
func available_names() -> Array:
	return _available.keys()


func available_path(name: String) -> String:
	return String(_available.get(name, ""))


## Fetch a published script by name and run it when the bytes are here.
##
## Returns true if it ran immediately (already local), false if it is now pending
## -- which is NOT a failure. AssetSync.ensure returns "" when the bytes are not
## here yet and the fetch stays open, so a script published by a peer who is
## currently offline will run when they come back.
func sync_and_run(name: String, bindings: Array = []) -> bool:
	if _available.has(name):
		return run_file(_available[name], name, bindings) != null
	if sync == null:
		push_warning("[ScriptHost] no AssetSync — cannot sync '%s'" % name)
		return false
	var path := String(sync.ensure(KIND, name))
	if path != "":
		_available[name] = path
		return run_file(path, name, bindings) != null
	_pending_run[name] = bindings
	print("[ScriptHost] '%s' not local yet — will run when the bytes arrive" % name)
	return false


func _process(delta: float) -> void:
	# Tick only what asked to be ticked. A script that throws here would take the
	# frame down, so it is unloaded on the first failure rather than every frame.
	for name: String in _loaded.keys():
		var e: Dictionary = _loaded[name]
		if not e.get("ticks", false):
			continue
		var inst: Object = e.get("instance")
		if inst == null or not is_instance_valid(inst):
			unload(name)
			continue
		# Benchmark (#114 §7): per brain script, CPU per tick (nav queries: bot.nav).
		var bench_t0 := Time.get_ticks_usec()
		inst.call(ENTRY_TICK, delta)
		SimStats.add("bot.brain:" + name.get_file(), Time.get_ticks_usec() - bench_t0)


## Compile source text into a GDScript. Returns null and emits script_failed on
## error. Does NOT run anything.
##
## Source text, NOT load(): load() on a fetched .gd goes through the resource
## cache, which does not invalidate for a path it has already seen -- so the
## SECOND version of a script would silently run as the first. Compiling the
## bytes sidesteps the cache entirely, which matters precisely because the whole
## point is that a script gets edited and re-published.
func compile(source: String, name: String) -> GDScript:
	var gd := GDScript.new()
	gd.source_code = source
	var err: int = gd.reload()
	if err != OK:
		var reason := "reload() failed with error %d" % err
		push_error("[ScriptHost] %s: %s" % [name, reason])
		script_failed.emit(name, reason)
		return null
	return gd


## Compile, instantiate and start a script under `name`. Replaces any script
## already loaded under that name -- that IS the update path.
##
## Returns the instance, or null on failure.
## `context` is applied to the instance BEFORE _bot_init, which is the only
## ordering that works: a brain that must act on its own body needs to know which
## body that is while it is initialising, not a frame later. Applied with set(),
## so a script simply declares `var bot_id: String` and receives it; a script that
## declares nothing is unaffected.
func run(source: String, name: String, bindings: Array = [], context: Dictionary = {}) -> Object:
	var gd := compile(source, name)
	if gd == null:
		return null
	return _start(gd, name, bindings, context)


## Instantiate an already-compiled GDScript and start it under `name`.
##
## Split out of run() so a script that arrives as a RESOURCE rather than as text
## can take the same path. Everything after compilation is identical for both,
## and the two cases drifting apart is exactly how a packed brain would end up
## skipping _bot_init or the context application.
func _start(gd: GDScript, name: String, bindings: Array = [], context: Dictionary = {}) -> Object:
	if _loaded.has(name):
		unload(name)
	var inst: Variant = gd.new()
	if inst == null:
		var reason := "new() returned null (script has no instantiable base?)"
		push_error("[ScriptHost] %s: %s" % [name, reason])
		script_failed.emit(name, reason)
		return null
	_loaded[name] = {
		"script": gd,
		"instance": inst,
		"ticks": (inst as Object).has_method(ENTRY_TICK),
		"bindings": (bindings if not bindings.is_empty() else DEFAULT_BINDINGS),
	}
	for k: Variant in context.keys():
		(inst as Object).set(String(k), context[k])
	if (inst as Object).has_method(ENTRY_INIT):
		(inst as Object).call(ENTRY_INIT)
	script_loaded.emit(name)
	return inst


## Run a script from a path on disk. The path may be res://, user:// or absolute
## -- a fetched script lands under user://content, an authored one is wherever
## you wrote it.
func run_file(path: String, name: String = "", bindings: Array = [], context: Dictionary = {}) -> Object:
	var script_name := name if name != "" else path.get_file().get_basename()

	# res:// is the PACKED case, and it cannot be read as text. GDScript exports
	# as .gdc binary tokens, so FileAccess + compile() returned error 7 for every
	# shipped brain in an exported build -- bots/*.gd reached the pck in 0.1141.0
	# and still could not run (hyper-games #77 item 3).
	#
	# load() is safe HERE and nowhere else in this file: a packed script is
	# immutable for the life of the build, so the resource cache cannot serve a
	# stale version of it. That is precisely the hazard compile() exists to avoid
	# for user:// scripts, which are fetched and re-published while running.
	if path.begins_with("res://"):
		var gd: Variant = load(path)
		if gd == null or not (gd is GDScript):
			var why := "load(%s) returned %s, expected a GDScript" % [path, "null" if gd == null else "a " + type_string(typeof(gd))]
			push_error("[ScriptHost] %s" % why)
			script_failed.emit(script_name, why)
			return null
		return _start(gd as GDScript, script_name, bindings, context)

	var f := FileAccess.open(path, FileAccess.READ)
	if f == null:
		var reason := "cannot open %s (error %d)" % [path, FileAccess.get_open_error()]
		push_error("[ScriptHost] %s" % reason)
		script_failed.emit(script_name, reason)
		return null
	var src := f.get_as_text()
	f.close()
	return run(src, script_name, bindings, context)


## Stop and drop a script. Safe to call for an unknown name.
func unload(name: String) -> void:
	if not _loaded.has(name):
		return
	var e: Dictionary = _loaded[name]
	var inst: Object = e.get("instance")
	if inst != null and is_instance_valid(inst) and inst.has_method(ENTRY_EXIT):
		inst.call(ENTRY_EXIT)
	# RefCounted instances free themselves; a Node would not, and a script that
	# returns a Node from new() is not something this holds a tree slot for.
	if inst is Node and is_instance_valid(inst):
		(inst as Node).queue_free()
	_loaded.erase(name)
	script_unloaded.emit(name)


func is_loaded(name: String) -> bool:
	return _loaded.has(name)


func loaded_names() -> Array:
	return _loaded.keys()


## Publish a local .gd so every peer in the room gets it. Returns the oid, or ""
## if the overlay is not up. Mutable kind: re-publishing the same name supersedes.
func publish(path: String, name: String) -> String:
	if sync == null:
		push_warning("[ScriptHost] no AssetSync — cannot publish %s" % name)
		return ""
	return String(sync.publish_file(KIND, name, path, "gd"))