class_name ActionEffectPlan extends RefCounted const SCHEMA_VERSION := 1 const MAX_PRIMITIVE_DEPTH := 16 const MAX_COLLECTION_SIZE := 4096 const RECORD_FIELDS := ["schema_version", "action_id", "operations", "reason_trace"] const OPERATION_FIELDS := [ "operation_id", "effect_id", "operation_kind", "authored_order", "payload" ] var _action_id: StringName var _operations: Array[Dictionary] = [] var _reason_trace: Array[StringName] = [] var _errors: Array[String] = [] func _init(action_id: StringName = &"", operations: Array = [], reason_trace: Array = []) -> void: _action_id = action_id if _action_id.is_empty(): _errors.append("action_id is empty") if operations.is_empty(): _errors.append("operations is empty") if reason_trace.is_empty(): _errors.append("reason_trace is empty") _validate_reason_trace(reason_trace) var operation_ids := {} var effect_ids := {} for operation_index in operations.size(): var raw_operation: Variant = operations[operation_index] if raw_operation is not Dictionary: _errors.append("operation %d is not a dictionary" % operation_index) continue if not is_primitive_record(raw_operation): _errors.append("operation %d contains a non-primitive value" % operation_index) continue var operation := _canonicalize(raw_operation) as Dictionary _validate_operation(operation, operation_index) var raw_operation_id: Variant = operation.get("operation_id", "") var raw_effect_id: Variant = operation.get("effect_id", "") var operation_id := StringName(raw_operation_id) if raw_operation_id is String else &"" var effect_id := StringName(raw_effect_id) if raw_effect_id is String else &"" if not operation_id.is_empty(): if operation_ids.has(operation_id): _errors.append("duplicate operation_id '%s'" % operation_id) operation_ids[operation_id] = true if not effect_id.is_empty(): if effect_ids.has(effect_id): _errors.append("duplicate effect_id '%s'" % effect_id) effect_ids[effect_id] = true _operations.append(operation) _errors.sort() static func from_dictionary(record: Dictionary) -> ActionEffectPlan: if not is_primitive_record(record): return null var canonical := _canonicalize(record) as Dictionary if not _has_exact_fields(canonical, RECORD_FIELDS): return null if canonical["schema_version"] is not int or int(canonical["schema_version"]) != SCHEMA_VERSION: return null if canonical["action_id"] is not String or String(canonical["action_id"]).is_empty(): return null if canonical["operations"] is not Array or canonical["reason_trace"] is not Array: return null var plan := ActionEffectPlan.new( StringName(canonical["action_id"]), canonical["operations"], canonical["reason_trace"] ) return plan if plan.is_valid() else null func is_valid() -> bool: return _errors.is_empty() func get_errors() -> Array[String]: return _errors.duplicate() func get_action_id() -> StringName: return _action_id func get_operations() -> Array[Dictionary]: return _operations.duplicate(true) func get_operation_count() -> int: return _operations.size() func get_reason_trace() -> Array[StringName]: return _reason_trace.duplicate() func to_dictionary() -> Dictionary: var trace: Array[String] = [] for trace_id in _reason_trace: trace.append(String(trace_id)) return { "schema_version": SCHEMA_VERSION, "action_id": String(_action_id), "operations": _operations.duplicate(true), "reason_trace": trace, } static func is_primitive_record(value: Variant, depth: int = 0) -> bool: if depth > MAX_PRIMITIVE_DEPTH: return false if value == null or value is bool or value is int or value is String or value is StringName: return true if value is float: return is_finite(value) if value is Array: if value.size() > MAX_COLLECTION_SIZE: return false for item in value: if not is_primitive_record(item, depth + 1): return false return true if value is Dictionary: if value.size() > MAX_COLLECTION_SIZE: return false var normalized_keys := {} for key in value: if key is not String and key is not StringName: return false var normalized_key := String(key) if normalized_key.is_empty() or normalized_keys.has(normalized_key): return false normalized_keys[normalized_key] = true if not is_primitive_record(value[key], depth + 1): return false return true return false func _validate_reason_trace(reason_trace: Array) -> void: for trace_value in reason_trace: if trace_value is not String and trace_value is not StringName: _errors.append("reason_trace contains a non-string value") continue var trace_id := StringName(trace_value) if trace_id.is_empty(): _errors.append("reason_trace contains an empty ID") continue _reason_trace.append(trace_id) func _validate_operation(operation: Dictionary, operation_index: int) -> void: if not _has_exact_fields(operation, OPERATION_FIELDS): _errors.append("operation %d has an invalid field set" % operation_index) return var operation_id := ( StringName(operation["operation_id"]) if operation["operation_id"] is String else &"" ) var effect_id := StringName(operation["effect_id"]) if operation["effect_id"] is String else &"" var operation_kind := ( StringName(operation["operation_kind"]) if operation["operation_kind"] is String else &"" ) if operation_id.is_empty(): _errors.append("operation %d has an empty operation_id" % operation_index) if effect_id.is_empty(): _errors.append("operation %d has an empty effect_id" % operation_index) if operation_kind not in ActionEffect.VALID_KINDS: _errors.append("operation %d has unknown kind '%s'" % [operation_index, operation_kind]) if ( operation["authored_order"] is not int or int(operation["authored_order"]) != operation_index ): _errors.append("operation %d does not preserve authored_order" % operation_index) if operation["payload"] is not Dictionary: _errors.append("operation %d payload is not a dictionary" % operation_index) return _validate_payload(operation_kind, operation["payload"], operation_index) func _validate_payload(kind: StringName, payload: Dictionary, operation_index: int) -> void: var expected_fields := _payload_fields(kind) if expected_fields.is_empty() or not _has_exact_fields(payload, expected_fields): _errors.append("operation %d payload has an invalid field set" % operation_index) return if payload["parameters"] is not Dictionary: _errors.append("operation %d parameters are not a dictionary" % operation_index) if ( kind in [ ActionEffect.KIND_METRIC_DELTA, ActionEffect.KIND_NEED_DELTA, ActionEffect.KIND_RELATIONSHIP_DELTA, ] ): _validate_nonempty_id(payload["subject_key"], "subject_key", operation_index) _validate_finite_number(payload["delta"], "delta", operation_index) return match kind: ActionEffect.KIND_INVENTORY_TRANSFER: _validate_nonempty_id(payload["item_id"], "item_id", operation_index) _validate_positive_number(payload["amount"], "amount", operation_index) _validate_nonempty_id(payload["source_role"], "source_role", operation_index) _validate_nonempty_id(payload["destination_role"], "destination_role", operation_index) if String(payload["source_role"]) == String(payload["destination_role"]): _errors.append("operation %d transfer roles must differ" % operation_index) ActionEffect.KIND_DAMAGE, ActionEffect.KIND_HEALING: _validate_positive_number(payload["amount"], "amount", operation_index) ActionEffect.KIND_SCHEDULE_ACTION: _validate_nonempty_id( payload["scheduled_action_id"], "scheduled_action_id", operation_index ) ActionEffect.KIND_SITE_CONDITION: _validate_nonempty_id(payload["subject_key"], "subject_key", operation_index) _validate_finite_number(payload["value"], "value", operation_index) func _validate_nonempty_id(value: Variant, field: String, operation_index: int) -> void: if (value is not String and value is not StringName) or String(value).is_empty(): _errors.append("operation %d has invalid %s" % [operation_index, field]) func _validate_finite_number(value: Variant, field: String, operation_index: int) -> void: if (value is not int and value is not float) or not is_finite(float(value)): _errors.append("operation %d has invalid %s" % [operation_index, field]) func _validate_positive_number(value: Variant, field: String, operation_index: int) -> void: _validate_finite_number(value, field, operation_index) if (value is int or value is float) and float(value) <= 0.0: _errors.append("operation %d requires positive %s" % [operation_index, field]) static func _payload_fields(kind: StringName) -> Array: if ( kind in [ ActionEffect.KIND_METRIC_DELTA, ActionEffect.KIND_NEED_DELTA, ActionEffect.KIND_RELATIONSHIP_DELTA, ] ): return ["subject_key", "delta", "parameters"] match kind: ActionEffect.KIND_INVENTORY_TRANSFER: return ["item_id", "amount", "source_role", "destination_role", "parameters"] ActionEffect.KIND_DAMAGE, ActionEffect.KIND_HEALING: return ["amount", "parameters"] ActionEffect.KIND_SCHEDULE_ACTION: return ["scheduled_action_id", "parameters"] ActionEffect.KIND_SITE_CONDITION: return ["subject_key", "value", "parameters"] return [] static func _has_exact_fields(record: Dictionary, fields: Array) -> bool: if record.size() != fields.size(): return false for field in fields: if not record.has(field): return false return true static func _canonicalize(value: Variant) -> Variant: if value is StringName: return String(value) if value is Array: var result: Array = [] for item in value: result.append(_canonicalize(item)) return result if value is Dictionary: var result := {} for key in value: result[String(key)] = _canonicalize(value[key]) return result return value