310 lines
9.8 KiB
GDScript
310 lines
9.8 KiB
GDScript
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
|