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])