class_name EventCondition extends RefCounted ## Side-effect-free, data-only event predicate. enum Operator { EQ, NE, LT, LTE, GT, GTE, HAS_FLAG, NOT_HAS_FLAG, IN_RANGE, ALL, ANY, NOT, ENTITY_EXISTS, PATH_EXISTS, TIME_IN_WINDOW, } var operator: Operator = Operator.EQ var path: Array[StringName] = [] var expected: Variant = null var children: Array[EventCondition] = [] func configure( p_operator: Variant, p_path: Variant = [], p_expected: Variant = null, p_children: Array = [] ) -> EventCondition: operator = _operator_from_variant(p_operator) path = _normalize_path(p_path) expected = _copy(p_expected) children.clear() for raw_child: Variant in p_children: var child := from_variant(raw_child) if child != null: children.append(child) return self func is_valid() -> bool: if operator < Operator.EQ or operator > Operator.TIME_IN_WINDOW: return false if operator in [Operator.ALL, Operator.ANY] and children.is_empty(): return false if operator == Operator.NOT and children.size() != 1: return false if operator not in [Operator.ALL, Operator.ANY, Operator.NOT, Operator.TIME_IN_WINDOW] and path.is_empty(): return false for child: EventCondition in children: if child == null or not child.is_valid(): return false return true func evaluate(context: Dictionary, callbacks: Dictionary = {}) -> bool: match operator: Operator.ALL: for child: EventCondition in children: if not child.evaluate(context, callbacks): return false return true Operator.ANY: for child: EventCondition in children: if child.evaluate(context, callbacks): return true return false Operator.NOT: return not children[0].evaluate(context, callbacks) Operator.TIME_IN_WINDOW: var minute := int(_resolve(context, path)) if not path.is_empty() else int(_dictionary_value(context, &"absolute_minute", 0)) return _time_in_window(minute, expected) var actual: Variant = _resolve(context, path) match operator: Operator.EQ: return actual == expected Operator.NE: return actual != expected Operator.LT: return _numeric(actual) < _numeric(expected) Operator.LTE: return _numeric(actual) <= _numeric(expected) Operator.GT: return _numeric(actual) > _numeric(expected) Operator.GTE: return _numeric(actual) >= _numeric(expected) Operator.HAS_FLAG: return _has_flag(actual, expected) Operator.NOT_HAS_FLAG: return not _has_flag(actual, expected) Operator.IN_RANGE: return _in_range(actual, expected) Operator.ENTITY_EXISTS: return _callback_predicate(callbacks, &"entity_exists", actual) Operator.PATH_EXISTS: return _callback_predicate(callbacks, &"path_exists", actual) return false func create_snapshot() -> Dictionary: var child_snapshots: Array[Dictionary] = [] for child: EventCondition in children: child_snapshots.append(child.create_snapshot()) var path_strings: Array[String] = [] for part: StringName in path: path_strings.append(String(part)) return { &"operator": Operator.keys()[operator], &"path": path_strings, &"expected": _copy(expected), &"children": child_snapshots, } static func from_variant(raw_value: Variant) -> EventCondition: if raw_value is EventCondition: return raw_value if not raw_value is Dictionary: return null var raw_children: Variant = _dictionary_value(raw_value, &"children", []) return EventCondition.new().configure( _dictionary_value(raw_value, &"operator", "EQ"), _dictionary_value(raw_value, &"path", []), _dictionary_value(raw_value, &"expected", _dictionary_value(raw_value, &"value", null)), raw_children if raw_children is Array else [] ) static func _operator_from_variant(raw_value: Variant) -> Operator: if raw_value is int: return clampi(int(raw_value), Operator.EQ, Operator.TIME_IN_WINDOW) as Operator var index := Operator.keys().find(String(raw_value).to_upper()) return (index if index >= 0 else Operator.EQ) as Operator static func _normalize_path(raw_value: Variant) -> Array[StringName]: var result: Array[StringName] = [] if raw_value is String or raw_value is StringName: for part: String in String(raw_value).split(".", false): result.append(StringName(part)) elif raw_value is Array: for raw_part: Variant in raw_value: var part := StringName(raw_part) if not part.is_empty(): result.append(part) return result static func _resolve(context: Dictionary, field_path: Array[StringName]) -> Variant: var current: Variant = context for part: StringName in field_path: if not current is Dictionary: return null current = _dictionary_value(current, part, null) return current static func _numeric(value: Variant) -> float: return float(value) if value is int or value is float else 0.0 static func _has_flag(actual: Variant, flag_value: Variant) -> bool: var flag := StringName(flag_value) if actual is Dictionary: return bool(_dictionary_value(actual, flag, false)) if actual is Array: for raw_entry: Variant in actual: if StringName(raw_entry) == flag: return true return false static func _in_range(actual: Variant, bounds: Variant) -> bool: if not bounds is Array or bounds.size() < 2: return false var numeric := _numeric(actual) return numeric >= _numeric(bounds[0]) and numeric <= _numeric(bounds[1]) static func _time_in_window(absolute_minute: int, raw_window: Variant) -> bool: if not raw_window is Array or raw_window.size() < 2: return false var minute_of_day := posmod(absolute_minute, 1_440) var start := posmod(int(raw_window[0]), 1_440) var finish := posmod(int(raw_window[1]), 1_440) if start == finish: return true if start < finish: return minute_of_day >= start and minute_of_day < finish return minute_of_day >= start or minute_of_day < finish static func _callback_predicate(callbacks: Dictionary, callback_id: StringName, value: Variant) -> bool: var callback: Variant = _dictionary_value(callbacks, callback_id, Callable()) if callback is Callable and callback.is_valid(): return bool(callback.call(value)) return false static func _dictionary_value(source: Dictionary, key: StringName, fallback: Variant) -> Variant: if source.has(key): return source[key] return source.get(String(key), fallback) static func _copy(value: Variant) -> Variant: return value.duplicate(true) if value is Dictionary or value is Array else value