class_name HeatState extends RefCounted ## Authoritative short-term attention state for the Authority domain. Heat is ## deliberately separate from suspicion and discovered evidence. const MAX_HEAT := 100 const MAX_SUSPICION := 100 var heat := 0 var suspicion := 0 var evidence_points := 0 var inspection_level := 0 var next_inspection_window := 360 var active_case_ids: Array[StringName] = [] var fines_due := 0 var known_illegal_facility_ids: Array[StringName] = [] var last_decay_day := 0 var revision := 0 var heat_history: Array[Dictionary] = [] var _next_history_sequence := 1 func configure(options: Dictionary = {}) -> HeatState: heat = clampi(int(_value(options, &"heat", 0)), 0, MAX_HEAT) suspicion = clampi(int(_value(options, &"suspicion", 0)), 0, MAX_SUSPICION) evidence_points = maxi(int(_value(options, &"evidence_points", 0)), 0) inspection_level = clampi(int(_value(options, &"inspection_level", 0)), 0, 5) next_inspection_window = maxi(int(_value(options, &"next_inspection_window", 360)), 0) active_case_ids = _sorted_unique_names(_value(options, &"active_case_ids", [])) fines_due = maxi(int(_value(options, &"fines_due", 0)), 0) known_illegal_facility_ids = _sorted_unique_names( _value(options, &"known_illegal_facility_ids", []) ) last_decay_day = maxi(int(_value(options, &"last_decay_day", 0)), 0) revision = maxi(int(_value(options, &"revision", 0)), 0) heat_history.clear() var saved_history: Variant = _value(options, &"heat_history", []) if saved_history is Array: for raw_entry: Variant in saved_history: if raw_entry is Dictionary: heat_history.append((raw_entry as Dictionary).duplicate(true)) _next_history_sequence = maxi( int(_value(options, &"next_history_sequence", heat_history.size() + 1)), 1 ) return self func apply_heat_delta( delta: int, source_id: StringName, absolute_minute: int, reason: StringName = &"" ) -> Dictionary: if absolute_minute < 0: return {&"ok": false, &"reason": &"INVALID_MINUTE"} var old_value := heat heat = clampi(heat + delta, 0, MAX_HEAT) var applied := heat - old_value if applied != 0: revision += 1 heat_history.append({ &"sequence": _next_history_sequence, &"absolute_minute": absolute_minute, &"source_id": String(source_id), &"reason": String(reason), &"requested_delta": delta, &"applied_delta": applied, &"old_value": old_value, &"new_value": heat, }) _next_history_sequence += 1 return { &"ok": true, &"old_value": old_value, &"new_value": heat, &"requested_delta": delta, &"applied_delta": applied, } ## Applies a given day at most once. An active-case floor can stop decay, but ## decay never raises an already-lower Heat value up to that floor. func apply_daily_decay(day_index: int, amount: int, active_case_floor: int) -> Dictionary: if day_index <= last_decay_day: return {&"ok": true, &"is_replay": true, &"old_value": heat, &"new_value": heat} if amount < 0: return {&"ok": false, &"reason": &"INVALID_DECAY"} var old_value := heat var floor_value := 0 if not active_case_ids.is_empty(): floor_value = clampi(active_case_floor, 0, MAX_HEAT) if floor_value > 0 and heat > floor_value: heat = maxi(heat - amount, floor_value) else: heat = maxi(heat - amount, 0) if floor_value == 0 else heat last_decay_day = day_index revision += 1 if heat != old_value: heat_history.append({ &"sequence": _next_history_sequence, &"absolute_minute": day_index * 1_440, &"source_id": "daily_decay", &"reason": "DAILY_DECAY", &"requested_delta": -amount, &"applied_delta": heat - old_value, &"old_value": old_value, &"new_value": heat, }) _next_history_sequence += 1 return { &"ok": true, &"is_replay": false, &"old_value": old_value, &"new_value": heat, &"floor": floor_value, } func set_suspicion(value: int) -> void: var normalized := clampi(value, 0, MAX_SUSPICION) if suspicion == normalized: return suspicion = normalized revision += 1 func set_evidence_points(value: int) -> void: var normalized := maxi(value, 0) if evidence_points == normalized: return evidence_points = normalized revision += 1 func add_active_case(case_id: StringName) -> bool: if case_id.is_empty() or active_case_ids.has(case_id): return false active_case_ids.append(case_id) active_case_ids.sort_custom(_name_less) revision += 1 return true func remove_active_case(case_id: StringName) -> bool: var index := active_case_ids.find(case_id) if index < 0: return false active_case_ids.remove_at(index) revision += 1 return true func add_known_illegal_facility(facility_id: StringName) -> bool: if facility_id.is_empty() or known_illegal_facility_ids.has(facility_id): return false known_illegal_facility_ids.append(facility_id) known_illegal_facility_ids.sort_custom(_name_less) revision += 1 return true func add_fine_due(amount: int) -> bool: if amount <= 0: return false fines_due += amount revision += 1 return true func is_valid() -> bool: if heat < 0 or heat > MAX_HEAT or suspicion < 0 or suspicion > MAX_SUSPICION: return false if evidence_points < 0 or inspection_level < 0 or inspection_level > 5: return false if next_inspection_window < 0 or fines_due < 0 or last_decay_day < 0 or revision < 0: return false if active_case_ids != _sorted_unique_names(active_case_ids): return false if known_illegal_facility_ids != _sorted_unique_names(known_illegal_facility_ids): return false var last_sequence := 0 for entry: Dictionary in heat_history: var sequence := int(entry.get(&"sequence", 0)) if sequence <= last_sequence: return false last_sequence = sequence return _next_history_sequence > last_sequence func create_snapshot() -> Dictionary: return { &"heat": heat, &"suspicion": suspicion, &"evidence_points": evidence_points, &"inspection_level": inspection_level, &"next_inspection_window": next_inspection_window, &"active_case_ids": _names_to_strings(active_case_ids), &"fines_due": fines_due, &"known_illegal_facility_ids": _names_to_strings(known_illegal_facility_ids), &"last_decay_day": last_decay_day, &"revision": revision, &"heat_history": heat_history.duplicate(true), &"next_history_sequence": _next_history_sequence, } static func from_snapshot(snapshot: Dictionary) -> HeatState: if not _snapshot_is_valid(snapshot): return null var state := HeatState.new().configure(snapshot) return state if state.is_valid() else null static func _snapshot_is_valid(snapshot: Dictionary) -> bool: for key: StringName in [ &"heat", &"suspicion", &"evidence_points", &"inspection_level", &"next_inspection_window", &"active_case_ids", &"fines_due", &"known_illegal_facility_ids", &"last_decay_day", &"revision", &"heat_history", &"next_history_sequence", ]: if not _has_key(snapshot, key): return false if not _value(snapshot, &"active_case_ids", null) is Array \ or not _value(snapshot, &"known_illegal_facility_ids", null) is Array \ or not _value(snapshot, &"heat_history", null) is Array: return false if not _name_array_is_canonical(_value(snapshot, &"active_case_ids", [])) \ or not _name_array_is_canonical(_value(snapshot, &"known_illegal_facility_ids", [])): return false var raw_heat := int(_value(snapshot, &"heat", -1)) var raw_suspicion := int(_value(snapshot, &"suspicion", -1)) var raw_level := int(_value(snapshot, &"inspection_level", -1)) if raw_heat < 0 or raw_heat > MAX_HEAT or raw_suspicion < 0 or raw_suspicion > MAX_SUSPICION: return false if int(_value(snapshot, &"evidence_points", -1)) < 0 or raw_level < 0 or raw_level > 5: return false if int(_value(snapshot, &"next_inspection_window", -1)) < 0 \ or int(_value(snapshot, &"fines_due", -1)) < 0 \ or int(_value(snapshot, &"last_decay_day", -1)) < 0 \ or int(_value(snapshot, &"revision", -1)) < 0: return false var last_sequence := 0 for raw_entry: Variant in _value(snapshot, &"heat_history", []): if not raw_entry is Dictionary: return false for entry_key: StringName in [ &"sequence", &"absolute_minute", &"source_id", &"reason", &"requested_delta", &"applied_delta", &"old_value", &"new_value", ]: if not _has_key(raw_entry, entry_key): return false var sequence := int(_value(raw_entry, &"sequence", 0)) var old_value := int(_value(raw_entry, &"old_value", -1)) var new_value := int(_value(raw_entry, &"new_value", -1)) var applied_delta := int(_value(raw_entry, &"applied_delta", 999)) if sequence <= last_sequence or int(_value(raw_entry, &"absolute_minute", -1)) < 0: return false if old_value < 0 or old_value > MAX_HEAT or new_value < 0 or new_value > MAX_HEAT: return false if new_value - old_value != applied_delta: return false last_sequence = sequence return int(_value(snapshot, &"next_history_sequence", 0)) > last_sequence static func _sorted_unique_names(values: Variant) -> Array[StringName]: var result: Array[StringName] = [] if values is Array: for raw_value: Variant in values: var value := StringName(raw_value) if not value.is_empty() and not result.has(value): result.append(value) result.sort_custom(_name_less) return result static func _names_to_strings(values: Array[StringName]) -> Array[String]: var result: Array[String] = [] for value: StringName in values: result.append(String(value)) return result static func _name_less(a: StringName, b: StringName) -> bool: return String(a) < String(b) static func _value(source: Dictionary, key: StringName, fallback: Variant) -> Variant: if source.has(key): return source[key] return source.get(String(key), fallback) static func _has_key(source: Dictionary, key: StringName) -> bool: return source.has(key) or source.has(String(key)) static func _name_array_is_canonical(value: Variant) -> bool: if not value is Array: return false var previous := "" for index: int in range(value.size()): var current := String(value[index]) if current.is_empty() or (index > 0 and current <= previous): return false previous = current return true