Files
modbison/scripts/scripting/script_behavior.gd
T
daddy 8822a03f53 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.
2026-09-14 19:32:22 +00:00

384 lines
15 KiB
GDScript

class_name ScriptBehavior
extends RefCounted
## A data-driven, non-Turing-complete behavior attached to a world object.
##
## This is the DSL described in docs/architecture/scripting_sandbox.md §3.1:
## a small, fixed set of trigger/condition/action verbs, expressed as plain
## JSON/Dictionary data, with zero ability to reference engine singletons,
## call arbitrary methods, or execute code. There is no interpreter to
## escape because there is no general execution -- every verb is a reviewed,
## hand-written GDScript function in [ScriptInterpreterNode], and every verb
## argument here is a plain scalar (String, int, float, bool) or a 3-element
## Array of numbers, never a Callable/Object/Script.
##
## Because the data is exactly this restricted, it plugs into the content
## moderation pipeline (docs/architecture/content_moderation.md) the same way
## an asset or room does: it's a plain JSON blob that can be hashed, stored,
## flagged and reviewed without needing to sandbox code execution to do so.
##
## ## Format
##
## ```json
## {
## "rules": [
## {
## "trigger": {"type": "on_interact"},
## "conditions": [
## {"type": "item_count", "item_id": "torch", "op": ">=", "value": 1}
## ],
## "actions": [
## {"verb": "give_item", "item_id": "gold_coin", "count": 3},
## {"verb": "play_sound", "sound_id": "chest_open"}
## ]
## }
## ]
## }
## ```
##
## Any rule that fails validation (unknown trigger/condition/action, wrong
## argument types, out-of-range values, an unrecognized key standing in for
## one of the whitelisted ones) is **dropped silently from execution** --
## never partially applied, never run "best effort". The rejection reason is
## recorded in [member rejected_rules] so callers/tests/moderation tooling
## can see *why*, but the interpreter never runs a rule that didn't fully
## validate.
# Sibling scripting scripts are referenced via preload rather than by their
# global class_name so this module parses correctly from a bare/cold
# compile (matches the convention in scripts/avatar/avatar_loadout.gd --
# a headless `godot --script` run doesn't always rebuild the global class
# registry; only an editor scan / `--import` reliably does).
const ScriptRuleClass := preload("res://scripts/scripting/script_rule.gd")
const VALID_TRIGGERS: Array[String] = ["on_interact", "on_enter", "on_touch", "on_timer"]
const VALID_CONDITION_TYPES: Array[String] = ["item_count", "flag", "timer_active", "timer_remaining"]
const VALID_OPS: Array[String] = ["==", "!=", "<", "<=", ">", ">="]
const VALID_VERBS: Array[String] = ["give_item", "play_sound", "move", "rotate", "spawn_from_catalog", "set_timer"]
const VALID_TARGETS: Array[String] = ["self", "player"]
const VALID_AXES: Array[String] = ["x", "y", "z"]
const MAX_STRING_LEN: int = 64
const MAX_ITEM_COUNT: int = 100000
const MAX_TIMER_SECONDS: float = 3600.0
const MAX_DEGREES: float = 100000.0
const MAX_OFFSET_MAGNITUDE: float = 10000.0
const DEFAULT_MOVE_DURATION: float = 0.4
const MIN_DURATION: float = 0.05
const MAX_DURATION: float = 5.0
## Successfully parsed, executable rules (each a ScriptRuleClass instance --
## left as a plain untyped Array since Godot's typed-array element type
## can't reliably reference a cross-file preloaded const; see the
## preload note above).
var rules: Array = []
## Human-readable reasons every rejected rule (or top-level parse failure)
## was dropped. Never empty on a fully-clean input, always non-empty
## whenever `rules` is missing entries an author intended.
var rejected_rules: Array[String] = []
## Optional passthrough content-moderation metadata (see
## docs/architecture/content_moderation.md §7.2 -- "Each content resource
## carries a content_flags metadata field"). Not enforced by anything in
## this system yet -- scripts as a moderated content type is still an open
## item there -- but the field round-trips through parsing so a future
## moderation pipeline has somewhere to read/write it without a format
## change. Untrusted data can only ever set this to a plain Dictionary of
## scalars; it's metadata, never consulted by the interpreter for control
## flow.
var content_flags: Dictionary = {}
# ---------------------------------------------------------------------------
# Construction
# ---------------------------------------------------------------------------
## Load and parse a ScriptBehavior from a JSON file on disk.
static func load_from_file(path: String) -> ScriptBehavior:
var behavior := ScriptBehavior.new()
var file := FileAccess.open(path, FileAccess.READ)
if file == null:
behavior.rejected_rules.append(
"cannot open '%s' (error %d)" % [path, FileAccess.get_open_error()]
)
return behavior
var text := file.get_as_text()
file.close()
var parsed: Variant = JSON.parse_string(text)
if parsed == null or not (parsed is Dictionary):
behavior.rejected_rules.append("invalid JSON in '%s'" % path)
return behavior
behavior._parse(parsed as Dictionary)
return behavior
## Parse a ScriptBehavior from an already-decoded Dictionary (e.g. embedded
## inline in a WorldObject's `properties["script_behavior"]`).
static func load_from_dict(data: Dictionary) -> ScriptBehavior:
var behavior := ScriptBehavior.new()
behavior._parse(data)
return behavior
# ---------------------------------------------------------------------------
# Parsing / validation
# ---------------------------------------------------------------------------
func _parse(data: Dictionary) -> void:
var raw_flags: Variant = data.get("content_flags")
if raw_flags is Dictionary:
content_flags = raw_flags as Dictionary
var raw_rules: Variant = data.get("rules")
if not (raw_rules is Array):
rejected_rules.append("top-level 'rules' key missing or not an array")
return
for entry in (raw_rules as Array):
if not (entry is Dictionary):
rejected_rules.append("rule entry is not an object: %s" % str(entry))
continue
var rule := _parse_rule(entry as Dictionary)
if rule != null:
rules.append(rule)
func _parse_rule(rule_dict: Dictionary) -> ScriptRuleClass:
var trigger: Variant = rule_dict.get("trigger")
if not (trigger is Dictionary):
rejected_rules.append("rule missing a 'trigger' object")
return null
var trigger_type: Variant = (trigger as Dictionary).get("type")
if not (trigger_type is String) or not VALID_TRIGGERS.has(trigger_type):
rejected_rules.append("rule has unknown/missing trigger type: %s" % str(trigger_type))
return null
var interval: float = 0.0
if trigger_type == "on_timer":
var raw_interval: Variant = (trigger as Dictionary).get("interval")
if not _is_number(raw_interval) or float(raw_interval) <= 0.0 or float(raw_interval) > MAX_TIMER_SECONDS:
rejected_rules.append("on_timer rule has invalid 'interval': %s" % str(raw_interval))
return null
interval = float(raw_interval)
var conditions: Array[Dictionary] = []
var raw_conditions: Variant = rule_dict.get("conditions", [])
if not (raw_conditions is Array):
rejected_rules.append("rule 'conditions' is not an array")
return null
for raw_cond in (raw_conditions as Array):
if not (raw_cond is Dictionary):
rejected_rules.append("condition entry is not an object: %s" % str(raw_cond))
return null
var cond := _parse_condition(raw_cond as Dictionary)
if cond.is_empty():
return null
conditions.append(cond)
var actions: Array[Dictionary] = []
var raw_actions: Variant = rule_dict.get("actions", [])
if not (raw_actions is Array) or raw_actions.is_empty():
rejected_rules.append("rule 'actions' is missing, not an array, or empty")
return null
for raw_action in (raw_actions as Array):
if not (raw_action is Dictionary):
rejected_rules.append("action entry is not an object: %s" % str(raw_action))
return null
var action := _parse_action(raw_action as Dictionary)
if action.is_empty():
return null
actions.append(action)
var rule := ScriptRuleClass.new()
rule.trigger_type = trigger_type
rule.trigger_interval = interval
rule.conditions = conditions
rule.actions = actions
return rule
## Returns a normalized condition Dictionary, or {} (empty) if invalid --
## the empty dict is the "rejected" sentinel since a valid condition always
## has at least "type" and "op".
func _parse_condition(raw: Dictionary) -> Dictionary:
var cond_type: Variant = raw.get("type")
if not (cond_type is String) or not VALID_CONDITION_TYPES.has(cond_type):
rejected_rules.append("condition has unknown/missing type: %s" % str(cond_type))
return {}
var op: Variant = raw.get("op")
if not (op is String) or not VALID_OPS.has(op):
rejected_rules.append("condition has unknown/missing op: %s" % str(op))
return {}
var value: Variant = raw.get("value")
if not _is_scalar(value):
rejected_rules.append("condition 'value' is not a plain scalar: %s" % str(value))
return {}
# Relational ops only make sense against numbers -- reject up front
# rather than silently coercing at eval time.
if op != "==" and op != "!=" and not _is_number(value):
rejected_rules.append("condition op '%s' requires a numeric value" % op)
return {}
match cond_type:
"item_count":
var item_id: Variant = raw.get("item_id")
if not _is_valid_id_string(item_id):
rejected_rules.append("item_count condition has invalid item_id: %s" % str(item_id))
return {}
return {"type": "item_count", "op": op, "value": value, "item_id": item_id}
"flag":
var name: Variant = raw.get("name")
if not _is_valid_id_string(name):
rejected_rules.append("flag condition has invalid name: %s" % str(name))
return {}
return {"type": "flag", "op": op, "value": value, "name": name}
"timer_active", "timer_remaining":
var timer_name: Variant = raw.get("name")
if not _is_valid_id_string(timer_name):
rejected_rules.append("%s condition has invalid name: %s" % [cond_type, str(timer_name)])
return {}
return {"type": cond_type, "op": op, "value": value, "name": timer_name}
# Unreachable given the VALID_CONDITION_TYPES check above, but never
# fall through to something that looks valid.
return {}
## Returns a normalized action Dictionary, or {} (empty) if invalid.
func _parse_action(raw: Dictionary) -> Dictionary:
var verb: Variant = raw.get("verb")
if not (verb is String) or not VALID_VERBS.has(verb):
rejected_rules.append("action has unknown/missing verb: %s" % str(verb))
return {}
match verb:
"give_item":
var item_id: Variant = raw.get("item_id")
var count: Variant = raw.get("count")
if not _is_valid_id_string(item_id):
rejected_rules.append("give_item has invalid item_id: %s" % str(item_id))
return {}
if not _is_number(count) or int(count) <= 0 or int(count) > MAX_ITEM_COUNT:
rejected_rules.append("give_item has invalid count: %s" % str(count))
return {}
return {"verb": "give_item", "item_id": item_id, "count": int(count)}
"play_sound":
var sound_id: Variant = raw.get("sound_id")
if not _is_valid_id_string(sound_id):
rejected_rules.append("play_sound has invalid sound_id: %s" % str(sound_id))
return {}
return {"verb": "play_sound", "sound_id": sound_id}
"move":
var target: Variant = raw.get("target", "self")
var offset: Variant = _parse_vector3(raw.get("offset"), MAX_OFFSET_MAGNITUDE)
var duration: Variant = _parse_duration(raw.get("duration", DEFAULT_MOVE_DURATION))
if not (target is String) or not VALID_TARGETS.has(target):
rejected_rules.append("move has invalid target: %s" % str(target))
return {}
if offset == null:
rejected_rules.append("move has invalid offset: %s" % str(raw.get("offset")))
return {}
if duration == null:
rejected_rules.append("move has invalid duration: %s" % str(raw.get("duration")))
return {}
return {"verb": "move", "target": target, "offset": offset, "duration": duration}
"rotate":
var r_target: Variant = raw.get("target", "self")
var degrees: Variant = raw.get("degrees")
var axis: Variant = raw.get("axis", "y")
var r_duration: Variant = _parse_duration(raw.get("duration", DEFAULT_MOVE_DURATION))
if not (r_target is String) or not VALID_TARGETS.has(r_target):
rejected_rules.append("rotate has invalid target: %s" % str(r_target))
return {}
if not _is_number(degrees) or absf(float(degrees)) > MAX_DEGREES:
rejected_rules.append("rotate has invalid degrees: %s" % str(degrees))
return {}
if not (axis is String) or not VALID_AXES.has(axis):
rejected_rules.append("rotate has invalid axis: %s" % str(axis))
return {}
if r_duration == null:
rejected_rules.append("rotate has invalid duration: %s" % str(raw.get("duration")))
return {}
return {"verb": "rotate", "target": r_target, "degrees": float(degrees), "axis": axis, "duration": r_duration}
"spawn_from_catalog":
var catalog_id: Variant = raw.get("catalog_id")
var position: Variant = _parse_vector3(raw.get("position"), MAX_OFFSET_MAGNITUDE)
if not _is_valid_id_string(catalog_id):
rejected_rules.append("spawn_from_catalog has invalid catalog_id: %s" % str(catalog_id))
return {}
if position == null:
rejected_rules.append("spawn_from_catalog has invalid position: %s" % str(raw.get("position")))
return {}
return {"verb": "spawn_from_catalog", "catalog_id": catalog_id, "position": position}
"set_timer":
var timer_name: Variant = raw.get("name")
var seconds: Variant = raw.get("seconds")
if not _is_valid_id_string(timer_name):
rejected_rules.append("set_timer has invalid name: %s" % str(timer_name))
return {}
if not _is_number(seconds) or float(seconds) <= 0.0 or float(seconds) > MAX_TIMER_SECONDS:
rejected_rules.append("set_timer has invalid seconds: %s" % str(seconds))
return {}
return {"verb": "set_timer", "name": timer_name, "seconds": float(seconds)}
# Unreachable given the VALID_VERBS check above.
return {}
# ---------------------------------------------------------------------------
# Scalar validation helpers
# ---------------------------------------------------------------------------
func _is_number(v: Variant) -> bool:
return v is int or v is float
## A "plain scalar" -- the only shapes a condition/action value is ever
## allowed to be. Deliberately excludes Dictionary, Array, Object, Callable,
## Node, Resource -- there is no representable way for UGC data to hand the
## interpreter anything but a bare String/int/float/bool.
func _is_scalar(v: Variant) -> bool:
return v is String or v is int or v is float or v is bool
## Identifiers (item ids, sound ids, catalog ids, flag/timer names) are
## always plain, short, non-empty strings -- never used as a path, never
## passed to load()/call() directly, only ever looked up as a Dictionary
## key against a trusted, code-defined table.
func _is_valid_id_string(v: Variant) -> bool:
if not (v is String):
return false
var s: String = v
return not s.is_empty() and s.length() <= MAX_STRING_LEN
func _parse_duration(raw: Variant) -> Variant:
if not _is_number(raw):
return null
var d: float = clampf(float(raw), MIN_DURATION, MAX_DURATION)
return d
## Parses a JSON array of exactly 3 numbers into a Vector3. Returns null
## (never a partially-applied Vector3) on anything else.
func _parse_vector3(raw: Variant, max_component: float) -> Variant:
if not (raw is Array) or (raw as Array).size() != 3:
return null
var comps: Array = []
for c in (raw as Array):
if not _is_number(c) or absf(float(c)) > max_component:
return null
comps.append(float(c))
return Vector3(comps[0], comps[1], comps[2])