Add ScriptBehavior DSL: data-driven, non-Turing-complete UGC scripting
Implements the DSL that docs/architecture/scripting_sandbox.md recommends
as the primary (and, for now, sufficient) approach in place of running
untrusted GDScript in-process: a fixed set of trigger/condition/action
verbs, expressed as plain JSON data, interpreted by trusted GDScript with
zero ability to reference engine singletons, call arbitrary methods, or
execute code.
- ScriptBehavior (scripts/scripting/script_behavior.gd): parses and
validates rules from JSON/Dictionary data. Every trigger, condition
type/op, and verb is checked against an explicit whitelist at parse
time; malformed or unknown entries are dropped and recorded in
rejected_rules, never partially executed. Carries an optional
content_flags passthrough so this data type isn't hostile to future
moderation-queue integration (docs/architecture/content_moderation.md).
- ScriptInterpreterNode (scripts/scripting/script_interpreter.gd): the
only code that executes rules. Triggers: on_interact (proximity +
"interact" input action), on_enter (edge-triggered Area3D overlap),
on_touch (continuous overlap), on_timer (physics-clocked Timer). Verbs:
give_item, play_sound, move, rotate, spawn_from_catalog, set_timer --
each a fixed, hand-written function; verb dispatch is an explicit
match(), never call()-by-name from data. move/rotate animate via Tween
rather than teleporting.
- PlayerInventory (scripts/scripting/player_inventory.gd): a small
per-player item-count stub for give_item to target, since no real
inventory/entity-state system exists yet (M9 Economy). Attached to
scenes/player.tscn as "Inventory".
- SpawnCatalog (scripts/scripting/spawn_catalog.gd): trusted catalog_id ->
asset_id lookup for spawn_from_catalog, mirroring the AvatarCatalog
data-driven-JSON convention. Rule data only ever supplies a catalog_id
key, never a resource path.
- Wired a real demo ("ScriptedChest") into scenes/worlds/test_level.tscn
with an on_interact rule (give_item + play_sound + rotate), plus an
"interact" input action (project.godot).
- scripts/smoke_test_script_behavior.gd: exercises every trigger type,
every verb, and every condition type against the real interpreter and
the wired demo scene, and proves unknown/malformed/unsafe rules
(unknown verb, unknown trigger, non-scalar smuggled arguments, etc.)
are rejected at parse time and never run -- 50/50 checks pass.
This commit is contained in:
@@ -0,0 +1,55 @@
|
||||
class_name SpawnCatalog
|
||||
extends RefCounted
|
||||
## Catalog of spawnable object templates for ScriptBehavior's
|
||||
## `spawn_from_catalog` verb.
|
||||
##
|
||||
## Mirrors the loading convention of [AvatarCatalog]
|
||||
## (assets/avatars/catalog.json / scripts/avatar/avatar_catalog.gd) --
|
||||
## a data-driven JSON manifest, loaded once, looked up by id.
|
||||
##
|
||||
## Crucially, a ScriptBehavior rule only ever supplies a `catalog_id`
|
||||
## *key* -- never a resource path. The mapping from key to actual asset
|
||||
## path lives entirely in this trusted, code-shipped JSON file. A
|
||||
## malicious/malformed catalog_id in UGC data just fails to resolve
|
||||
## ([method resolve] returns an empty Dictionary); there's no way for the
|
||||
## data to point anywhere else.
|
||||
|
||||
var _catalog: Dictionary = {}
|
||||
var _loaded: bool = false
|
||||
var _catalog_path: String = "res://assets/scripting/spawn_catalog.json"
|
||||
|
||||
|
||||
## Load the catalog from disk. Call this before any lookups.
|
||||
func load_catalog(path: String = _catalog_path) -> Error:
|
||||
_catalog_path = path
|
||||
_catalog = {}
|
||||
|
||||
var file := FileAccess.open(path, FileAccess.READ)
|
||||
if file == null:
|
||||
return FileAccess.get_open_error()
|
||||
|
||||
var json_text: String = file.get_as_text()
|
||||
file.close()
|
||||
|
||||
var result: Variant = JSON.parse_string(json_text)
|
||||
if result == null or not (result is Dictionary) or not (result as Dictionary).has("templates"):
|
||||
return ERR_INVALID_DATA
|
||||
|
||||
_catalog = (result as Dictionary)["templates"]
|
||||
_loaded = true
|
||||
return OK
|
||||
|
||||
|
||||
func is_loaded() -> bool:
|
||||
return _loaded
|
||||
|
||||
|
||||
## Resolve a catalog_id to its template Dictionary ({} if unknown/unloaded).
|
||||
func resolve(catalog_id: String) -> Dictionary:
|
||||
if not _loaded:
|
||||
return {}
|
||||
return _catalog.get(catalog_id, {})
|
||||
|
||||
|
||||
func has_template(catalog_id: String) -> bool:
|
||||
return _loaded and _catalog.has(catalog_id)
|
||||
Reference in New Issue
Block a user