class_name RelationshipState extends RefCounted ## Authoritative, direction-neutral relationship state for one normalized actor pair. ## Directional facts belong in flags or a higher-level actor-specific projection. enum RouteLock { NONE, SOFT, EXCLUSIVE_PENDING, EXCLUSIVE_LOCKED, CLOSED, } const METRIC_IDS: Array[StringName] = [ &"trust", &"respect", &"affection", &"fear", &"dependence", ] var relationship_id: StringName = &"" var actor_a_id: StringName = &"" var actor_b_id: StringName = &"" var metrics: Dictionary = {} var shield_current := 0 var shield_max := 0 var route_lock: RouteLock = RouteLock.NONE var route_id: StringName = &"" var last_event_at := -1 var daily_attack_count := 0 var attack_day := -1 var action_counts: Dictionary = {} var daily_metric_deltas: Dictionary = {} var metric_delta_day := -1 var flags: Dictionary = {} var crossed_thresholds: Dictionary = {} func configure( p_relationship_id: StringName, p_actor_one_id: StringName, p_actor_two_id: StringName, options: Dictionary = {} ) -> RelationshipState: relationship_id = p_relationship_id var first := String(p_actor_one_id) var second := String(p_actor_two_id) if first <= second: actor_a_id = p_actor_one_id actor_b_id = p_actor_two_id else: actor_a_id = p_actor_two_id actor_b_id = p_actor_one_id metrics = _normalize_metrics(_value(options, &"metrics", options)) shield_max = clampi(int(_value(options, &"shield_max", 0)), 0, 100) shield_current = clampi( int(_value(options, &"shield_current", shield_max)), 0, shield_max ) route_lock = _route_lock_from_variant(_value(options, &"route_lock", RouteLock.NONE)) route_id = StringName(_value(options, &"route_id", &"")) last_event_at = int(_value(options, &"last_event_at", -1)) daily_attack_count = maxi(int(_value(options, &"daily_attack_count", 0)), 0) attack_day = int(_value(options, &"attack_day", -1)) action_counts = _normalize_non_negative_dictionary( _value(options, &"action_counts", {}) ) daily_metric_deltas = _normalize_metric_deltas( _value(options, &"daily_metric_deltas", {}) ) metric_delta_day = int(_value(options, &"metric_delta_day", -1)) flags = _normalize_flags(_value(options, &"flags", {})) crossed_thresholds = _normalize_crossed_thresholds( _value(options, &"crossed_thresholds", {}) ) return self func is_valid() -> bool: if relationship_id.is_empty() or actor_a_id.is_empty() or actor_b_id.is_empty(): return false if actor_a_id == actor_b_id or String(actor_a_id) > String(actor_b_id): return false if shield_max < 0 or shield_max > 100: return false if shield_current < 0 or shield_current > shield_max: return false if route_lock < RouteLock.NONE or route_lock > RouteLock.CLOSED: return false if route_lock in [RouteLock.EXCLUSIVE_PENDING, RouteLock.EXCLUSIVE_LOCKED] and route_id.is_empty(): return false for metric_id: StringName in METRIC_IDS: if not metrics.has(metric_id): return false var metric_value: Variant = metrics[metric_id] if not metric_value is int or int(metric_value) < 0 or int(metric_value) > 100: return false for raw_action_id: Variant in action_counts: if StringName(raw_action_id).is_empty() or int(action_counts[raw_action_id]) < 0: return false return true func get_metric(metric_id: StringName) -> int: return int(metrics.get(metric_id, 0)) func has_flag(flag_id: StringName) -> bool: return bool(flags.get(flag_id, false)) func get_stage(metric_id: StringName) -> int: var value := get_metric(metric_id) if value >= 100: return 4 if value >= 80: return 3 if value >= 50: return 2 if value >= 20: return 1 return 0 func pair_key() -> String: return "%s|%s" % [actor_a_id, actor_b_id] func create_snapshot() -> Dictionary: return { &"relationship_id": String(relationship_id), &"actor_a_id": String(actor_a_id), &"actor_b_id": String(actor_b_id), &"metrics": _string_key_dictionary(metrics), &"shield_current": shield_current, &"shield_max": shield_max, &"route_lock": RouteLock.keys()[route_lock], &"route_id": String(route_id), &"last_event_at": last_event_at, &"daily_attack_count": daily_attack_count, &"attack_day": attack_day, &"action_counts": _string_key_dictionary(action_counts), &"daily_metric_deltas": _string_key_dictionary(daily_metric_deltas), &"metric_delta_day": metric_delta_day, &"flags": _string_key_dictionary(flags), &"crossed_thresholds": _crossed_threshold_snapshot(), } static func from_snapshot(snapshot: Dictionary) -> RelationshipState: return RelationshipState.new().configure( StringName(snapshot.get(&"relationship_id", snapshot.get("relationship_id", ""))), StringName(snapshot.get(&"actor_a_id", snapshot.get("actor_a_id", ""))), StringName(snapshot.get(&"actor_b_id", snapshot.get("actor_b_id", ""))), snapshot ) static func _value(source: Dictionary, key: StringName, fallback: Variant) -> Variant: if source.has(key): return source[key] var string_key := String(key) return source.get(string_key, fallback) static func _normalize_metrics(raw_value: Variant) -> Dictionary: var source: Dictionary = raw_value if raw_value is Dictionary else {} var result: Dictionary = {} for metric_id: StringName in METRIC_IDS: var fallback := 0 if metric_id == &"affection": fallback = int(_value(source, &"affinity", 0)) elif metric_id == &"dependence": fallback = int(_value(source, &"obligation", 0)) result[metric_id] = clampi(int(_value(source, metric_id, fallback)), 0, 100) return result static func _normalize_non_negative_dictionary(raw_value: Variant) -> Dictionary: var result: Dictionary = {} if not raw_value is Dictionary: return result for raw_key: Variant in raw_value: var key := StringName(raw_key) if not key.is_empty(): result[key] = maxi(int(raw_value[raw_key]), 0) return result static func _normalize_metric_deltas(raw_value: Variant) -> Dictionary: var source: Dictionary = raw_value if raw_value is Dictionary else {} var result: Dictionary = {} for metric_id: StringName in METRIC_IDS: result[metric_id] = int(_value(source, metric_id, 0)) return result static func _normalize_flags(raw_value: Variant) -> Dictionary: var result: Dictionary = {} if not raw_value is Dictionary: return result for raw_key: Variant in raw_value: var key := StringName(raw_key) if not key.is_empty(): result[key] = bool(raw_value[raw_key]) return result static func _normalize_crossed_thresholds(raw_value: Variant) -> Dictionary: var result: Dictionary = {} if not raw_value is Dictionary: return result for metric_id: StringName in METRIC_IDS: var raw_thresholds: Variant = _value(raw_value, metric_id, []) var thresholds: Array[int] = [] if raw_thresholds is Array: for raw_threshold: Variant in raw_thresholds: var threshold := int(raw_threshold) if threshold in [20, 50, 80, 100] and not thresholds.has(threshold): thresholds.append(threshold) thresholds.sort() result[metric_id] = thresholds return result func _crossed_threshold_snapshot() -> Dictionary: var result: Dictionary = {} for metric_id: StringName in METRIC_IDS: var values: Array = crossed_thresholds.get(metric_id, []) result[String(metric_id)] = values.duplicate() return result static func _route_lock_from_variant(raw_value: Variant) -> RouteLock: if raw_value is int: return clampi(int(raw_value), RouteLock.NONE, RouteLock.CLOSED) as RouteLock var index := RouteLock.keys().find(String(raw_value).to_upper()) return (index if index >= 0 else RouteLock.NONE) as RouteLock static func _string_key_dictionary(source: Dictionary) -> Dictionary: var result: Dictionary = {} var keys: Array[String] = [] for raw_key: Variant in source: keys.append(String(raw_key)) keys.sort() for key: String in keys: var value: Variant = source.get(StringName(key), source.get(key)) result[key] = value.duplicate(true) if value is Array or value is Dictionary else value return result