Preserve approved pixel tower prototype as development baseline
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1 @@
|
||||
uid://dg3e85o0gd2pd
|
||||
@@ -0,0 +1,170 @@
|
||||
class_name AuthorityEvidence
|
||||
extends RefCounted
|
||||
|
||||
enum DiscoveryMethod {
|
||||
LATENT,
|
||||
REACHED,
|
||||
LINE_OF_SIGHT,
|
||||
TRUSTED_SOURCE,
|
||||
KNOWN_ILLEGAL,
|
||||
}
|
||||
|
||||
var evidence_id: StringName = &""
|
||||
var incident_id: StringName = &""
|
||||
var source: StringName = &""
|
||||
var severity := 0
|
||||
var facility_id: StringName = &""
|
||||
var location_cell := Vector3i.ZERO
|
||||
var created_at := 0
|
||||
var expires_at := -1
|
||||
var discovered_at := -1
|
||||
var discovered_by: StringName = &""
|
||||
var discovery_method: DiscoveryMethod = DiscoveryMethod.LATENT
|
||||
var revision := 0
|
||||
|
||||
|
||||
func configure(
|
||||
p_evidence_id: StringName,
|
||||
p_source: StringName,
|
||||
p_severity: int,
|
||||
options: Dictionary = {}
|
||||
) -> AuthorityEvidence:
|
||||
evidence_id = p_evidence_id
|
||||
incident_id = StringName(_value(options, &"incident_id", &""))
|
||||
source = p_source
|
||||
severity = clampi(p_severity, 0, 100)
|
||||
facility_id = StringName(_value(options, &"facility_id", &""))
|
||||
location_cell = _vector3i(_value(options, &"location_cell", Vector3i.ZERO))
|
||||
created_at = maxi(int(_value(options, &"created_at", 0)), 0)
|
||||
expires_at = int(_value(options, &"expires_at", -1))
|
||||
discovered_at = int(_value(options, &"discovered_at", -1))
|
||||
discovered_by = StringName(_value(options, &"discovered_by", &""))
|
||||
discovery_method = _method_from_value(_value(options, &"discovery_method", DiscoveryMethod.LATENT))
|
||||
revision = maxi(int(_value(options, &"revision", 0)), 0)
|
||||
return self
|
||||
|
||||
|
||||
func is_discovered() -> bool:
|
||||
return discovered_at >= 0
|
||||
|
||||
|
||||
func is_expired(absolute_minute: int) -> bool:
|
||||
return expires_at >= 0 and absolute_minute >= expires_at
|
||||
|
||||
|
||||
func mark_discovered(
|
||||
inspector_id: StringName,
|
||||
absolute_minute: int,
|
||||
method: DiscoveryMethod
|
||||
) -> bool:
|
||||
if is_discovered() or inspector_id.is_empty() or absolute_minute < created_at:
|
||||
return false
|
||||
if method == DiscoveryMethod.LATENT:
|
||||
return false
|
||||
discovered_at = absolute_minute
|
||||
discovered_by = inspector_id
|
||||
discovery_method = method
|
||||
revision += 1
|
||||
return true
|
||||
|
||||
|
||||
func is_valid() -> bool:
|
||||
if evidence_id.is_empty() or source.is_empty() or severity < 0 or severity > 100:
|
||||
return false
|
||||
if created_at < 0 or expires_at < -1 or revision < 0:
|
||||
return false
|
||||
if expires_at >= 0 and expires_at < created_at:
|
||||
return false
|
||||
if discovered_at < -1:
|
||||
return false
|
||||
if discovered_at >= 0:
|
||||
if discovered_by.is_empty() or discovery_method == DiscoveryMethod.LATENT:
|
||||
return false
|
||||
if discovered_at < created_at:
|
||||
return false
|
||||
else:
|
||||
if not discovered_by.is_empty() or discovery_method != DiscoveryMethod.LATENT:
|
||||
return false
|
||||
return true
|
||||
|
||||
|
||||
func create_snapshot() -> Dictionary:
|
||||
return {
|
||||
&"evidence_id": String(evidence_id),
|
||||
&"incident_id": String(incident_id),
|
||||
&"source": String(source),
|
||||
&"severity": severity,
|
||||
&"facility_id": String(facility_id),
|
||||
&"location_cell": [location_cell.x, location_cell.y, location_cell.z],
|
||||
&"created_at": created_at,
|
||||
&"expires_at": expires_at,
|
||||
&"discovered_at": discovered_at,
|
||||
&"discovered_by": String(discovered_by),
|
||||
&"discovery_method": DiscoveryMethod.keys()[discovery_method],
|
||||
&"revision": revision,
|
||||
}
|
||||
|
||||
|
||||
static func from_snapshot(snapshot: Dictionary) -> AuthorityEvidence:
|
||||
if not _snapshot_is_valid(snapshot):
|
||||
return null
|
||||
var evidence := AuthorityEvidence.new().configure(
|
||||
StringName(_value(snapshot, &"evidence_id", &"")),
|
||||
StringName(_value(snapshot, &"source", &"")),
|
||||
int(_value(snapshot, &"severity", -1)),
|
||||
snapshot
|
||||
)
|
||||
return evidence if evidence.is_valid() else null
|
||||
|
||||
|
||||
static func _snapshot_is_valid(snapshot: Dictionary) -> bool:
|
||||
for key: StringName in [
|
||||
&"evidence_id", &"incident_id", &"source", &"severity", &"facility_id",
|
||||
&"location_cell", &"created_at", &"expires_at", &"discovered_at",
|
||||
&"discovered_by", &"discovery_method", &"revision",
|
||||
]:
|
||||
if not _has_key(snapshot, key):
|
||||
return false
|
||||
if StringName(_value(snapshot, &"evidence_id", &"")).is_empty() \
|
||||
or StringName(_value(snapshot, &"source", &"")).is_empty():
|
||||
return false
|
||||
if not _value(snapshot, &"location_cell", null) is Array \
|
||||
or (_value(snapshot, &"location_cell", []) as Array).size() != 3:
|
||||
return false
|
||||
var severity_value := int(_value(snapshot, &"severity", -1))
|
||||
if severity_value < 0 or severity_value > 100:
|
||||
return false
|
||||
if int(_value(snapshot, &"created_at", -1)) < 0 \
|
||||
or int(_value(snapshot, &"expires_at", -2)) < -1 \
|
||||
or int(_value(snapshot, &"discovered_at", -2)) < -1 \
|
||||
or int(_value(snapshot, &"revision", -1)) < 0:
|
||||
return false
|
||||
return DiscoveryMethod.keys().has(String(_value(snapshot, &"discovery_method", "")))
|
||||
|
||||
|
||||
static func _method_from_value(value: Variant) -> DiscoveryMethod:
|
||||
if value is String or value is StringName:
|
||||
var index := DiscoveryMethod.keys().find(String(value))
|
||||
return (index as DiscoveryMethod) if index >= 0 else DiscoveryMethod.LATENT
|
||||
var number := int(value)
|
||||
return (number as DiscoveryMethod) if number >= 0 and number < DiscoveryMethod.size() else DiscoveryMethod.LATENT
|
||||
|
||||
|
||||
static func _vector3i(value: Variant) -> Vector3i:
|
||||
if value is Vector3i:
|
||||
return value
|
||||
if value is Array and value.size() >= 3:
|
||||
return Vector3i(int(value[0]), int(value[1]), int(value[2]))
|
||||
if value is Dictionary:
|
||||
return Vector3i(int(value.get(&"x", 0)), int(value.get(&"y", 0)), int(value.get(&"z", 0)))
|
||||
return Vector3i.ZERO
|
||||
|
||||
|
||||
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))
|
||||
@@ -0,0 +1 @@
|
||||
uid://mnr0hrt63kc3
|
||||
@@ -0,0 +1,309 @@
|
||||
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
|
||||
@@ -0,0 +1 @@
|
||||
uid://cjymlwh4jkx64
|
||||
@@ -0,0 +1,226 @@
|
||||
class_name AuthorityIncident
|
||||
extends RefCounted
|
||||
|
||||
enum State {
|
||||
OPEN,
|
||||
RESOLVED,
|
||||
DISMISSED,
|
||||
}
|
||||
|
||||
var incident_id: StringName = &""
|
||||
var incident_type: StringName = &""
|
||||
var created_at := 0
|
||||
var location_cell := Vector3i.ZERO
|
||||
var facility_id: StringName = &""
|
||||
var actor_ids: Array[StringName] = []
|
||||
var severity := 0
|
||||
var heat_delta := 0
|
||||
var detector_ids: Array[StringName] = []
|
||||
var evidence_ids: Array[StringName] = []
|
||||
var state: State = State.OPEN
|
||||
var resolution_id: StringName = &""
|
||||
var state_reason: StringName = &""
|
||||
var revision := 0
|
||||
|
||||
|
||||
func configure(
|
||||
p_incident_id: StringName,
|
||||
p_incident_type: StringName,
|
||||
p_created_at: int,
|
||||
options: Dictionary = {}
|
||||
) -> AuthorityIncident:
|
||||
incident_id = p_incident_id
|
||||
incident_type = p_incident_type
|
||||
created_at = maxi(p_created_at, 0)
|
||||
location_cell = _vector3i(_value(options, &"location_cell", Vector3i.ZERO))
|
||||
facility_id = StringName(_value(options, &"facility_id", &""))
|
||||
actor_ids = _sorted_unique_names(_value(options, &"actor_ids", []))
|
||||
severity = clampi(int(_value(options, &"severity", 0)), 0, 100)
|
||||
heat_delta = clampi(int(_value(options, &"heat_delta", severity / 5)), 0, 100)
|
||||
detector_ids = _sorted_unique_names(_value(options, &"detector_ids", []))
|
||||
evidence_ids = _sorted_unique_names(_value(options, &"evidence_ids", []))
|
||||
state = _state_from_value(_value(options, &"state", State.OPEN))
|
||||
resolution_id = StringName(_value(options, &"resolution_id", &""))
|
||||
state_reason = StringName(_value(options, &"state_reason", &""))
|
||||
revision = maxi(int(_value(options, &"revision", 0)), 0)
|
||||
return self
|
||||
|
||||
|
||||
func add_detector(detector_id: StringName) -> bool:
|
||||
if detector_id.is_empty() or detector_ids.has(detector_id):
|
||||
return false
|
||||
detector_ids.append(detector_id)
|
||||
detector_ids.sort_custom(_name_less)
|
||||
revision += 1
|
||||
return true
|
||||
|
||||
|
||||
func add_evidence(evidence_id: StringName) -> bool:
|
||||
if evidence_id.is_empty() or evidence_ids.has(evidence_id):
|
||||
return false
|
||||
evidence_ids.append(evidence_id)
|
||||
evidence_ids.sort_custom(_name_less)
|
||||
revision += 1
|
||||
return true
|
||||
|
||||
|
||||
func resolve(p_resolution_id: StringName, reason: StringName = &"") -> bool:
|
||||
if state != State.OPEN or p_resolution_id.is_empty():
|
||||
return false
|
||||
state = State.RESOLVED
|
||||
resolution_id = p_resolution_id
|
||||
state_reason = reason
|
||||
revision += 1
|
||||
return true
|
||||
|
||||
|
||||
func dismiss(reason: StringName) -> bool:
|
||||
if state != State.OPEN:
|
||||
return false
|
||||
state = State.DISMISSED
|
||||
state_reason = reason
|
||||
revision += 1
|
||||
return true
|
||||
|
||||
|
||||
func is_valid() -> bool:
|
||||
if incident_id.is_empty() or incident_type.is_empty() or created_at < 0:
|
||||
return false
|
||||
if severity < 0 or severity > 100 or heat_delta < 0 or heat_delta > 100 or revision < 0:
|
||||
return false
|
||||
if actor_ids != _sorted_unique_names(actor_ids):
|
||||
return false
|
||||
if detector_ids != _sorted_unique_names(detector_ids):
|
||||
return false
|
||||
if evidence_ids != _sorted_unique_names(evidence_ids):
|
||||
return false
|
||||
if state == State.RESOLVED and resolution_id.is_empty():
|
||||
return false
|
||||
if state != State.RESOLVED and not resolution_id.is_empty():
|
||||
return false
|
||||
return true
|
||||
|
||||
|
||||
func create_snapshot() -> Dictionary:
|
||||
return {
|
||||
&"incident_id": String(incident_id),
|
||||
&"incident_type": String(incident_type),
|
||||
&"created_at": created_at,
|
||||
&"location_cell": [location_cell.x, location_cell.y, location_cell.z],
|
||||
&"facility_id": String(facility_id),
|
||||
&"actor_ids": _names_to_strings(actor_ids),
|
||||
&"severity": severity,
|
||||
&"heat_delta": heat_delta,
|
||||
&"detector_ids": _names_to_strings(detector_ids),
|
||||
&"evidence_ids": _names_to_strings(evidence_ids),
|
||||
&"state": State.keys()[state],
|
||||
&"resolution_id": String(resolution_id),
|
||||
&"state_reason": String(state_reason),
|
||||
&"revision": revision,
|
||||
}
|
||||
|
||||
|
||||
static func from_snapshot(snapshot: Dictionary) -> AuthorityIncident:
|
||||
if not _snapshot_is_valid(snapshot):
|
||||
return null
|
||||
var incident := AuthorityIncident.new().configure(
|
||||
StringName(_value(snapshot, &"incident_id", &"")),
|
||||
StringName(_value(snapshot, &"incident_type", &"")),
|
||||
int(_value(snapshot, &"created_at", -1)),
|
||||
snapshot
|
||||
)
|
||||
return incident if incident.is_valid() else null
|
||||
|
||||
|
||||
static func _snapshot_is_valid(snapshot: Dictionary) -> bool:
|
||||
for key: StringName in [
|
||||
&"incident_id", &"incident_type", &"created_at", &"location_cell",
|
||||
&"facility_id", &"actor_ids", &"severity", &"heat_delta",
|
||||
&"detector_ids", &"evidence_ids", &"state", &"resolution_id",
|
||||
&"state_reason", &"revision",
|
||||
]:
|
||||
if not _has_key(snapshot, key):
|
||||
return false
|
||||
if StringName(_value(snapshot, &"incident_id", &"")).is_empty() \
|
||||
or StringName(_value(snapshot, &"incident_type", &"")).is_empty():
|
||||
return false
|
||||
if not _value(snapshot, &"location_cell", null) is Array \
|
||||
or (_value(snapshot, &"location_cell", []) as Array).size() != 3:
|
||||
return false
|
||||
if not _value(snapshot, &"actor_ids", null) is Array \
|
||||
or not _value(snapshot, &"detector_ids", null) is Array \
|
||||
or not _value(snapshot, &"evidence_ids", null) is Array:
|
||||
return false
|
||||
if not _name_array_is_canonical(_value(snapshot, &"actor_ids", [])) \
|
||||
or not _name_array_is_canonical(_value(snapshot, &"detector_ids", [])) \
|
||||
or not _name_array_is_canonical(_value(snapshot, &"evidence_ids", [])):
|
||||
return false
|
||||
var severity_value := int(_value(snapshot, &"severity", -1))
|
||||
var heat_value := int(_value(snapshot, &"heat_delta", -1))
|
||||
if int(_value(snapshot, &"created_at", -1)) < 0 or severity_value < 0 or severity_value > 100:
|
||||
return false
|
||||
if heat_value < 0 or heat_value > 100 or int(_value(snapshot, &"revision", -1)) < 0:
|
||||
return false
|
||||
return State.keys().has(String(_value(snapshot, &"state", "")))
|
||||
|
||||
|
||||
static func _state_from_value(value: Variant) -> State:
|
||||
if value is String or value is StringName:
|
||||
var index := State.keys().find(String(value))
|
||||
return (index as State) if index >= 0 else State.OPEN
|
||||
var number := int(value)
|
||||
return (number as State) if number >= 0 and number < State.size() else State.OPEN
|
||||
|
||||
|
||||
static func _vector3i(value: Variant) -> Vector3i:
|
||||
if value is Vector3i:
|
||||
return value
|
||||
if value is Array and value.size() >= 3:
|
||||
return Vector3i(int(value[0]), int(value[1]), int(value[2]))
|
||||
if value is Dictionary:
|
||||
return Vector3i(int(value.get(&"x", 0)), int(value.get(&"y", 0)), int(value.get(&"z", 0)))
|
||||
return Vector3i.ZERO
|
||||
|
||||
|
||||
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
|
||||
@@ -0,0 +1 @@
|
||||
uid://b0ao5te7fi1i3
|
||||
@@ -0,0 +1,390 @@
|
||||
class_name AuthorityInspection
|
||||
extends RefCounted
|
||||
|
||||
enum State {
|
||||
SCHEDULED,
|
||||
ARRIVING,
|
||||
CHECK_IN,
|
||||
PATROLLING,
|
||||
REVIEWING,
|
||||
RESOLVING,
|
||||
DEPARTING,
|
||||
CLOSED,
|
||||
BLOCKED_ENTRY,
|
||||
INTERRUPTED,
|
||||
HOSTILE,
|
||||
CANCELLED,
|
||||
}
|
||||
|
||||
enum Result {
|
||||
PENDING,
|
||||
NO_ACTION,
|
||||
WARNING,
|
||||
FINE,
|
||||
TEMPORARY_CLOSURE,
|
||||
CONFISCATION,
|
||||
ARREST_ATTEMPT,
|
||||
RAID_ESCALATION,
|
||||
}
|
||||
|
||||
var inspection_id: StringName = &""
|
||||
var inspector_id: StringName = &""
|
||||
var scheduled_at := 0
|
||||
var entrance_cell := Vector3i.ZERO
|
||||
var state: State = State.SCHEDULED
|
||||
var state_changed_at := 0
|
||||
var time_budget_minutes := 120
|
||||
var time_spent_minutes := 0
|
||||
var waypoint_route: Array[Dictionary] = []
|
||||
var current_waypoint_index := 0
|
||||
var visited_waypoint_ids: Array[StringName] = []
|
||||
var inaccessible_waypoint_ids: Array[StringName] = []
|
||||
var discovered_evidence_ids: Array[StringName] = []
|
||||
var evidence_score := 0
|
||||
var result: Result = Result.PENDING
|
||||
var result_reason: StringName = &""
|
||||
var exit_reason: StringName = &""
|
||||
var resolution_id: StringName = &""
|
||||
var closed_at := -1
|
||||
var revision := 0
|
||||
|
||||
|
||||
func configure(
|
||||
p_inspection_id: StringName,
|
||||
p_inspector_id: StringName,
|
||||
p_scheduled_at: int,
|
||||
options: Dictionary = {}
|
||||
) -> AuthorityInspection:
|
||||
inspection_id = p_inspection_id
|
||||
inspector_id = p_inspector_id
|
||||
scheduled_at = maxi(p_scheduled_at, 0)
|
||||
entrance_cell = _vector3i(_value(options, &"entrance_cell", Vector3i.ZERO))
|
||||
state = _state_from_value(_value(options, &"state", State.SCHEDULED))
|
||||
state_changed_at = maxi(int(_value(options, &"state_changed_at", scheduled_at)), 0)
|
||||
time_budget_minutes = maxi(int(_value(options, &"time_budget_minutes", 120)), 1)
|
||||
time_spent_minutes = maxi(int(_value(options, &"time_spent_minutes", 0)), 0)
|
||||
waypoint_route.clear()
|
||||
var raw_route: Variant = _value(options, &"waypoint_route", [])
|
||||
if raw_route is Array:
|
||||
for raw_waypoint: Variant in raw_route:
|
||||
if raw_waypoint is Dictionary:
|
||||
var normalized := normalize_waypoint(raw_waypoint)
|
||||
if not normalized.is_empty():
|
||||
waypoint_route.append(normalized)
|
||||
current_waypoint_index = maxi(int(_value(options, &"current_waypoint_index", 0)), 0)
|
||||
visited_waypoint_ids = _sorted_unique_names(_value(options, &"visited_waypoint_ids", []))
|
||||
inaccessible_waypoint_ids = _sorted_unique_names(_value(options, &"inaccessible_waypoint_ids", []))
|
||||
discovered_evidence_ids = _sorted_unique_names(_value(options, &"discovered_evidence_ids", []))
|
||||
evidence_score = maxi(int(_value(options, &"evidence_score", 0)), 0)
|
||||
result = _result_from_value(_value(options, &"result", Result.PENDING))
|
||||
result_reason = StringName(_value(options, &"result_reason", &""))
|
||||
exit_reason = StringName(_value(options, &"exit_reason", &""))
|
||||
resolution_id = StringName(_value(options, &"resolution_id", &""))
|
||||
closed_at = int(_value(options, &"closed_at", -1))
|
||||
revision = maxi(int(_value(options, &"revision", 0)), 0)
|
||||
return self
|
||||
|
||||
|
||||
func set_state(new_state: State, absolute_minute: int, reason: StringName = &"") -> bool:
|
||||
if absolute_minute < state_changed_at or state == new_state:
|
||||
return false
|
||||
state = new_state
|
||||
state_changed_at = absolute_minute
|
||||
if new_state == State.CLOSED:
|
||||
closed_at = absolute_minute
|
||||
if not reason.is_empty():
|
||||
result_reason = reason
|
||||
revision += 1
|
||||
return true
|
||||
|
||||
|
||||
func add_visited_waypoint(waypoint_id: StringName, travel_minutes: int) -> bool:
|
||||
if waypoint_id.is_empty() or visited_waypoint_ids.has(waypoint_id):
|
||||
return false
|
||||
visited_waypoint_ids.append(waypoint_id)
|
||||
visited_waypoint_ids.sort_custom(_name_less)
|
||||
time_spent_minutes += maxi(travel_minutes, 0)
|
||||
current_waypoint_index += 1
|
||||
revision += 1
|
||||
return true
|
||||
|
||||
|
||||
func add_inaccessible_waypoint(waypoint_id: StringName) -> bool:
|
||||
if waypoint_id.is_empty() or inaccessible_waypoint_ids.has(waypoint_id):
|
||||
return false
|
||||
inaccessible_waypoint_ids.append(waypoint_id)
|
||||
inaccessible_waypoint_ids.sort_custom(_name_less)
|
||||
revision += 1
|
||||
return true
|
||||
|
||||
|
||||
func add_discovered_evidence(evidence_id: StringName) -> bool:
|
||||
if evidence_id.is_empty() or discovered_evidence_ids.has(evidence_id):
|
||||
return false
|
||||
discovered_evidence_ids.append(evidence_id)
|
||||
discovered_evidence_ids.sort_custom(_name_less)
|
||||
revision += 1
|
||||
return true
|
||||
|
||||
|
||||
func is_terminal() -> bool:
|
||||
return state in [State.CLOSED, State.CANCELLED]
|
||||
|
||||
|
||||
func is_valid() -> bool:
|
||||
if inspection_id.is_empty() or inspector_id.is_empty() or scheduled_at < 0:
|
||||
return false
|
||||
if state_changed_at < scheduled_at or time_budget_minutes <= 0 or time_spent_minutes < 0:
|
||||
return false
|
||||
if current_waypoint_index < 0 or current_waypoint_index > waypoint_route.size():
|
||||
return false
|
||||
if evidence_score < 0 or closed_at < -1 or revision < 0:
|
||||
return false
|
||||
if visited_waypoint_ids != _sorted_unique_names(visited_waypoint_ids):
|
||||
return false
|
||||
if inaccessible_waypoint_ids != _sorted_unique_names(inaccessible_waypoint_ids):
|
||||
return false
|
||||
if discovered_evidence_ids != _sorted_unique_names(discovered_evidence_ids):
|
||||
return false
|
||||
var waypoint_ids: Dictionary = {}
|
||||
var previous_waypoint: Dictionary = {}
|
||||
for waypoint: Dictionary in waypoint_route:
|
||||
var normalized := normalize_waypoint(waypoint)
|
||||
if normalized.is_empty():
|
||||
return false
|
||||
var waypoint_id: StringName = normalized[&"waypoint_id"]
|
||||
if waypoint_ids.has(waypoint_id):
|
||||
return false
|
||||
if not previous_waypoint.is_empty():
|
||||
var previous_utility := int(previous_waypoint[&"utility"])
|
||||
var current_utility := int(normalized[&"utility"])
|
||||
if current_utility > previous_utility:
|
||||
return false
|
||||
if current_utility == previous_utility \
|
||||
and String(normalized[&"waypoint_id"]) < String(previous_waypoint[&"waypoint_id"]):
|
||||
return false
|
||||
waypoint_ids[waypoint_id] = true
|
||||
previous_waypoint = normalized
|
||||
if current_waypoint_index != visited_waypoint_ids.size():
|
||||
return false
|
||||
for route_index: int in range(current_waypoint_index):
|
||||
if not visited_waypoint_ids.has(StringName(waypoint_route[route_index][&"waypoint_id"])):
|
||||
return false
|
||||
for waypoint_id: StringName in visited_waypoint_ids:
|
||||
if not waypoint_ids.has(waypoint_id):
|
||||
return false
|
||||
for waypoint_id: StringName in inaccessible_waypoint_ids:
|
||||
if not waypoint_ids.has(waypoint_id):
|
||||
return false
|
||||
if result == Result.PENDING and not resolution_id.is_empty():
|
||||
return false
|
||||
if result != Result.PENDING and resolution_id.is_empty():
|
||||
return false
|
||||
if state == State.CLOSED and closed_at < state_changed_at:
|
||||
return false
|
||||
if state != State.CLOSED and closed_at >= 0:
|
||||
return false
|
||||
return true
|
||||
|
||||
|
||||
func create_snapshot() -> Dictionary:
|
||||
var saved_route: Array[Dictionary] = []
|
||||
for waypoint: Dictionary in waypoint_route:
|
||||
saved_route.append({
|
||||
&"waypoint_id": String(waypoint[&"waypoint_id"]),
|
||||
&"cell": _cell_to_array(waypoint[&"cell"]),
|
||||
&"suspect_score": int(waypoint[&"suspect_score"]),
|
||||
&"evidence_potential": int(waypoint[&"evidence_potential"]),
|
||||
&"travel_minutes": int(waypoint[&"travel_minutes"]),
|
||||
&"inspection_minutes": int(waypoint[&"inspection_minutes"]),
|
||||
&"time_budget_penalty": int(waypoint[&"time_budget_penalty"]),
|
||||
&"utility": int(waypoint[&"utility"]),
|
||||
})
|
||||
return {
|
||||
&"inspection_id": String(inspection_id),
|
||||
&"inspector_id": String(inspector_id),
|
||||
&"scheduled_at": scheduled_at,
|
||||
&"entrance_cell": [entrance_cell.x, entrance_cell.y, entrance_cell.z],
|
||||
&"state": State.keys()[state],
|
||||
&"state_changed_at": state_changed_at,
|
||||
&"time_budget_minutes": time_budget_minutes,
|
||||
&"time_spent_minutes": time_spent_minutes,
|
||||
&"waypoint_route": saved_route,
|
||||
&"current_waypoint_index": current_waypoint_index,
|
||||
&"visited_waypoint_ids": _names_to_strings(visited_waypoint_ids),
|
||||
&"inaccessible_waypoint_ids": _names_to_strings(inaccessible_waypoint_ids),
|
||||
&"discovered_evidence_ids": _names_to_strings(discovered_evidence_ids),
|
||||
&"evidence_score": evidence_score,
|
||||
&"result": Result.keys()[result],
|
||||
&"result_reason": String(result_reason),
|
||||
&"exit_reason": String(exit_reason),
|
||||
&"resolution_id": String(resolution_id),
|
||||
&"closed_at": closed_at,
|
||||
&"revision": revision,
|
||||
}
|
||||
|
||||
|
||||
static func from_snapshot(snapshot: Dictionary) -> AuthorityInspection:
|
||||
if not _snapshot_is_valid(snapshot):
|
||||
return null
|
||||
var inspection := AuthorityInspection.new().configure(
|
||||
StringName(_value(snapshot, &"inspection_id", &"")),
|
||||
StringName(_value(snapshot, &"inspector_id", &"")),
|
||||
int(_value(snapshot, &"scheduled_at", -1)),
|
||||
snapshot
|
||||
)
|
||||
return inspection if inspection.is_valid() else null
|
||||
|
||||
|
||||
static func _snapshot_is_valid(snapshot: Dictionary) -> bool:
|
||||
for key: StringName in [
|
||||
&"inspection_id", &"inspector_id", &"scheduled_at", &"entrance_cell",
|
||||
&"state", &"state_changed_at", &"time_budget_minutes", &"time_spent_minutes",
|
||||
&"waypoint_route", &"current_waypoint_index", &"visited_waypoint_ids",
|
||||
&"inaccessible_waypoint_ids", &"discovered_evidence_ids", &"evidence_score",
|
||||
&"result", &"result_reason", &"exit_reason", &"resolution_id",
|
||||
&"closed_at", &"revision",
|
||||
]:
|
||||
if not _has_key(snapshot, key):
|
||||
return false
|
||||
if StringName(_value(snapshot, &"inspection_id", &"")).is_empty() \
|
||||
or StringName(_value(snapshot, &"inspector_id", &"")).is_empty():
|
||||
return false
|
||||
if not _value(snapshot, &"entrance_cell", null) is Array \
|
||||
or (_value(snapshot, &"entrance_cell", []) as Array).size() != 3:
|
||||
return false
|
||||
for array_key: StringName in [
|
||||
&"waypoint_route", &"visited_waypoint_ids", &"inaccessible_waypoint_ids",
|
||||
&"discovered_evidence_ids",
|
||||
]:
|
||||
if not _value(snapshot, array_key, null) is Array:
|
||||
return false
|
||||
for names_key: StringName in [
|
||||
&"visited_waypoint_ids", &"inaccessible_waypoint_ids", &"discovered_evidence_ids",
|
||||
]:
|
||||
if not _name_array_is_canonical(_value(snapshot, names_key, [])):
|
||||
return false
|
||||
if not State.keys().has(String(_value(snapshot, &"state", ""))) \
|
||||
or not Result.keys().has(String(_value(snapshot, &"result", ""))):
|
||||
return false
|
||||
if int(_value(snapshot, &"scheduled_at", -1)) < 0 \
|
||||
or int(_value(snapshot, &"state_changed_at", -1)) < 0 \
|
||||
or int(_value(snapshot, &"time_budget_minutes", 0)) <= 0 \
|
||||
or int(_value(snapshot, &"time_spent_minutes", -1)) < 0 \
|
||||
or int(_value(snapshot, &"current_waypoint_index", -1)) < 0 \
|
||||
or int(_value(snapshot, &"evidence_score", -1)) < 0 \
|
||||
or int(_value(snapshot, &"closed_at", -2)) < -1 \
|
||||
or int(_value(snapshot, &"revision", -1)) < 0:
|
||||
return false
|
||||
for raw_waypoint: Variant in _value(snapshot, &"waypoint_route", []):
|
||||
if not raw_waypoint is Dictionary:
|
||||
return false
|
||||
for waypoint_key: StringName in [
|
||||
&"waypoint_id", &"cell", &"suspect_score", &"evidence_potential",
|
||||
&"travel_minutes", &"inspection_minutes", &"time_budget_penalty", &"utility",
|
||||
]:
|
||||
if not _has_key(raw_waypoint, waypoint_key):
|
||||
return false
|
||||
if not _value(raw_waypoint, &"cell", null) is Array \
|
||||
or (_value(raw_waypoint, &"cell", []) as Array).size() != 3:
|
||||
return false
|
||||
var normalized := normalize_waypoint(raw_waypoint)
|
||||
if normalized.is_empty() or int(_value(raw_waypoint, &"utility", 2_147_483_647)) != int(normalized[&"utility"]):
|
||||
return false
|
||||
return true
|
||||
|
||||
|
||||
static func normalize_waypoint(value: Dictionary) -> Dictionary:
|
||||
var waypoint_id := StringName(_value(value, &"waypoint_id", &""))
|
||||
if waypoint_id.is_empty():
|
||||
return {}
|
||||
var suspect_score := clampi(int(_value(value, &"suspect_score", 0)), 0, 100)
|
||||
var evidence_potential := clampi(int(_value(value, &"evidence_potential", 0)), 0, 100)
|
||||
var travel_minutes := maxi(int(_value(value, &"travel_minutes", 0)), 0)
|
||||
var inspection_minutes := maxi(int(_value(value, &"inspection_minutes", 5)), 1)
|
||||
var time_budget_penalty := maxi(int(_value(value, &"time_budget_penalty", 0)), 0)
|
||||
return {
|
||||
&"waypoint_id": waypoint_id,
|
||||
&"cell": _vector3i(_value(value, &"cell", Vector3i.ZERO)),
|
||||
&"suspect_score": suspect_score,
|
||||
&"evidence_potential": evidence_potential,
|
||||
&"travel_minutes": travel_minutes,
|
||||
&"inspection_minutes": inspection_minutes,
|
||||
&"time_budget_penalty": time_budget_penalty,
|
||||
&"utility": suspect_score * 5 + evidence_potential * 4 - travel_minutes * 2 - time_budget_penalty,
|
||||
}
|
||||
|
||||
|
||||
static func _state_from_value(value: Variant) -> State:
|
||||
if value is String or value is StringName:
|
||||
var index := State.keys().find(String(value))
|
||||
return (index as State) if index >= 0 else State.SCHEDULED
|
||||
var number := int(value)
|
||||
return (number as State) if number >= 0 and number < State.size() else State.SCHEDULED
|
||||
|
||||
|
||||
static func _result_from_value(value: Variant) -> Result:
|
||||
if value is String or value is StringName:
|
||||
var index := Result.keys().find(String(value))
|
||||
return (index as Result) if index >= 0 else Result.PENDING
|
||||
var number := int(value)
|
||||
return (number as Result) if number >= 0 and number < Result.size() else Result.PENDING
|
||||
|
||||
|
||||
static func _vector3i(value: Variant) -> Vector3i:
|
||||
if value is Vector3i:
|
||||
return value
|
||||
if value is Array and value.size() >= 3:
|
||||
return Vector3i(int(value[0]), int(value[1]), int(value[2]))
|
||||
if value is Dictionary:
|
||||
return Vector3i(int(value.get(&"x", 0)), int(value.get(&"y", 0)), int(value.get(&"z", 0)))
|
||||
return Vector3i.ZERO
|
||||
|
||||
|
||||
static func _cell_to_array(value: Variant) -> Array[int]:
|
||||
var cell := _vector3i(value)
|
||||
return [cell.x, cell.y, cell.z]
|
||||
|
||||
|
||||
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
|
||||
@@ -0,0 +1 @@
|
||||
uid://6n3jyvx6a6lq
|
||||
@@ -0,0 +1,220 @@
|
||||
class_name ConstructionJob
|
||||
extends RefCounted
|
||||
|
||||
enum State {
|
||||
PLANNED,
|
||||
BUILDING,
|
||||
COMPLETED,
|
||||
CANCELLED,
|
||||
BLOCKED,
|
||||
}
|
||||
|
||||
const MAX_PROGRESS := 10_000
|
||||
const NORMAL_WORK_RATE := 10_000
|
||||
|
||||
var job_id: StringName = &""
|
||||
var definition_id: StringName = &""
|
||||
var origin := Vector3i.ZERO
|
||||
var rotation := 0
|
||||
var layer := 0
|
||||
var reserved_cells: Array[Vector3i] = []
|
||||
var progress := 0
|
||||
var state: State = State.PLANNED
|
||||
var state_reason := ""
|
||||
var assigned_builder_ids: Array[StringName] = []
|
||||
var construction_cost: Dictionary = {}
|
||||
var build_minutes := 60
|
||||
var work_units_done := 0
|
||||
var created_at := 0
|
||||
var started_at := -1
|
||||
var completed_at := -1
|
||||
var cancelled_at := -1
|
||||
var facility_instance_id: StringName = &""
|
||||
|
||||
|
||||
func configure(
|
||||
p_job_id: StringName,
|
||||
p_definition_id: StringName,
|
||||
p_origin: Vector3i,
|
||||
p_rotation: int,
|
||||
p_layer: int,
|
||||
p_reserved_cells: Array[Vector3i],
|
||||
p_construction_cost: Dictionary,
|
||||
p_build_minutes: int,
|
||||
p_created_at: int
|
||||
) -> ConstructionJob:
|
||||
job_id = p_job_id
|
||||
definition_id = p_definition_id
|
||||
origin = p_origin
|
||||
rotation = _normalize_rotation(p_rotation)
|
||||
layer = p_layer
|
||||
reserved_cells.assign(p_reserved_cells)
|
||||
construction_cost = _normalize_cost(p_construction_cost)
|
||||
build_minutes = maxi(p_build_minutes, 1)
|
||||
created_at = maxi(p_created_at, 0)
|
||||
return self
|
||||
|
||||
|
||||
func advance_one_minute(work_rate_basis_points: int = NORMAL_WORK_RATE, absolute_minute: int = -1) -> bool:
|
||||
if state == State.COMPLETED or state == State.CANCELLED:
|
||||
return false
|
||||
if progress >= MAX_PROGRESS:
|
||||
return true
|
||||
if work_rate_basis_points <= 0:
|
||||
mark_blocked("No effective construction work was supplied.")
|
||||
return false
|
||||
if started_at < 0:
|
||||
started_at = absolute_minute
|
||||
state = State.BUILDING
|
||||
state_reason = ""
|
||||
work_units_done += work_rate_basis_points
|
||||
progress = mini(MAX_PROGRESS, work_units_done / build_minutes)
|
||||
return progress >= MAX_PROGRESS
|
||||
|
||||
|
||||
func mark_blocked(reason: String) -> void:
|
||||
if state == State.COMPLETED or state == State.CANCELLED:
|
||||
return
|
||||
state = State.BLOCKED
|
||||
state_reason = reason
|
||||
|
||||
|
||||
func mark_completed(instance_id: StringName, absolute_minute: int = -1) -> void:
|
||||
progress = MAX_PROGRESS
|
||||
state = State.COMPLETED
|
||||
state_reason = ""
|
||||
facility_instance_id = instance_id
|
||||
completed_at = absolute_minute
|
||||
|
||||
|
||||
func mark_cancelled(absolute_minute: int = -1) -> void:
|
||||
state = State.CANCELLED
|
||||
state_reason = ""
|
||||
cancelled_at = absolute_minute
|
||||
|
||||
|
||||
func is_terminal() -> bool:
|
||||
return state == State.COMPLETED or state == State.CANCELLED
|
||||
|
||||
|
||||
func refundable_cost() -> Dictionary:
|
||||
var refund: Dictionary = {}
|
||||
var remaining_basis_points := maxi(MAX_PROGRESS - progress, 0)
|
||||
for resource_name: Variant in construction_cost:
|
||||
var amount := int(construction_cost[resource_name])
|
||||
refund[StringName(resource_name)] = amount * remaining_basis_points / MAX_PROGRESS
|
||||
return refund
|
||||
|
||||
|
||||
func create_snapshot() -> Dictionary:
|
||||
return {
|
||||
&"job_id": String(job_id),
|
||||
&"definition_id": String(definition_id),
|
||||
&"origin": [origin.x, origin.y, origin.z],
|
||||
&"rotation": rotation,
|
||||
&"layer": layer,
|
||||
&"reserved_cells": _serialize_cells(reserved_cells),
|
||||
&"progress": progress,
|
||||
&"state": State.keys()[state],
|
||||
&"state_reason": state_reason,
|
||||
&"assigned_builder_ids": _string_names_to_strings(assigned_builder_ids),
|
||||
&"construction_cost": construction_cost.duplicate(true),
|
||||
&"build_minutes": build_minutes,
|
||||
&"work_units_done": work_units_done,
|
||||
&"created_at": created_at,
|
||||
&"started_at": started_at,
|
||||
&"completed_at": completed_at,
|
||||
&"cancelled_at": cancelled_at,
|
||||
&"facility_instance_id": String(facility_instance_id),
|
||||
}
|
||||
|
||||
|
||||
static func from_snapshot(snapshot: Dictionary) -> ConstructionJob:
|
||||
if not _is_valid_snapshot(snapshot):
|
||||
return null
|
||||
var job := ConstructionJob.new()
|
||||
job.job_id = StringName(snapshot.get(&"job_id", ""))
|
||||
job.definition_id = StringName(snapshot.get(&"definition_id", ""))
|
||||
job.origin = _parse_cell(snapshot.get(&"origin", []))
|
||||
job.rotation = _normalize_rotation(int(snapshot.get(&"rotation", 0)))
|
||||
job.layer = int(snapshot.get(&"layer", job.origin.z))
|
||||
job.reserved_cells = _parse_cells(snapshot.get(&"reserved_cells", []))
|
||||
job.progress = clampi(int(snapshot.get(&"progress", 0)), 0, MAX_PROGRESS)
|
||||
var state_index := State.keys().find(String(snapshot.get(&"state", "PLANNED")))
|
||||
if state_index < 0:
|
||||
return null
|
||||
job.state = state_index as State
|
||||
job.state_reason = String(snapshot.get(&"state_reason", ""))
|
||||
var saved_builders: Variant = snapshot.get(&"assigned_builder_ids", [])
|
||||
if saved_builders is Array:
|
||||
for builder_id: Variant in saved_builders:
|
||||
job.assigned_builder_ids.append(StringName(builder_id))
|
||||
job.construction_cost = _normalize_cost(snapshot.get(&"construction_cost", {}))
|
||||
job.build_minutes = maxi(int(snapshot.get(&"build_minutes", 60)), 1)
|
||||
job.work_units_done = maxi(int(snapshot.get(&"work_units_done", 0)), 0)
|
||||
job.created_at = maxi(int(snapshot.get(&"created_at", 0)), 0)
|
||||
job.started_at = int(snapshot.get(&"started_at", -1))
|
||||
job.completed_at = int(snapshot.get(&"completed_at", -1))
|
||||
job.cancelled_at = int(snapshot.get(&"cancelled_at", -1))
|
||||
job.facility_instance_id = StringName(snapshot.get(&"facility_instance_id", ""))
|
||||
return job
|
||||
|
||||
|
||||
static func _is_valid_snapshot(snapshot: Dictionary) -> bool:
|
||||
if String(snapshot.get(&"job_id", "")).is_empty():
|
||||
return false
|
||||
if String(snapshot.get(&"definition_id", "")).is_empty():
|
||||
return false
|
||||
if not snapshot.get(&"origin", null) is Array:
|
||||
return false
|
||||
if not snapshot.get(&"reserved_cells", null) is Array:
|
||||
return false
|
||||
if not snapshot.get(&"construction_cost", null) is Dictionary:
|
||||
return false
|
||||
return true
|
||||
|
||||
|
||||
static func _normalize_cost(source: Variant) -> Dictionary:
|
||||
var normalized: Dictionary = {}
|
||||
if not source is Dictionary:
|
||||
return normalized
|
||||
for resource_name: Variant in source:
|
||||
normalized[StringName(resource_name)] = maxi(int(source[resource_name]), 0)
|
||||
return normalized
|
||||
|
||||
|
||||
static func _normalize_rotation(value: int) -> int:
|
||||
var normalized := value % 360
|
||||
if normalized < 0:
|
||||
normalized += 360
|
||||
return (normalized / 90) * 90
|
||||
|
||||
|
||||
static func _parse_cell(source: Variant) -> Vector3i:
|
||||
if not source is Array or source.size() != 3:
|
||||
return Vector3i.ZERO
|
||||
return Vector3i(int(source[0]), int(source[1]), int(source[2]))
|
||||
|
||||
|
||||
static func _parse_cells(source: Variant) -> Array[Vector3i]:
|
||||
var cells: Array[Vector3i] = []
|
||||
if not source is Array:
|
||||
return cells
|
||||
for value: Variant in source:
|
||||
if value is Array and value.size() == 3:
|
||||
cells.append(_parse_cell(value))
|
||||
return cells
|
||||
|
||||
|
||||
static func _serialize_cells(source: Array[Vector3i]) -> Array[Array]:
|
||||
var serialized: Array[Array] = []
|
||||
for cell: Vector3i in source:
|
||||
serialized.append([cell.x, cell.y, cell.z])
|
||||
return serialized
|
||||
|
||||
|
||||
static func _string_names_to_strings(source: Array[StringName]) -> Array[String]:
|
||||
var serialized: Array[String] = []
|
||||
for value: StringName in source:
|
||||
serialized.append(String(value))
|
||||
return serialized
|
||||
@@ -0,0 +1 @@
|
||||
uid://csul0q7j7m1a3
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1 @@
|
||||
uid://cmjm3lvgmc06d
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1 @@
|
||||
uid://b6sa4hwanps1l
|
||||
@@ -0,0 +1,116 @@
|
||||
class_name PlacementReport
|
||||
extends RefCounted
|
||||
|
||||
const ERROR_INVALID_DEFINITION := &"INVALID_DEFINITION"
|
||||
const ERROR_MISSING_DEPENDENCY := &"MISSING_DEPENDENCY"
|
||||
const ERROR_OUT_OF_BOUNDS := &"OUT_OF_BOUNDS"
|
||||
const ERROR_ZONE_MISMATCH := &"ZONE_MISMATCH"
|
||||
const ERROR_LAYER_MISMATCH := &"LAYER_MISMATCH"
|
||||
const ERROR_OVERLAP := &"OVERLAP"
|
||||
const ERROR_NOT_EXCAVATED := &"NOT_EXCAVATED"
|
||||
const ERROR_UNSUPPORTED := &"UNSUPPORTED"
|
||||
const ERROR_INSUFFICIENT_RESOURCES := &"INSUFFICIENT_RESOURCES"
|
||||
|
||||
var can_build := false
|
||||
var error_codes: Array[StringName] = []
|
||||
var reasons: Array[Dictionary] = []
|
||||
|
||||
var cells: Array[Vector3i] = []
|
||||
var out_of_bounds_cells: Array[Vector3i] = []
|
||||
var zone_mismatch_cells: Array[Vector3i] = []
|
||||
var layer_mismatch_cells: Array[Vector3i] = []
|
||||
var occupied_cells: Array[Vector3i] = []
|
||||
var unexcavated_cells: Array[Vector3i] = []
|
||||
var unsupported_cells: Array[Vector3i] = []
|
||||
var access_points: Array[Vector3i] = []
|
||||
var mana_connection := false
|
||||
|
||||
var total_cost: Dictionary = {}
|
||||
var missing_resources: Dictionary = {}
|
||||
var build_time := 0
|
||||
var predicted_environment_delta: Dictionary = {}
|
||||
var predicted_operating_cost: Dictionary = {}
|
||||
var predicted_heat := 0
|
||||
|
||||
|
||||
func add_error(
|
||||
code: StringName,
|
||||
reason: String,
|
||||
affected_cells: Array = [],
|
||||
details: Dictionary = {}
|
||||
) -> void:
|
||||
if not error_codes.has(code):
|
||||
error_codes.append(code)
|
||||
var normalized_cells: Array[Vector3i] = []
|
||||
for cell: Variant in affected_cells:
|
||||
if cell is Vector3i:
|
||||
normalized_cells.append(cell)
|
||||
reasons.append({
|
||||
&"code": code,
|
||||
&"message": reason,
|
||||
&"cells": normalized_cells,
|
||||
&"details": details.duplicate(true),
|
||||
})
|
||||
can_build = false
|
||||
|
||||
|
||||
func finalize() -> PlacementReport:
|
||||
can_build = error_codes.is_empty()
|
||||
return self
|
||||
|
||||
|
||||
func has_error(code: StringName) -> bool:
|
||||
return error_codes.has(code)
|
||||
|
||||
|
||||
func reason_for(code: StringName) -> Dictionary:
|
||||
for reason: Dictionary in reasons:
|
||||
if StringName(reason.get(&"code", &"")) == code:
|
||||
return reason.duplicate(true)
|
||||
return {}
|
||||
|
||||
|
||||
func to_dictionary() -> Dictionary:
|
||||
var serialized_reasons: Array[Dictionary] = []
|
||||
for reason: Dictionary in reasons:
|
||||
var serialized := reason.duplicate(true)
|
||||
serialized[&"code"] = String(reason.get(&"code", &""))
|
||||
serialized[&"cells"] = _serialize_cells(reason.get(&"cells", []))
|
||||
serialized_reasons.append(serialized)
|
||||
return {
|
||||
&"can_build": can_build,
|
||||
&"error_codes": _string_names_to_strings(error_codes),
|
||||
&"reasons": serialized_reasons,
|
||||
&"cells": _serialize_cells(cells),
|
||||
&"out_of_bounds_cells": _serialize_cells(out_of_bounds_cells),
|
||||
&"zone_mismatch_cells": _serialize_cells(zone_mismatch_cells),
|
||||
&"layer_mismatch_cells": _serialize_cells(layer_mismatch_cells),
|
||||
&"occupied_cells": _serialize_cells(occupied_cells),
|
||||
&"unexcavated_cells": _serialize_cells(unexcavated_cells),
|
||||
&"unsupported_cells": _serialize_cells(unsupported_cells),
|
||||
&"access_points": _serialize_cells(access_points),
|
||||
&"mana_connection": mana_connection,
|
||||
&"total_cost": total_cost.duplicate(true),
|
||||
&"missing_resources": missing_resources.duplicate(true),
|
||||
&"build_time": build_time,
|
||||
&"predicted_environment_delta": predicted_environment_delta.duplicate(true),
|
||||
&"predicted_operating_cost": predicted_operating_cost.duplicate(true),
|
||||
&"predicted_heat": predicted_heat,
|
||||
}
|
||||
|
||||
|
||||
static func _serialize_cells(source: Variant) -> Array[Array]:
|
||||
var serialized: Array[Array] = []
|
||||
if not source is Array:
|
||||
return serialized
|
||||
for cell: Variant in source:
|
||||
if cell is Vector3i:
|
||||
serialized.append([cell.x, cell.y, cell.z])
|
||||
return serialized
|
||||
|
||||
|
||||
static func _string_names_to_strings(source: Array[StringName]) -> Array[String]:
|
||||
var serialized: Array[String] = []
|
||||
for value: StringName in source:
|
||||
serialized.append(String(value))
|
||||
return serialized
|
||||
@@ -0,0 +1 @@
|
||||
uid://bqgwlfqero41j
|
||||
@@ -0,0 +1,90 @@
|
||||
class_name SimulationCommand
|
||||
extends RefCounted
|
||||
|
||||
var command_id := 0
|
||||
var issued_tick := 0
|
||||
var execute_tick := 0
|
||||
var priority := 0
|
||||
var command_type: StringName = &""
|
||||
var actor_id: StringName = &""
|
||||
var target_id: StringName = &""
|
||||
var payload: Dictionary = {}
|
||||
var idempotency_key: StringName = &""
|
||||
|
||||
|
||||
func _init(
|
||||
p_command_id: int = 0,
|
||||
p_issued_tick: int = 0,
|
||||
p_execute_tick: int = 0,
|
||||
p_priority: int = 0,
|
||||
p_command_type: StringName = &"",
|
||||
p_actor_id: StringName = &"",
|
||||
p_target_id: StringName = &"",
|
||||
p_payload: Dictionary = {},
|
||||
p_idempotency_key: StringName = &""
|
||||
) -> void:
|
||||
command_id = p_command_id
|
||||
issued_tick = p_issued_tick
|
||||
execute_tick = p_execute_tick
|
||||
priority = p_priority
|
||||
command_type = p_command_type
|
||||
actor_id = p_actor_id
|
||||
target_id = p_target_id
|
||||
payload = p_payload.duplicate(true)
|
||||
idempotency_key = p_idempotency_key
|
||||
|
||||
|
||||
func validation_error() -> String:
|
||||
if command_id <= 0:
|
||||
return "command_id must be greater than zero."
|
||||
if issued_tick < 0:
|
||||
return "issued_tick cannot be negative."
|
||||
if execute_tick < issued_tick:
|
||||
return "execute_tick cannot be earlier than issued_tick."
|
||||
return ""
|
||||
|
||||
|
||||
func is_valid() -> bool:
|
||||
return validation_error().is_empty()
|
||||
|
||||
|
||||
func copy() -> SimulationCommand:
|
||||
return SimulationCommand.new(
|
||||
command_id,
|
||||
issued_tick,
|
||||
execute_tick,
|
||||
priority,
|
||||
command_type,
|
||||
actor_id,
|
||||
target_id,
|
||||
payload,
|
||||
idempotency_key
|
||||
)
|
||||
|
||||
|
||||
func to_dictionary() -> Dictionary:
|
||||
return {
|
||||
"command_id": command_id,
|
||||
"issued_tick": issued_tick,
|
||||
"execute_tick": execute_tick,
|
||||
"priority": priority,
|
||||
"command_type": String(command_type),
|
||||
"actor_id": String(actor_id),
|
||||
"target_id": String(target_id),
|
||||
"payload": payload.duplicate(true),
|
||||
"idempotency_key": String(idempotency_key),
|
||||
}
|
||||
|
||||
|
||||
static func from_dictionary(data: Dictionary) -> SimulationCommand:
|
||||
return SimulationCommand.new(
|
||||
int(data.get("command_id", 0)),
|
||||
int(data.get("issued_tick", 0)),
|
||||
int(data.get("execute_tick", 0)),
|
||||
int(data.get("priority", 0)),
|
||||
StringName(data.get("command_type", "")),
|
||||
StringName(data.get("actor_id", "")),
|
||||
StringName(data.get("target_id", "")),
|
||||
data.get("payload", {}) as Dictionary,
|
||||
StringName(data.get("idempotency_key", ""))
|
||||
)
|
||||
@@ -0,0 +1 @@
|
||||
uid://crauxmerklv67
|
||||
@@ -0,0 +1,407 @@
|
||||
class_name CommandQueue
|
||||
extends RefCounted
|
||||
|
||||
const SNAPSHOT_VERSION := 2
|
||||
const LEGACY_SNAPSHOT_VERSION := 1
|
||||
|
||||
var _pending: Array[SimulationCommand] = []
|
||||
var _in_flight: Dictionary = {}
|
||||
var _known_command_ids: Dictionary = {}
|
||||
var _idempotency_to_command_id: Dictionary = {}
|
||||
var _processed_results: Dictionary = {}
|
||||
var _accepted_metadata: Dictionary = {}
|
||||
|
||||
|
||||
func enqueue(command: SimulationCommand) -> CommandResult:
|
||||
if command == null:
|
||||
return CommandResult.failed(
|
||||
CommandResult.Code.INVALID_COMMAND,
|
||||
0,
|
||||
"Command is null."
|
||||
)
|
||||
|
||||
var validation_error := command.validation_error()
|
||||
if not validation_error.is_empty():
|
||||
return CommandResult.failed(
|
||||
CommandResult.Code.INVALID_COMMAND,
|
||||
command.command_id,
|
||||
validation_error
|
||||
)
|
||||
|
||||
if _known_command_ids.has(command.command_id):
|
||||
return CommandResult.failed(
|
||||
CommandResult.Code.DUPLICATE_COMMAND_ID,
|
||||
command.command_id,
|
||||
"command_id has already been accepted.",
|
||||
{"original_command_id": command.command_id}
|
||||
)
|
||||
|
||||
if not command.idempotency_key.is_empty():
|
||||
var key := command.idempotency_key
|
||||
if _idempotency_to_command_id.has(key):
|
||||
var original_id: int = _idempotency_to_command_id[key]
|
||||
var duplicate_details := {
|
||||
"idempotency_key": String(key),
|
||||
"original_command_id": original_id,
|
||||
"processed": _processed_results.has(original_id),
|
||||
}
|
||||
if _processed_results.has(original_id):
|
||||
var prior: CommandResult = _processed_results[original_id]
|
||||
duplicate_details["original_result"] = prior.to_dictionary()
|
||||
return CommandResult.failed(
|
||||
CommandResult.Code.DUPLICATE_IDEMPOTENCY_KEY,
|
||||
command.command_id,
|
||||
"idempotency_key has already been accepted.",
|
||||
duplicate_details
|
||||
)
|
||||
|
||||
var queued_command := command.copy()
|
||||
_pending.append(queued_command)
|
||||
_known_command_ids[queued_command.command_id] = true
|
||||
_accepted_metadata[queued_command.command_id] = {
|
||||
"command_id": queued_command.command_id,
|
||||
"idempotency_key": String(queued_command.idempotency_key),
|
||||
}
|
||||
if not queued_command.idempotency_key.is_empty():
|
||||
_idempotency_to_command_id[queued_command.idempotency_key] = queued_command.command_id
|
||||
_pending.sort_custom(_comes_before)
|
||||
return CommandResult.succeeded(queued_command.command_id)
|
||||
|
||||
|
||||
func drain_ready(current_tick: int) -> Array[SimulationCommand]:
|
||||
var ready: Array[SimulationCommand] = []
|
||||
while not _pending.is_empty():
|
||||
var command := _pending[0]
|
||||
if command.execute_tick > current_tick:
|
||||
break
|
||||
_pending.pop_front()
|
||||
_in_flight[command.command_id] = command
|
||||
ready.append(command)
|
||||
return ready
|
||||
|
||||
|
||||
func execute_ready(current_tick: int, executor: Callable) -> Array[CommandResult]:
|
||||
var results: Array[CommandResult] = []
|
||||
for command in drain_ready(current_tick):
|
||||
var result: CommandResult
|
||||
if not executor.is_valid():
|
||||
result = CommandResult.failed(
|
||||
CommandResult.Code.EXECUTOR_FAILED,
|
||||
command.command_id,
|
||||
"Executor is not callable."
|
||||
)
|
||||
else:
|
||||
var returned: Variant = executor.call(command)
|
||||
if returned is CommandResult:
|
||||
result = returned
|
||||
elif returned is bool and returned:
|
||||
result = CommandResult.succeeded(command.command_id)
|
||||
else:
|
||||
result = CommandResult.failed(
|
||||
CommandResult.Code.EXECUTOR_FAILED,
|
||||
command.command_id,
|
||||
"Executor did not return a successful CommandResult."
|
||||
)
|
||||
|
||||
if result.command_id == 0:
|
||||
result.command_id = command.command_id
|
||||
complete(command.command_id, result)
|
||||
results.append(result.copy())
|
||||
return results
|
||||
|
||||
|
||||
func complete(command_id: int, result: CommandResult) -> CommandResult:
|
||||
if not _in_flight.has(command_id):
|
||||
return CommandResult.failed(
|
||||
CommandResult.Code.NOT_FOUND,
|
||||
command_id,
|
||||
"Command is not in flight."
|
||||
)
|
||||
if result == null:
|
||||
return CommandResult.failed(
|
||||
CommandResult.Code.INVALID_COMMAND,
|
||||
command_id,
|
||||
"Result is null."
|
||||
)
|
||||
|
||||
var stored_result := result.copy()
|
||||
stored_result.command_id = command_id
|
||||
_processed_results[command_id] = stored_result
|
||||
_in_flight.erase(command_id)
|
||||
return stored_result.copy()
|
||||
|
||||
|
||||
func get_processed_result(command_id: int) -> CommandResult:
|
||||
if not _processed_results.has(command_id):
|
||||
return null
|
||||
var result: CommandResult = _processed_results[command_id]
|
||||
return result.copy()
|
||||
|
||||
|
||||
func get_result_by_idempotency_key(key: StringName) -> CommandResult:
|
||||
if not _idempotency_to_command_id.has(key):
|
||||
return null
|
||||
var command_id: int = _idempotency_to_command_id[key]
|
||||
if not _processed_results.has(command_id):
|
||||
return null
|
||||
var result: CommandResult = _processed_results[command_id]
|
||||
return result.replay_copy()
|
||||
|
||||
|
||||
func has_idempotency_key(key: StringName) -> bool:
|
||||
return _idempotency_to_command_id.has(key)
|
||||
|
||||
|
||||
func pending_count() -> int:
|
||||
return _pending.size()
|
||||
|
||||
|
||||
func in_flight_count() -> int:
|
||||
return _in_flight.size()
|
||||
|
||||
|
||||
func processed_count() -> int:
|
||||
return _processed_results.size()
|
||||
|
||||
|
||||
func get_accepted_command_metadata() -> Array[Dictionary]:
|
||||
var result: Array[Dictionary] = []
|
||||
var command_ids: Array = _accepted_metadata.keys()
|
||||
command_ids.sort()
|
||||
for command_id: Variant in command_ids:
|
||||
result.append((_accepted_metadata[command_id] as Dictionary).duplicate(true))
|
||||
return result
|
||||
|
||||
|
||||
func next_execute_tick() -> int:
|
||||
if _pending.is_empty():
|
||||
return -1
|
||||
return _pending[0].execute_tick
|
||||
|
||||
|
||||
func create_snapshot() -> Dictionary:
|
||||
var pending_data: Array[Dictionary] = []
|
||||
for command in _pending:
|
||||
pending_data.append(command.to_dictionary())
|
||||
var in_flight_ids: Array = _in_flight.keys()
|
||||
in_flight_ids.sort()
|
||||
var in_flight_data: Array[Dictionary] = []
|
||||
for command_id: Variant in in_flight_ids:
|
||||
var command: SimulationCommand = _in_flight[command_id]
|
||||
in_flight_data.append(command.to_dictionary())
|
||||
var processed_ids: Array = _processed_results.keys()
|
||||
processed_ids.sort()
|
||||
var processed_data: Array[Dictionary] = []
|
||||
for command_id: Variant in processed_ids:
|
||||
var result: CommandResult = _processed_results[command_id]
|
||||
processed_data.append(result.to_dictionary())
|
||||
var idempotency_data := {}
|
||||
var idempotency_keys: Array = _idempotency_to_command_id.keys()
|
||||
idempotency_keys.sort()
|
||||
for key: Variant in idempotency_keys:
|
||||
idempotency_data[String(key)] = int(_idempotency_to_command_id[key])
|
||||
return {
|
||||
"version": SNAPSHOT_VERSION,
|
||||
"pending": pending_data,
|
||||
"in_flight": in_flight_data,
|
||||
"processed": processed_data,
|
||||
"idempotency": idempotency_data,
|
||||
"accepted": get_accepted_command_metadata(),
|
||||
}
|
||||
|
||||
|
||||
func restore_snapshot(snapshot: Dictionary) -> bool:
|
||||
var version := LEGACY_SNAPSHOT_VERSION
|
||||
if snapshot.has("version"):
|
||||
if typeof(snapshot["version"]) != TYPE_INT:
|
||||
return false
|
||||
version = int(snapshot["version"])
|
||||
if version not in [LEGACY_SNAPSHOT_VERSION, SNAPSHOT_VERSION]:
|
||||
return false
|
||||
if version == SNAPSHOT_VERSION and not _has_exact_keys(
|
||||
snapshot,
|
||||
["version", "pending", "in_flight", "processed", "idempotency", "accepted"]
|
||||
):
|
||||
return false
|
||||
for required_key in ["pending", "in_flight", "processed", "idempotency"]:
|
||||
if not snapshot.has(required_key):
|
||||
return false
|
||||
if not snapshot["pending"] is Array \
|
||||
or not snapshot["in_flight"] is Array \
|
||||
or not snapshot["processed"] is Array \
|
||||
or not snapshot["idempotency"] is Dictionary \
|
||||
or (version == SNAPSHOT_VERSION and not snapshot.get("accepted", null) is Array):
|
||||
return false
|
||||
|
||||
var restored_pending: Array[SimulationCommand] = []
|
||||
var restored_in_flight := {}
|
||||
var restored_known_ids := {}
|
||||
var restored_processed := {}
|
||||
var restored_idempotency := {}
|
||||
var command_keys_by_id := {}
|
||||
for command_data: Variant in snapshot["pending"]:
|
||||
if not command_data is Dictionary:
|
||||
return false
|
||||
if version == SNAPSHOT_VERSION and not _strict_command_dictionary(command_data):
|
||||
return false
|
||||
var command := SimulationCommand.from_dictionary(command_data)
|
||||
if not command.is_valid() or restored_known_ids.has(command.command_id):
|
||||
return false
|
||||
restored_pending.append(command)
|
||||
restored_known_ids[command.command_id] = true
|
||||
command_keys_by_id[command.command_id] = command.idempotency_key
|
||||
for command_data: Variant in snapshot["in_flight"]:
|
||||
if not command_data is Dictionary:
|
||||
return false
|
||||
if version == SNAPSHOT_VERSION and not _strict_command_dictionary(command_data):
|
||||
return false
|
||||
var command := SimulationCommand.from_dictionary(command_data)
|
||||
if not command.is_valid() or restored_known_ids.has(command.command_id):
|
||||
return false
|
||||
restored_in_flight[command.command_id] = command
|
||||
restored_known_ids[command.command_id] = true
|
||||
command_keys_by_id[command.command_id] = command.idempotency_key
|
||||
for result_data: Variant in snapshot["processed"]:
|
||||
if not result_data is Dictionary:
|
||||
return false
|
||||
if version == SNAPSHOT_VERSION and not _strict_result_dictionary(result_data):
|
||||
return false
|
||||
var result := CommandResult.from_dictionary(result_data)
|
||||
if result.command_id <= 0 or restored_known_ids.has(result.command_id):
|
||||
return false
|
||||
restored_processed[result.command_id] = result
|
||||
restored_known_ids[result.command_id] = true
|
||||
var mapped_key_by_id := {}
|
||||
for key: Variant in snapshot["idempotency"]:
|
||||
if typeof(key) not in [TYPE_STRING, TYPE_STRING_NAME] \
|
||||
or StringName(key).is_empty() \
|
||||
or typeof(snapshot["idempotency"][key]) != TYPE_INT:
|
||||
return false
|
||||
var mapped_command_id := int(snapshot["idempotency"][key])
|
||||
if not restored_known_ids.has(mapped_command_id) \
|
||||
or mapped_key_by_id.has(mapped_command_id):
|
||||
return false
|
||||
restored_idempotency[StringName(key)] = mapped_command_id
|
||||
mapped_key_by_id[mapped_command_id] = StringName(key)
|
||||
for command_id: Variant in command_keys_by_id:
|
||||
var command_key := StringName(command_keys_by_id[command_id])
|
||||
if command_key.is_empty():
|
||||
if mapped_key_by_id.has(command_id):
|
||||
return false
|
||||
elif mapped_key_by_id.get(command_id, &"") != command_key:
|
||||
return false
|
||||
|
||||
var restored_metadata := {}
|
||||
if version == SNAPSHOT_VERSION:
|
||||
var previous_command_id := 0
|
||||
for metadata_value: Variant in snapshot["accepted"]:
|
||||
if not metadata_value is Dictionary or not _has_exact_keys(
|
||||
metadata_value, ["command_id", "idempotency_key"]
|
||||
):
|
||||
return false
|
||||
var command_id_value: Variant = metadata_value.get("command_id", null)
|
||||
var idempotency_value: Variant = metadata_value.get("idempotency_key", null)
|
||||
if typeof(command_id_value) != TYPE_INT \
|
||||
or int(command_id_value) <= previous_command_id \
|
||||
or typeof(idempotency_value) not in [TYPE_STRING, TYPE_STRING_NAME]:
|
||||
return false
|
||||
var command_id := int(command_id_value)
|
||||
var idempotency_key := StringName(idempotency_value)
|
||||
if not restored_known_ids.has(command_id) or restored_metadata.has(command_id):
|
||||
return false
|
||||
if command_keys_by_id.has(command_id) \
|
||||
and StringName(command_keys_by_id[command_id]) != idempotency_key:
|
||||
return false
|
||||
if idempotency_key.is_empty():
|
||||
if mapped_key_by_id.has(command_id):
|
||||
return false
|
||||
elif mapped_key_by_id.get(command_id, &"") != idempotency_key:
|
||||
return false
|
||||
restored_metadata[command_id] = {
|
||||
"command_id": command_id,
|
||||
"idempotency_key": String(idempotency_key),
|
||||
}
|
||||
previous_command_id = command_id
|
||||
if restored_metadata.size() != restored_known_ids.size():
|
||||
return false
|
||||
else:
|
||||
# Legacy snapshots did not retain processed Command input. Their reverse
|
||||
# idempotency map still lets migration preserve the exactly-once key.
|
||||
var legacy_ids: Array = restored_known_ids.keys()
|
||||
legacy_ids.sort()
|
||||
for command_id: Variant in legacy_ids:
|
||||
var idempotency_key := StringName(command_keys_by_id.get(
|
||||
command_id, mapped_key_by_id.get(command_id, &"")
|
||||
))
|
||||
restored_metadata[command_id] = {
|
||||
"command_id": int(command_id),
|
||||
"idempotency_key": String(idempotency_key),
|
||||
}
|
||||
|
||||
restored_pending.sort_custom(_comes_before)
|
||||
_pending = restored_pending
|
||||
_in_flight = restored_in_flight
|
||||
_known_command_ids = restored_known_ids
|
||||
_processed_results = restored_processed
|
||||
_idempotency_to_command_id = restored_idempotency
|
||||
_accepted_metadata = restored_metadata
|
||||
return true
|
||||
|
||||
|
||||
func _strict_command_dictionary(data: Dictionary) -> bool:
|
||||
if not _has_exact_keys(data, [
|
||||
"command_id", "issued_tick", "execute_tick", "priority", "command_type",
|
||||
"actor_id", "target_id", "payload", "idempotency_key",
|
||||
]):
|
||||
return false
|
||||
for key: String in ["command_id", "issued_tick", "execute_tick", "priority"]:
|
||||
if typeof(data.get(key, null)) != TYPE_INT:
|
||||
return false
|
||||
for key: String in ["command_type", "actor_id", "target_id", "idempotency_key"]:
|
||||
if typeof(data.get(key, null)) not in [TYPE_STRING, TYPE_STRING_NAME]:
|
||||
return false
|
||||
return data.get("payload", null) is Dictionary
|
||||
|
||||
|
||||
func _strict_result_dictionary(data: Dictionary) -> bool:
|
||||
if not _has_exact_keys(data, [
|
||||
"code", "command_id", "target_id", "message", "resolution_hints",
|
||||
"details", "is_replay",
|
||||
]):
|
||||
return false
|
||||
var code_value: Variant = data.get("code", null)
|
||||
if typeof(code_value) not in [TYPE_STRING, TYPE_STRING_NAME] \
|
||||
or CommandResult.Code.keys().find(String(code_value)) < 0 \
|
||||
or typeof(data.get("command_id", null)) != TYPE_INT \
|
||||
or typeof(data.get("target_id", null)) not in [TYPE_STRING, TYPE_STRING_NAME] \
|
||||
or typeof(data.get("message", null)) != TYPE_STRING \
|
||||
or not data.get("resolution_hints", null) is Array \
|
||||
or not data.get("details", null) is Dictionary \
|
||||
or typeof(data.get("is_replay", null)) != TYPE_BOOL:
|
||||
return false
|
||||
for hint: Variant in data["resolution_hints"]:
|
||||
if typeof(hint) != TYPE_STRING:
|
||||
return false
|
||||
return true
|
||||
|
||||
|
||||
func _has_exact_keys(source: Dictionary, expected: Array) -> bool:
|
||||
if source.size() != expected.size():
|
||||
return false
|
||||
var seen: Dictionary = {}
|
||||
for raw_key: Variant in source:
|
||||
if typeof(raw_key) not in [TYPE_STRING, TYPE_STRING_NAME]:
|
||||
return false
|
||||
var key := String(raw_key)
|
||||
if key not in expected or seen.has(key):
|
||||
return false
|
||||
seen[key] = true
|
||||
return seen.size() == expected.size()
|
||||
|
||||
|
||||
func _comes_before(left: SimulationCommand, right: SimulationCommand) -> bool:
|
||||
if left.execute_tick != right.execute_tick:
|
||||
return left.execute_tick < right.execute_tick
|
||||
if left.priority != right.priority:
|
||||
return left.priority > right.priority
|
||||
return left.command_id < right.command_id
|
||||
@@ -0,0 +1 @@
|
||||
uid://dscjo2hyabf7
|
||||
@@ -0,0 +1,104 @@
|
||||
class_name CommandResult
|
||||
extends RefCounted
|
||||
|
||||
enum Code {
|
||||
OK,
|
||||
INVALID_COMMAND,
|
||||
INVALID_PAYLOAD,
|
||||
DUPLICATE_COMMAND_ID,
|
||||
DUPLICATE_IDEMPOTENCY_KEY,
|
||||
NOT_FOUND,
|
||||
VALIDATION_FAILED,
|
||||
TRANSACTION_CLOSED,
|
||||
TRANSACTION_APPLY_FAILED,
|
||||
EXECUTOR_FAILED,
|
||||
}
|
||||
|
||||
var code: Code = Code.OK
|
||||
var command_id := 0
|
||||
var target_id: StringName = &""
|
||||
var message := ""
|
||||
var resolution_hints: Array[String] = []
|
||||
var details: Dictionary = {}
|
||||
var is_replay := false
|
||||
|
||||
|
||||
func _init(
|
||||
p_code: Code = Code.OK,
|
||||
p_command_id: int = 0,
|
||||
p_message: String = "",
|
||||
p_details: Dictionary = {}
|
||||
) -> void:
|
||||
code = p_code
|
||||
command_id = p_command_id
|
||||
message = p_message
|
||||
details = p_details.duplicate(true)
|
||||
|
||||
|
||||
static func succeeded(
|
||||
p_command_id: int = 0,
|
||||
p_details: Dictionary = {}
|
||||
) -> CommandResult:
|
||||
return CommandResult.new(Code.OK, p_command_id, "", p_details)
|
||||
|
||||
|
||||
static func failed(
|
||||
p_code: Code,
|
||||
p_command_id: int,
|
||||
p_message: String,
|
||||
p_details: Dictionary = {}
|
||||
) -> CommandResult:
|
||||
assert(p_code != Code.OK, "Use CommandResult.succeeded() for a successful result.")
|
||||
return CommandResult.new(p_code, p_command_id, p_message, p_details)
|
||||
|
||||
|
||||
func is_success() -> bool:
|
||||
return code == Code.OK
|
||||
|
||||
|
||||
func copy() -> CommandResult:
|
||||
var result := CommandResult.new(code, command_id, message, details)
|
||||
result.target_id = target_id
|
||||
result.resolution_hints.assign(resolution_hints)
|
||||
result.is_replay = is_replay
|
||||
return result
|
||||
|
||||
|
||||
func replay_copy() -> CommandResult:
|
||||
var result := copy()
|
||||
result.is_replay = true
|
||||
return result
|
||||
|
||||
|
||||
func to_dictionary() -> Dictionary:
|
||||
return {
|
||||
"code": Code.keys()[code],
|
||||
"command_id": command_id,
|
||||
"target_id": String(target_id),
|
||||
"message": message,
|
||||
"resolution_hints": resolution_hints.duplicate(),
|
||||
"details": details.duplicate(true),
|
||||
"is_replay": is_replay,
|
||||
}
|
||||
|
||||
|
||||
static func from_dictionary(data: Dictionary) -> CommandResult:
|
||||
var code_name := String(data.get("code", "INVALID_COMMAND"))
|
||||
var parsed_code := Code.INVALID_COMMAND
|
||||
var code_names := Code.keys()
|
||||
var code_index := code_names.find(code_name)
|
||||
if code_index >= 0:
|
||||
parsed_code = code_index as Code
|
||||
var result := CommandResult.new(
|
||||
parsed_code,
|
||||
int(data.get("command_id", 0)),
|
||||
String(data.get("message", "")),
|
||||
data.get("details", {}) as Dictionary
|
||||
)
|
||||
result.target_id = StringName(data.get("target_id", ""))
|
||||
var saved_hints: Variant = data.get("resolution_hints", [])
|
||||
if saved_hints is Array:
|
||||
for hint: Variant in saved_hints:
|
||||
result.resolution_hints.append(String(hint))
|
||||
result.is_replay = bool(data.get("is_replay", false))
|
||||
return result
|
||||
@@ -0,0 +1 @@
|
||||
uid://b4gb8xmpwer3o
|
||||
@@ -0,0 +1,762 @@
|
||||
class_name DefinitionRegistry
|
||||
extends RefCounted
|
||||
|
||||
## Loads, validates, and indexes immutable game definitions.
|
||||
##
|
||||
## Invalid definitions are reported but never exposed through lookup/query APIs.
|
||||
## All returned definition and issue lists use a deterministic ID-first order.
|
||||
|
||||
const DEFAULT_FACILITY_DIRECTORY := "res://data/facilities"
|
||||
const FACILITY_FILE_EXTENSION := "tres"
|
||||
|
||||
const SEVERITY_ERROR := &"error"
|
||||
const SEVERITY_WARNING := &"warning"
|
||||
|
||||
var _facilities_by_id: Dictionary = {}
|
||||
var _ordered_facility_ids: Array[StringName] = []
|
||||
var _errors: Array[Dictionary] = []
|
||||
var _warnings: Array[Dictionary] = []
|
||||
var _source_count := 0
|
||||
|
||||
|
||||
## Scans the default facility directory and rebuilds the registry.
|
||||
func rebuild(directory_path: String = DEFAULT_FACILITY_DIRECTORY) -> Dictionary:
|
||||
_reset()
|
||||
var paths := _discover_facility_paths(directory_path)
|
||||
if not _errors.is_empty():
|
||||
_finish_rebuild()
|
||||
return get_report()
|
||||
_load_and_index(paths)
|
||||
_finish_rebuild()
|
||||
return get_report()
|
||||
|
||||
|
||||
## Rebuilds from an explicit path list. Primarily useful for content packs and tests.
|
||||
func rebuild_from_paths(paths: Array[String]) -> Dictionary:
|
||||
_reset()
|
||||
var ordered_paths := paths.duplicate()
|
||||
ordered_paths.sort()
|
||||
_load_and_index(ordered_paths)
|
||||
_finish_rebuild()
|
||||
return get_report()
|
||||
|
||||
|
||||
func is_valid() -> bool:
|
||||
return _errors.is_empty()
|
||||
|
||||
|
||||
func has_fatal_errors() -> bool:
|
||||
return not _errors.is_empty()
|
||||
|
||||
|
||||
func facility_count() -> int:
|
||||
return _ordered_facility_ids.size()
|
||||
|
||||
|
||||
func source_count() -> int:
|
||||
return _source_count
|
||||
|
||||
|
||||
func has_facility(facility_id: StringName) -> bool:
|
||||
return _facilities_by_id.has(facility_id)
|
||||
|
||||
|
||||
func get_facility(facility_id: StringName) -> FacilityDefinition:
|
||||
return _facilities_by_id.get(facility_id) as FacilityDefinition
|
||||
|
||||
|
||||
func get_facility_ids() -> Array[StringName]:
|
||||
return _ordered_facility_ids.duplicate()
|
||||
|
||||
|
||||
func get_all_facilities() -> Array[FacilityDefinition]:
|
||||
var result: Array[FacilityDefinition] = []
|
||||
for facility_id in _ordered_facility_ids:
|
||||
result.append(_facilities_by_id[facility_id] as FacilityDefinition)
|
||||
return result
|
||||
|
||||
|
||||
func query_facilities_by_tag(tag: StringName) -> Array[FacilityDefinition]:
|
||||
var result: Array[FacilityDefinition] = []
|
||||
for facility_id in _ordered_facility_ids:
|
||||
var definition := _facilities_by_id[facility_id] as FacilityDefinition
|
||||
if tag in definition.tags:
|
||||
result.append(definition)
|
||||
return result
|
||||
|
||||
|
||||
func query_facilities_by_zone(zone: int) -> Array[FacilityDefinition]:
|
||||
var result: Array[FacilityDefinition] = []
|
||||
for facility_id in _ordered_facility_ids:
|
||||
var definition := _facilities_by_id[facility_id] as FacilityDefinition
|
||||
if definition.zone == zone:
|
||||
result.append(definition)
|
||||
return result
|
||||
|
||||
|
||||
func query_facilities_by_layer(layer: int) -> Array[FacilityDefinition]:
|
||||
var result: Array[FacilityDefinition] = []
|
||||
for facility_id in _ordered_facility_ids:
|
||||
var definition := _facilities_by_id[facility_id] as FacilityDefinition
|
||||
if definition.layer == layer:
|
||||
result.append(definition)
|
||||
return result
|
||||
|
||||
|
||||
func get_fatal_errors() -> Array[Dictionary]:
|
||||
return _errors.duplicate(true)
|
||||
|
||||
|
||||
func get_warnings() -> Array[Dictionary]:
|
||||
return _warnings.duplicate(true)
|
||||
|
||||
|
||||
func get_report() -> Dictionary:
|
||||
return {
|
||||
&"valid": is_valid(),
|
||||
&"source_count": _source_count,
|
||||
&"definition_count": facility_count(),
|
||||
&"error_count": _errors.size(),
|
||||
&"warning_count": _warnings.size(),
|
||||
&"errors": get_fatal_errors(),
|
||||
&"warnings": get_warnings(),
|
||||
}
|
||||
|
||||
|
||||
func _reset() -> void:
|
||||
_facilities_by_id.clear()
|
||||
_ordered_facility_ids.clear()
|
||||
_errors.clear()
|
||||
_warnings.clear()
|
||||
_source_count = 0
|
||||
|
||||
|
||||
func _discover_facility_paths(directory_path: String) -> Array[String]:
|
||||
var result: Array[String] = []
|
||||
var directory := DirAccess.open(directory_path)
|
||||
if directory == null:
|
||||
_add_error(
|
||||
&"directory_unreadable",
|
||||
&"",
|
||||
directory_path,
|
||||
&"resource_path",
|
||||
"Facility definition directory cannot be opened."
|
||||
)
|
||||
return result
|
||||
|
||||
directory.list_dir_begin()
|
||||
var file_name := directory.get_next()
|
||||
while not file_name.is_empty():
|
||||
if not directory.current_is_dir() and file_name.get_extension().to_lower() == FACILITY_FILE_EXTENSION:
|
||||
result.append(directory_path.path_join(file_name))
|
||||
file_name = directory.get_next()
|
||||
directory.list_dir_end()
|
||||
result.sort()
|
||||
|
||||
if result.is_empty():
|
||||
_add_warning(
|
||||
&"no_definitions_found",
|
||||
&"",
|
||||
directory_path,
|
||||
&"resource_path",
|
||||
"No facility definitions were found."
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
func _load_and_index(paths: Array[String]) -> void:
|
||||
var candidates: Array[Dictionary] = []
|
||||
var seen_paths: Dictionary = {}
|
||||
|
||||
for path in paths:
|
||||
_source_count += 1
|
||||
if seen_paths.has(path):
|
||||
_add_warning(
|
||||
&"duplicate_source_path",
|
||||
&"",
|
||||
path,
|
||||
&"resource_path",
|
||||
"The same source path was supplied more than once."
|
||||
)
|
||||
seen_paths[path] = true
|
||||
|
||||
if not _validate_requested_path(path):
|
||||
continue
|
||||
var resource := ResourceLoader.load(path)
|
||||
if resource == null:
|
||||
_add_error(
|
||||
&"resource_load_failed",
|
||||
&"",
|
||||
path,
|
||||
&"resource_path",
|
||||
"The resource could not be loaded."
|
||||
)
|
||||
continue
|
||||
if not resource is FacilityDefinition:
|
||||
_add_error(
|
||||
&"wrong_resource_type",
|
||||
&"",
|
||||
path,
|
||||
&"resource",
|
||||
"Expected FacilityDefinition, got %s." % resource.get_class()
|
||||
)
|
||||
continue
|
||||
|
||||
var definition := resource as FacilityDefinition
|
||||
candidates.append({
|
||||
&"definition": definition,
|
||||
&"path": path,
|
||||
&"valid": _validate_facility(definition, path),
|
||||
})
|
||||
|
||||
candidates.sort_custom(_candidate_less)
|
||||
var counts_by_id: Dictionary = {}
|
||||
var paths_by_id: Dictionary = {}
|
||||
for candidate in candidates:
|
||||
var definition := candidate[&"definition"] as FacilityDefinition
|
||||
if definition.id.is_empty():
|
||||
continue
|
||||
counts_by_id[definition.id] = int(counts_by_id.get(definition.id, 0)) + 1
|
||||
if not paths_by_id.has(definition.id):
|
||||
paths_by_id[definition.id] = []
|
||||
var id_paths := paths_by_id[definition.id] as Array
|
||||
id_paths.append(String(candidate[&"path"]))
|
||||
|
||||
for candidate in candidates:
|
||||
var definition := candidate[&"definition"] as FacilityDefinition
|
||||
var path := String(candidate[&"path"])
|
||||
if definition.id.is_empty():
|
||||
continue
|
||||
if int(counts_by_id[definition.id]) > 1:
|
||||
var duplicate_paths := paths_by_id[definition.id] as Array
|
||||
_add_error(
|
||||
&"duplicate_id",
|
||||
definition.id,
|
||||
path,
|
||||
&"id",
|
||||
"Facility ID is duplicated by: %s." % ", ".join(duplicate_paths)
|
||||
)
|
||||
continue
|
||||
if not bool(candidate[&"valid"]):
|
||||
continue
|
||||
_facilities_by_id[definition.id] = definition
|
||||
_ordered_facility_ids.append(definition.id)
|
||||
|
||||
|
||||
func _validate_requested_path(path: String) -> bool:
|
||||
var valid := true
|
||||
if path.is_empty():
|
||||
_add_error(&"empty_resource_path", &"", path, &"resource_path", "Resource path is empty.")
|
||||
return false
|
||||
if not path.begins_with("res://"):
|
||||
_add_error(
|
||||
&"non_project_resource_path",
|
||||
&"",
|
||||
path,
|
||||
&"resource_path",
|
||||
"Facility definitions must use a res:// project path."
|
||||
)
|
||||
valid = false
|
||||
if path.get_extension().to_lower() != FACILITY_FILE_EXTENSION:
|
||||
_add_error(
|
||||
&"wrong_resource_extension",
|
||||
&"",
|
||||
path,
|
||||
&"resource_path",
|
||||
"Facility definitions must use the .tres extension."
|
||||
)
|
||||
valid = false
|
||||
if not FileAccess.file_exists(path):
|
||||
_add_error(
|
||||
&"resource_not_found",
|
||||
&"",
|
||||
path,
|
||||
&"resource_path",
|
||||
"Facility definition file does not exist."
|
||||
)
|
||||
valid = false
|
||||
return valid
|
||||
|
||||
|
||||
func _validate_facility(definition: FacilityDefinition, requested_path: String) -> bool:
|
||||
var valid := true
|
||||
var facility_id := definition.id
|
||||
var actual_path := definition.resource_path
|
||||
|
||||
if facility_id.is_empty():
|
||||
_add_error(&"missing_required_value", facility_id, requested_path, &"id", "Facility ID is required.")
|
||||
valid = false
|
||||
elif not _is_snake_case_id(String(facility_id)):
|
||||
_add_error(
|
||||
&"invalid_id_format",
|
||||
facility_id,
|
||||
requested_path,
|
||||
&"id",
|
||||
"Facility ID must be lowercase ASCII snake_case and begin with a letter."
|
||||
)
|
||||
valid = false
|
||||
|
||||
if definition.display_name.strip_edges().is_empty():
|
||||
_add_error(
|
||||
&"missing_required_value",
|
||||
facility_id,
|
||||
requested_path,
|
||||
&"display_name",
|
||||
"Display name is required."
|
||||
)
|
||||
valid = false
|
||||
if definition.description.strip_edges().is_empty():
|
||||
_add_warning(
|
||||
&"empty_description",
|
||||
facility_id,
|
||||
requested_path,
|
||||
&"description",
|
||||
"Description is empty."
|
||||
)
|
||||
|
||||
if actual_path.is_empty():
|
||||
_add_error(
|
||||
&"missing_resource_path",
|
||||
facility_id,
|
||||
requested_path,
|
||||
&"resource_path",
|
||||
"Loaded FacilityDefinition has no resource_path."
|
||||
)
|
||||
valid = false
|
||||
elif actual_path != requested_path:
|
||||
_add_error(
|
||||
&"resource_path_mismatch",
|
||||
facility_id,
|
||||
requested_path,
|
||||
&"resource_path",
|
||||
"Loaded resource_path '%s' does not match its requested path." % actual_path
|
||||
)
|
||||
valid = false
|
||||
|
||||
if definition.zone < FacilityDefinition.MapZone.ANY \
|
||||
or definition.zone > FacilityDefinition.MapZone.UNDERGROUND:
|
||||
_add_error(&"value_out_of_range", facility_id, requested_path, &"zone", "Zone enum value is out of range.")
|
||||
valid = false
|
||||
if definition.layer < FacilityDefinition.ViewLayer.FRONT \
|
||||
or definition.layer > FacilityDefinition.ViewLayer.BACK:
|
||||
_add_error(&"value_out_of_range", facility_id, requested_path, &"layer", "Layer enum value is out of range.")
|
||||
valid = false
|
||||
if definition.footprint.x <= 0 or definition.footprint.y <= 0:
|
||||
_add_error(
|
||||
&"value_out_of_range",
|
||||
facility_id,
|
||||
requested_path,
|
||||
&"footprint",
|
||||
"Footprint dimensions must both be greater than zero."
|
||||
)
|
||||
valid = false
|
||||
if definition.build_minutes <= 0:
|
||||
_add_error(&"value_out_of_range", facility_id, requested_path, &"build_minutes", "Build time must be greater than zero.")
|
||||
valid = false
|
||||
if definition.weight_per_cell not in [1, 2, 3]:
|
||||
_add_error(&"value_out_of_range", facility_id, requested_path, &"weight_per_cell", "Structural weight must be 1, 2, or 3.")
|
||||
valid = false
|
||||
if definition.allowed_rotations.is_empty():
|
||||
_add_error(&"missing_required_value", facility_id, requested_path, &"allowed_rotations", "At least one rotation is required.")
|
||||
valid = false
|
||||
else:
|
||||
var seen_rotations := {}
|
||||
for rotation in definition.allowed_rotations:
|
||||
if rotation not in [0, 90, 180, 270] or seen_rotations.has(rotation):
|
||||
_add_error(&"invalid_rotation", facility_id, requested_path, &"allowed_rotations", "Rotations must be unique quarter turns.")
|
||||
valid = false
|
||||
seen_rotations[rotation] = true
|
||||
if definition.staff_min < 0 or definition.staff_max < definition.staff_min:
|
||||
_add_error(&"value_out_of_range", facility_id, requested_path, &"staff_min", "Staff limits are inconsistent.")
|
||||
valid = false
|
||||
if definition.capacity < 0 or definition.queue_capacity < 0:
|
||||
_add_error(&"value_out_of_range", facility_id, requested_path, &"capacity", "Service and queue capacities cannot be negative.")
|
||||
valid = false
|
||||
if definition.base_service_minutes <= 0 or definition.base_price < 0:
|
||||
_add_error(&"value_out_of_range", facility_id, requested_path, &"base_service_minutes", "Service duration must be positive and price cannot be negative.")
|
||||
valid = false
|
||||
for item_id: Variant in definition.inventory_inputs:
|
||||
if StringName(item_id).is_empty() or int(definition.inventory_inputs[item_id]) <= 0:
|
||||
_add_error(&"invalid_inventory_requirement", facility_id, requested_path, &"inventory_inputs", "Inventory input ids and quantities must be positive.")
|
||||
valid = false
|
||||
for item_id: Variant in definition.inventory_outputs:
|
||||
if StringName(item_id).is_empty() or int(definition.inventory_outputs[item_id]) <= 0:
|
||||
_add_error(&"invalid_inventory_output", facility_id, requested_path, &"inventory_outputs", "Inventory output ids and quantities must be positive.")
|
||||
valid = false
|
||||
|
||||
if not _validate_construction_cost(definition.construction_cost, facility_id, requested_path):
|
||||
valid = false
|
||||
if definition.maintenance_mana < 0:
|
||||
_add_error(
|
||||
&"value_out_of_range",
|
||||
facility_id,
|
||||
requested_path,
|
||||
&"maintenance_mana",
|
||||
"Maintenance mana cannot be negative."
|
||||
)
|
||||
valid = false
|
||||
if definition.staff_min < 0:
|
||||
_add_error(&"value_out_of_range", facility_id, requested_path, &"staff_min", "Minimum staff cannot be negative.")
|
||||
valid = false
|
||||
if definition.staff_max < 0:
|
||||
_add_error(&"value_out_of_range", facility_id, requested_path, &"staff_max", "Maximum staff cannot be negative.")
|
||||
valid = false
|
||||
if definition.staff_max < definition.staff_min:
|
||||
_add_error(
|
||||
&"invalid_value_relation",
|
||||
facility_id,
|
||||
requested_path,
|
||||
&"staff_max",
|
||||
"Maximum staff must be greater than or equal to minimum staff."
|
||||
)
|
||||
valid = false
|
||||
if definition.capacity < 0:
|
||||
_add_error(&"value_out_of_range", facility_id, requested_path, &"capacity", "Capacity cannot be negative.")
|
||||
valid = false
|
||||
|
||||
if not _validate_tags(definition.tags, facility_id, requested_path):
|
||||
valid = false
|
||||
if not _validate_detailed_profile(definition, requested_path):
|
||||
valid = false
|
||||
if not facility_id.is_empty() and requested_path.get_file().get_basename() != String(facility_id):
|
||||
_add_warning(
|
||||
&"id_filename_mismatch",
|
||||
facility_id,
|
||||
requested_path,
|
||||
&"id",
|
||||
"Facility ID does not match the resource filename."
|
||||
)
|
||||
return valid
|
||||
|
||||
|
||||
func _validate_detailed_profile(definition: FacilityDefinition, path: String) -> bool:
|
||||
var facility_id := definition.id
|
||||
if definition.definition_schema_version == FacilityDefinition.LEGACY_SCHEMA_VERSION:
|
||||
return true
|
||||
if definition.definition_schema_version != FacilityDefinition.DETAILED_SCHEMA_VERSION:
|
||||
_add_error(&"unsupported_definition_schema", facility_id, path, &"definition_schema_version", "Facility definition schema is unsupported.")
|
||||
return false
|
||||
var valid := true
|
||||
if definition.category.is_empty() or not _is_snake_case_id(String(definition.category)):
|
||||
_add_error(&"invalid_category", facility_id, path, &"category", "Detailed facilities require one snake_case category.")
|
||||
valid = false
|
||||
if not _validate_service_profiles(definition, path):
|
||||
valid = false
|
||||
for field_row: Array in [
|
||||
[&"service_types", definition.service_types, true],
|
||||
[&"required_connection_tags", definition.required_connection_tags, false],
|
||||
[&"preferred_adjacency_tags", definition.preferred_adjacency_tags, false],
|
||||
[&"forbidden_adjacency_tags", definition.forbidden_adjacency_tags, false],
|
||||
[&"allowed_guest_tags", definition.allowed_guest_tags, false],
|
||||
[&"forbidden_guest_tags", definition.forbidden_guest_tags, false],
|
||||
[&"research_requirements", definition.research_requirements, false],
|
||||
[&"statistics_keys", definition.statistics_keys, true],
|
||||
]:
|
||||
if not _validate_name_list(field_row[1], bool(field_row[2]), facility_id, path, field_row[0]):
|
||||
valid = false
|
||||
if not _validate_access_offsets(definition, definition.guest_access_offsets, &"guest_access_offsets", path):
|
||||
valid = false
|
||||
if not _validate_access_offsets(definition, definition.staff_access_offsets, &"staff_access_offsets", path):
|
||||
valid = false
|
||||
if not definition.pass_through and definition.guest_access_offsets.is_empty() and definition.staff_access_offsets.is_empty():
|
||||
_add_error(&"missing_access_point", facility_id, path, &"guest_access_offsets", "A non-route detailed facility requires a guest or staff access point.")
|
||||
valid = false
|
||||
if definition.max_level < 1 or definition.max_level > 5 or definition.upgrade_costs.size() != definition.max_level - 1:
|
||||
_add_error(&"invalid_upgrade_ladder", facility_id, path, &"upgrade_costs", "Upgrade costs must contain exactly max_level - 1 entries.")
|
||||
valid = false
|
||||
for index: int in definition.upgrade_costs.size():
|
||||
if not _validate_construction_cost(definition.upgrade_costs[index], facility_id, path):
|
||||
valid = false
|
||||
if definition.demolition_refund_percent < 0 or definition.demolition_refund_percent > 100:
|
||||
_add_error(&"value_out_of_range", facility_id, path, &"demolition_refund_percent", "Demolition refund percent must be from 0 through 100.")
|
||||
valid = false
|
||||
if definition.unlock_rank < 1 or definition.unlock_rank > 7:
|
||||
_add_error(&"value_out_of_range", facility_id, path, &"unlock_rank", "Unlock rank must be from 1 through 7.")
|
||||
valid = false
|
||||
var expected_schedule_keys: Array[StringName] = [&"morning", &"day", &"night"]
|
||||
if definition.opening_schedule.size() != expected_schedule_keys.size() \
|
||||
or not definition.opening_schedule.has(&"morning") \
|
||||
or not definition.opening_schedule.has(&"day") \
|
||||
or not definition.opening_schedule.has(&"night"):
|
||||
_add_error(&"invalid_opening_schedule", facility_id, path, &"opening_schedule", "Opening schedule must contain exactly morning, day, and night.")
|
||||
valid = false
|
||||
else:
|
||||
for time_band: StringName in expected_schedule_keys:
|
||||
if not definition.opening_schedule[time_band] is bool:
|
||||
_add_error(&"wrong_value_type", facility_id, path, &"opening_schedule", "Opening schedule values must be booleans.")
|
||||
valid = false
|
||||
if not _validate_nonnegative_int_map(definition.incident_weights, facility_id, path, &"incident_weights"):
|
||||
valid = false
|
||||
if not _validate_policy_schema(definition.policy_schema, facility_id, path):
|
||||
valid = false
|
||||
if definition.adult_content and (not definition.tags.has(&"adult_only") or definition.content_replacement_id.is_empty()):
|
||||
_add_error(&"unsafe_adult_content_profile", facility_id, path, &"adult_content", "Adult content requires adult_only and a safe content replacement ID.")
|
||||
valid = false
|
||||
if not definition.adult_content and not definition.content_replacement_id.is_empty():
|
||||
_add_warning(&"unused_content_replacement", facility_id, path, &"content_replacement_id", "A non-adult facility does not use a content replacement.")
|
||||
return valid
|
||||
|
||||
|
||||
func _validate_service_profiles(definition: FacilityDefinition, path: String) -> bool:
|
||||
var valid := true
|
||||
if definition.default_service_type.is_empty() or not definition.service_types.has(definition.default_service_type):
|
||||
_add_error(&"invalid_default_service", definition.id, path, &"default_service_type", "Default service must reference one declared service type.")
|
||||
valid = false
|
||||
if definition.service_profiles.size() != definition.service_types.size():
|
||||
_add_error(&"service_profile_mismatch", definition.id, path, &"service_profiles", "Every declared service type requires exactly one profile.")
|
||||
valid = false
|
||||
for service_type: StringName in definition.service_types:
|
||||
if not definition.service_profiles.has(service_type) or not definition.service_profiles[service_type] is Dictionary:
|
||||
_add_error(&"missing_service_profile", definition.id, path, &"service_profiles", "Service '%s' requires a Dictionary profile." % service_type)
|
||||
valid = false
|
||||
continue
|
||||
var profile := definition.service_profiles[service_type] as Dictionary
|
||||
for required_key: StringName in [&"duration_minutes", &"base_quality", &"base_price", &"capacity_units", &"need_effects"]:
|
||||
if not profile.has(required_key):
|
||||
_add_error(&"missing_service_profile_field", definition.id, path, &"service_profiles", "Service '%s' is missing '%s'." % [service_type, required_key])
|
||||
valid = false
|
||||
if profile.has(&"duration_minutes") and (not profile[&"duration_minutes"] is int or int(profile[&"duration_minutes"]) <= 0):
|
||||
_add_error(&"invalid_service_profile_value", definition.id, path, &"service_profiles", "Service '%s' duration_minutes must be a positive integer." % service_type)
|
||||
valid = false
|
||||
if profile.has(&"base_quality") and (not profile[&"base_quality"] is int or int(profile[&"base_quality"]) < 0 or int(profile[&"base_quality"]) > 100):
|
||||
_add_error(&"invalid_service_profile_value", definition.id, path, &"service_profiles", "Service '%s' base_quality must be an integer from 0 through 100." % service_type)
|
||||
valid = false
|
||||
if profile.has(&"base_price") and (not profile[&"base_price"] is int or int(profile[&"base_price"]) < 0):
|
||||
_add_error(&"invalid_service_profile_value", definition.id, path, &"service_profiles", "Service '%s' base_price must be a non-negative integer." % service_type)
|
||||
valid = false
|
||||
if profile.has(&"capacity_units") and (not profile[&"capacity_units"] is int or int(profile[&"capacity_units"]) <= 0):
|
||||
_add_error(&"invalid_service_profile_value", definition.id, path, &"service_profiles", "Service '%s' capacity_units must be a positive integer." % service_type)
|
||||
valid = false
|
||||
if not profile.has(&"need_effects"):
|
||||
pass # The missing-field issue was emitted above.
|
||||
elif not profile[&"need_effects"] is Dictionary:
|
||||
_add_error(&"invalid_service_profile_value", definition.id, path, &"service_profiles", "Service '%s' need_effects must be a Dictionary." % service_type)
|
||||
valid = false
|
||||
elif not _validate_signed_int_map(profile[&"need_effects"], -100, 100, definition.id, path, &"service_profiles"):
|
||||
valid = false
|
||||
return valid
|
||||
|
||||
|
||||
func _validate_name_list(values: Array[StringName], required: bool, facility_id: StringName, path: String, field: StringName) -> bool:
|
||||
if required and values.is_empty():
|
||||
_add_error(&"missing_required_value", facility_id, path, field, "%s requires at least one ID." % field)
|
||||
return false
|
||||
var valid := true
|
||||
var seen: Dictionary = {}
|
||||
for value: StringName in values:
|
||||
if value.is_empty() or not _is_snake_case_id(String(value)) or seen.has(value):
|
||||
_add_error(&"invalid_id_list", facility_id, path, field, "%s must contain unique snake_case IDs." % field)
|
||||
valid = false
|
||||
seen[value] = true
|
||||
return valid
|
||||
|
||||
|
||||
func _validate_access_offsets(definition: FacilityDefinition, offsets: Array[Vector2i], field: StringName, path: String) -> bool:
|
||||
var valid := true
|
||||
var seen: Dictionary = {}
|
||||
for offset: Vector2i in offsets:
|
||||
if offset.x < 0 or offset.y < 0 or offset.x >= definition.footprint.x or offset.y >= definition.footprint.y or seen.has(offset):
|
||||
_add_error(&"invalid_access_offset", definition.id, path, field, "Access points must be unique cells inside the unrotated footprint.")
|
||||
valid = false
|
||||
seen[offset] = true
|
||||
return valid
|
||||
|
||||
|
||||
func _validate_nonnegative_int_map(values: Dictionary, facility_id: StringName, path: String, field: StringName) -> bool:
|
||||
var valid := true
|
||||
for raw_key: Variant in values:
|
||||
var key := StringName(raw_key)
|
||||
if key.is_empty() or not _is_snake_case_id(String(key)) or not values[raw_key] is int or int(values[raw_key]) < 0:
|
||||
_add_error(&"invalid_weight_map", facility_id, path, field, "%s requires snake_case IDs and non-negative integer weights." % field)
|
||||
valid = false
|
||||
return valid
|
||||
|
||||
|
||||
func _validate_signed_int_map(values: Dictionary, minimum: int, maximum: int, facility_id: StringName, path: String, field: StringName) -> bool:
|
||||
var valid := true
|
||||
for raw_key: Variant in values:
|
||||
var key := StringName(raw_key)
|
||||
if key.is_empty() or not _is_snake_case_id(String(key)) or not values[raw_key] is int or int(values[raw_key]) < minimum or int(values[raw_key]) > maximum:
|
||||
_add_error(&"invalid_signed_value_map", facility_id, path, field, "%s requires snake_case IDs and integer values from %d through %d." % [field, minimum, maximum])
|
||||
valid = false
|
||||
return valid
|
||||
|
||||
|
||||
func _validate_policy_schema(values: Dictionary, facility_id: StringName, path: String) -> bool:
|
||||
var valid := true
|
||||
for raw_key: Variant in values:
|
||||
var key := StringName(raw_key)
|
||||
var row: Variant = values[raw_key]
|
||||
if key.is_empty() or not _is_snake_case_id(String(key)) or not row is Dictionary:
|
||||
_add_error(&"invalid_policy_schema", facility_id, path, &"policy_schema", "Policy entries require snake_case IDs and Dictionary schemas.")
|
||||
valid = false
|
||||
continue
|
||||
var schema := row as Dictionary
|
||||
if not schema.has(&"type") or not schema.has(&"default") or StringName(schema[&"type"]) not in [&"bool", &"enum", &"int"]:
|
||||
_add_error(&"invalid_policy_schema", facility_id, path, &"policy_schema", "Every policy requires type and default; type must be bool, enum, or int.")
|
||||
valid = false
|
||||
return valid
|
||||
|
||||
|
||||
func _validate_construction_cost(cost: Dictionary, facility_id: StringName, path: String) -> bool:
|
||||
var valid := true
|
||||
for required_key in [&"gold", &"materials"]:
|
||||
if not cost.has(required_key):
|
||||
_add_error(
|
||||
&"missing_required_key",
|
||||
facility_id,
|
||||
path,
|
||||
&"construction_cost",
|
||||
"Construction cost is missing '%s'." % required_key
|
||||
)
|
||||
valid = false
|
||||
continue
|
||||
var value: Variant = cost[required_key]
|
||||
if not value is int:
|
||||
_add_error(
|
||||
&"wrong_value_type",
|
||||
facility_id,
|
||||
path,
|
||||
&"construction_cost",
|
||||
"Construction cost '%s' must be an integer." % required_key
|
||||
)
|
||||
valid = false
|
||||
elif int(value) < 0:
|
||||
_add_error(
|
||||
&"value_out_of_range",
|
||||
facility_id,
|
||||
path,
|
||||
&"construction_cost",
|
||||
"Construction cost '%s' cannot be negative." % required_key
|
||||
)
|
||||
valid = false
|
||||
|
||||
var cost_keys := cost.keys()
|
||||
cost_keys.sort()
|
||||
for key in cost_keys:
|
||||
if key != &"gold" and key != &"materials":
|
||||
_add_warning(
|
||||
&"unknown_cost_key",
|
||||
facility_id,
|
||||
path,
|
||||
&"construction_cost",
|
||||
"Unknown construction cost key '%s' is ignored by the base game." % key
|
||||
)
|
||||
return valid
|
||||
|
||||
|
||||
func _validate_tags(tags: Array[StringName], facility_id: StringName, path: String) -> bool:
|
||||
var valid := true
|
||||
var seen: Dictionary = {}
|
||||
for tag in tags:
|
||||
if tag.is_empty() or not _is_snake_case_id(String(tag)):
|
||||
_add_error(
|
||||
&"invalid_tag_format",
|
||||
facility_id,
|
||||
path,
|
||||
&"tags",
|
||||
"Tags must be non-empty lowercase ASCII snake_case identifiers."
|
||||
)
|
||||
valid = false
|
||||
if seen.has(tag):
|
||||
_add_warning(&"duplicate_tag", facility_id, path, &"tags", "Duplicate tag '%s'." % tag)
|
||||
seen[tag] = true
|
||||
return valid
|
||||
|
||||
|
||||
func _is_snake_case_id(value: String) -> bool:
|
||||
if value.is_empty():
|
||||
return false
|
||||
var first := value.unicode_at(0)
|
||||
if first < 97 or first > 122:
|
||||
return false
|
||||
var previous_was_underscore := false
|
||||
for index in value.length():
|
||||
var character := value.unicode_at(index)
|
||||
var is_letter := character >= 97 and character <= 122
|
||||
var is_number := character >= 48 and character <= 57
|
||||
var is_underscore := character == 95
|
||||
if not is_letter and not is_number and not is_underscore:
|
||||
return false
|
||||
if is_underscore and previous_was_underscore:
|
||||
return false
|
||||
previous_was_underscore = is_underscore
|
||||
return not previous_was_underscore
|
||||
|
||||
|
||||
func _finish_rebuild() -> void:
|
||||
_ordered_facility_ids.sort_custom(_id_less)
|
||||
_errors.sort_custom(_issue_less)
|
||||
_warnings.sort_custom(_issue_less)
|
||||
|
||||
|
||||
func _add_error(
|
||||
code: StringName,
|
||||
definition_id: StringName,
|
||||
resource_path: String,
|
||||
field: StringName,
|
||||
message: String
|
||||
) -> void:
|
||||
_errors.append(_make_issue(SEVERITY_ERROR, code, definition_id, resource_path, field, message))
|
||||
|
||||
|
||||
func _add_warning(
|
||||
code: StringName,
|
||||
definition_id: StringName,
|
||||
resource_path: String,
|
||||
field: StringName,
|
||||
message: String
|
||||
) -> void:
|
||||
_warnings.append(_make_issue(SEVERITY_WARNING, code, definition_id, resource_path, field, message))
|
||||
|
||||
|
||||
func _make_issue(
|
||||
severity: StringName,
|
||||
code: StringName,
|
||||
definition_id: StringName,
|
||||
resource_path: String,
|
||||
field: StringName,
|
||||
message: String
|
||||
) -> Dictionary:
|
||||
return {
|
||||
&"severity": severity,
|
||||
&"code": code,
|
||||
&"definition_id": definition_id,
|
||||
&"resource_path": resource_path,
|
||||
&"field": field,
|
||||
&"message": message,
|
||||
}
|
||||
|
||||
|
||||
func _candidate_less(left: Dictionary, right: Dictionary) -> bool:
|
||||
var left_definition := left[&"definition"] as FacilityDefinition
|
||||
var right_definition := right[&"definition"] as FacilityDefinition
|
||||
var left_id := String(left_definition.id)
|
||||
var right_id := String(right_definition.id)
|
||||
if left_id != right_id:
|
||||
return left_id < right_id
|
||||
return String(left[&"path"]) < String(right[&"path"])
|
||||
|
||||
|
||||
func _id_less(left: StringName, right: StringName) -> bool:
|
||||
return String(left) < String(right)
|
||||
|
||||
|
||||
func _issue_less(left: Dictionary, right: Dictionary) -> bool:
|
||||
var left_id := String(left[&"definition_id"])
|
||||
var right_id := String(right[&"definition_id"])
|
||||
if left_id != right_id:
|
||||
return left_id < right_id
|
||||
var left_path := String(left[&"resource_path"])
|
||||
var right_path := String(right[&"resource_path"])
|
||||
if left_path != right_path:
|
||||
return left_path < right_path
|
||||
var left_field := String(left[&"field"])
|
||||
var right_field := String(right[&"field"])
|
||||
if left_field != right_field:
|
||||
return left_field < right_field
|
||||
var left_code := String(left[&"code"])
|
||||
var right_code := String(right[&"code"])
|
||||
if left_code != right_code:
|
||||
return left_code < right_code
|
||||
return String(left[&"message"]) < String(right[&"message"])
|
||||
@@ -0,0 +1 @@
|
||||
uid://c51liwjesdyql
|
||||
@@ -0,0 +1,154 @@
|
||||
class_name DeterministicRng
|
||||
extends RefCounted
|
||||
|
||||
## A small deterministic random stream for simulation code.
|
||||
##
|
||||
## Each subsystem must create its own stream from the shared root seed and a
|
||||
## stable stream ID. Saving both state and counter allows an exact continuation
|
||||
## without coupling unrelated systems through one global random sequence.
|
||||
|
||||
const ALGORITHM_ID: StringName = &"xorshift32-sha256-v1"
|
||||
const SNAPSHOT_VERSION := 1
|
||||
const UINT32_MASK := 0xffffffff
|
||||
const UINT32_RANGE := 0x100000000
|
||||
const FALLBACK_NON_ZERO_STATE := 0x6d2b79f5
|
||||
|
||||
var _root_seed := 0
|
||||
var _stream_id: StringName = &"default"
|
||||
var _state := FALLBACK_NON_ZERO_STATE
|
||||
var _counter := 0
|
||||
|
||||
|
||||
static func from_root_seed(root_seed: int, stream_id: StringName) -> Variant:
|
||||
# Loading by resource path keeps this factory usable on a clean checkout,
|
||||
# before Godot has generated its global class-name cache.
|
||||
var result: Variant = load("res://scripts/core/deterministic_rng.gd").new()
|
||||
result.reset(root_seed, stream_id)
|
||||
return result
|
||||
|
||||
|
||||
func reset(root_seed: int, stream_id: StringName) -> void:
|
||||
_root_seed = root_seed
|
||||
_stream_id = stream_id
|
||||
_state = _derive_non_zero_state(root_seed, stream_id)
|
||||
_counter = 0
|
||||
|
||||
|
||||
func next_u32() -> int:
|
||||
# xorshift32 operates only on non-negative 32-bit values. Intermediates stay
|
||||
# below the signed 64-bit limit used by GDScript integers.
|
||||
var value := _state
|
||||
value ^= (value << 13) & UINT32_MASK
|
||||
value ^= value >> 17
|
||||
value ^= (value << 5) & UINT32_MASK
|
||||
_state = value & UINT32_MASK
|
||||
_counter += 1
|
||||
return _state
|
||||
|
||||
|
||||
func randi_range(minimum: int, maximum: int) -> int:
|
||||
if minimum > maximum:
|
||||
push_error("DeterministicRng range minimum must not exceed maximum")
|
||||
return minimum
|
||||
|
||||
# A crossing-zero subtraction can overflow only for ranges much larger than
|
||||
# this generator supports, so reject it before evaluating the subtraction.
|
||||
var width := 0
|
||||
if minimum < 0 and maximum > 0:
|
||||
if maximum > minimum + UINT32_MASK:
|
||||
push_error("DeterministicRng supports ranges of at most 2^32 values")
|
||||
return minimum
|
||||
width = maximum - minimum
|
||||
else:
|
||||
width = maximum - minimum
|
||||
if width > UINT32_MASK:
|
||||
push_error("DeterministicRng supports ranges of at most 2^32 values")
|
||||
return minimum
|
||||
|
||||
var span := width + 1
|
||||
if span == 1:
|
||||
return minimum
|
||||
|
||||
# Rejection sampling avoids modulo bias for spans which do not divide 2^32.
|
||||
var acceptance_limit := UINT32_RANGE - (UINT32_RANGE % span)
|
||||
while true:
|
||||
var sample := next_u32()
|
||||
if sample < acceptance_limit:
|
||||
return minimum + (sample % span)
|
||||
return minimum
|
||||
|
||||
|
||||
func snapshot() -> Dictionary:
|
||||
return {
|
||||
"version": SNAPSHOT_VERSION,
|
||||
"algorithm": String(ALGORITHM_ID),
|
||||
"root_seed": _root_seed,
|
||||
"stream_id": String(_stream_id),
|
||||
"state": _state,
|
||||
"counter": _counter,
|
||||
}
|
||||
|
||||
|
||||
func restore(saved_state: Dictionary) -> bool:
|
||||
if int(saved_state.get("version", -1)) != SNAPSHOT_VERSION:
|
||||
push_error("Unsupported DeterministicRng snapshot version")
|
||||
return false
|
||||
if StringName(saved_state.get("algorithm", "")) != ALGORITHM_ID:
|
||||
push_error("DeterministicRng snapshot algorithm does not match")
|
||||
return false
|
||||
|
||||
var restored_state := int(saved_state.get("state", 0))
|
||||
var restored_counter := int(saved_state.get("counter", -1))
|
||||
if restored_state <= 0 or restored_state > UINT32_MASK:
|
||||
push_error("DeterministicRng snapshot contains an invalid state")
|
||||
return false
|
||||
if restored_counter < 0:
|
||||
push_error("DeterministicRng snapshot contains an invalid counter")
|
||||
return false
|
||||
|
||||
_root_seed = int(saved_state.get("root_seed", 0))
|
||||
_stream_id = StringName(saved_state.get("stream_id", "default"))
|
||||
_state = restored_state
|
||||
_counter = restored_counter
|
||||
return true
|
||||
|
||||
|
||||
func get_root_seed() -> int:
|
||||
return _root_seed
|
||||
|
||||
|
||||
func get_stream_id() -> StringName:
|
||||
return _stream_id
|
||||
|
||||
|
||||
func get_state() -> int:
|
||||
return _state
|
||||
|
||||
|
||||
func get_counter() -> int:
|
||||
return _counter
|
||||
|
||||
|
||||
static func _derive_non_zero_state(root_seed: int, stream_id: StringName) -> int:
|
||||
var stream_bytes := String(stream_id).to_utf8_buffer()
|
||||
var context := HashingContext.new()
|
||||
if context.start(HashingContext.HASH_SHA256) != OK:
|
||||
push_error("Could not initialize the deterministic RNG seed hash")
|
||||
return FALLBACK_NON_ZERO_STATE
|
||||
context.update("the-tree:deterministic-rng:v1\n".to_utf8_buffer())
|
||||
context.update(("%d\n%d\n" % [root_seed, stream_bytes.size()]).to_utf8_buffer())
|
||||
context.update(stream_bytes)
|
||||
var digest := context.finish()
|
||||
|
||||
var derived_state := 0
|
||||
for byte_index in range(4):
|
||||
derived_state = (derived_state << 8) | int(digest[byte_index])
|
||||
if derived_state == 0:
|
||||
# The all-zero state is absorbing for xorshift32. A second digest word is
|
||||
# already independently distributed; retain a literal fallback as a final
|
||||
# guard for the astronomically unlikely double-zero case.
|
||||
for byte_index in range(4, 8):
|
||||
derived_state = (derived_state << 8) | int(digest[byte_index])
|
||||
if derived_state == 0:
|
||||
derived_state = FALLBACK_NON_ZERO_STATE
|
||||
return derived_state
|
||||
@@ -0,0 +1 @@
|
||||
uid://d3kcft1dnys2a
|
||||
@@ -0,0 +1,75 @@
|
||||
class_name SimulationDomainEvent
|
||||
extends RefCounted
|
||||
|
||||
var event_id: StringName = &""
|
||||
var event_type: StringName = &""
|
||||
var absolute_minute := 0
|
||||
var source_service: StringName = &""
|
||||
var entity_ids: Array[StringName] = []
|
||||
var payload: Dictionary = {}
|
||||
var causation_command_id := 0
|
||||
|
||||
func _init(
|
||||
p_event_id: StringName = &"",
|
||||
p_event_type: StringName = &"",
|
||||
p_absolute_minute := 0,
|
||||
p_source_service: StringName = &"",
|
||||
p_entity_ids: Array[StringName] = [],
|
||||
p_payload: Dictionary = {},
|
||||
p_causation_command_id := 0
|
||||
) -> void:
|
||||
event_id = p_event_id
|
||||
event_type = p_event_type
|
||||
absolute_minute = p_absolute_minute
|
||||
source_service = p_source_service
|
||||
entity_ids = p_entity_ids.duplicate()
|
||||
payload = p_payload.duplicate(true)
|
||||
causation_command_id = p_causation_command_id
|
||||
|
||||
func is_valid() -> bool:
|
||||
return not event_id.is_empty() \
|
||||
and not event_type.is_empty() \
|
||||
and not source_service.is_empty() \
|
||||
and absolute_minute >= 0 \
|
||||
and causation_command_id >= 0
|
||||
|
||||
func copy() -> SimulationDomainEvent:
|
||||
return SimulationDomainEvent.new(
|
||||
event_id,
|
||||
event_type,
|
||||
absolute_minute,
|
||||
source_service,
|
||||
entity_ids,
|
||||
payload,
|
||||
causation_command_id
|
||||
)
|
||||
|
||||
func to_dictionary() -> Dictionary:
|
||||
var saved_entity_ids: Array[String] = []
|
||||
for entity_id in entity_ids:
|
||||
saved_entity_ids.append(String(entity_id))
|
||||
return {
|
||||
"event_id": String(event_id),
|
||||
"event_type": String(event_type),
|
||||
"absolute_minute": absolute_minute,
|
||||
"source_service": String(source_service),
|
||||
"entity_ids": saved_entity_ids,
|
||||
"payload": payload.duplicate(true),
|
||||
"causation_command_id": causation_command_id,
|
||||
}
|
||||
|
||||
static func from_dictionary(data: Dictionary) -> SimulationDomainEvent:
|
||||
var restored_entity_ids: Array[StringName] = []
|
||||
var saved_entity_ids: Variant = data.get("entity_ids", [])
|
||||
if saved_entity_ids is Array:
|
||||
for entity_id: Variant in saved_entity_ids:
|
||||
restored_entity_ids.append(StringName(entity_id))
|
||||
return SimulationDomainEvent.new(
|
||||
StringName(data.get("event_id", "")),
|
||||
StringName(data.get("event_type", "")),
|
||||
int(data.get("absolute_minute", -1)),
|
||||
StringName(data.get("source_service", "")),
|
||||
restored_entity_ids,
|
||||
data.get("payload", {}) as Dictionary,
|
||||
int(data.get("causation_command_id", 0))
|
||||
)
|
||||
@@ -0,0 +1 @@
|
||||
uid://y0x57c1kmwj7
|
||||
@@ -0,0 +1,144 @@
|
||||
class_name DomainEventStream
|
||||
extends RefCounted
|
||||
|
||||
const SNAPSHOT_VERSION := 1
|
||||
const WILDCARD_EVENT_TYPE: StringName = &"*"
|
||||
const RECENT_EVENT_LIMIT := 256
|
||||
const MAX_BATCH_SIZE := 4_096
|
||||
|
||||
var _subscribers: Dictionary = {}
|
||||
var _published_ids: Dictionary = {}
|
||||
var _recent_events: Array[SimulationDomainEvent] = []
|
||||
var _pending_deliveries: Array[SimulationDomainEvent] = []
|
||||
var _delivery_active := false
|
||||
|
||||
func subscribe(event_type: StringName, subscriber_id: StringName, callback: Callable) -> bool:
|
||||
if event_type.is_empty() or subscriber_id.is_empty() or not callback.is_valid():
|
||||
return false
|
||||
if not _subscribers.has(event_type):
|
||||
_subscribers[event_type] = {}
|
||||
var subscribers: Dictionary = _subscribers[event_type]
|
||||
if subscribers.has(subscriber_id):
|
||||
return false
|
||||
subscribers[subscriber_id] = callback
|
||||
return true
|
||||
|
||||
func unsubscribe(event_type: StringName, subscriber_id: StringName) -> void:
|
||||
if _subscribers.has(event_type):
|
||||
var subscribers: Dictionary = _subscribers[event_type]
|
||||
subscribers.erase(subscriber_id)
|
||||
|
||||
func publish(event: SimulationDomainEvent) -> bool:
|
||||
return publish_batch([event])
|
||||
|
||||
|
||||
## Validate and commit the entire event set before the first subscriber runs.
|
||||
## Reentrant publications join the end of the delivery queue, so callbacks can
|
||||
## never observe or deliver only a prefix of the committed batch.
|
||||
func publish_batch(events: Array) -> bool:
|
||||
if events.size() > MAX_BATCH_SIZE:
|
||||
return false
|
||||
var committed: Array[SimulationDomainEvent] = []
|
||||
var batch_ids: Dictionary = {}
|
||||
for event_value: Variant in events:
|
||||
if not event_value is SimulationDomainEvent:
|
||||
return false
|
||||
var event: SimulationDomainEvent = event_value
|
||||
if event == null or not event.is_valid() \
|
||||
or _published_ids.has(event.event_id) or batch_ids.has(event.event_id):
|
||||
return false
|
||||
batch_ids[event.event_id] = true
|
||||
committed.append(event.copy())
|
||||
for event: SimulationDomainEvent in committed:
|
||||
_published_ids[event.event_id] = true
|
||||
_recent_events.append(event.copy())
|
||||
if _recent_events.size() > RECENT_EVENT_LIMIT:
|
||||
_recent_events.pop_front()
|
||||
_pending_deliveries.append(event.copy())
|
||||
_drain_deliveries()
|
||||
return true
|
||||
|
||||
|
||||
func _drain_deliveries() -> void:
|
||||
if _delivery_active:
|
||||
return
|
||||
_delivery_active = true
|
||||
while not _pending_deliveries.is_empty():
|
||||
var event: SimulationDomainEvent = _pending_deliveries.pop_front()
|
||||
_deliver(event)
|
||||
_delivery_active = false
|
||||
|
||||
|
||||
func _deliver(event: SimulationDomainEvent) -> void:
|
||||
var callbacks := {}
|
||||
_collect_callbacks(event.event_type, callbacks)
|
||||
_collect_callbacks(WILDCARD_EVENT_TYPE, callbacks)
|
||||
var subscriber_ids: Array = callbacks.keys()
|
||||
subscriber_ids.sort()
|
||||
for subscriber_id: Variant in subscriber_ids:
|
||||
var callback: Callable = callbacks[subscriber_id]
|
||||
if callback.is_valid():
|
||||
callback.call(event.copy())
|
||||
|
||||
func has_published(event_id: StringName) -> bool:
|
||||
return _published_ids.has(event_id)
|
||||
|
||||
func recent_events() -> Array[SimulationDomainEvent]:
|
||||
var result: Array[SimulationDomainEvent] = []
|
||||
for event in _recent_events:
|
||||
result.append(event.copy())
|
||||
return result
|
||||
|
||||
func create_snapshot() -> Dictionary:
|
||||
var published_ids: Array = _published_ids.keys()
|
||||
published_ids.sort_custom(func(left: Variant, right: Variant) -> bool:
|
||||
return String(left) < String(right)
|
||||
)
|
||||
var saved_ids: Array[String] = []
|
||||
for event_id: Variant in published_ids:
|
||||
saved_ids.append(String(event_id))
|
||||
var saved_recent: Array[Dictionary] = []
|
||||
for event in _recent_events:
|
||||
saved_recent.append(event.to_dictionary())
|
||||
return {
|
||||
"version": SNAPSHOT_VERSION,
|
||||
"published_ids": saved_ids,
|
||||
"recent_events": saved_recent,
|
||||
}
|
||||
|
||||
func restore_snapshot(snapshot: Dictionary) -> bool:
|
||||
if _delivery_active or not _pending_deliveries.is_empty():
|
||||
return false
|
||||
if int(snapshot.get("version", -1)) != SNAPSHOT_VERSION:
|
||||
return false
|
||||
if not snapshot.get("published_ids", null) is Array \
|
||||
or not snapshot.get("recent_events", null) is Array:
|
||||
return false
|
||||
var restored_ids := {}
|
||||
for event_id: Variant in snapshot["published_ids"]:
|
||||
var normalized_id := StringName(event_id)
|
||||
if normalized_id.is_empty() or restored_ids.has(normalized_id):
|
||||
return false
|
||||
restored_ids[normalized_id] = true
|
||||
var restored_recent: Array[SimulationDomainEvent] = []
|
||||
for event_data: Variant in snapshot["recent_events"]:
|
||||
if not event_data is Dictionary:
|
||||
return false
|
||||
var event := SimulationDomainEvent.from_dictionary(event_data)
|
||||
if not event.is_valid() or not restored_ids.has(event.event_id):
|
||||
return false
|
||||
restored_recent.append(event)
|
||||
if restored_recent.size() > RECENT_EVENT_LIMIT:
|
||||
return false
|
||||
_published_ids = restored_ids
|
||||
_recent_events = restored_recent
|
||||
_pending_deliveries.clear()
|
||||
return true
|
||||
|
||||
func _collect_callbacks(event_type: StringName, target: Dictionary) -> void:
|
||||
if not _subscribers.has(event_type):
|
||||
return
|
||||
var source: Dictionary = _subscribers[event_type]
|
||||
for subscriber_id: Variant in source:
|
||||
if not target.has(subscriber_id):
|
||||
target[subscriber_id] = source[subscriber_id]
|
||||
@@ -0,0 +1 @@
|
||||
uid://d21vwgov273if
|
||||
@@ -0,0 +1,174 @@
|
||||
class_name SimulationPerformanceBudget
|
||||
extends RefCounted
|
||||
|
||||
## Lightweight runtime profiler with explicit integer-microsecond samples.
|
||||
## It is diagnostic state only and deliberately stays outside Save snapshots.
|
||||
|
||||
signal budget_exceeded(metric_id: StringName, report: Dictionary)
|
||||
|
||||
enum Aggregation {
|
||||
AVERAGE,
|
||||
P95,
|
||||
MAXIMUM,
|
||||
}
|
||||
|
||||
const DEFAULT_SAMPLE_CAPACITY := 512
|
||||
|
||||
const DEFAULT_BUDGETS := {
|
||||
&"simulation_x1": {&"aggregation": Aggregation.AVERAGE, &"limit_us": 4_000},
|
||||
&"simulation_x4": {&"aggregation": Aggregation.P95, &"limit_us": 12_000},
|
||||
&"path_query": {&"aggregation": Aggregation.P95, &"limit_us": 2_000},
|
||||
&"main_thread_stall": {&"aggregation": Aggregation.MAXIMUM, &"limit_us": 50_000},
|
||||
&"save": {&"aggregation": Aggregation.MAXIMUM, &"limit_us": 5_000_000},
|
||||
&"load": {&"aggregation": Aggregation.MAXIMUM, &"limit_us": 8_000_000},
|
||||
}
|
||||
|
||||
var sample_capacity := DEFAULT_SAMPLE_CAPACITY
|
||||
var _budgets: Dictionary = {}
|
||||
var _samples: Dictionary = {}
|
||||
var _totals: Dictionary = {}
|
||||
var _sample_counts: Dictionary = {}
|
||||
|
||||
|
||||
func _init(capacity := DEFAULT_SAMPLE_CAPACITY) -> void:
|
||||
sample_capacity = maxi(int(capacity), 1)
|
||||
for raw_metric_id: Variant in DEFAULT_BUDGETS:
|
||||
_budgets[StringName(raw_metric_id)] = (
|
||||
DEFAULT_BUDGETS[raw_metric_id] as Dictionary
|
||||
).duplicate(true)
|
||||
|
||||
|
||||
func set_budget(
|
||||
metric_id: StringName,
|
||||
limit_us: int,
|
||||
aggregation: Aggregation = Aggregation.P95
|
||||
) -> bool:
|
||||
if metric_id.is_empty() or limit_us <= 0 \
|
||||
or aggregation < Aggregation.AVERAGE or aggregation > Aggregation.MAXIMUM:
|
||||
return false
|
||||
_budgets[metric_id] = {
|
||||
&"aggregation": aggregation,
|
||||
&"limit_us": limit_us,
|
||||
}
|
||||
return true
|
||||
|
||||
|
||||
func record_sample(metric_id: StringName, duration_us: int) -> Dictionary:
|
||||
if metric_id.is_empty() or duration_us < 0:
|
||||
return {&"ok": false, &"error": "Metric ID and non-negative duration are required."}
|
||||
if not _samples.has(metric_id):
|
||||
_samples[metric_id] = []
|
||||
var values: Array = _samples[metric_id]
|
||||
values.append(duration_us)
|
||||
if values.size() > sample_capacity:
|
||||
values.pop_front()
|
||||
_totals[metric_id] = int(_totals.get(metric_id, 0)) + duration_us
|
||||
_sample_counts[metric_id] = int(_sample_counts.get(metric_id, 0)) + 1
|
||||
var report := get_report(metric_id)
|
||||
if bool(report.get(&"budget_exceeded", false)):
|
||||
budget_exceeded.emit(metric_id, report.duplicate(true))
|
||||
return {&"ok": true, &"report": report}
|
||||
|
||||
|
||||
func record_simulation_tick(duration_us: int, speed: int) -> Dictionary:
|
||||
var metric_id := &"simulation_x4" if speed == 4 else &"simulation_x1"
|
||||
return record_sample(metric_id, duration_us)
|
||||
|
||||
|
||||
## Records the full main-thread cost of one rendered/process frame, including
|
||||
## all game-minute ticks consumed at accelerated speed. This keeps x4 from
|
||||
## accidentally reporting four individually-fast ticks while the frame stalls.
|
||||
func record_simulation_frame(duration_us: int, speed: int, ticks_run: int) -> Dictionary:
|
||||
if ticks_run <= 0:
|
||||
return {&"ok": false, &"error": "At least one completed tick is required."}
|
||||
var simulation_result := record_simulation_tick(duration_us, speed)
|
||||
var stall_result := record_sample(&"main_thread_stall", duration_us)
|
||||
return {
|
||||
&"ok": bool(simulation_result[&"ok"]) and bool(stall_result[&"ok"]),
|
||||
&"ticks_run": ticks_run,
|
||||
&"simulation": simulation_result,
|
||||
&"main_thread": stall_result,
|
||||
}
|
||||
|
||||
|
||||
func get_report(metric_id: StringName) -> Dictionary:
|
||||
var values: Array = _samples.get(metric_id, [])
|
||||
var count := values.size()
|
||||
var lifetime_count := int(_sample_counts.get(metric_id, 0))
|
||||
var lifetime_total := int(_totals.get(metric_id, 0))
|
||||
var average_us := lifetime_total / lifetime_count if lifetime_count > 0 else 0
|
||||
var maximum_us := 0
|
||||
for raw_value: Variant in values:
|
||||
maximum_us = maxi(maximum_us, int(raw_value))
|
||||
var p95_us := _percentile(values, 95)
|
||||
var budget: Dictionary = _budgets.get(metric_id, {})
|
||||
var aggregation := int(budget.get(&"aggregation", Aggregation.P95))
|
||||
var measured_us := _aggregated_value(aggregation, average_us, p95_us, maximum_us)
|
||||
var limit_us := int(budget.get(&"limit_us", 0))
|
||||
return {
|
||||
&"metric_id": metric_id,
|
||||
&"window_sample_count": count,
|
||||
&"lifetime_sample_count": lifetime_count,
|
||||
&"average_us": average_us,
|
||||
&"p95_us": p95_us,
|
||||
&"maximum_us": maximum_us,
|
||||
&"aggregation": aggregation,
|
||||
&"measured_us": measured_us,
|
||||
&"limit_us": limit_us,
|
||||
&"budget_exceeded": limit_us > 0 and measured_us > limit_us,
|
||||
}
|
||||
|
||||
|
||||
func get_all_reports() -> Array[Dictionary]:
|
||||
var metric_ids: Array[StringName] = []
|
||||
for raw_metric_id: Variant in _budgets:
|
||||
metric_ids.append(StringName(raw_metric_id))
|
||||
for raw_metric_id: Variant in _samples:
|
||||
var metric_id := StringName(raw_metric_id)
|
||||
if not metric_ids.has(metric_id):
|
||||
metric_ids.append(metric_id)
|
||||
metric_ids.sort_custom(func(a: StringName, b: StringName) -> bool:
|
||||
return String(a) < String(b)
|
||||
)
|
||||
var reports: Array[Dictionary] = []
|
||||
for metric_id: StringName in metric_ids:
|
||||
reports.append(get_report(metric_id))
|
||||
return reports
|
||||
|
||||
|
||||
func clear(metric_id: StringName = &"") -> void:
|
||||
if metric_id.is_empty():
|
||||
_samples.clear()
|
||||
_totals.clear()
|
||||
_sample_counts.clear()
|
||||
return
|
||||
_samples.erase(metric_id)
|
||||
_totals.erase(metric_id)
|
||||
_sample_counts.erase(metric_id)
|
||||
|
||||
|
||||
func _aggregated_value(
|
||||
aggregation: int,
|
||||
average_us: int,
|
||||
p95_us: int,
|
||||
maximum_us: int
|
||||
) -> int:
|
||||
match aggregation:
|
||||
Aggregation.AVERAGE:
|
||||
return average_us
|
||||
Aggregation.MAXIMUM:
|
||||
return maximum_us
|
||||
_:
|
||||
return p95_us
|
||||
|
||||
|
||||
func _percentile(raw_values: Array, percentile: int) -> int:
|
||||
if raw_values.is_empty():
|
||||
return 0
|
||||
var values: Array[int] = []
|
||||
for raw_value: Variant in raw_values:
|
||||
values.append(int(raw_value))
|
||||
values.sort()
|
||||
var rank_numerator := clampi(percentile, 0, 100) * values.size()
|
||||
var index := maxi((rank_numerator + 99) / 100 - 1, 0)
|
||||
return values[mini(index, values.size() - 1)]
|
||||
@@ -0,0 +1 @@
|
||||
uid://dt8ia8mm76hc1
|
||||
@@ -0,0 +1,142 @@
|
||||
class_name SaveMigrationRegistry
|
||||
extends RefCounted
|
||||
|
||||
## A deterministic, in-memory registry for adjacent save-format migrations.
|
||||
##
|
||||
## Every registered step migrates exactly N -> N + 1. The callback receives a
|
||||
## deep copy of the current value plus the two version numbers and must return
|
||||
## either the migrated Dictionary, or { ok = true, data = Dictionary }. A
|
||||
## callback can reject a migration with { ok = false, code = ..., message = ... }.
|
||||
## The caller-owned source Dictionary is never passed to migration code.
|
||||
|
||||
var _steps: Dictionary = {}
|
||||
var _migration_ids: Dictionary = {}
|
||||
|
||||
|
||||
func register_step(from_version: int, migration_id: StringName, migrator: Callable) -> Dictionary:
|
||||
if from_version < 0:
|
||||
return _failure(&"INVALID_FROM_VERSION", "from_version must be non-negative")
|
||||
if migration_id.is_empty():
|
||||
return _failure(&"INVALID_MIGRATION_ID", "migration_id must not be empty")
|
||||
if not migrator.is_valid():
|
||||
return _failure(&"INVALID_MIGRATOR", str(migration_id))
|
||||
if _steps.has(from_version):
|
||||
return _failure(&"DUPLICATE_MIGRATION_STEP", _required_step_id(from_version))
|
||||
if _migration_ids.has(migration_id):
|
||||
return _failure(&"DUPLICATE_MIGRATION_ID", str(migration_id))
|
||||
_steps[from_version] = {
|
||||
&"id": migration_id,
|
||||
&"migrator": migrator,
|
||||
}
|
||||
_migration_ids[migration_id] = from_version
|
||||
return {
|
||||
&"ok": true,
|
||||
&"code": &"OK",
|
||||
&"migration_id": migration_id,
|
||||
&"from_version": from_version,
|
||||
&"to_version": from_version + 1,
|
||||
}
|
||||
|
||||
|
||||
func has_step(from_version: int) -> bool:
|
||||
return _steps.has(from_version)
|
||||
|
||||
|
||||
func get_step_id(from_version: int) -> StringName:
|
||||
if not _steps.has(from_version):
|
||||
return StringName()
|
||||
return StringName(_steps[from_version][&"id"])
|
||||
|
||||
|
||||
func get_registered_steps() -> Array[Dictionary]:
|
||||
var versions: Array = _steps.keys()
|
||||
versions.sort()
|
||||
var result: Array[Dictionary] = []
|
||||
for version_value: Variant in versions:
|
||||
var from_version := int(version_value)
|
||||
result.append({
|
||||
&"id": get_step_id(from_version),
|
||||
&"from_version": from_version,
|
||||
&"to_version": from_version + 1,
|
||||
})
|
||||
return result
|
||||
|
||||
|
||||
func migrate(source: Dictionary, from_version: int, target_version: int) -> Dictionary:
|
||||
if from_version < 0 or target_version < 0 or target_version < from_version:
|
||||
return _failure(&"INVALID_VERSION_RANGE", "%d -> %d" % [from_version, target_version])
|
||||
var source_guard := source.duplicate(true)
|
||||
var working := source.duplicate(true)
|
||||
var applied_ids: Array[StringName] = []
|
||||
for version in range(from_version, target_version):
|
||||
if not _steps.has(version):
|
||||
return {
|
||||
&"ok": false,
|
||||
&"code": &"MIGRATION_STEP_MISSING",
|
||||
&"message": "missing adjacent migration %d -> %d" % [version, version + 1],
|
||||
&"failed_from_version": version,
|
||||
&"failed_to_version": version + 1,
|
||||
&"required_migration_id": _required_step_id(version),
|
||||
&"applied_migration_ids": applied_ids,
|
||||
}
|
||||
var step: Dictionary = _steps[version]
|
||||
var migration_id := StringName(step[&"id"])
|
||||
var migrator: Callable = step[&"migrator"]
|
||||
# Each callback receives its own deep copy so even a failing mutator cannot
|
||||
# contaminate the last accepted intermediate value.
|
||||
var callback_value: Variant = migrator.call(working.duplicate(true), version, version + 1)
|
||||
var unpacked := _unpack_callback_result(callback_value)
|
||||
if not unpacked[&"ok"]:
|
||||
return {
|
||||
&"ok": false,
|
||||
&"code": StringName(unpacked.get(&"code", &"MIGRATION_STEP_FAILED")),
|
||||
&"message": str(unpacked.get(&"message", migration_id)),
|
||||
&"failed_migration_id": migration_id,
|
||||
&"failed_from_version": version,
|
||||
&"failed_to_version": version + 1,
|
||||
&"applied_migration_ids": applied_ids,
|
||||
}
|
||||
working = unpacked[&"data"].duplicate(true)
|
||||
applied_ids.append(migration_id)
|
||||
if source != source_guard:
|
||||
# This should be unreachable because callbacks only see deep copies. Keep
|
||||
# the guard explicit so purity regressions fail closed.
|
||||
return _failure(&"SOURCE_MUTATED", "migration source changed unexpectedly")
|
||||
return {
|
||||
&"ok": true,
|
||||
&"code": &"OK",
|
||||
&"data": working.duplicate(true),
|
||||
&"from_version": from_version,
|
||||
&"target_version": target_version,
|
||||
&"applied_migration_ids": applied_ids,
|
||||
}
|
||||
|
||||
|
||||
func _unpack_callback_result(value: Variant) -> Dictionary:
|
||||
if not value is Dictionary:
|
||||
return _failure(&"MIGRATION_INVALID_RESULT", "migration callback must return a Dictionary")
|
||||
var result: Dictionary = value
|
||||
if result.has(&"ok") or result.has("ok"):
|
||||
var result_ok := bool(result.get(&"ok", result.get("ok", false)))
|
||||
if not result_ok:
|
||||
return _failure(
|
||||
StringName(result.get(&"code", result.get("code", &"MIGRATION_STEP_FAILED"))),
|
||||
str(result.get(&"message", result.get("message", "migration rejected")))
|
||||
)
|
||||
var data_value: Variant = result.get(&"data", result.get("data", null))
|
||||
if not data_value is Dictionary:
|
||||
return _failure(&"MIGRATION_INVALID_RESULT", "successful migration has no Dictionary data")
|
||||
return {&"ok": true, &"code": &"OK", &"data": data_value}
|
||||
return {&"ok": true, &"code": &"OK", &"data": result}
|
||||
|
||||
|
||||
func _required_step_id(from_version: int) -> StringName:
|
||||
return StringName("format_%d_to_%d" % [from_version, from_version + 1])
|
||||
|
||||
|
||||
func _failure(code: StringName, message: String) -> Dictionary:
|
||||
return {
|
||||
&"ok": false,
|
||||
&"code": code,
|
||||
&"message": message,
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
uid://dclgekguin7m2
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1 @@
|
||||
uid://chwd86m6gactn
|
||||
@@ -0,0 +1,240 @@
|
||||
class_name SimulationDigest
|
||||
extends RefCounted
|
||||
|
||||
## Produces a stable SHA-256 digest for snapshot-shaped Variant data.
|
||||
##
|
||||
## Dictionary insertion order never affects the result. Type tags and byte
|
||||
## lengths make the canonical form unambiguous, and Array order remains
|
||||
## significant. Runtime Objects and other process-local values are rejected;
|
||||
## callers must first export them to Dictionaries, Arrays and value types.
|
||||
|
||||
const FORMAT_VERSION := 1
|
||||
const MAX_NESTING_DEPTH := 128
|
||||
|
||||
|
||||
static func compute(value: Variant) -> String:
|
||||
var canonical := canonical_bytes(value)
|
||||
if canonical.is_empty():
|
||||
return ""
|
||||
var context := HashingContext.new()
|
||||
if context.start(HashingContext.HASH_SHA256) != OK:
|
||||
push_error("Could not initialize the simulation digest hash")
|
||||
return ""
|
||||
context.update(canonical)
|
||||
return context.finish().hex_encode()
|
||||
|
||||
|
||||
static func canonical_bytes(value: Variant) -> PackedByteArray:
|
||||
var output := PackedByteArray()
|
||||
_append_ascii(output, "the-tree:simulation-digest:v%d;" % FORMAT_VERSION)
|
||||
if not _encode_value(value, output, 0):
|
||||
return PackedByteArray()
|
||||
return output
|
||||
|
||||
|
||||
static func _encode_value(value: Variant, output: PackedByteArray, depth: int) -> bool:
|
||||
if depth > MAX_NESTING_DEPTH:
|
||||
push_error("SimulationDigest data exceeds the maximum nesting depth")
|
||||
return false
|
||||
|
||||
match typeof(value):
|
||||
TYPE_NIL:
|
||||
_append_ascii(output, "n")
|
||||
TYPE_BOOL:
|
||||
_append_ascii(output, "b1" if bool(value) else "b0")
|
||||
TYPE_INT:
|
||||
_append_ascii(output, "i%d;" % int(value))
|
||||
TYPE_FLOAT:
|
||||
_append_ascii(output, "f")
|
||||
return _append_float64(output, float(value))
|
||||
TYPE_STRING:
|
||||
_append_sized_bytes(output, "s", String(value).to_utf8_buffer())
|
||||
TYPE_STRING_NAME:
|
||||
_append_sized_bytes(output, "q", String(value).to_utf8_buffer())
|
||||
TYPE_VECTOR2:
|
||||
var vector: Vector2 = value
|
||||
_append_ascii(output, "v2")
|
||||
return _append_float64(output, vector.x) and _append_float64(output, vector.y)
|
||||
TYPE_VECTOR2I:
|
||||
var vector: Vector2i = value
|
||||
_append_ascii(output, "w2")
|
||||
_append_ascii(output, "i%d;i%d;" % [vector.x, vector.y])
|
||||
TYPE_RECT2:
|
||||
var rect: Rect2 = value
|
||||
_append_ascii(output, "r2")
|
||||
return (
|
||||
_append_float64(output, rect.position.x)
|
||||
and _append_float64(output, rect.position.y)
|
||||
and _append_float64(output, rect.size.x)
|
||||
and _append_float64(output, rect.size.y)
|
||||
)
|
||||
TYPE_RECT2I:
|
||||
var rect: Rect2i = value
|
||||
_append_ascii(output, "x2")
|
||||
_append_ascii(output, "i%d;i%d;i%d;i%d;" % [rect.position.x, rect.position.y, rect.size.x, rect.size.y])
|
||||
TYPE_VECTOR3:
|
||||
var vector: Vector3 = value
|
||||
_append_ascii(output, "v3")
|
||||
return (
|
||||
_append_float64(output, vector.x)
|
||||
and _append_float64(output, vector.y)
|
||||
and _append_float64(output, vector.z)
|
||||
)
|
||||
TYPE_VECTOR3I:
|
||||
var vector: Vector3i = value
|
||||
_append_ascii(output, "w3")
|
||||
_append_ascii(output, "i%d;i%d;i%d;" % [vector.x, vector.y, vector.z])
|
||||
TYPE_VECTOR4:
|
||||
var vector: Vector4 = value
|
||||
_append_ascii(output, "v4")
|
||||
return (
|
||||
_append_float64(output, vector.x)
|
||||
and _append_float64(output, vector.y)
|
||||
and _append_float64(output, vector.z)
|
||||
and _append_float64(output, vector.w)
|
||||
)
|
||||
TYPE_VECTOR4I:
|
||||
var vector: Vector4i = value
|
||||
_append_ascii(output, "w4")
|
||||
_append_ascii(output, "i%d;i%d;i%d;i%d;" % [vector.x, vector.y, vector.z, vector.w])
|
||||
TYPE_COLOR:
|
||||
var color: Color = value
|
||||
_append_ascii(output, "c")
|
||||
return (
|
||||
_append_float64(output, color.r)
|
||||
and _append_float64(output, color.g)
|
||||
and _append_float64(output, color.b)
|
||||
and _append_float64(output, color.a)
|
||||
)
|
||||
TYPE_ARRAY:
|
||||
return _encode_array(value, output, depth)
|
||||
TYPE_DICTIONARY:
|
||||
return _encode_dictionary(value, output, depth)
|
||||
TYPE_PACKED_BYTE_ARRAY:
|
||||
_append_sized_bytes(output, "pb", value)
|
||||
TYPE_PACKED_INT32_ARRAY, TYPE_PACKED_INT64_ARRAY:
|
||||
return _encode_packed_int_array(value, output)
|
||||
TYPE_PACKED_FLOAT32_ARRAY, TYPE_PACKED_FLOAT64_ARRAY:
|
||||
return _encode_packed_float_array(value, output)
|
||||
TYPE_PACKED_STRING_ARRAY:
|
||||
return _encode_packed_string_array(value, output)
|
||||
TYPE_PACKED_VECTOR2_ARRAY:
|
||||
return _encode_packed_vector2_array(value, output)
|
||||
TYPE_PACKED_VECTOR3_ARRAY:
|
||||
return _encode_packed_vector3_array(value, output)
|
||||
TYPE_PACKED_COLOR_ARRAY:
|
||||
return _encode_packed_color_array(value, output)
|
||||
_:
|
||||
push_error("SimulationDigest does not support Variant type %s" % type_string(typeof(value)))
|
||||
return false
|
||||
return true
|
||||
|
||||
|
||||
static func _encode_array(value: Array, output: PackedByteArray, depth: int) -> bool:
|
||||
_append_ascii(output, "a%d:" % value.size())
|
||||
for item in value:
|
||||
if not _encode_value(item, output, depth + 1):
|
||||
return false
|
||||
return true
|
||||
|
||||
|
||||
static func _encode_dictionary(value: Dictionary, output: PackedByteArray, depth: int) -> bool:
|
||||
var entries: Array[Dictionary] = []
|
||||
for key in value:
|
||||
var encoded_key := PackedByteArray()
|
||||
if not _encode_value(key, encoded_key, depth + 1):
|
||||
return false
|
||||
entries.append({
|
||||
"sort_key": encoded_key.hex_encode(),
|
||||
"encoded_key": encoded_key,
|
||||
"value": value[key],
|
||||
})
|
||||
entries.sort_custom(func(left: Dictionary, right: Dictionary) -> bool:
|
||||
return String(left["sort_key"]) < String(right["sort_key"])
|
||||
)
|
||||
|
||||
_append_ascii(output, "d%d:" % entries.size())
|
||||
for entry in entries:
|
||||
var encoded_key: PackedByteArray = entry["encoded_key"]
|
||||
output.append_array(encoded_key)
|
||||
if not _encode_value(entry["value"], output, depth + 1):
|
||||
return false
|
||||
return true
|
||||
|
||||
|
||||
static func _encode_packed_int_array(value: Variant, output: PackedByteArray) -> bool:
|
||||
var tag := "pi32" if typeof(value) == TYPE_PACKED_INT32_ARRAY else "pi64"
|
||||
_append_ascii(output, "%s%d:" % [tag, value.size()])
|
||||
for item in value:
|
||||
_append_ascii(output, "i%d;" % int(item))
|
||||
return true
|
||||
|
||||
|
||||
static func _encode_packed_float_array(value: Variant, output: PackedByteArray) -> bool:
|
||||
var tag := "pf32" if typeof(value) == TYPE_PACKED_FLOAT32_ARRAY else "pf64"
|
||||
_append_ascii(output, "%s%d:" % [tag, value.size()])
|
||||
for item in value:
|
||||
if not _append_float64(output, float(item)):
|
||||
return false
|
||||
return true
|
||||
|
||||
|
||||
static func _encode_packed_string_array(value: PackedStringArray, output: PackedByteArray) -> bool:
|
||||
_append_ascii(output, "ps%d:" % value.size())
|
||||
for item in value:
|
||||
_append_sized_bytes(output, "s", item.to_utf8_buffer())
|
||||
return true
|
||||
|
||||
|
||||
static func _encode_packed_vector2_array(value: PackedVector2Array, output: PackedByteArray) -> bool:
|
||||
_append_ascii(output, "pv2%d:" % value.size())
|
||||
for item in value:
|
||||
if not _append_float64(output, item.x) or not _append_float64(output, item.y):
|
||||
return false
|
||||
return true
|
||||
|
||||
|
||||
static func _encode_packed_vector3_array(value: PackedVector3Array, output: PackedByteArray) -> bool:
|
||||
_append_ascii(output, "pv3%d:" % value.size())
|
||||
for item in value:
|
||||
if (
|
||||
not _append_float64(output, item.x)
|
||||
or not _append_float64(output, item.y)
|
||||
or not _append_float64(output, item.z)
|
||||
):
|
||||
return false
|
||||
return true
|
||||
|
||||
|
||||
static func _encode_packed_color_array(value: PackedColorArray, output: PackedByteArray) -> bool:
|
||||
_append_ascii(output, "pc%d:" % value.size())
|
||||
for item in value:
|
||||
if (
|
||||
not _append_float64(output, item.r)
|
||||
or not _append_float64(output, item.g)
|
||||
or not _append_float64(output, item.b)
|
||||
or not _append_float64(output, item.a)
|
||||
):
|
||||
return false
|
||||
return true
|
||||
|
||||
|
||||
static func _append_float64(output: PackedByteArray, value: float) -> bool:
|
||||
if is_nan(value) or is_inf(value):
|
||||
push_error("SimulationDigest rejects NaN and infinite float values")
|
||||
return false
|
||||
var normalized := 0.0 if value == 0.0 else value
|
||||
var encoded := PackedByteArray()
|
||||
encoded.resize(8)
|
||||
encoded.encode_double(0, normalized)
|
||||
output.append_array(encoded)
|
||||
return true
|
||||
|
||||
|
||||
static func _append_sized_bytes(output: PackedByteArray, tag: String, value: PackedByteArray) -> void:
|
||||
_append_ascii(output, "%s%d:" % [tag, value.size()])
|
||||
output.append_array(value)
|
||||
|
||||
|
||||
static func _append_ascii(output: PackedByteArray, value: String) -> void:
|
||||
output.append_array(value.to_utf8_buffer())
|
||||
@@ -0,0 +1 @@
|
||||
uid://dxqv2isl2o0le
|
||||
@@ -0,0 +1,258 @@
|
||||
class_name SimulationKernel
|
||||
extends Node
|
||||
|
||||
signal time_changed(day: int, hour: int, minute: int)
|
||||
signal day_started(day: int)
|
||||
signal settlement_requested(day: int)
|
||||
signal tick_started(absolute_minute: int)
|
||||
signal tick_phase_started(phase: TickPhase, absolute_minute: int)
|
||||
signal tick_completed(absolute_minute: int)
|
||||
signal simulation_overloaded(backlog_minutes: int)
|
||||
|
||||
enum TickPhase {
|
||||
APPLY_COMMANDS,
|
||||
TIME_BOUNDARIES,
|
||||
MANA,
|
||||
CONSTRUCTION,
|
||||
LOGISTICS,
|
||||
FACILITY_COMPLETION,
|
||||
VITALS_AND_ENVIRONMENT,
|
||||
INCIDENTS_AND_EVENTS,
|
||||
FACILITY_QUEUES,
|
||||
AI_DECISIONS,
|
||||
PUBLISH_SNAPSHOT,
|
||||
}
|
||||
|
||||
const MINUTES_PER_DAY := 24 * 60
|
||||
const START_ABSOLUTE_MINUTE := 6 * 60
|
||||
const REAL_SECONDS_PER_GAME_HOUR := 30.0
|
||||
const REAL_SECONDS_PER_GAME_MINUTE := REAL_SECONDS_PER_GAME_HOUR / 60.0
|
||||
const MAX_TICKS_PER_FRAME := 8
|
||||
const MAX_BACKLOG_MINUTES := 120
|
||||
|
||||
var absolute_minute := START_ABSOLUTE_MINUTE
|
||||
var day := 1
|
||||
var minute_of_day := START_ABSOLUTE_MINUTE
|
||||
var speed := 1
|
||||
var paused := false
|
||||
var snapshot_version := 0
|
||||
var performance_budget := SimulationPerformanceBudget.new()
|
||||
|
||||
var _accumulator := 0.0
|
||||
var _skip_next_process := false
|
||||
var _phase_handlers: Dictionary = {}
|
||||
var _pause_request_serial := 0
|
||||
var _abort_current_tick_requested := false
|
||||
var _last_tick_advanced := false
|
||||
|
||||
func _ready() -> void:
|
||||
_emit_current_time()
|
||||
|
||||
func _process(delta: float) -> void:
|
||||
process_real_delta(delta)
|
||||
|
||||
func process_real_delta(delta: float) -> int:
|
||||
if _skip_next_process:
|
||||
_skip_next_process = false
|
||||
return 0
|
||||
if paused or speed <= 0 or delta <= 0.0:
|
||||
return 0
|
||||
|
||||
var frame_started_at_us := Time.get_ticks_usec()
|
||||
_accumulator += delta * speed
|
||||
var due_ticks := int(_accumulator / REAL_SECONDS_PER_GAME_MINUTE)
|
||||
var tick_budget := mini(due_ticks, MAX_TICKS_PER_FRAME)
|
||||
var ticks_run := 0
|
||||
for _index in range(tick_budget):
|
||||
if paused:
|
||||
break
|
||||
advance_one_minute()
|
||||
if not _last_tick_advanced:
|
||||
break
|
||||
_accumulator -= REAL_SECONDS_PER_GAME_MINUTE
|
||||
ticks_run += 1
|
||||
# A boundary handler may pause in the middle of a catch-up frame. Never
|
||||
# consume another queued minute after that decision.
|
||||
if paused:
|
||||
break
|
||||
if ticks_run > 0:
|
||||
performance_budget.record_simulation_frame(
|
||||
int(Time.get_ticks_usec() - frame_started_at_us),
|
||||
speed,
|
||||
ticks_run
|
||||
)
|
||||
|
||||
var backlog_minutes := get_backlog_minutes()
|
||||
if backlog_minutes > MAX_BACKLOG_MINUTES:
|
||||
paused = true
|
||||
simulation_overloaded.emit(backlog_minutes)
|
||||
return ticks_run
|
||||
|
||||
func reset(start_day := 1, start_hour := 6, start_minute := 0) -> void:
|
||||
var safe_day := maxi(start_day, 1)
|
||||
var safe_minute := posmod(start_hour * 60 + start_minute, MINUTES_PER_DAY)
|
||||
absolute_minute = (safe_day - 1) * MINUTES_PER_DAY + safe_minute
|
||||
_sync_calendar_fields()
|
||||
_accumulator = 0.0
|
||||
paused = false
|
||||
snapshot_version = 0
|
||||
performance_budget.clear()
|
||||
_abort_current_tick_requested = false
|
||||
_last_tick_advanced = false
|
||||
_emit_current_time()
|
||||
|
||||
func advance_minutes(amount: int) -> void:
|
||||
if amount <= 0:
|
||||
return
|
||||
# Tests and deterministic tooling historically use an already-paused clock as
|
||||
# an explicit manual stepping mode. Preserve that contract, while a pause
|
||||
# raised during an initially-running batch is a hard boundary stop.
|
||||
var manual_paused_stepping := paused
|
||||
var pause_serial_before := _pause_request_serial
|
||||
for _index in range(amount):
|
||||
if not manual_paused_stepping and paused:
|
||||
break
|
||||
advance_one_minute()
|
||||
if not _last_tick_advanced:
|
||||
break
|
||||
if _pause_request_serial != pause_serial_before \
|
||||
or (not manual_paused_stepping and paused):
|
||||
break
|
||||
|
||||
func advance_one_minute() -> void:
|
||||
_abort_current_tick_requested = false
|
||||
_last_tick_advanced = false
|
||||
tick_started.emit(absolute_minute)
|
||||
_run_phase(TickPhase.APPLY_COMMANDS)
|
||||
# A Runtime gate can reject the tick before the Clock changes. This is
|
||||
# distinct from an ordinary pause raised by a command that already committed
|
||||
# for the upcoming minute.
|
||||
if _abort_current_tick_requested:
|
||||
_abort_current_tick_requested = false
|
||||
return
|
||||
|
||||
var previous_absolute_minute := absolute_minute
|
||||
absolute_minute += 1
|
||||
_sync_calendar_fields()
|
||||
_handle_time_boundaries(previous_absolute_minute)
|
||||
_run_phase(TickPhase.TIME_BOUNDARIES)
|
||||
|
||||
for phase in [
|
||||
TickPhase.MANA,
|
||||
TickPhase.CONSTRUCTION,
|
||||
TickPhase.LOGISTICS,
|
||||
TickPhase.FACILITY_COMPLETION,
|
||||
TickPhase.VITALS_AND_ENVIRONMENT,
|
||||
TickPhase.INCIDENTS_AND_EVENTS,
|
||||
TickPhase.FACILITY_QUEUES,
|
||||
TickPhase.AI_DECISIONS,
|
||||
TickPhase.PUBLISH_SNAPSHOT,
|
||||
]:
|
||||
_run_phase(phase)
|
||||
|
||||
snapshot_version += 1
|
||||
tick_completed.emit(absolute_minute)
|
||||
_emit_current_time()
|
||||
_abort_current_tick_requested = false
|
||||
_last_tick_advanced = true
|
||||
|
||||
func request_current_tick_abort() -> void:
|
||||
_abort_current_tick_requested = true
|
||||
|
||||
func set_speed(value: int) -> bool:
|
||||
if value not in [1, 2, 4]:
|
||||
return false
|
||||
speed = value
|
||||
paused = false
|
||||
return true
|
||||
|
||||
func toggle_pause() -> bool:
|
||||
paused = not paused
|
||||
if paused:
|
||||
_pause_request_serial += 1
|
||||
return paused
|
||||
|
||||
func set_paused(value: bool) -> void:
|
||||
paused = value
|
||||
if value:
|
||||
_pause_request_serial += 1
|
||||
|
||||
func get_hour() -> int:
|
||||
return minute_of_day / 60
|
||||
|
||||
func get_minute() -> int:
|
||||
return minute_of_day % 60
|
||||
|
||||
func get_backlog_minutes() -> int:
|
||||
return int(_accumulator / REAL_SECONDS_PER_GAME_MINUTE)
|
||||
|
||||
func get_performance_reports() -> Array[Dictionary]:
|
||||
return performance_budget.get_all_reports()
|
||||
|
||||
func register_tick_handler(phase: TickPhase, handler_id: StringName, callback: Callable) -> bool:
|
||||
if handler_id.is_empty() or not callback.is_valid():
|
||||
return false
|
||||
if not _phase_handlers.has(phase):
|
||||
_phase_handlers[phase] = {}
|
||||
var handlers: Dictionary = _phase_handlers[phase]
|
||||
if handlers.has(handler_id):
|
||||
return false
|
||||
handlers[handler_id] = callback
|
||||
return true
|
||||
|
||||
func unregister_tick_handler(phase: TickPhase, handler_id: StringName) -> void:
|
||||
if not _phase_handlers.has(phase):
|
||||
return
|
||||
var handlers: Dictionary = _phase_handlers[phase]
|
||||
handlers.erase(handler_id)
|
||||
|
||||
func clock_snapshot() -> Dictionary:
|
||||
return {
|
||||
&"absolute_minute": absolute_minute,
|
||||
&"speed": speed,
|
||||
&"paused": paused,
|
||||
&"snapshot_version": snapshot_version,
|
||||
}
|
||||
|
||||
func restore_clock(snapshot: Dictionary) -> bool:
|
||||
if not snapshot.has(&"absolute_minute"):
|
||||
return false
|
||||
var restored_speed := int(snapshot.get(&"speed", 1))
|
||||
if restored_speed not in [1, 2, 4]:
|
||||
return false
|
||||
absolute_minute = maxi(int(snapshot[&"absolute_minute"]), 0)
|
||||
speed = restored_speed
|
||||
paused = bool(snapshot.get(&"paused", true))
|
||||
snapshot_version = maxi(int(snapshot.get(&"snapshot_version", 0)), 0)
|
||||
_accumulator = 0.0
|
||||
_skip_next_process = true
|
||||
_sync_calendar_fields()
|
||||
_emit_current_time()
|
||||
return true
|
||||
|
||||
func _run_phase(phase: TickPhase) -> void:
|
||||
tick_phase_started.emit(phase, absolute_minute)
|
||||
if not _phase_handlers.has(phase):
|
||||
return
|
||||
var handlers: Dictionary = _phase_handlers[phase]
|
||||
var handler_ids: Array = handlers.keys()
|
||||
handler_ids.sort_custom(func(left: Variant, right: Variant) -> bool:
|
||||
return String(left) < String(right)
|
||||
)
|
||||
for handler_id: Variant in handler_ids:
|
||||
var callback: Callable = handlers[handler_id]
|
||||
if callback.is_valid():
|
||||
callback.call(absolute_minute)
|
||||
|
||||
func _sync_calendar_fields() -> void:
|
||||
day = absolute_minute / MINUTES_PER_DAY + 1
|
||||
minute_of_day = absolute_minute % MINUTES_PER_DAY
|
||||
|
||||
func _handle_time_boundaries(previous_absolute_minute: int) -> void:
|
||||
if minute_of_day == 0:
|
||||
var completed_day := previous_absolute_minute / MINUTES_PER_DAY + 1
|
||||
settlement_requested.emit(completed_day)
|
||||
day_started.emit(day)
|
||||
|
||||
func _emit_current_time() -> void:
|
||||
time_changed.emit(day, get_hour(), get_minute())
|
||||
@@ -0,0 +1 @@
|
||||
uid://vj3mrw0wb5bx
|
||||
@@ -0,0 +1,47 @@
|
||||
class_name SimulationSnapshot
|
||||
extends RefCounted
|
||||
|
||||
const SCHEMA_VERSION := 1
|
||||
|
||||
static func capture(clock: SimulationKernel, state: Node, extra := {}) -> Dictionary:
|
||||
var snapshot := {
|
||||
&"schema_version": SCHEMA_VERSION,
|
||||
&"clock": clock.clock_snapshot(),
|
||||
&"game_state": state.create_snapshot(),
|
||||
}
|
||||
if extra is Dictionary and not extra.is_empty():
|
||||
snapshot[&"systems"] = extra.duplicate(true)
|
||||
return snapshot
|
||||
|
||||
static func validate(snapshot: Dictionary) -> Dictionary:
|
||||
var errors: Array[String] = []
|
||||
if int(snapshot.get(&"schema_version", -1)) != SCHEMA_VERSION:
|
||||
errors.append("unsupported_schema_version")
|
||||
if not snapshot.get(&"clock", null) is Dictionary:
|
||||
errors.append("missing_clock")
|
||||
else:
|
||||
var clock_data: Dictionary = snapshot[&"clock"]
|
||||
if int(clock_data.get(&"absolute_minute", -1)) < 0:
|
||||
errors.append("invalid_absolute_minute")
|
||||
if int(clock_data.get(&"speed", 0)) not in [1, 2, 4]:
|
||||
errors.append("invalid_speed")
|
||||
if not snapshot.get(&"game_state", null) is Dictionary:
|
||||
errors.append("missing_game_state")
|
||||
return {
|
||||
&"ok": errors.is_empty(),
|
||||
&"errors": errors,
|
||||
}
|
||||
|
||||
static func restore(snapshot: Dictionary, clock: SimulationKernel, state: Node) -> bool:
|
||||
var validation := validate(snapshot)
|
||||
if not validation[&"ok"]:
|
||||
return false
|
||||
var previous_clock := clock.clock_snapshot()
|
||||
var previous_state: Dictionary = state.create_snapshot()
|
||||
if not clock.restore_clock(snapshot[&"clock"]):
|
||||
return false
|
||||
if not state.restore_snapshot(snapshot[&"game_state"]):
|
||||
clock.restore_clock(previous_clock)
|
||||
state.restore_snapshot(previous_state)
|
||||
return false
|
||||
return true
|
||||
@@ -0,0 +1 @@
|
||||
uid://c0vxraq44s1wu
|
||||
@@ -0,0 +1,60 @@
|
||||
class_name StableIdGenerator
|
||||
extends RefCounted
|
||||
|
||||
const SNAPSHOT_VERSION := 1
|
||||
const MAX_COUNTER := 999999999999
|
||||
|
||||
var _counters: Dictionary = {}
|
||||
|
||||
func next_id(prefix: StringName) -> StringName:
|
||||
if not _is_valid_prefix(String(prefix)):
|
||||
push_error("Stable ID prefix must be lowercase ASCII snake_case")
|
||||
return &""
|
||||
var next_value := int(_counters.get(prefix, 0)) + 1
|
||||
if next_value > MAX_COUNTER:
|
||||
push_error("Stable ID counter exhausted for '%s'" % prefix)
|
||||
return &""
|
||||
_counters[prefix] = next_value
|
||||
return StringName("%s.%012d" % [prefix, next_value])
|
||||
|
||||
func get_counter(prefix: StringName) -> int:
|
||||
return int(_counters.get(prefix, 0))
|
||||
|
||||
func create_snapshot() -> Dictionary:
|
||||
var counters := {}
|
||||
var prefixes: Array = _counters.keys()
|
||||
prefixes.sort()
|
||||
for prefix: Variant in prefixes:
|
||||
counters[String(prefix)] = int(_counters[prefix])
|
||||
return {
|
||||
"version": SNAPSHOT_VERSION,
|
||||
"counters": counters,
|
||||
}
|
||||
|
||||
func restore_snapshot(snapshot: Dictionary) -> bool:
|
||||
if int(snapshot.get("version", -1)) != SNAPSHOT_VERSION:
|
||||
return false
|
||||
var saved_counters: Variant = snapshot.get("counters", null)
|
||||
if not saved_counters is Dictionary:
|
||||
return false
|
||||
var restored := {}
|
||||
for prefix: Variant in saved_counters:
|
||||
var normalized_prefix := String(prefix)
|
||||
var counter := int(saved_counters[prefix])
|
||||
if not _is_valid_prefix(normalized_prefix) or counter < 0 or counter > MAX_COUNTER:
|
||||
return false
|
||||
restored[StringName(normalized_prefix)] = counter
|
||||
_counters = restored
|
||||
return true
|
||||
|
||||
func reset() -> void:
|
||||
_counters.clear()
|
||||
|
||||
static func _is_valid_prefix(prefix: String) -> bool:
|
||||
if prefix.is_empty() or prefix[0] < "a" or prefix[0] > "z":
|
||||
return false
|
||||
for character in prefix:
|
||||
if (character >= "a" and character <= "z") or (character >= "0" and character <= "9") or character == "_":
|
||||
continue
|
||||
return false
|
||||
return true
|
||||
@@ -0,0 +1 @@
|
||||
uid://6dn6weviv7a8
|
||||
@@ -0,0 +1,266 @@
|
||||
class_name StateTransaction
|
||||
extends RefCounted
|
||||
|
||||
enum Status {
|
||||
OPEN,
|
||||
COMMITTED,
|
||||
ROLLED_BACK,
|
||||
FAILED,
|
||||
}
|
||||
|
||||
class StagedChange:
|
||||
extends RefCounted
|
||||
|
||||
var label := ""
|
||||
var validate_action := Callable()
|
||||
var apply_action := Callable()
|
||||
var rollback_action := Callable()
|
||||
|
||||
func _init(
|
||||
p_label: String,
|
||||
p_validate_action: Callable,
|
||||
p_apply_action: Callable,
|
||||
p_rollback_action: Callable
|
||||
) -> void:
|
||||
label = p_label
|
||||
validate_action = p_validate_action
|
||||
apply_action = p_apply_action
|
||||
rollback_action = p_rollback_action
|
||||
|
||||
|
||||
var status: Status = Status.OPEN
|
||||
var _changes: Array[StagedChange] = []
|
||||
var _validators: Array[Callable] = []
|
||||
var _configuration_errors: Array[String] = []
|
||||
|
||||
|
||||
func stage_dictionary_set(
|
||||
target: Dictionary,
|
||||
key: Variant,
|
||||
value: Variant,
|
||||
label: String = ""
|
||||
) -> bool:
|
||||
if not _require_open("stage_dictionary_set"):
|
||||
return false
|
||||
|
||||
var had_key := target.has(key)
|
||||
var original_value: Variant = target.get(key)
|
||||
var original_snapshot: Variant = _copy_value(original_value)
|
||||
var staged_value: Variant = _copy_value(value)
|
||||
var change_label := label if not label.is_empty() else "set dictionary key %s" % str(key)
|
||||
|
||||
return stage_custom(
|
||||
change_label,
|
||||
func() -> Variant:
|
||||
if target.has(key) != had_key:
|
||||
return "Dictionary key presence changed before commit."
|
||||
if had_key and target[key] != original_snapshot:
|
||||
return "Dictionary value changed before commit."
|
||||
return true,
|
||||
func() -> Variant:
|
||||
target[key] = _copy_value(staged_value)
|
||||
return true,
|
||||
func() -> void:
|
||||
if had_key:
|
||||
target[key] = original_value
|
||||
else:
|
||||
target.erase(key)
|
||||
)
|
||||
|
||||
|
||||
func stage_dictionary_erase(
|
||||
target: Dictionary,
|
||||
key: Variant,
|
||||
label: String = ""
|
||||
) -> bool:
|
||||
if not _require_open("stage_dictionary_erase"):
|
||||
return false
|
||||
|
||||
var had_key := target.has(key)
|
||||
var original_value: Variant = target.get(key)
|
||||
var original_snapshot: Variant = _copy_value(original_value)
|
||||
var change_label := label if not label.is_empty() else "erase dictionary key %s" % str(key)
|
||||
|
||||
return stage_custom(
|
||||
change_label,
|
||||
func() -> Variant:
|
||||
if target.has(key) != had_key:
|
||||
return "Dictionary key presence changed before commit."
|
||||
if had_key and target[key] != original_snapshot:
|
||||
return "Dictionary value changed before commit."
|
||||
return true,
|
||||
func() -> Variant:
|
||||
target.erase(key)
|
||||
return true,
|
||||
func() -> void:
|
||||
if had_key:
|
||||
target[key] = original_value
|
||||
)
|
||||
|
||||
|
||||
func stage_dictionary_increment(
|
||||
target: Dictionary,
|
||||
key: Variant,
|
||||
delta: Variant,
|
||||
minimum: Variant = null,
|
||||
maximum: Variant = null,
|
||||
label: String = ""
|
||||
) -> bool:
|
||||
if not _require_open("stage_dictionary_increment"):
|
||||
return false
|
||||
if not target.has(key) or not _is_number(target[key]) or not _is_number(delta):
|
||||
_configuration_errors.append("Increment requires an existing numeric value and numeric delta.")
|
||||
return false
|
||||
|
||||
var next_value: Variant = target[key] + delta
|
||||
if minimum != null and (not _is_number(minimum) or next_value < minimum):
|
||||
_configuration_errors.append("Increment result is below its minimum.")
|
||||
return false
|
||||
if maximum != null and (not _is_number(maximum) or next_value > maximum):
|
||||
_configuration_errors.append("Increment result is above its maximum.")
|
||||
return false
|
||||
return stage_dictionary_set(target, key, next_value, label)
|
||||
|
||||
|
||||
func add_validator(validator: Callable) -> bool:
|
||||
if not _require_open("add_validator"):
|
||||
return false
|
||||
if not validator.is_valid():
|
||||
_configuration_errors.append("Validator is not callable.")
|
||||
return false
|
||||
_validators.append(validator)
|
||||
return true
|
||||
|
||||
|
||||
func stage_custom(
|
||||
label: String,
|
||||
validate_action: Callable,
|
||||
apply_action: Callable,
|
||||
rollback_action: Callable
|
||||
) -> bool:
|
||||
if not _require_open("stage_custom"):
|
||||
return false
|
||||
if not apply_action.is_valid() or not rollback_action.is_valid():
|
||||
_configuration_errors.append("Custom change '%s' requires apply and rollback actions." % label)
|
||||
return false
|
||||
_changes.append(StagedChange.new(label, validate_action, apply_action, rollback_action))
|
||||
return true
|
||||
|
||||
|
||||
func validate() -> CommandResult:
|
||||
if status != Status.OPEN:
|
||||
return CommandResult.failed(
|
||||
CommandResult.Code.TRANSACTION_CLOSED,
|
||||
0,
|
||||
"Transaction is not open."
|
||||
)
|
||||
if not _configuration_errors.is_empty():
|
||||
return CommandResult.failed(
|
||||
CommandResult.Code.VALIDATION_FAILED,
|
||||
0,
|
||||
_configuration_errors[0],
|
||||
{"errors": _configuration_errors.duplicate()}
|
||||
)
|
||||
|
||||
for index in _validators.size():
|
||||
var validator_result := _validation_result(_validators[index].call(), "validator %d" % index)
|
||||
if validator_result != null:
|
||||
return validator_result
|
||||
|
||||
for change in _changes:
|
||||
if not change.validate_action.is_valid():
|
||||
continue
|
||||
var change_result := _validation_result(change.validate_action.call(), change.label)
|
||||
if change_result != null:
|
||||
return change_result
|
||||
return CommandResult.succeeded()
|
||||
|
||||
|
||||
func commit() -> CommandResult:
|
||||
var validation_result := validate()
|
||||
if not validation_result.is_success():
|
||||
if status == Status.OPEN:
|
||||
status = Status.FAILED
|
||||
return validation_result
|
||||
|
||||
var applied: Array[StagedChange] = []
|
||||
for change in _changes:
|
||||
var returned: Variant = change.apply_action.call()
|
||||
var apply_failure := _apply_result(returned, change.label)
|
||||
if apply_failure != null:
|
||||
change.rollback_action.call()
|
||||
for index in range(applied.size() - 1, -1, -1):
|
||||
applied[index].rollback_action.call()
|
||||
status = Status.ROLLED_BACK
|
||||
apply_failure.details["rolled_back"] = true
|
||||
return apply_failure
|
||||
applied.append(change)
|
||||
|
||||
status = Status.COMMITTED
|
||||
return CommandResult.succeeded(0, {"change_count": _changes.size()})
|
||||
|
||||
|
||||
func rollback() -> CommandResult:
|
||||
if status != Status.OPEN:
|
||||
return CommandResult.failed(
|
||||
CommandResult.Code.TRANSACTION_CLOSED,
|
||||
0,
|
||||
"Only an open transaction can be cancelled."
|
||||
)
|
||||
status = Status.ROLLED_BACK
|
||||
return CommandResult.succeeded()
|
||||
|
||||
|
||||
func change_count() -> int:
|
||||
return _changes.size()
|
||||
|
||||
|
||||
func _require_open(operation: String) -> bool:
|
||||
if status == Status.OPEN:
|
||||
return true
|
||||
_configuration_errors.append("%s cannot run on a closed transaction." % operation)
|
||||
return false
|
||||
|
||||
|
||||
func _validation_result(returned: Variant, label: String) -> CommandResult:
|
||||
if returned == null or (returned is bool and returned):
|
||||
return null
|
||||
if returned is CommandResult:
|
||||
var command_result: CommandResult = returned
|
||||
if command_result.is_success():
|
||||
return null
|
||||
return command_result.copy()
|
||||
var message := str(returned) if returned is String and not returned.is_empty() else "Validation failed."
|
||||
return CommandResult.failed(
|
||||
CommandResult.Code.VALIDATION_FAILED,
|
||||
0,
|
||||
"%s: %s" % [label, message],
|
||||
{"change": label}
|
||||
)
|
||||
|
||||
|
||||
func _apply_result(returned: Variant, label: String) -> CommandResult:
|
||||
if returned == null or (returned is bool and returned):
|
||||
return null
|
||||
if returned is CommandResult:
|
||||
var command_result: CommandResult = returned
|
||||
if command_result.is_success():
|
||||
return null
|
||||
return command_result.copy()
|
||||
var message := str(returned) if returned is String and not returned.is_empty() else "Apply action failed."
|
||||
return CommandResult.failed(
|
||||
CommandResult.Code.TRANSACTION_APPLY_FAILED,
|
||||
0,
|
||||
"%s: %s" % [label, message],
|
||||
{"change": label}
|
||||
)
|
||||
|
||||
|
||||
static func _copy_value(value: Variant) -> Variant:
|
||||
if value is Dictionary or value is Array:
|
||||
return value.duplicate(true)
|
||||
return value
|
||||
|
||||
|
||||
static func _is_number(value: Variant) -> bool:
|
||||
return value is int or value is float
|
||||
@@ -0,0 +1 @@
|
||||
uid://drilrnwk4am7v
|
||||
@@ -0,0 +1,114 @@
|
||||
class_name FacilityCatalog
|
||||
extends RefCounted
|
||||
|
||||
## Canonical MVP order from GAME_SPECIFICATION section 7.6.
|
||||
##
|
||||
## DefinitionRegistry deliberately sorts its query results by stable ID. This
|
||||
## list instead preserves the design-table order for tools that present the
|
||||
## baseline catalog to designers or players.
|
||||
const BASELINE_FACILITY_IDS: Array[StringName] = [
|
||||
&"reception",
|
||||
&"standard_room",
|
||||
&"cafeteria",
|
||||
&"kitchen",
|
||||
&"public_bath",
|
||||
&"warehouse",
|
||||
&"staff_waiting_room",
|
||||
&"security_station",
|
||||
&"tavern",
|
||||
&"massage_room",
|
||||
&"guest_elevator",
|
||||
&"service_elevator",
|
||||
&"staff_door",
|
||||
&"guest_corridor",
|
||||
&"service_corridor",
|
||||
&"adult_private_room",
|
||||
]
|
||||
|
||||
const FACILITY_PATHS: Array[String] = [
|
||||
"res://data/facilities/reception.tres",
|
||||
"res://data/facilities/standard_room.tres",
|
||||
"res://data/facilities/cafeteria.tres",
|
||||
"res://data/facilities/kitchen.tres",
|
||||
"res://data/facilities/public_bath.tres",
|
||||
"res://data/facilities/warehouse.tres",
|
||||
"res://data/facilities/staff_waiting_room.tres",
|
||||
"res://data/facilities/security_station.tres",
|
||||
"res://data/facilities/tavern.tres",
|
||||
"res://data/facilities/massage_room.tres",
|
||||
"res://data/facilities/guest_elevator.tres",
|
||||
"res://data/facilities/service_elevator.tres",
|
||||
"res://data/facilities/staff_door.tres",
|
||||
"res://data/facilities/guest_corridor.tres",
|
||||
"res://data/facilities/service_corridor.tres",
|
||||
"res://data/facilities/adult_private_room.tres",
|
||||
]
|
||||
|
||||
## Optional post-MVP expansion definitions. They live below a subdirectory so
|
||||
## DefinitionRegistry's baseline directory scan remains exactly compatible with
|
||||
## existing saves and the sixteen-facility acceptance fixture.
|
||||
const EXPANSION_FACILITY_IDS: Array[StringName] = [
|
||||
&"premium_room",
|
||||
&"tea_lounge",
|
||||
&"general_store",
|
||||
&"clinic",
|
||||
&"guild_counter",
|
||||
&"underground_guest_room",
|
||||
&"private_bath",
|
||||
&"secret_tavern",
|
||||
&"black_market",
|
||||
&"vip_private_salon",
|
||||
&"laundry",
|
||||
&"cold_storage",
|
||||
&"mana_control_room",
|
||||
&"maintenance_workshop",
|
||||
&"staff_dormitory",
|
||||
&"waste_processing",
|
||||
]
|
||||
|
||||
const EXPANSION_FACILITY_PATHS: Array[String] = [
|
||||
"res://data/facilities/expansion/premium_room.tres",
|
||||
"res://data/facilities/expansion/tea_lounge.tres",
|
||||
"res://data/facilities/expansion/general_store.tres",
|
||||
"res://data/facilities/expansion/clinic.tres",
|
||||
"res://data/facilities/expansion/guild_counter.tres",
|
||||
"res://data/facilities/expansion/underground_guest_room.tres",
|
||||
"res://data/facilities/expansion/private_bath.tres",
|
||||
"res://data/facilities/expansion/secret_tavern.tres",
|
||||
"res://data/facilities/expansion/black_market.tres",
|
||||
"res://data/facilities/expansion/vip_private_salon.tres",
|
||||
"res://data/facilities/expansion/laundry.tres",
|
||||
"res://data/facilities/expansion/cold_storage.tres",
|
||||
"res://data/facilities/expansion/mana_control_room.tres",
|
||||
"res://data/facilities/expansion/maintenance_workshop.tres",
|
||||
"res://data/facilities/expansion/staff_dormitory.tres",
|
||||
"res://data/facilities/expansion/waste_processing.tres",
|
||||
]
|
||||
|
||||
static func load_all() -> Array[FacilityDefinition]:
|
||||
var result: Array[FacilityDefinition] = []
|
||||
for path in FACILITY_PATHS:
|
||||
var definition := load(path) as FacilityDefinition
|
||||
if definition != null:
|
||||
result.append(definition)
|
||||
return result
|
||||
|
||||
static func load_expansion() -> Array[FacilityDefinition]:
|
||||
var result: Array[FacilityDefinition] = []
|
||||
for path in EXPANSION_FACILITY_PATHS:
|
||||
var definition := load(path) as FacilityDefinition
|
||||
if definition != null:
|
||||
result.append(definition)
|
||||
return result
|
||||
|
||||
static func load_available(include_expansion := false) -> Array[FacilityDefinition]:
|
||||
var result := load_all()
|
||||
if include_expansion:
|
||||
result.append_array(load_expansion())
|
||||
return result
|
||||
|
||||
static func by_id(facility_id: StringName) -> FacilityDefinition:
|
||||
for definition in load_available(true):
|
||||
if definition.id == facility_id:
|
||||
return definition
|
||||
return null
|
||||
@@ -0,0 +1 @@
|
||||
uid://c61tnpw8yjyx7
|
||||
@@ -0,0 +1,143 @@
|
||||
class_name FacilityDefinition
|
||||
extends Resource
|
||||
|
||||
const LEGACY_SCHEMA_VERSION := 1
|
||||
const DETAILED_SCHEMA_VERSION := 2
|
||||
|
||||
enum MapZone {
|
||||
ANY,
|
||||
ABOVE_GROUND,
|
||||
BOUNDARY,
|
||||
UNDERGROUND,
|
||||
}
|
||||
|
||||
enum ViewLayer {
|
||||
FRONT,
|
||||
BACK,
|
||||
}
|
||||
|
||||
@export_group("Identity")
|
||||
@export_range(LEGACY_SCHEMA_VERSION, DETAILED_SCHEMA_VERSION, 1) var definition_schema_version := LEGACY_SCHEMA_VERSION
|
||||
@export var id: StringName
|
||||
@export var display_name := ""
|
||||
@export_multiline var description := ""
|
||||
@export var category: StringName = &"uncategorized"
|
||||
@export var service_types: Array[StringName] = []
|
||||
@export var default_service_type: StringName = &""
|
||||
@export var service_profiles: Dictionary = {}
|
||||
|
||||
@export_group("Placement")
|
||||
@export var zone := MapZone.ANY
|
||||
@export var layer := ViewLayer.FRONT
|
||||
@export var footprint := Vector2i.ONE
|
||||
@export var allowed_rotations: Array[int] = [0, 90, 180, 270]
|
||||
@export var guest_access_offsets: Array[Vector2i] = []
|
||||
@export var staff_access_offsets: Array[Vector2i] = []
|
||||
@export var required_connection_tags: Array[StringName] = []
|
||||
@export var preferred_adjacency_tags: Array[StringName] = []
|
||||
@export var forbidden_adjacency_tags: Array[StringName] = []
|
||||
|
||||
@export_group("Construction")
|
||||
@export var construction_cost := {&"gold": 100, &"materials": 1}
|
||||
@export var build_minutes := 60
|
||||
@export_range(1, 3, 1) var weight_per_cell := 1
|
||||
@export var pass_through := false
|
||||
@export_range(0, 100, 1) var demolition_refund_percent := 25
|
||||
@export_range(1, 5, 1) var max_level := 3
|
||||
@export var upgrade_costs: Array[Dictionary] = []
|
||||
|
||||
@export_group("Upkeep and Power")
|
||||
@export var maintenance_gold := 0
|
||||
@export var maintenance_mana := 0
|
||||
@export_range(0, 100, 1) var power_priority := 50
|
||||
|
||||
@export_group("Staffing")
|
||||
@export var staff_min := 0
|
||||
@export var staff_max := 0
|
||||
@export var required_roles: Array[StringName] = []
|
||||
@export var primary_skill: StringName = &"service"
|
||||
|
||||
@export_group("Service")
|
||||
@export var capacity := 0
|
||||
@export var queue_capacity := 0
|
||||
@export var base_service_minutes := 1
|
||||
@export var base_quality := 50
|
||||
@export var base_price := 0
|
||||
@export var inventory_inputs: Dictionary = {}
|
||||
@export var inventory_outputs: Dictionary = {}
|
||||
@export var opening_schedule := {&"morning": true, &"day": true, &"night": true}
|
||||
@export var policy_schema: Dictionary = {}
|
||||
|
||||
@export_group("Environment and Risk")
|
||||
@export_range(0, 100, 1) var cleanliness_decay_per_service := 0
|
||||
@export_range(0, 100, 1) var noise_per_service := 0
|
||||
@export_range(-100, 100, 1) var beauty_effect := 0
|
||||
@export_range(-100, 100, 1) var security_effect := 0
|
||||
@export_range(-100, 100, 1) var concealment_effect := 0
|
||||
@export_range(0, 100, 1) var heat_per_service := 0
|
||||
@export var incident_weights: Dictionary = {}
|
||||
|
||||
@export_group("Audience and Content")
|
||||
@export var allowed_guest_tags: Array[StringName] = []
|
||||
@export var forbidden_guest_tags: Array[StringName] = []
|
||||
@export var adult_content := false
|
||||
@export var content_replacement_id: StringName = &""
|
||||
|
||||
@export_group("Progression")
|
||||
@export_range(1, 7, 1) var unlock_rank := 1
|
||||
@export var research_requirements: Array[StringName] = []
|
||||
|
||||
@export_group("Analytics")
|
||||
@export var statistics_keys: Array[StringName] = []
|
||||
|
||||
@export_group("Classification")
|
||||
@export var tags: Array[StringName] = []
|
||||
|
||||
func is_valid() -> bool:
|
||||
return not id.is_empty() \
|
||||
and not display_name.is_empty() \
|
||||
and footprint.x > 0 \
|
||||
and footprint.y > 0 \
|
||||
and build_minutes > 0 \
|
||||
and weight_per_cell in [1, 2, 3] \
|
||||
and not allowed_rotations.is_empty() \
|
||||
and maintenance_gold >= 0 \
|
||||
and maintenance_mana >= 0 \
|
||||
and staff_min >= 0 \
|
||||
and staff_max >= staff_min \
|
||||
and capacity >= 0 \
|
||||
and queue_capacity >= 0 \
|
||||
and base_service_minutes > 0 \
|
||||
and base_price >= 0 \
|
||||
and detailed_profile_is_valid()
|
||||
|
||||
|
||||
func has_detailed_profile() -> bool:
|
||||
return definition_schema_version == DETAILED_SCHEMA_VERSION
|
||||
|
||||
|
||||
func detailed_profile_is_valid() -> bool:
|
||||
if definition_schema_version == LEGACY_SCHEMA_VERSION:
|
||||
return true
|
||||
if definition_schema_version != DETAILED_SCHEMA_VERSION:
|
||||
return false
|
||||
if category.is_empty() or service_types.is_empty() or default_service_type.is_empty():
|
||||
return false
|
||||
if not service_types.has(default_service_type) or not service_profiles.has(default_service_type):
|
||||
return false
|
||||
if guest_access_offsets.is_empty() and staff_access_offsets.is_empty() and not pass_through:
|
||||
return false
|
||||
if demolition_refund_percent < 0 or demolition_refund_percent > 100:
|
||||
return false
|
||||
if max_level < 1 or max_level > 5 or upgrade_costs.size() != max_level - 1:
|
||||
return false
|
||||
if unlock_rank < 1 or unlock_rank > 7:
|
||||
return false
|
||||
for time_band: StringName in [&"morning", &"day", &"night"]:
|
||||
if not opening_schedule.has(time_band) or not opening_schedule[time_band] is bool:
|
||||
return false
|
||||
if opening_schedule.size() != 3:
|
||||
return false
|
||||
if adult_content and (not tags.has(&"adult_only") or content_replacement_id.is_empty()):
|
||||
return false
|
||||
return true
|
||||
@@ -0,0 +1 @@
|
||||
uid://cpmiimqx2vb58
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1 @@
|
||||
uid://nw4dttch8rt4
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1 @@
|
||||
uid://cai6ix6leyrxp
|
||||
@@ -0,0 +1,895 @@
|
||||
class_name EconomyService
|
||||
extends RefCounted
|
||||
|
||||
## Authoritative Gold ledger. There is intentionally no public balance setter:
|
||||
## every change is derived from an append-only, balanced LedgerEntry.
|
||||
|
||||
signal transaction_posted(transaction_id: StringName, entries: Array)
|
||||
|
||||
const LedgerEntryType = preload("res://scripts/economy/ledger_entry.gd")
|
||||
const CommandResultType = preload("res://scripts/core/command_result.gd")
|
||||
|
||||
const SNAPSHOT_VERSION := 1
|
||||
const SYSTEM_SOURCE: StringName = &"SYSTEM_SOURCE"
|
||||
const SYSTEM_SINK: StringName = &"SYSTEM_SINK"
|
||||
const DEFAULT_CATEGORY: StringName = &"TRANSFER"
|
||||
|
||||
var current_minute := 0
|
||||
|
||||
var _accounts: Dictionary = {}
|
||||
var _balance_cache: Dictionary = {}
|
||||
var _ledger: Array[LedgerEntry] = []
|
||||
var _transaction_ids: Dictionary = {}
|
||||
var _idempotency_keys: Dictionary = {}
|
||||
var _next_transaction_sequence := 1
|
||||
var _pending_transaction_notifications: Dictionary = {}
|
||||
|
||||
|
||||
func _init(account_definitions: Dictionary = {}) -> void:
|
||||
# SOURCE may go negative because it is the explicit counter-account for
|
||||
# initialization/generation. SINK only receives destruction entries.
|
||||
_register_account_internal(SYSTEM_SOURCE, true, true)
|
||||
_register_account_internal(SYSTEM_SINK, false, true)
|
||||
var account_ids: Array = account_definitions.keys()
|
||||
account_ids.sort_custom(_variant_string_less)
|
||||
for account_value: Variant in account_ids:
|
||||
var account_id := StringName(account_value)
|
||||
if account_id in [SYSTEM_SOURCE, SYSTEM_SINK]:
|
||||
continue
|
||||
var definition: Variant = account_definitions[account_value]
|
||||
var allow_negative := false
|
||||
if definition is bool:
|
||||
allow_negative = definition
|
||||
elif definition is Dictionary:
|
||||
allow_negative = bool(definition.get(
|
||||
&"allow_negative",
|
||||
definition.get("allow_negative", false)
|
||||
))
|
||||
_register_account_internal(account_id, allow_negative, false)
|
||||
|
||||
|
||||
func set_current_minute(absolute_minute: int) -> bool:
|
||||
if absolute_minute < 0:
|
||||
return false
|
||||
current_minute = absolute_minute
|
||||
return true
|
||||
|
||||
|
||||
func register_account(account_id: StringName, allow_negative := false) -> bool:
|
||||
if account_id.is_empty() or _accounts.has(account_id):
|
||||
return false
|
||||
return _register_account_internal(account_id, allow_negative, false)
|
||||
|
||||
|
||||
func has_account(account_id: StringName) -> bool:
|
||||
return _accounts.has(account_id)
|
||||
|
||||
|
||||
func account_allows_negative(account_id: StringName) -> bool:
|
||||
if not _accounts.has(account_id):
|
||||
return false
|
||||
return bool((_accounts[account_id] as Dictionary).get(&"allow_negative", false))
|
||||
|
||||
|
||||
func get_account(account_id: StringName) -> Dictionary:
|
||||
if not _accounts.has(account_id):
|
||||
return {}
|
||||
var result: Dictionary = (_accounts[account_id] as Dictionary).duplicate(true)
|
||||
result[&"account_id"] = account_id
|
||||
result[&"balance"] = get_balance(account_id)
|
||||
return result
|
||||
|
||||
|
||||
func get_accounts() -> Array[Dictionary]:
|
||||
var result: Array[Dictionary] = []
|
||||
for account_id: StringName in _sorted_string_name_keys(_accounts):
|
||||
result.append(get_account(account_id))
|
||||
return result
|
||||
|
||||
|
||||
func get_balance(account_id: StringName) -> int:
|
||||
return int(_balance_cache.get(account_id, 0))
|
||||
|
||||
|
||||
func initialize_account(
|
||||
account_id: StringName,
|
||||
amount: int,
|
||||
absolute_minute: int = 0,
|
||||
idempotency_key: StringName = &"",
|
||||
emit_notification := true
|
||||
) -> CommandResult:
|
||||
if amount == 0:
|
||||
return _failure(
|
||||
CommandResultType.Code.VALIDATION_FAILED,
|
||||
"An initial balance must be non-zero."
|
||||
)
|
||||
if amount > 0:
|
||||
return transfer(
|
||||
SYSTEM_SOURCE,
|
||||
account_id,
|
||||
amount,
|
||||
absolute_minute,
|
||||
&"INITIALIZATION",
|
||||
account_id,
|
||||
idempotency_key,
|
||||
&"account.initial_balance",
|
||||
&"",
|
||||
emit_notification
|
||||
)
|
||||
return transfer(
|
||||
account_id,
|
||||
SYSTEM_SINK,
|
||||
-amount,
|
||||
absolute_minute,
|
||||
&"INITIALIZATION",
|
||||
account_id,
|
||||
idempotency_key,
|
||||
&"account.initial_balance",
|
||||
&"",
|
||||
emit_notification
|
||||
)
|
||||
|
||||
|
||||
## Moves Gold from from_account to to_account. The receiver is debited and the
|
||||
## sender is credited, so signed balance is always debit minus credit.
|
||||
func transfer(
|
||||
from_account: StringName,
|
||||
to_account: StringName,
|
||||
amount: int,
|
||||
absolute_minute: int = -1,
|
||||
category: StringName = DEFAULT_CATEGORY,
|
||||
source_entity_id: StringName = &"",
|
||||
idempotency_key: StringName = &"",
|
||||
memo_key: StringName = &"",
|
||||
related_transaction_id: StringName = &"",
|
||||
emit_notification := true
|
||||
) -> CommandResult:
|
||||
var minute := current_minute if absolute_minute < 0 else absolute_minute
|
||||
var entry := LedgerEntryType.new().configure(
|
||||
&"",
|
||||
minute,
|
||||
to_account,
|
||||
from_account,
|
||||
amount,
|
||||
category,
|
||||
source_entity_id,
|
||||
related_transaction_id,
|
||||
memo_key
|
||||
)
|
||||
return post([entry], idempotency_key, &"", emit_notification)
|
||||
|
||||
|
||||
## Convenience API for callers which naturally specify debit and credit.
|
||||
func post_entry(
|
||||
debit_account: StringName,
|
||||
credit_account: StringName,
|
||||
amount: int,
|
||||
absolute_minute: int,
|
||||
category: StringName,
|
||||
source_entity_id: StringName = &"",
|
||||
idempotency_key: StringName = &"",
|
||||
memo_key: StringName = &"",
|
||||
related_transaction_id: StringName = &""
|
||||
) -> CommandResult:
|
||||
var entry := LedgerEntryType.new().configure(
|
||||
&"",
|
||||
absolute_minute,
|
||||
debit_account,
|
||||
credit_account,
|
||||
amount,
|
||||
category,
|
||||
source_entity_id,
|
||||
related_transaction_id,
|
||||
memo_key
|
||||
)
|
||||
return post([entry], idempotency_key)
|
||||
|
||||
|
||||
## Appends one transaction. All entries are validated and all resulting account
|
||||
## balances are checked before any live state is changed.
|
||||
func post(
|
||||
entries: Array,
|
||||
idempotency_key: StringName = &"",
|
||||
transaction_id: StringName = &"",
|
||||
emit_notification := true
|
||||
) -> CommandResult:
|
||||
if entries.is_empty():
|
||||
return _failure(CommandResultType.Code.INVALID_PAYLOAD, "A transaction needs at least one entry.")
|
||||
if not idempotency_key.is_empty() and _idempotency_keys.has(idempotency_key):
|
||||
return _failure(
|
||||
CommandResultType.Code.DUPLICATE_IDEMPOTENCY_KEY,
|
||||
"The idempotency key has already been posted.",
|
||||
{&"transaction_id": _idempotency_keys[idempotency_key]}
|
||||
)
|
||||
|
||||
var normalized: Array[LedgerEntry] = []
|
||||
var draft_ids: Dictionary = {}
|
||||
for value: Variant in entries:
|
||||
var entry := _entry_from_value(value)
|
||||
if entry == null:
|
||||
return _failure(CommandResultType.Code.INVALID_PAYLOAD, "A ledger line is malformed.")
|
||||
if not entry.transaction_id.is_empty():
|
||||
draft_ids[entry.transaction_id] = true
|
||||
normalized.append(entry)
|
||||
if draft_ids.size() > 1:
|
||||
return _failure(
|
||||
CommandResultType.Code.VALIDATION_FAILED,
|
||||
"All entries in one post must share a transaction ID."
|
||||
)
|
||||
|
||||
var generated := false
|
||||
var generated_sequence := _next_transaction_sequence
|
||||
var effective_id := transaction_id
|
||||
if effective_id.is_empty() and draft_ids.size() == 1:
|
||||
effective_id = draft_ids.keys()[0]
|
||||
if effective_id.is_empty():
|
||||
var generated_data := _peek_generated_transaction_id()
|
||||
effective_id = generated_data[&"transaction_id"]
|
||||
generated_sequence = int(generated_data[&"sequence"])
|
||||
generated = true
|
||||
if effective_id.is_empty() or _transaction_ids.has(effective_id):
|
||||
return _failure(
|
||||
CommandResultType.Code.DUPLICATE_COMMAND_ID,
|
||||
"The transaction ID is empty or already exists.",
|
||||
{&"transaction_id": effective_id}
|
||||
)
|
||||
|
||||
var candidate_balances := _balance_cache.duplicate(true)
|
||||
var debit_total := 0
|
||||
var credit_total := 0
|
||||
for entry: LedgerEntry in normalized:
|
||||
if not entry.transaction_id.is_empty() and entry.transaction_id != effective_id:
|
||||
return _failure(
|
||||
CommandResultType.Code.VALIDATION_FAILED,
|
||||
"A ledger line conflicts with the requested transaction ID."
|
||||
)
|
||||
entry.transaction_id = effective_id
|
||||
var errors := entry.validation_errors()
|
||||
if not errors.is_empty():
|
||||
return _failure(
|
||||
CommandResultType.Code.VALIDATION_FAILED,
|
||||
"A ledger line violates the ledger schema.",
|
||||
{&"errors": errors}
|
||||
)
|
||||
if not _accounts.has(entry.debit_account) or not _accounts.has(entry.credit_account):
|
||||
return _failure(
|
||||
CommandResultType.Code.NOT_FOUND,
|
||||
"A ledger account is not registered.",
|
||||
{
|
||||
&"debit_account": entry.debit_account,
|
||||
&"credit_account": entry.credit_account,
|
||||
}
|
||||
)
|
||||
if not entry.related_transaction_id.is_empty() \
|
||||
and not _transaction_ids.has(entry.related_transaction_id):
|
||||
return _failure(
|
||||
CommandResultType.Code.NOT_FOUND,
|
||||
"The related transaction does not exist.",
|
||||
{&"related_transaction_id": entry.related_transaction_id}
|
||||
)
|
||||
if not _system_account_use_is_valid(entry):
|
||||
return _failure(
|
||||
CommandResultType.Code.VALIDATION_FAILED,
|
||||
"SYSTEM_SOURCE and SYSTEM_SINK are restricted to explicit system flows.",
|
||||
{&"category": entry.category}
|
||||
)
|
||||
candidate_balances[entry.debit_account] = int(candidate_balances[entry.debit_account]) + entry.amount
|
||||
candidate_balances[entry.credit_account] = int(candidate_balances[entry.credit_account]) - entry.amount
|
||||
debit_total += entry.amount
|
||||
credit_total += entry.amount
|
||||
|
||||
if debit_total != credit_total:
|
||||
return _failure(CommandResultType.Code.VALIDATION_FAILED, "Debit and credit totals differ.")
|
||||
for account_id: StringName in _sorted_string_name_keys(candidate_balances):
|
||||
if int(candidate_balances[account_id]) < 0 and not account_allows_negative(account_id):
|
||||
return _failure(
|
||||
CommandResultType.Code.VALIDATION_FAILED,
|
||||
"An account does not permit a negative balance.",
|
||||
{
|
||||
&"account_id": account_id,
|
||||
&"available": get_balance(account_id),
|
||||
&"resulting_balance": int(candidate_balances[account_id]),
|
||||
}
|
||||
)
|
||||
|
||||
# Commit starts here. Every possibly failing check is complete.
|
||||
var start_index := _ledger.size()
|
||||
for entry: LedgerEntry in normalized:
|
||||
_ledger.append(entry.copy())
|
||||
_balance_cache = candidate_balances
|
||||
_transaction_ids[effective_id] = {
|
||||
&"start_index": start_index,
|
||||
&"entry_count": normalized.size(),
|
||||
&"idempotency_key": idempotency_key,
|
||||
}
|
||||
if not idempotency_key.is_empty():
|
||||
_idempotency_keys[idempotency_key] = effective_id
|
||||
if generated:
|
||||
_next_transaction_sequence = generated_sequence + 1
|
||||
var posted_entries := get_transaction_entries(effective_id)
|
||||
if emit_notification:
|
||||
transaction_posted.emit(effective_id, posted_entries)
|
||||
else:
|
||||
_pending_transaction_notifications[effective_id] = true
|
||||
return CommandResultType.succeeded(0, {
|
||||
&"transaction_id": effective_id,
|
||||
&"entry_count": normalized.size(),
|
||||
&"entries": _entries_to_dictionaries(posted_entries),
|
||||
})
|
||||
|
||||
|
||||
func has_pending_transaction_notification(transaction_id: StringName) -> bool:
|
||||
return _pending_transaction_notifications.has(transaction_id)
|
||||
|
||||
|
||||
func commit_transaction_notification(transaction_id: StringName) -> bool:
|
||||
if not _pending_transaction_notifications.has(transaction_id) \
|
||||
or not _transaction_ids.has(transaction_id):
|
||||
return false
|
||||
_pending_transaction_notifications.erase(transaction_id)
|
||||
transaction_posted.emit(transaction_id, get_transaction_entries(transaction_id))
|
||||
return true
|
||||
|
||||
|
||||
func discard_transaction_notification(transaction_id: StringName) -> bool:
|
||||
if not _pending_transaction_notifications.has(transaction_id):
|
||||
return false
|
||||
_pending_transaction_notifications.erase(transaction_id)
|
||||
return true
|
||||
|
||||
|
||||
func reverse(
|
||||
transaction_id: StringName,
|
||||
absolute_minute: int = -1,
|
||||
idempotency_key: StringName = &"",
|
||||
category: StringName = &"REVERSAL",
|
||||
source_entity_id: StringName = &"",
|
||||
memo_key: StringName = &"transaction.reversal"
|
||||
) -> CommandResult:
|
||||
var originals := get_transaction_entries(transaction_id)
|
||||
if originals.is_empty():
|
||||
return _failure(
|
||||
CommandResultType.Code.NOT_FOUND,
|
||||
"The transaction to reverse was not found.",
|
||||
{&"transaction_id": transaction_id}
|
||||
)
|
||||
if _has_related_financial_entry(transaction_id):
|
||||
return _failure(
|
||||
CommandResultType.Code.VALIDATION_FAILED,
|
||||
"The transaction has already been reversed or refunded.",
|
||||
{&"transaction_id": transaction_id}
|
||||
)
|
||||
var minute := current_minute if absolute_minute < 0 else absolute_minute
|
||||
var entries: Array[LedgerEntry] = []
|
||||
for original: LedgerEntry in originals:
|
||||
entries.append(original.reversed(
|
||||
&"",
|
||||
minute,
|
||||
category,
|
||||
source_entity_id,
|
||||
memo_key
|
||||
))
|
||||
return post(entries, idempotency_key)
|
||||
|
||||
|
||||
func reverse_transaction(
|
||||
transaction_id: StringName,
|
||||
absolute_minute: int = -1,
|
||||
idempotency_key: StringName = &""
|
||||
) -> CommandResult:
|
||||
return reverse(transaction_id, absolute_minute, idempotency_key)
|
||||
|
||||
|
||||
## Partial refunds are deliberately limited to a one-line source transaction;
|
||||
## this avoids silently choosing an allocation across compound entries.
|
||||
func refund(
|
||||
transaction_id: StringName,
|
||||
amount: int,
|
||||
absolute_minute: int = -1,
|
||||
idempotency_key: StringName = &"",
|
||||
source_entity_id: StringName = &"",
|
||||
memo_key: StringName = &"transaction.refund"
|
||||
) -> CommandResult:
|
||||
var originals := get_transaction_entries(transaction_id)
|
||||
if originals.is_empty():
|
||||
return _failure(CommandResultType.Code.NOT_FOUND, "The transaction to refund was not found.")
|
||||
if originals.size() != 1:
|
||||
return _failure(
|
||||
CommandResultType.Code.VALIDATION_FAILED,
|
||||
"A partial refund needs a one-line source transaction."
|
||||
)
|
||||
var original: LedgerEntry = originals[0]
|
||||
var refundable := original.amount - get_refunded_amount(transaction_id)
|
||||
if amount <= 0 or amount > refundable:
|
||||
return _failure(
|
||||
CommandResultType.Code.VALIDATION_FAILED,
|
||||
"The refund amount is outside the remaining refundable amount.",
|
||||
{&"refundable": refundable}
|
||||
)
|
||||
var minute := current_minute if absolute_minute < 0 else absolute_minute
|
||||
var entry := original.reversed(
|
||||
&"",
|
||||
minute,
|
||||
&"REFUND",
|
||||
source_entity_id,
|
||||
memo_key
|
||||
)
|
||||
entry.amount = amount
|
||||
return post([entry], idempotency_key)
|
||||
|
||||
|
||||
func refund_transaction(
|
||||
transaction_id: StringName,
|
||||
amount: int,
|
||||
absolute_minute: int = -1,
|
||||
idempotency_key: StringName = &""
|
||||
) -> CommandResult:
|
||||
return refund(transaction_id, amount, absolute_minute, idempotency_key)
|
||||
|
||||
|
||||
func get_refunded_amount(transaction_id: StringName) -> int:
|
||||
var originals := get_transaction_entries(transaction_id)
|
||||
if originals.size() != 1:
|
||||
return 0
|
||||
var original: LedgerEntry = originals[0]
|
||||
var total := 0
|
||||
for entry: LedgerEntry in _ledger:
|
||||
if entry.related_transaction_id == transaction_id \
|
||||
and entry.debit_account == original.credit_account \
|
||||
and entry.credit_account == original.debit_account:
|
||||
total += entry.amount
|
||||
return total
|
||||
|
||||
|
||||
func has_transaction(transaction_id: StringName) -> bool:
|
||||
return _transaction_ids.has(transaction_id)
|
||||
|
||||
|
||||
func get_transaction_id_for_idempotency_key(idempotency_key: StringName) -> StringName:
|
||||
return _idempotency_keys.get(idempotency_key, &"")
|
||||
|
||||
|
||||
func get_entries() -> Array[LedgerEntry]:
|
||||
var result: Array[LedgerEntry] = []
|
||||
for entry: LedgerEntry in _ledger:
|
||||
result.append(entry.copy())
|
||||
return result
|
||||
|
||||
|
||||
func get_transaction_entries(transaction_id: StringName) -> Array[LedgerEntry]:
|
||||
var result: Array[LedgerEntry] = []
|
||||
if not _transaction_ids.has(transaction_id):
|
||||
return result
|
||||
var metadata: Dictionary = _transaction_ids[transaction_id]
|
||||
var start_index := int(metadata[&"start_index"])
|
||||
var entry_count := int(metadata[&"entry_count"])
|
||||
for index in range(start_index, start_index + entry_count):
|
||||
result.append(_ledger[index].copy())
|
||||
return result
|
||||
|
||||
|
||||
func aggregate_categories(from_minute: int, to_minute_exclusive: int) -> Dictionary:
|
||||
var totals: Dictionary = {}
|
||||
for entry: LedgerEntry in _ledger:
|
||||
if entry.absolute_minute < from_minute or entry.absolute_minute >= to_minute_exclusive:
|
||||
continue
|
||||
totals[entry.category] = int(totals.get(entry.category, 0)) + entry.amount
|
||||
return _sorted_dictionary(totals)
|
||||
|
||||
|
||||
func aggregate_accounts(from_minute: int, to_minute_exclusive: int) -> Dictionary:
|
||||
var totals: Dictionary = {}
|
||||
for account_id: StringName in _sorted_string_name_keys(_accounts):
|
||||
totals[account_id] = {&"debit": 0, &"credit": 0, &"net": 0}
|
||||
for entry: LedgerEntry in _ledger:
|
||||
if entry.absolute_minute < from_minute or entry.absolute_minute >= to_minute_exclusive:
|
||||
continue
|
||||
var debit: Dictionary = totals[entry.debit_account]
|
||||
debit[&"debit"] = int(debit[&"debit"]) + entry.amount
|
||||
debit[&"net"] = int(debit[&"net"]) + entry.amount
|
||||
var credit: Dictionary = totals[entry.credit_account]
|
||||
credit[&"credit"] = int(credit[&"credit"]) + entry.amount
|
||||
credit[&"net"] = int(credit[&"net"]) - entry.amount
|
||||
return totals
|
||||
|
||||
|
||||
func get_daily_summary(day: int) -> Dictionary:
|
||||
if day < 1:
|
||||
return {}
|
||||
var start_minute := (day - 1) * 1_440
|
||||
var end_minute := start_minute + 1_440
|
||||
var transaction_set: Dictionary = {}
|
||||
var entry_count := 0
|
||||
var total_debit := 0
|
||||
var total_credit := 0
|
||||
for entry: LedgerEntry in _ledger:
|
||||
if entry.absolute_minute < start_minute or entry.absolute_minute >= end_minute:
|
||||
continue
|
||||
entry_count += 1
|
||||
transaction_set[entry.transaction_id] = true
|
||||
total_debit += entry.amount
|
||||
total_credit += entry.amount
|
||||
return {
|
||||
&"day": day,
|
||||
&"start_minute": start_minute,
|
||||
&"end_minute_exclusive": end_minute,
|
||||
&"category_totals": aggregate_categories(start_minute, end_minute),
|
||||
&"account_totals": aggregate_accounts(start_minute, end_minute),
|
||||
&"transaction_count": transaction_set.size(),
|
||||
&"entry_count": entry_count,
|
||||
&"total_debit": total_debit,
|
||||
&"total_credit": total_credit,
|
||||
&"balanced": total_debit == total_credit,
|
||||
}
|
||||
|
||||
|
||||
func daily_summary(day: int) -> Dictionary:
|
||||
return get_daily_summary(day)
|
||||
|
||||
|
||||
func validate_ledger() -> Dictionary:
|
||||
var calculated := _calculate_and_validate(_accounts, _ledger, _idempotency_keys)
|
||||
var errors: Array = calculated[&"errors"]
|
||||
var expected_balances: Dictionary = calculated[&"balances"]
|
||||
if expected_balances != _balance_cache:
|
||||
errors.append({
|
||||
&"code": &"BALANCE_CACHE_MISMATCH",
|
||||
&"expected": expected_balances,
|
||||
&"actual": _balance_cache.duplicate(true),
|
||||
})
|
||||
var expected_transactions: Dictionary = calculated[&"transactions"]
|
||||
if expected_transactions != _transaction_ids:
|
||||
errors.append({
|
||||
&"code": &"TRANSACTION_INDEX_MISMATCH",
|
||||
&"expected": expected_transactions,
|
||||
&"actual": _transaction_ids.duplicate(true),
|
||||
})
|
||||
return {
|
||||
&"ok": errors.is_empty(),
|
||||
&"errors": errors,
|
||||
&"total_debit": calculated[&"total_debit"],
|
||||
&"total_credit": calculated[&"total_credit"],
|
||||
&"account_count": _accounts.size(),
|
||||
&"transaction_count": _transaction_ids.size(),
|
||||
&"entry_count": _ledger.size(),
|
||||
}
|
||||
|
||||
|
||||
func validate() -> Dictionary:
|
||||
return validate_ledger()
|
||||
|
||||
|
||||
func rebuild_balance_cache() -> bool:
|
||||
var calculated := _calculate_and_validate(_accounts, _ledger, _idempotency_keys)
|
||||
if not (calculated[&"errors"] as Array).is_empty():
|
||||
return false
|
||||
_balance_cache = (calculated[&"balances"] as Dictionary).duplicate(true)
|
||||
return true
|
||||
|
||||
|
||||
func create_snapshot() -> Dictionary:
|
||||
var saved_accounts: Array[Dictionary] = []
|
||||
for account_id: StringName in _sorted_string_name_keys(_accounts):
|
||||
var definition: Dictionary = _accounts[account_id]
|
||||
saved_accounts.append({
|
||||
&"account_id": String(account_id),
|
||||
&"allow_negative": bool(definition[&"allow_negative"]),
|
||||
&"system": bool(definition[&"system"]),
|
||||
})
|
||||
var saved_entries: Array[Dictionary] = []
|
||||
for entry: LedgerEntry in _ledger:
|
||||
saved_entries.append(entry.to_dictionary())
|
||||
var saved_idempotency: Array[Dictionary] = []
|
||||
for key: StringName in _sorted_string_name_keys(_idempotency_keys):
|
||||
saved_idempotency.append({
|
||||
&"key": String(key),
|
||||
&"transaction_id": String(_idempotency_keys[key]),
|
||||
})
|
||||
return {
|
||||
&"schema_version": SNAPSHOT_VERSION,
|
||||
&"current_minute": current_minute,
|
||||
&"next_transaction_sequence": _next_transaction_sequence,
|
||||
&"accounts": saved_accounts,
|
||||
&"entries": saved_entries,
|
||||
&"idempotency": saved_idempotency,
|
||||
}
|
||||
|
||||
|
||||
func restore_snapshot(snapshot: Dictionary) -> bool:
|
||||
if int(snapshot.get(&"schema_version", snapshot.get("schema_version", 0))) != SNAPSHOT_VERSION:
|
||||
return false
|
||||
var saved_accounts: Variant = snapshot.get(&"accounts", snapshot.get("accounts", null))
|
||||
var saved_entries: Variant = snapshot.get(&"entries", snapshot.get("entries", null))
|
||||
var saved_idempotency: Variant = snapshot.get(&"idempotency", snapshot.get("idempotency", null))
|
||||
if not saved_accounts is Array or not saved_entries is Array or not saved_idempotency is Array:
|
||||
return false
|
||||
|
||||
var restored_accounts: Dictionary = {}
|
||||
for value: Variant in saved_accounts:
|
||||
if not value is Dictionary:
|
||||
return false
|
||||
var account_id := StringName(value.get(&"account_id", value.get("account_id", "")))
|
||||
if account_id.is_empty() or restored_accounts.has(account_id):
|
||||
return false
|
||||
restored_accounts[account_id] = {
|
||||
&"allow_negative": bool(value.get(&"allow_negative", value.get("allow_negative", false))),
|
||||
&"system": bool(value.get(&"system", value.get("system", false))),
|
||||
}
|
||||
if not restored_accounts.has(SYSTEM_SOURCE) or not restored_accounts.has(SYSTEM_SINK):
|
||||
return false
|
||||
if not bool((restored_accounts[SYSTEM_SOURCE] as Dictionary)[&"allow_negative"]) \
|
||||
or bool((restored_accounts[SYSTEM_SINK] as Dictionary)[&"allow_negative"]):
|
||||
return false
|
||||
if not bool((restored_accounts[SYSTEM_SOURCE] as Dictionary)[&"system"]) \
|
||||
or not bool((restored_accounts[SYSTEM_SINK] as Dictionary)[&"system"]):
|
||||
return false
|
||||
for account_id: Variant in restored_accounts:
|
||||
if account_id not in [SYSTEM_SOURCE, SYSTEM_SINK] \
|
||||
and bool((restored_accounts[account_id] as Dictionary)[&"system"]):
|
||||
return false
|
||||
|
||||
var restored_ledger: Array[LedgerEntry] = []
|
||||
for value: Variant in saved_entries:
|
||||
if not value is Dictionary:
|
||||
return false
|
||||
restored_ledger.append(LedgerEntryType.from_dictionary(value))
|
||||
|
||||
var restored_idempotency: Dictionary = {}
|
||||
var idempotent_transactions: Dictionary = {}
|
||||
for value: Variant in saved_idempotency:
|
||||
if not value is Dictionary:
|
||||
return false
|
||||
var key := StringName(value.get(&"key", value.get("key", "")))
|
||||
var transaction_id := StringName(value.get(
|
||||
&"transaction_id",
|
||||
value.get("transaction_id", "")
|
||||
))
|
||||
if key.is_empty() or transaction_id.is_empty() \
|
||||
or restored_idempotency.has(key) or idempotent_transactions.has(transaction_id):
|
||||
return false
|
||||
restored_idempotency[key] = transaction_id
|
||||
idempotent_transactions[transaction_id] = true
|
||||
|
||||
var calculated := _calculate_and_validate(
|
||||
restored_accounts,
|
||||
restored_ledger,
|
||||
restored_idempotency
|
||||
)
|
||||
if not (calculated[&"errors"] as Array).is_empty():
|
||||
return false
|
||||
var restored_transactions: Dictionary = calculated[&"transactions"]
|
||||
var next_sequence := int(snapshot.get(
|
||||
&"next_transaction_sequence",
|
||||
snapshot.get("next_transaction_sequence", 0)
|
||||
))
|
||||
var restored_minute := int(snapshot.get(&"current_minute", snapshot.get("current_minute", -1)))
|
||||
if next_sequence < 1 or restored_minute < 0:
|
||||
return false
|
||||
|
||||
# Atomic swap: live fields are untouched until every saved value is valid.
|
||||
_accounts = restored_accounts
|
||||
_ledger = restored_ledger
|
||||
_idempotency_keys = restored_idempotency
|
||||
_transaction_ids = restored_transactions
|
||||
_balance_cache = (calculated[&"balances"] as Dictionary).duplicate(true)
|
||||
_next_transaction_sequence = next_sequence
|
||||
_pending_transaction_notifications.clear()
|
||||
current_minute = restored_minute
|
||||
return true
|
||||
|
||||
|
||||
func _register_account_internal(
|
||||
account_id: StringName,
|
||||
allow_negative: bool,
|
||||
is_system: bool
|
||||
) -> bool:
|
||||
if account_id.is_empty() or _accounts.has(account_id):
|
||||
return false
|
||||
_accounts[account_id] = {
|
||||
&"allow_negative": allow_negative,
|
||||
&"system": is_system,
|
||||
}
|
||||
_balance_cache[account_id] = 0
|
||||
return true
|
||||
|
||||
|
||||
func _entry_from_value(value: Variant) -> LedgerEntry:
|
||||
if value is LedgerEntry:
|
||||
return (value as LedgerEntry).copy()
|
||||
if value is Dictionary:
|
||||
return LedgerEntryType.from_dictionary(value)
|
||||
return null
|
||||
|
||||
|
||||
func _peek_generated_transaction_id() -> Dictionary:
|
||||
var sequence := maxi(_next_transaction_sequence, 1)
|
||||
var transaction_id := StringName("ledger_%08d" % sequence)
|
||||
while _transaction_ids.has(transaction_id):
|
||||
sequence += 1
|
||||
transaction_id = StringName("ledger_%08d" % sequence)
|
||||
return {&"transaction_id": transaction_id, &"sequence": sequence}
|
||||
|
||||
|
||||
func _has_related_financial_entry(transaction_id: StringName) -> bool:
|
||||
for entry: LedgerEntry in _ledger:
|
||||
if entry.related_transaction_id == transaction_id:
|
||||
return true
|
||||
return false
|
||||
|
||||
|
||||
func _system_account_use_is_valid(entry: LedgerEntry) -> bool:
|
||||
if entry.debit_account not in [SYSTEM_SOURCE, SYSTEM_SINK] \
|
||||
and entry.credit_account not in [SYSTEM_SOURCE, SYSTEM_SINK]:
|
||||
return true
|
||||
var normalized := String(entry.category).to_upper()
|
||||
return normalized.begins_with("INIT") \
|
||||
or normalized in ["GENERATION", "DESTRUCTION", "REVERSAL", "REFUND", "MIGRATION"]
|
||||
|
||||
|
||||
func _calculate_and_validate(
|
||||
accounts: Dictionary,
|
||||
ledger: Array[LedgerEntry],
|
||||
idempotency: Dictionary
|
||||
) -> Dictionary:
|
||||
var errors: Array[Dictionary] = []
|
||||
var balances: Dictionary = {}
|
||||
for account_id: Variant in accounts:
|
||||
balances[StringName(account_id)] = 0
|
||||
var transactions: Dictionary = {}
|
||||
var closed_transactions: Dictionary = {}
|
||||
var active_transaction: StringName = &""
|
||||
var transaction_start := 0
|
||||
var transaction_count := 0
|
||||
var transaction_delta: Dictionary = {}
|
||||
var total_debit := 0
|
||||
var total_credit := 0
|
||||
|
||||
for index in ledger.size():
|
||||
var entry: LedgerEntry = ledger[index]
|
||||
var entry_errors := entry.validation_errors()
|
||||
if not entry_errors.is_empty():
|
||||
errors.append({&"code": &"INVALID_ENTRY", &"index": index, &"errors": entry_errors})
|
||||
continue
|
||||
if not accounts.has(entry.debit_account) or not accounts.has(entry.credit_account):
|
||||
errors.append({&"code": &"UNKNOWN_ACCOUNT", &"index": index})
|
||||
continue
|
||||
if not _system_account_use_is_valid(entry):
|
||||
errors.append({&"code": &"INVALID_SYSTEM_ACCOUNT_USE", &"index": index})
|
||||
if not entry.related_transaction_id.is_empty() \
|
||||
and entry.related_transaction_id == entry.transaction_id:
|
||||
errors.append({&"code": &"SELF_RELATED_TRANSACTION", &"index": index})
|
||||
|
||||
if active_transaction != entry.transaction_id:
|
||||
if not active_transaction.is_empty():
|
||||
_finalize_calculated_transaction(
|
||||
active_transaction,
|
||||
transaction_start,
|
||||
transaction_count,
|
||||
transaction_delta,
|
||||
accounts,
|
||||
balances,
|
||||
transactions,
|
||||
idempotency,
|
||||
errors
|
||||
)
|
||||
closed_transactions[active_transaction] = true
|
||||
if closed_transactions.has(entry.transaction_id):
|
||||
errors.append({&"code": &"NON_CONTIGUOUS_TRANSACTION", &"index": index})
|
||||
active_transaction = entry.transaction_id
|
||||
transaction_start = index
|
||||
transaction_count = 0
|
||||
transaction_delta = {}
|
||||
transaction_count += 1
|
||||
transaction_delta[entry.debit_account] = int(transaction_delta.get(entry.debit_account, 0)) + entry.amount
|
||||
transaction_delta[entry.credit_account] = int(transaction_delta.get(entry.credit_account, 0)) - entry.amount
|
||||
total_debit += entry.amount
|
||||
total_credit += entry.amount
|
||||
|
||||
if not active_transaction.is_empty():
|
||||
_finalize_calculated_transaction(
|
||||
active_transaction,
|
||||
transaction_start,
|
||||
transaction_count,
|
||||
transaction_delta,
|
||||
accounts,
|
||||
balances,
|
||||
transactions,
|
||||
idempotency,
|
||||
errors
|
||||
)
|
||||
if total_debit != total_credit:
|
||||
errors.append({&"code": &"LEDGER_IMBALANCE"})
|
||||
for entry: LedgerEntry in ledger:
|
||||
if not entry.related_transaction_id.is_empty() \
|
||||
and not transactions.has(entry.related_transaction_id):
|
||||
errors.append({
|
||||
&"code": &"MISSING_RELATED_TRANSACTION",
|
||||
&"transaction_id": entry.transaction_id,
|
||||
})
|
||||
for key: Variant in idempotency:
|
||||
var related_id := StringName(idempotency[key])
|
||||
if StringName(key).is_empty() or not transactions.has(related_id):
|
||||
errors.append({&"code": &"INVALID_IDEMPOTENCY_REFERENCE", &"key": key})
|
||||
return {
|
||||
&"errors": errors,
|
||||
&"balances": balances,
|
||||
&"transactions": transactions,
|
||||
&"total_debit": total_debit,
|
||||
&"total_credit": total_credit,
|
||||
}
|
||||
|
||||
|
||||
func _finalize_calculated_transaction(
|
||||
transaction_id: StringName,
|
||||
start_index: int,
|
||||
entry_count: int,
|
||||
delta: Dictionary,
|
||||
accounts: Dictionary,
|
||||
balances: Dictionary,
|
||||
transactions: Dictionary,
|
||||
idempotency: Dictionary,
|
||||
errors: Array[Dictionary]
|
||||
) -> void:
|
||||
if transactions.has(transaction_id):
|
||||
errors.append({&"code": &"DUPLICATE_TRANSACTION_ID", &"transaction_id": transaction_id})
|
||||
return
|
||||
var candidate := balances.duplicate(true)
|
||||
for account_id: Variant in delta:
|
||||
candidate[account_id] = int(candidate.get(account_id, 0)) + int(delta[account_id])
|
||||
for account_id: Variant in candidate:
|
||||
var definition: Dictionary = accounts.get(account_id, {})
|
||||
if int(candidate[account_id]) < 0 and not bool(definition.get(&"allow_negative", false)):
|
||||
errors.append({
|
||||
&"code": &"NEGATIVE_BALANCE_NOT_ALLOWED",
|
||||
&"transaction_id": transaction_id,
|
||||
&"account_id": account_id,
|
||||
})
|
||||
return
|
||||
balances.clear()
|
||||
balances.merge(candidate, true)
|
||||
var idempotency_key: StringName = &""
|
||||
for key: Variant in idempotency:
|
||||
if StringName(idempotency[key]) == transaction_id:
|
||||
idempotency_key = StringName(key)
|
||||
break
|
||||
transactions[transaction_id] = {
|
||||
&"start_index": start_index,
|
||||
&"entry_count": entry_count,
|
||||
&"idempotency_key": idempotency_key,
|
||||
}
|
||||
|
||||
|
||||
static func _entries_to_dictionaries(entries: Array[LedgerEntry]) -> Array[Dictionary]:
|
||||
var result: Array[Dictionary] = []
|
||||
for entry: LedgerEntry in entries:
|
||||
result.append(entry.to_dictionary())
|
||||
return result
|
||||
|
||||
|
||||
static func _sorted_string_name_keys(source: Dictionary) -> Array[StringName]:
|
||||
var result: Array[StringName] = []
|
||||
for key: Variant in source:
|
||||
result.append(StringName(key))
|
||||
result.sort_custom(func(left: StringName, right: StringName) -> bool:
|
||||
return String(left) < String(right)
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
static func _sorted_dictionary(source: Dictionary) -> Dictionary:
|
||||
var result: Dictionary = {}
|
||||
for key: StringName in _sorted_string_name_keys(source):
|
||||
result[key] = source[key]
|
||||
return result
|
||||
|
||||
|
||||
static func _variant_string_less(left: Variant, right: Variant) -> bool:
|
||||
return String(left) < String(right)
|
||||
|
||||
|
||||
static func _failure(
|
||||
code: CommandResult.Code,
|
||||
message: String,
|
||||
details: Dictionary = {}
|
||||
) -> CommandResult:
|
||||
return CommandResultType.failed(code, 0, message, details)
|
||||
@@ -0,0 +1 @@
|
||||
uid://03xji7ptsclm
|
||||
@@ -0,0 +1,146 @@
|
||||
class_name LedgerBackedResourceState
|
||||
extends RefCounted
|
||||
|
||||
## Construction/operating adapter that keeps Gold authoritative in the ledger
|
||||
## while preserving the generic resource-state contract used by services.
|
||||
|
||||
const PLAYER_CASH := &"PLAYER_CASH"
|
||||
|
||||
var _state: Variant
|
||||
var _economy: EconomyService
|
||||
var _counter_account: StringName
|
||||
var _expense_category: StringName
|
||||
var _refund_category: StringName
|
||||
var _source_entity_id: StringName
|
||||
var _minute_provider: Callable
|
||||
|
||||
|
||||
func _init(
|
||||
state: Variant,
|
||||
economy: EconomyService,
|
||||
counter_account: StringName,
|
||||
expense_category: StringName,
|
||||
refund_category: StringName,
|
||||
source_entity_id: StringName,
|
||||
minute_provider: Callable = Callable()
|
||||
) -> void:
|
||||
_state = state
|
||||
_economy = economy
|
||||
_counter_account = counter_account
|
||||
_expense_category = expense_category
|
||||
_refund_category = refund_category
|
||||
_source_entity_id = source_entity_id
|
||||
_minute_provider = minute_provider
|
||||
|
||||
|
||||
func get_resource(resource_name: StringName) -> int:
|
||||
if resource_name == &"gold":
|
||||
return _economy.get_balance(PLAYER_CASH)
|
||||
return int(_state.call(&"get_resource", resource_name))
|
||||
|
||||
|
||||
func can_afford(cost: Dictionary) -> bool:
|
||||
for raw_name: Variant in cost:
|
||||
var resource_name := StringName(raw_name)
|
||||
var amount := int(cost[raw_name])
|
||||
if amount < 0 or get_resource(resource_name) < amount:
|
||||
return false
|
||||
return true
|
||||
|
||||
|
||||
func spend(cost: Dictionary) -> bool:
|
||||
if not can_afford(cost):
|
||||
return false
|
||||
var deltas: Dictionary = {}
|
||||
for raw_name: Variant in cost:
|
||||
deltas[StringName(raw_name)] = -int(cost[raw_name])
|
||||
return apply_resource_deltas(deltas)
|
||||
|
||||
|
||||
func apply_resource_deltas(deltas: Dictionary) -> bool:
|
||||
if not _validate_deltas(deltas):
|
||||
return false
|
||||
var before := create_snapshot()
|
||||
var non_gold: Dictionary = {}
|
||||
var gold_delta := 0
|
||||
for raw_name: Variant in deltas:
|
||||
var resource_name := StringName(raw_name)
|
||||
if resource_name == &"gold":
|
||||
gold_delta += int(deltas[raw_name])
|
||||
else:
|
||||
non_gold[resource_name] = int(deltas[raw_name])
|
||||
if not non_gold.is_empty() and not bool(_state.call(&"apply_resource_deltas", non_gold)):
|
||||
return false
|
||||
|
||||
if gold_delta != 0:
|
||||
var result: CommandResult
|
||||
if gold_delta < 0:
|
||||
result = _economy.transfer(
|
||||
PLAYER_CASH,
|
||||
_counter_account,
|
||||
-gold_delta,
|
||||
_current_minute(),
|
||||
_expense_category,
|
||||
_source_entity_id
|
||||
)
|
||||
else:
|
||||
result = _economy.transfer(
|
||||
_counter_account,
|
||||
PLAYER_CASH,
|
||||
gold_delta,
|
||||
_current_minute(),
|
||||
_refund_category,
|
||||
_source_entity_id
|
||||
)
|
||||
if not result.is_success():
|
||||
restore_snapshot(before)
|
||||
return false
|
||||
_sync_gold_mirror()
|
||||
return true
|
||||
|
||||
|
||||
func create_snapshot() -> Dictionary:
|
||||
return {
|
||||
&"state": _state.call(&"create_snapshot"),
|
||||
&"economy": _economy.create_snapshot(),
|
||||
}
|
||||
|
||||
|
||||
func restore_snapshot(snapshot: Dictionary) -> bool:
|
||||
if not snapshot.get(&"state", null) is Dictionary \
|
||||
or not snapshot.get(&"economy", null) is Dictionary:
|
||||
return false
|
||||
var previous_state: Dictionary = _state.call(&"create_snapshot")
|
||||
var previous_economy: Dictionary = _economy.create_snapshot()
|
||||
if not _economy.restore_snapshot(snapshot[&"economy"]):
|
||||
return false
|
||||
if not bool(_state.call(&"restore_snapshot", snapshot[&"state"])):
|
||||
_economy.restore_snapshot(previous_economy)
|
||||
_state.call(&"restore_snapshot", previous_state)
|
||||
return false
|
||||
if int(_state.call(&"get_resource", &"gold")) != _economy.get_balance(PLAYER_CASH):
|
||||
_economy.restore_snapshot(previous_economy)
|
||||
_state.call(&"restore_snapshot", previous_state)
|
||||
return false
|
||||
return true
|
||||
|
||||
|
||||
func _validate_deltas(deltas: Dictionary) -> bool:
|
||||
var next_values: Dictionary = {}
|
||||
for raw_name: Variant in deltas:
|
||||
var resource_name := StringName(raw_name)
|
||||
var next_value := int(next_values.get(resource_name, get_resource(resource_name))) + int(deltas[raw_name])
|
||||
if next_value < 0:
|
||||
return false
|
||||
next_values[resource_name] = next_value
|
||||
return true
|
||||
|
||||
|
||||
func _sync_gold_mirror() -> void:
|
||||
_state.call(&"set_resource", &"gold", _economy.get_balance(PLAYER_CASH))
|
||||
|
||||
|
||||
func _current_minute() -> int:
|
||||
if _minute_provider.is_valid():
|
||||
return maxi(int(_minute_provider.call()), 0)
|
||||
return _economy.current_minute
|
||||
@@ -0,0 +1 @@
|
||||
uid://b3l5lmaxndas7
|
||||
@@ -0,0 +1,131 @@
|
||||
class_name LedgerEntry
|
||||
extends RefCounted
|
||||
|
||||
## One immutable-in-practice double-entry ledger line.
|
||||
##
|
||||
## A debit increases the signed balance of debit_account and a credit decreases
|
||||
## the signed balance of credit_account. EconomyService is the only component
|
||||
## which may append entries or update its derived balance cache.
|
||||
|
||||
var transaction_id: StringName = &""
|
||||
var absolute_minute := 0
|
||||
var debit_account: StringName = &""
|
||||
var credit_account: StringName = &""
|
||||
var amount := 0
|
||||
var category: StringName = &""
|
||||
var source_entity_id: StringName = &""
|
||||
var related_transaction_id: StringName = &""
|
||||
var memo_key: StringName = &""
|
||||
|
||||
|
||||
func configure(
|
||||
p_transaction_id: StringName,
|
||||
p_absolute_minute: int,
|
||||
p_debit_account: StringName,
|
||||
p_credit_account: StringName,
|
||||
p_amount: int,
|
||||
p_category: StringName,
|
||||
p_source_entity_id: StringName = &"",
|
||||
p_related_transaction_id: StringName = &"",
|
||||
p_memo_key: StringName = &""
|
||||
) -> LedgerEntry:
|
||||
transaction_id = p_transaction_id
|
||||
absolute_minute = p_absolute_minute
|
||||
debit_account = p_debit_account
|
||||
credit_account = p_credit_account
|
||||
amount = p_amount
|
||||
category = p_category
|
||||
source_entity_id = p_source_entity_id
|
||||
related_transaction_id = p_related_transaction_id
|
||||
memo_key = p_memo_key
|
||||
return self
|
||||
|
||||
|
||||
func validation_errors() -> Array[StringName]:
|
||||
var errors: Array[StringName] = []
|
||||
if transaction_id.is_empty():
|
||||
errors.append(&"EMPTY_TRANSACTION_ID")
|
||||
if absolute_minute < 0:
|
||||
errors.append(&"INVALID_ABSOLUTE_MINUTE")
|
||||
if debit_account.is_empty():
|
||||
errors.append(&"EMPTY_DEBIT_ACCOUNT")
|
||||
if credit_account.is_empty():
|
||||
errors.append(&"EMPTY_CREDIT_ACCOUNT")
|
||||
if not debit_account.is_empty() and debit_account == credit_account:
|
||||
errors.append(&"IDENTICAL_ACCOUNTS")
|
||||
if amount <= 0:
|
||||
errors.append(&"INVALID_AMOUNT")
|
||||
if category.is_empty():
|
||||
errors.append(&"EMPTY_CATEGORY")
|
||||
if not related_transaction_id.is_empty() and related_transaction_id == transaction_id:
|
||||
errors.append(&"SELF_RELATED_TRANSACTION")
|
||||
return errors
|
||||
|
||||
|
||||
func is_valid() -> bool:
|
||||
return validation_errors().is_empty()
|
||||
|
||||
|
||||
func reversed(
|
||||
p_transaction_id: StringName,
|
||||
p_absolute_minute: int,
|
||||
p_category: StringName = &"REVERSAL",
|
||||
p_source_entity_id: StringName = &"",
|
||||
p_memo_key: StringName = &""
|
||||
) -> LedgerEntry:
|
||||
return LedgerEntry.new().configure(
|
||||
p_transaction_id,
|
||||
p_absolute_minute,
|
||||
credit_account,
|
||||
debit_account,
|
||||
amount,
|
||||
p_category,
|
||||
p_source_entity_id if not p_source_entity_id.is_empty() else source_entity_id,
|
||||
transaction_id,
|
||||
p_memo_key
|
||||
)
|
||||
|
||||
|
||||
func copy() -> LedgerEntry:
|
||||
return LedgerEntry.new().configure(
|
||||
transaction_id,
|
||||
absolute_minute,
|
||||
debit_account,
|
||||
credit_account,
|
||||
amount,
|
||||
category,
|
||||
source_entity_id,
|
||||
related_transaction_id,
|
||||
memo_key
|
||||
)
|
||||
|
||||
|
||||
func to_dictionary() -> Dictionary:
|
||||
return {
|
||||
&"transaction_id": String(transaction_id),
|
||||
&"absolute_minute": absolute_minute,
|
||||
&"debit_account": String(debit_account),
|
||||
&"credit_account": String(credit_account),
|
||||
&"amount": amount,
|
||||
&"category": String(category),
|
||||
&"source_entity_id": String(source_entity_id),
|
||||
&"related_transaction_id": String(related_transaction_id),
|
||||
&"memo_key": String(memo_key),
|
||||
}
|
||||
|
||||
|
||||
static func from_dictionary(data: Dictionary) -> LedgerEntry:
|
||||
return LedgerEntry.new().configure(
|
||||
StringName(data.get(&"transaction_id", data.get("transaction_id", ""))),
|
||||
int(data.get(&"absolute_minute", data.get("absolute_minute", -1))),
|
||||
StringName(data.get(&"debit_account", data.get("debit_account", ""))),
|
||||
StringName(data.get(&"credit_account", data.get("credit_account", ""))),
|
||||
int(data.get(&"amount", data.get("amount", 0))),
|
||||
StringName(data.get(&"category", data.get("category", ""))),
|
||||
StringName(data.get(&"source_entity_id", data.get("source_entity_id", ""))),
|
||||
StringName(data.get(
|
||||
&"related_transaction_id",
|
||||
data.get("related_transaction_id", "")
|
||||
)),
|
||||
StringName(data.get(&"memo_key", data.get("memo_key", "")))
|
||||
)
|
||||
@@ -0,0 +1 @@
|
||||
uid://rrovqycon673
|
||||
@@ -0,0 +1,68 @@
|
||||
class_name EventAction
|
||||
extends RefCounted
|
||||
|
||||
enum Type {
|
||||
SET_FLAG,
|
||||
ADD_RELATIONSHIP_VALUE,
|
||||
DAMAGE_SHIELD,
|
||||
ADD_LEDGER_ENTRY,
|
||||
ADD_OR_REMOVE_ITEM,
|
||||
CREATE_TASK,
|
||||
CREATE_INCIDENT,
|
||||
SET_FACILITY_STATE,
|
||||
TELEPORT_ENTITY_FOR_RECOVERY,
|
||||
SCHEDULE_EVENT,
|
||||
SET_ROUTE_LOCK,
|
||||
}
|
||||
|
||||
var action_id: StringName = &""
|
||||
var type: Type = Type.SET_FLAG
|
||||
var payload: Dictionary = {}
|
||||
|
||||
|
||||
func configure(
|
||||
p_action_id: StringName,
|
||||
p_type: Variant,
|
||||
p_payload: Dictionary = {}
|
||||
) -> EventAction:
|
||||
action_id = p_action_id
|
||||
type = _type_from_variant(p_type)
|
||||
payload = p_payload.duplicate(true)
|
||||
return self
|
||||
|
||||
|
||||
func is_valid() -> bool:
|
||||
return not action_id.is_empty() and type >= Type.SET_FLAG and type <= Type.SET_ROUTE_LOCK
|
||||
|
||||
|
||||
func create_snapshot() -> Dictionary:
|
||||
return {
|
||||
&"action_id": String(action_id),
|
||||
&"type": Type.keys()[type],
|
||||
&"payload": payload.duplicate(true),
|
||||
}
|
||||
|
||||
|
||||
static func from_variant(raw_value: Variant) -> EventAction:
|
||||
if raw_value is EventAction:
|
||||
return raw_value
|
||||
if not raw_value is Dictionary:
|
||||
return null
|
||||
return EventAction.new().configure(
|
||||
StringName(_value(raw_value, &"action_id", &"")),
|
||||
_value(raw_value, &"type", "SET_FLAG"),
|
||||
_value(raw_value, &"payload", {}) if _value(raw_value, &"payload", {}) is Dictionary else {}
|
||||
)
|
||||
|
||||
|
||||
static func _type_from_variant(raw_value: Variant) -> Type:
|
||||
if raw_value is int:
|
||||
return clampi(int(raw_value), Type.SET_FLAG, Type.SET_ROUTE_LOCK) as Type
|
||||
var index := Type.keys().find(String(raw_value).to_upper())
|
||||
return (index if index >= 0 else Type.SET_FLAG) as Type
|
||||
|
||||
|
||||
static func _value(source: Dictionary, key: StringName, fallback: Variant) -> Variant:
|
||||
if source.has(key):
|
||||
return source[key]
|
||||
return source.get(String(key), fallback)
|
||||
@@ -0,0 +1 @@
|
||||
uid://5wriphjpijed
|
||||
@@ -0,0 +1,105 @@
|
||||
class_name EventChoice
|
||||
extends RefCounted
|
||||
|
||||
const EventConditionType = preload("res://scripts/events/event_condition.gd")
|
||||
const EventActionType = preload("res://scripts/events/event_action.gd")
|
||||
|
||||
var choice_id: StringName = &""
|
||||
var text_key: StringName = &""
|
||||
var conditions: Array[EventCondition] = []
|
||||
var cost: Dictionary = {}
|
||||
var effect_preview: Dictionary = {}
|
||||
var actions: Array[EventAction] = []
|
||||
|
||||
|
||||
func configure(
|
||||
p_choice_id: StringName,
|
||||
p_text_key: StringName,
|
||||
p_conditions: Array = [],
|
||||
p_cost: Dictionary = {},
|
||||
p_effect_preview: Dictionary = {},
|
||||
p_actions: Array = []
|
||||
) -> EventChoice:
|
||||
choice_id = p_choice_id
|
||||
text_key = p_text_key
|
||||
conditions.clear()
|
||||
for raw_condition: Variant in p_conditions:
|
||||
var condition := EventConditionType.from_variant(raw_condition)
|
||||
if condition != null:
|
||||
conditions.append(condition)
|
||||
cost = p_cost.duplicate(true)
|
||||
effect_preview = p_effect_preview.duplicate(true)
|
||||
actions.clear()
|
||||
for raw_action: Variant in p_actions:
|
||||
var action := EventActionType.from_variant(raw_action)
|
||||
if action != null:
|
||||
actions.append(action)
|
||||
return self
|
||||
|
||||
|
||||
func is_valid() -> bool:
|
||||
if choice_id.is_empty() or text_key.is_empty():
|
||||
return false
|
||||
for condition: EventCondition in conditions:
|
||||
if condition == null or not condition.is_valid():
|
||||
return false
|
||||
for action: EventAction in actions:
|
||||
if action == null or not action.is_valid():
|
||||
return false
|
||||
for raw_key: Variant in cost:
|
||||
if StringName(raw_key).is_empty() or int(cost[raw_key]) < 0:
|
||||
return false
|
||||
return true
|
||||
|
||||
|
||||
func conditions_match(context: Dictionary, callbacks: Dictionary = {}) -> bool:
|
||||
for condition: EventCondition in conditions:
|
||||
if not condition.evaluate(context, callbacks):
|
||||
return false
|
||||
return true
|
||||
|
||||
|
||||
func create_snapshot() -> Dictionary:
|
||||
var condition_snapshots: Array[Dictionary] = []
|
||||
var action_snapshots: Array[Dictionary] = []
|
||||
for condition: EventCondition in conditions:
|
||||
condition_snapshots.append(condition.create_snapshot())
|
||||
for action: EventAction in actions:
|
||||
action_snapshots.append(action.create_snapshot())
|
||||
return {
|
||||
&"choice_id": String(choice_id),
|
||||
&"text_key": String(text_key),
|
||||
&"conditions": condition_snapshots,
|
||||
&"cost": cost.duplicate(true),
|
||||
&"effect_preview": effect_preview.duplicate(true),
|
||||
&"actions": action_snapshots,
|
||||
}
|
||||
|
||||
|
||||
static func from_variant(raw_value: Variant) -> EventChoice:
|
||||
if raw_value is EventChoice:
|
||||
return raw_value
|
||||
if not raw_value is Dictionary:
|
||||
return null
|
||||
return EventChoice.new().configure(
|
||||
StringName(_value(raw_value, &"choice_id", &"")),
|
||||
StringName(_value(raw_value, &"text_key", &"")),
|
||||
_array(_value(raw_value, &"conditions", [])),
|
||||
_dictionary(_value(raw_value, &"cost", {})),
|
||||
_dictionary(_value(raw_value, &"effect_preview", {})),
|
||||
_array(_value(raw_value, &"actions", []))
|
||||
)
|
||||
|
||||
|
||||
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 _array(value: Variant) -> Array:
|
||||
return value if value is Array else []
|
||||
|
||||
|
||||
static func _dictionary(value: Variant) -> Dictionary:
|
||||
return value if value is Dictionary else {}
|
||||
@@ -0,0 +1 @@
|
||||
uid://d0r5rxpsaqgd2
|
||||
@@ -0,0 +1,214 @@
|
||||
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
|
||||
@@ -0,0 +1 @@
|
||||
uid://6enckempkxxo
|
||||
@@ -0,0 +1,265 @@
|
||||
class_name EventDefinition
|
||||
extends RefCounted
|
||||
|
||||
const EventConditionType = preload("res://scripts/events/event_condition.gd")
|
||||
const EventActionType = preload("res://scripts/events/event_action.gd")
|
||||
const EventChoiceType = preload("res://scripts/events/event_choice.gd")
|
||||
|
||||
enum TransactionPolicy {
|
||||
ROLLBACK_ALL,
|
||||
SKIP_FAILED,
|
||||
BRANCH_FAILURE,
|
||||
}
|
||||
|
||||
enum OnceScope {
|
||||
NONE,
|
||||
PER_DAY,
|
||||
PER_ACTOR_PAIR,
|
||||
PER_SAVE,
|
||||
}
|
||||
|
||||
enum MissingActorPolicy {
|
||||
CANCEL,
|
||||
REPLACE_WITH_ROLE,
|
||||
CONTINUE_WITHOUT,
|
||||
BRANCH_FAILURE,
|
||||
}
|
||||
|
||||
enum ContentRating {
|
||||
GENERAL,
|
||||
MATURE,
|
||||
RESTRICTED,
|
||||
}
|
||||
|
||||
enum FilterBehavior {
|
||||
NONE,
|
||||
FADE,
|
||||
REPLACE,
|
||||
DISABLE,
|
||||
}
|
||||
|
||||
var event_id: StringName = &""
|
||||
var category: StringName = &"FLAVOR"
|
||||
var priority := 0
|
||||
var conditions: Array[EventCondition] = []
|
||||
var participant_roles: Array[StringName] = []
|
||||
var location_rule: Dictionary = {}
|
||||
var start_window: Array[int] = []
|
||||
var expires_after := 0
|
||||
var foreground := true
|
||||
var interrupt_policy: StringName = &"NORMAL"
|
||||
var choices: Array[EventChoice] = []
|
||||
var automatic_actions: Array[EventAction] = []
|
||||
var fallback_actions: Array[EventAction] = []
|
||||
var failure_actions: Array[EventAction] = []
|
||||
var replacement_event_id: StringName = &""
|
||||
var once_scope: OnceScope = OnceScope.NONE
|
||||
var transaction_policy: TransactionPolicy = TransactionPolicy.ROLLBACK_ALL
|
||||
var missing_actor_policy: MissingActorPolicy = MissingActorPolicy.CANCEL
|
||||
var content_rating: ContentRating = ContentRating.GENERAL
|
||||
var content_tags: Array[StringName] = []
|
||||
var filter_behavior: FilterBehavior = FilterBehavior.NONE
|
||||
|
||||
|
||||
func configure(p_event_id: StringName, options: Dictionary = {}) -> EventDefinition:
|
||||
event_id = p_event_id
|
||||
category = StringName(_value(options, &"category", &"FLAVOR"))
|
||||
priority = int(_value(options, &"priority", 0))
|
||||
conditions = _conditions(_value(options, &"conditions", []))
|
||||
participant_roles = _sorted_unique_names(_value(options, &"participants", []))
|
||||
location_rule = _dictionary(_value(options, &"location_rule", {})).duplicate(true)
|
||||
start_window = _integer_array(_value(options, &"start_window", []))
|
||||
expires_after = maxi(int(_value(options, &"expires_after", 0)), 0)
|
||||
foreground = bool(_value(options, &"foreground", true))
|
||||
interrupt_policy = StringName(_value(options, &"interrupt_policy", &"NORMAL"))
|
||||
choices = _choices(_value(options, &"choices", []))
|
||||
automatic_actions = _actions(_value(options, &"automatic_actions", []))
|
||||
fallback_actions = _actions(_value(options, &"fallback_actions", []))
|
||||
failure_actions = _actions(_value(options, &"failure_actions", []))
|
||||
replacement_event_id = StringName(_value(options, &"replacement_event_id", &""))
|
||||
once_scope = _enum_value(OnceScope.keys(), _value(options, &"once_scope", OnceScope.NONE), OnceScope.NONE) as OnceScope
|
||||
transaction_policy = _enum_value(TransactionPolicy.keys(), _value(options, &"transaction_policy", TransactionPolicy.ROLLBACK_ALL), TransactionPolicy.ROLLBACK_ALL) as TransactionPolicy
|
||||
missing_actor_policy = _enum_value(MissingActorPolicy.keys(), _value(options, &"missing_actor_policy", MissingActorPolicy.CANCEL), MissingActorPolicy.CANCEL) as MissingActorPolicy
|
||||
content_rating = _enum_value(ContentRating.keys(), _value(options, &"content_rating", ContentRating.GENERAL), ContentRating.GENERAL) as ContentRating
|
||||
content_tags = _sorted_unique_names(_value(options, &"content_tags", []))
|
||||
filter_behavior = _enum_value(FilterBehavior.keys(), _value(options, &"filter_behavior", FilterBehavior.NONE), FilterBehavior.NONE) as FilterBehavior
|
||||
return self
|
||||
|
||||
|
||||
func is_valid() -> bool:
|
||||
if event_id.is_empty() or category.is_empty():
|
||||
return false
|
||||
if filter_behavior == FilterBehavior.REPLACE and replacement_event_id.is_empty():
|
||||
return false
|
||||
if replacement_event_id == event_id:
|
||||
return false
|
||||
if start_window.size() not in [0, 2]:
|
||||
return false
|
||||
for condition: EventCondition in conditions:
|
||||
if condition == null or not condition.is_valid():
|
||||
return false
|
||||
var choice_ids: Dictionary = {}
|
||||
for choice: EventChoice in choices:
|
||||
if choice == null or not choice.is_valid() or choice_ids.has(choice.choice_id):
|
||||
return false
|
||||
choice_ids[choice.choice_id] = true
|
||||
for action_list: Array in [automatic_actions, fallback_actions, failure_actions]:
|
||||
for action: EventAction in action_list:
|
||||
if action == null or not action.is_valid():
|
||||
return false
|
||||
return true
|
||||
|
||||
|
||||
func get_choice(choice_id: StringName) -> EventChoice:
|
||||
for choice: EventChoice in choices:
|
||||
if choice.choice_id == choice_id:
|
||||
return choice
|
||||
return null
|
||||
|
||||
|
||||
func conditions_match(context: Dictionary, callbacks: Dictionary = {}) -> bool:
|
||||
for condition: EventCondition in conditions:
|
||||
if not condition.evaluate(context, callbacks):
|
||||
return false
|
||||
return true
|
||||
|
||||
|
||||
func create_snapshot() -> Dictionary:
|
||||
return {
|
||||
&"event_id": String(event_id),
|
||||
&"category": String(category),
|
||||
&"priority": priority,
|
||||
&"conditions": _snapshots(conditions),
|
||||
&"participants": _names_to_strings(participant_roles),
|
||||
&"location_rule": location_rule.duplicate(true),
|
||||
&"start_window": start_window.duplicate(),
|
||||
&"expires_after": expires_after,
|
||||
&"foreground": foreground,
|
||||
&"interrupt_policy": String(interrupt_policy),
|
||||
&"choices": _snapshots(choices),
|
||||
&"automatic_actions": _snapshots(automatic_actions),
|
||||
&"fallback_actions": _snapshots(fallback_actions),
|
||||
&"failure_actions": _snapshots(failure_actions),
|
||||
&"replacement_event_id": String(replacement_event_id),
|
||||
&"once_scope": OnceScope.keys()[once_scope],
|
||||
&"transaction_policy": TransactionPolicy.keys()[transaction_policy],
|
||||
&"missing_actor_policy": MissingActorPolicy.keys()[missing_actor_policy],
|
||||
&"content_rating": ContentRating.keys()[content_rating],
|
||||
&"content_tags": _names_to_strings(content_tags),
|
||||
&"filter_behavior": FilterBehavior.keys()[filter_behavior],
|
||||
}
|
||||
|
||||
|
||||
static func from_variant(raw_value: Variant) -> EventDefinition:
|
||||
if raw_value is EventDefinition:
|
||||
return raw_value
|
||||
if not raw_value is Dictionary:
|
||||
return null
|
||||
# Serialized definitions are authoritative data, not a user-facing settings
|
||||
# form. Unknown enum names and out-of-range integer values must fail closed
|
||||
# instead of silently selecting a less restrictive fallback.
|
||||
var enum_fields := [
|
||||
[&"once_scope", OnceScope.keys()],
|
||||
[&"transaction_policy", TransactionPolicy.keys()],
|
||||
[&"missing_actor_policy", MissingActorPolicy.keys()],
|
||||
[&"content_rating", ContentRating.keys()],
|
||||
[&"filter_behavior", FilterBehavior.keys()],
|
||||
]
|
||||
for field: Array in enum_fields:
|
||||
var key: StringName = field[0]
|
||||
if raw_value.has(key) or raw_value.has(String(key)):
|
||||
if _strict_enum_value(field[1], _value(raw_value, key, null)) < 0:
|
||||
return null
|
||||
return EventDefinition.new().configure(
|
||||
StringName(_value(raw_value, &"event_id", &"")),
|
||||
raw_value
|
||||
)
|
||||
|
||||
|
||||
static func _conditions(raw_value: Variant) -> Array[EventCondition]:
|
||||
var result: Array[EventCondition] = []
|
||||
if raw_value is Array:
|
||||
for raw_entry: Variant in raw_value:
|
||||
var entry := EventConditionType.from_variant(raw_entry)
|
||||
if entry != null:
|
||||
result.append(entry)
|
||||
return result
|
||||
|
||||
|
||||
static func _choices(raw_value: Variant) -> Array[EventChoice]:
|
||||
var result: Array[EventChoice] = []
|
||||
if raw_value is Array:
|
||||
for raw_entry: Variant in raw_value:
|
||||
var entry := EventChoiceType.from_variant(raw_entry)
|
||||
if entry != null:
|
||||
result.append(entry)
|
||||
return result
|
||||
|
||||
|
||||
static func _actions(raw_value: Variant) -> Array[EventAction]:
|
||||
var result: Array[EventAction] = []
|
||||
if raw_value is Array:
|
||||
for raw_entry: Variant in raw_value:
|
||||
var entry := EventActionType.from_variant(raw_entry)
|
||||
if entry != null:
|
||||
result.append(entry)
|
||||
return result
|
||||
|
||||
|
||||
static func _snapshots(values: Array) -> Array[Dictionary]:
|
||||
var result: Array[Dictionary] = []
|
||||
for value: Variant in values:
|
||||
result.append(value.create_snapshot())
|
||||
return result
|
||||
|
||||
|
||||
static func _sorted_unique_names(raw_value: Variant) -> Array[StringName]:
|
||||
var result: Array[StringName] = []
|
||||
if raw_value is Array:
|
||||
for raw_entry: Variant in raw_value:
|
||||
var entry := StringName(raw_entry)
|
||||
if not entry.is_empty() and not result.has(entry):
|
||||
result.append(entry)
|
||||
result.sort_custom(func(a: StringName, b: StringName) -> bool: return String(a) < String(b))
|
||||
return result
|
||||
|
||||
|
||||
static func _integer_array(raw_value: Variant) -> Array[int]:
|
||||
var result: Array[int] = []
|
||||
if raw_value is Array:
|
||||
for raw_entry: Variant in raw_value:
|
||||
result.append(int(raw_entry))
|
||||
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 _enum_value(keys: Array, raw_value: Variant, fallback: int) -> int:
|
||||
if raw_value is int:
|
||||
return clampi(int(raw_value), 0, keys.size() - 1)
|
||||
var index := keys.find(String(raw_value).to_upper())
|
||||
return index if index >= 0 else fallback
|
||||
|
||||
|
||||
static func _strict_enum_value(keys: Array, raw_value: Variant) -> int:
|
||||
if raw_value is int:
|
||||
var numeric := int(raw_value)
|
||||
return numeric if numeric >= 0 and numeric < keys.size() else -1
|
||||
if raw_value is String or raw_value is StringName:
|
||||
return keys.find(String(raw_value).to_upper())
|
||||
return -1
|
||||
|
||||
|
||||
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 _dictionary(value: Variant) -> Dictionary:
|
||||
return value if value is Dictionary else {}
|
||||
@@ -0,0 +1 @@
|
||||
uid://cvyev0x3oeq1f
|
||||
@@ -0,0 +1,151 @@
|
||||
class_name EventInstance
|
||||
extends RefCounted
|
||||
|
||||
enum State {
|
||||
QUEUED,
|
||||
RUNNING,
|
||||
WAITING_CHOICE,
|
||||
COMPLETED,
|
||||
CANCELLED,
|
||||
EXPIRED,
|
||||
ERROR,
|
||||
INTERRUPTED,
|
||||
}
|
||||
|
||||
var event_instance_id: StringName = &""
|
||||
var source_event_id: StringName = &""
|
||||
var resolved_event_id: StringName = &""
|
||||
var actor_ids: Array[StringName] = []
|
||||
var priority := 0
|
||||
var created_at := 0
|
||||
var expires_at := -1
|
||||
var foreground := true
|
||||
var state: State = State.QUEUED
|
||||
var deduplication_key: StringName = &""
|
||||
var context: Dictionary = {}
|
||||
var presentation_mode: StringName = &"FULL"
|
||||
var choice_instance_id: StringName = &""
|
||||
var selected_choice_id: StringName = &""
|
||||
var reserved_costs: Dictionary = {}
|
||||
var failure_reason := ""
|
||||
var retry_count := 0
|
||||
|
||||
|
||||
func configure(
|
||||
p_instance_id: StringName,
|
||||
p_source_event_id: StringName,
|
||||
p_resolved_event_id: StringName,
|
||||
p_actor_ids: Array,
|
||||
p_priority: int,
|
||||
p_created_at: int,
|
||||
p_expires_at: int,
|
||||
p_foreground: bool,
|
||||
p_deduplication_key: StringName,
|
||||
p_context: Dictionary = {},
|
||||
presentation: StringName = &"FULL"
|
||||
) -> EventInstance:
|
||||
event_instance_id = p_instance_id
|
||||
source_event_id = p_source_event_id
|
||||
resolved_event_id = p_resolved_event_id
|
||||
actor_ids = _sorted_unique_names(p_actor_ids)
|
||||
priority = p_priority
|
||||
created_at = maxi(p_created_at, 0)
|
||||
expires_at = p_expires_at
|
||||
foreground = p_foreground
|
||||
deduplication_key = p_deduplication_key
|
||||
context = p_context.duplicate(true)
|
||||
presentation_mode = presentation
|
||||
return self
|
||||
|
||||
|
||||
func is_valid() -> bool:
|
||||
if event_instance_id.is_empty() or source_event_id.is_empty() or resolved_event_id.is_empty():
|
||||
return false
|
||||
if created_at < 0 or (expires_at >= 0 and expires_at < created_at):
|
||||
return false
|
||||
if state < State.QUEUED or state > State.INTERRUPTED:
|
||||
return false
|
||||
return true
|
||||
|
||||
|
||||
func is_terminal() -> bool:
|
||||
return state in [State.COMPLETED, State.CANCELLED, State.EXPIRED, State.ERROR]
|
||||
|
||||
|
||||
func create_snapshot() -> Dictionary:
|
||||
return {
|
||||
&"event_instance_id": String(event_instance_id),
|
||||
&"source_event_id": String(source_event_id),
|
||||
&"resolved_event_id": String(resolved_event_id),
|
||||
&"actor_ids": _names_to_strings(actor_ids),
|
||||
&"priority": priority,
|
||||
&"created_at": created_at,
|
||||
&"expires_at": expires_at,
|
||||
&"foreground": foreground,
|
||||
&"state": State.keys()[state],
|
||||
&"deduplication_key": String(deduplication_key),
|
||||
&"context": context.duplicate(true),
|
||||
&"presentation_mode": String(presentation_mode),
|
||||
&"choice_instance_id": String(choice_instance_id),
|
||||
&"selected_choice_id": String(selected_choice_id),
|
||||
&"reserved_costs": reserved_costs.duplicate(true),
|
||||
&"failure_reason": failure_reason,
|
||||
&"retry_count": retry_count,
|
||||
}
|
||||
|
||||
|
||||
static func from_snapshot(snapshot: Dictionary) -> EventInstance:
|
||||
var instance := EventInstance.new().configure(
|
||||
StringName(_value(snapshot, &"event_instance_id", &"")),
|
||||
StringName(_value(snapshot, &"source_event_id", &"")),
|
||||
StringName(_value(snapshot, &"resolved_event_id", &"")),
|
||||
_array(_value(snapshot, &"actor_ids", [])),
|
||||
int(_value(snapshot, &"priority", 0)),
|
||||
int(_value(snapshot, &"created_at", 0)),
|
||||
int(_value(snapshot, &"expires_at", -1)),
|
||||
bool(_value(snapshot, &"foreground", true)),
|
||||
StringName(_value(snapshot, &"deduplication_key", &"")),
|
||||
_dictionary(_value(snapshot, &"context", {})),
|
||||
StringName(_value(snapshot, &"presentation_mode", &"FULL"))
|
||||
)
|
||||
var state_name := String(_value(snapshot, &"state", "QUEUED")).to_upper()
|
||||
var state_index := State.keys().find(state_name)
|
||||
instance.state = (state_index if state_index >= 0 else State.ERROR) as State
|
||||
instance.choice_instance_id = StringName(_value(snapshot, &"choice_instance_id", &""))
|
||||
instance.selected_choice_id = StringName(_value(snapshot, &"selected_choice_id", &""))
|
||||
instance.reserved_costs = _dictionary(_value(snapshot, &"reserved_costs", {})).duplicate(true)
|
||||
instance.failure_reason = String(_value(snapshot, &"failure_reason", ""))
|
||||
instance.retry_count = maxi(int(_value(snapshot, &"retry_count", 0)), 0)
|
||||
return instance
|
||||
|
||||
|
||||
static func _sorted_unique_names(raw_value: Variant) -> Array[StringName]:
|
||||
var result: Array[StringName] = []
|
||||
if raw_value is Array:
|
||||
for raw_entry: Variant in raw_value:
|
||||
var entry := StringName(raw_entry)
|
||||
if not entry.is_empty() and not result.has(entry):
|
||||
result.append(entry)
|
||||
result.sort_custom(func(a: StringName, b: StringName) -> bool: return String(a) < String(b))
|
||||
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 _value(source: Dictionary, key: StringName, fallback: Variant) -> Variant:
|
||||
if source.has(key):
|
||||
return source[key]
|
||||
return source.get(String(key), fallback)
|
||||
|
||||
|
||||
static func _array(value: Variant) -> Array:
|
||||
return value if value is Array else []
|
||||
|
||||
|
||||
static func _dictionary(value: Variant) -> Dictionary:
|
||||
return value if value is Dictionary else {}
|
||||
@@ -0,0 +1 @@
|
||||
uid://c0xntlstpehhd
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1 @@
|
||||
uid://dyvxeffcwc22y
|
||||
@@ -0,0 +1,490 @@
|
||||
class_name FacilityInstance
|
||||
extends RefCounted
|
||||
|
||||
const ServiceInstanceType = preload("res://scripts/facility/service_instance.gd")
|
||||
|
||||
## Values are deliberately ordered by operational priority.
|
||||
enum State {
|
||||
BROKEN,
|
||||
NO_ACCESS,
|
||||
NO_POWER,
|
||||
NO_STAFF,
|
||||
CLOSED,
|
||||
DEGRADED,
|
||||
OPEN,
|
||||
}
|
||||
|
||||
const FULL_DAY_MINUTES := 1_440
|
||||
const DEFAULT_DURABILITY := 10_000
|
||||
const DEGRADED_DURABILITY_THRESHOLD := 3_000
|
||||
|
||||
var instance_id: StringName = &""
|
||||
var definition_id: StringName = &""
|
||||
var origin := Vector3i.ZERO
|
||||
var rotation := 0
|
||||
var occupied_cells: Array[Vector3i] = []
|
||||
var level := 1
|
||||
var state: State = State.OPEN
|
||||
var state_reasons: Array[StringName] = []
|
||||
var durability := DEFAULT_DURABILITY
|
||||
var local_inventory_id: StringName = &""
|
||||
var assigned_staff_by_shift: Dictionary = {}
|
||||
var active_staff_ids: Array[StringName] = []
|
||||
var queue_entries: Array[StringName] = []
|
||||
var active_services: Dictionary = {}
|
||||
var policy_current: Dictionary = {}
|
||||
var policy_pending: Dictionary = {}
|
||||
var policy_revision := 0
|
||||
var policy_pending_revision := 0
|
||||
var policy_pending_effective_at := -1
|
||||
var statistics_today: Dictionary = {}
|
||||
var lifetime_statistics: Dictionary = {}
|
||||
var created_at := 0
|
||||
|
||||
var capacity := 1
|
||||
var queue_capacity := 0
|
||||
var base_service_minutes := 1
|
||||
var base_quality := 0
|
||||
var base_price := 0
|
||||
var staff_min := 0
|
||||
var staff_max := 0
|
||||
var access_available := true
|
||||
var power_available := true
|
||||
var operator_open := true
|
||||
var opening_intervals: Array[Vector2i] = [Vector2i(0, FULL_DAY_MINUTES)]
|
||||
var degraded_reasons: Array[StringName] = []
|
||||
|
||||
|
||||
func configure(
|
||||
p_instance_id: StringName,
|
||||
p_definition_id: StringName,
|
||||
options: Dictionary = {}
|
||||
) -> FacilityInstance:
|
||||
instance_id = p_instance_id
|
||||
definition_id = p_definition_id
|
||||
origin = _vector3i(_value(options, &"origin", Vector3i.ZERO))
|
||||
rotation = _normalize_rotation(int(_value(options, &"rotation", 0)))
|
||||
occupied_cells = _cell_array(_value(options, &"occupied_cells", []))
|
||||
level = maxi(int(_value(options, &"level", 1)), 1)
|
||||
durability = clampi(int(_value(options, &"durability", DEFAULT_DURABILITY)), 0, DEFAULT_DURABILITY)
|
||||
local_inventory_id = StringName(_value(options, &"local_inventory_id", &""))
|
||||
capacity = maxi(int(_value(options, &"capacity", 1)), 1)
|
||||
queue_capacity = maxi(int(_value(options, &"queue_capacity", 0)), 0)
|
||||
base_service_minutes = maxi(int(_value(options, &"base_service_minutes", 1)), 1)
|
||||
base_quality = int(_value(options, &"base_quality", 0))
|
||||
base_price = maxi(int(_value(options, &"base_price", 0)), 0)
|
||||
staff_min = maxi(int(_value(options, &"staff_min", 0)), 0)
|
||||
staff_max = maxi(int(_value(options, &"staff_max", maxi(staff_min, 0))), staff_min)
|
||||
policy_current = _normalize_policy(_value(options, &"policy_current", {}))
|
||||
policy_revision = maxi(int(_value(options, &"policy_revision", 0)), 0)
|
||||
created_at = maxi(int(_value(options, &"created_at", 0)), 0)
|
||||
access_available = bool(_value(options, &"access_available", true))
|
||||
power_available = bool(_value(options, &"power_available", true))
|
||||
operator_open = bool(_value(options, &"operator_open", true))
|
||||
var intervals := _parse_intervals(_value(options, &"opening_intervals", []))
|
||||
if not intervals.is_empty():
|
||||
opening_intervals = intervals
|
||||
active_staff_ids = _sorted_unique_names(_value(options, &"active_staff_ids", []))
|
||||
degraded_reasons = _sorted_unique_names(_value(options, &"degraded_reasons", []))
|
||||
evaluate_state(created_at)
|
||||
return self
|
||||
|
||||
|
||||
func is_valid() -> bool:
|
||||
return not instance_id.is_empty() \
|
||||
and not definition_id.is_empty() \
|
||||
and capacity > 0 \
|
||||
and queue_capacity >= 0 \
|
||||
and base_service_minutes > 0 \
|
||||
and staff_min >= 0 \
|
||||
and staff_max >= staff_min \
|
||||
and _intervals_are_valid(opening_intervals)
|
||||
|
||||
|
||||
func set_opening_hours(open_minute_of_day: int, close_minute_of_day: int) -> bool:
|
||||
if open_minute_of_day < 0 or open_minute_of_day >= FULL_DAY_MINUTES:
|
||||
return false
|
||||
if close_minute_of_day < 0 or close_minute_of_day > FULL_DAY_MINUTES:
|
||||
return false
|
||||
if open_minute_of_day == close_minute_of_day:
|
||||
opening_intervals = [Vector2i(0, FULL_DAY_MINUTES)]
|
||||
elif open_minute_of_day < close_minute_of_day:
|
||||
opening_intervals = [Vector2i(open_minute_of_day, close_minute_of_day)]
|
||||
else:
|
||||
opening_intervals = []
|
||||
if close_minute_of_day > 0:
|
||||
opening_intervals.append(Vector2i(0, close_minute_of_day))
|
||||
if open_minute_of_day < FULL_DAY_MINUTES:
|
||||
opening_intervals.append(Vector2i(open_minute_of_day, FULL_DAY_MINUTES))
|
||||
return true
|
||||
|
||||
|
||||
func set_opening_intervals(intervals: Variant) -> bool:
|
||||
var parsed := _parse_intervals(intervals)
|
||||
if parsed.is_empty() or not _intervals_are_valid(parsed):
|
||||
return false
|
||||
opening_intervals = parsed
|
||||
return true
|
||||
|
||||
|
||||
func is_within_opening_hours(absolute_minute: int) -> bool:
|
||||
var minute_of_day := posmod(absolute_minute, FULL_DAY_MINUTES)
|
||||
for interval: Vector2i in opening_intervals:
|
||||
if minute_of_day >= interval.x and minute_of_day < interval.y:
|
||||
return true
|
||||
return false
|
||||
|
||||
|
||||
func set_operational_conditions(conditions: Dictionary, absolute_minute: int) -> bool:
|
||||
if _has_value(conditions, &"durability"):
|
||||
durability = clampi(int(_value(conditions, &"durability", durability)), 0, DEFAULT_DURABILITY)
|
||||
if _has_value(conditions, &"access_available"):
|
||||
access_available = bool(_value(conditions, &"access_available", access_available))
|
||||
if _has_value(conditions, &"power_available"):
|
||||
power_available = bool(_value(conditions, &"power_available", power_available))
|
||||
if _has_value(conditions, &"operator_open"):
|
||||
operator_open = bool(_value(conditions, &"operator_open", operator_open))
|
||||
if _has_value(conditions, &"active_staff_ids"):
|
||||
active_staff_ids = _sorted_unique_names(_value(conditions, &"active_staff_ids", []))
|
||||
if _has_value(conditions, &"degraded_reasons"):
|
||||
degraded_reasons = _sorted_unique_names(_value(conditions, &"degraded_reasons", []))
|
||||
return evaluate_state(absolute_minute)
|
||||
|
||||
|
||||
func evaluate_state(absolute_minute: int) -> bool:
|
||||
var next_reasons: Array[StringName] = []
|
||||
var next_state := State.OPEN
|
||||
if durability <= 0:
|
||||
next_reasons.append(&"BROKEN")
|
||||
next_state = State.BROKEN
|
||||
if not access_available:
|
||||
next_reasons.append(&"NO_ACCESS")
|
||||
if next_state > State.NO_ACCESS:
|
||||
next_state = State.NO_ACCESS
|
||||
if not power_available:
|
||||
next_reasons.append(&"NO_POWER")
|
||||
if next_state > State.NO_POWER:
|
||||
next_state = State.NO_POWER
|
||||
if active_staff_ids.size() < staff_min:
|
||||
next_reasons.append(&"NO_STAFF")
|
||||
if next_state > State.NO_STAFF:
|
||||
next_state = State.NO_STAFF
|
||||
if not operator_open:
|
||||
next_reasons.append(&"CLOSED_BY_OPERATOR")
|
||||
if next_state > State.CLOSED:
|
||||
next_state = State.CLOSED
|
||||
if not is_within_opening_hours(absolute_minute):
|
||||
next_reasons.append(&"OUTSIDE_OPENING_HOURS")
|
||||
if next_state > State.CLOSED:
|
||||
next_state = State.CLOSED
|
||||
if durability > 0 and durability < DEGRADED_DURABILITY_THRESHOLD:
|
||||
next_reasons.append(&"LOW_DURABILITY")
|
||||
if next_state > State.DEGRADED:
|
||||
next_state = State.DEGRADED
|
||||
for reason: StringName in degraded_reasons:
|
||||
if not next_reasons.has(reason):
|
||||
next_reasons.append(reason)
|
||||
if next_state > State.DEGRADED:
|
||||
next_state = State.DEGRADED
|
||||
var changed := state != next_state
|
||||
state = next_state
|
||||
state_reasons = next_reasons
|
||||
return changed
|
||||
|
||||
|
||||
func can_start_service(absolute_minute: int) -> bool:
|
||||
evaluate_state(absolute_minute)
|
||||
return state in [State.OPEN, State.DEGRADED] and available_slots() > 0
|
||||
|
||||
|
||||
func available_slots() -> int:
|
||||
return maxi(capacity - active_services.size(), 0)
|
||||
|
||||
|
||||
func has_queue_space() -> bool:
|
||||
return queue_entries.size() < queue_capacity
|
||||
|
||||
|
||||
func apply_policy_patch(patch: Dictionary) -> bool:
|
||||
var changed := false
|
||||
var keys: Array = patch.keys()
|
||||
keys.sort_custom(func(a: Variant, b: Variant) -> bool: return String(a) < String(b))
|
||||
for raw_key: Variant in keys:
|
||||
var key := StringName(raw_key)
|
||||
var value: Variant = patch[raw_key]
|
||||
var has_existing := _has_value(policy_current, key)
|
||||
var existing: Variant = _value(policy_current, key, null)
|
||||
if not has_existing or existing != value:
|
||||
policy_current.erase(String(key))
|
||||
policy_current[key] = _copy(value)
|
||||
changed = true
|
||||
if changed:
|
||||
policy_revision += 1
|
||||
return changed
|
||||
|
||||
|
||||
func schedule_policy_patch(patch: Dictionary, effective_at: int) -> bool:
|
||||
if effective_at < 0 or patch.is_empty():
|
||||
return false
|
||||
policy_pending = _normalize_policy(patch)
|
||||
policy_pending_effective_at = effective_at
|
||||
policy_pending_revision = policy_revision + 1
|
||||
return true
|
||||
|
||||
|
||||
func apply_pending_policy(absolute_minute: int) -> bool:
|
||||
if policy_pending.is_empty() or policy_pending_effective_at < 0:
|
||||
return false
|
||||
if absolute_minute < policy_pending_effective_at:
|
||||
return false
|
||||
var changed := apply_policy_patch(policy_pending)
|
||||
policy_pending.clear()
|
||||
policy_pending_effective_at = -1
|
||||
policy_pending_revision = 0
|
||||
return changed
|
||||
|
||||
|
||||
func create_snapshot() -> Dictionary:
|
||||
var saved_services: Array[Dictionary] = []
|
||||
var service_ids := _sorted_keys(active_services)
|
||||
for service_id: StringName in service_ids:
|
||||
var service: ServiceInstance = active_services[service_id]
|
||||
saved_services.append(service.create_snapshot())
|
||||
return {
|
||||
&"instance_id": String(instance_id),
|
||||
&"definition_id": String(definition_id),
|
||||
&"origin": [origin.x, origin.y, origin.z],
|
||||
&"rotation": rotation,
|
||||
&"occupied_cells": _serialize_cells(occupied_cells),
|
||||
&"level": level,
|
||||
&"state": State.keys()[state],
|
||||
&"state_reasons": _names_to_strings(state_reasons),
|
||||
&"durability": durability,
|
||||
&"local_inventory_id": String(local_inventory_id),
|
||||
&"assigned_staff_by_shift": assigned_staff_by_shift.duplicate(true),
|
||||
&"active_staff_ids": _names_to_strings(active_staff_ids),
|
||||
&"queue_entries": _names_to_strings(queue_entries),
|
||||
&"active_services": saved_services,
|
||||
&"policy_current": policy_current.duplicate(true),
|
||||
&"policy_pending": policy_pending.duplicate(true),
|
||||
&"policy_revision": policy_revision,
|
||||
&"policy_pending_revision": policy_pending_revision,
|
||||
&"policy_pending_effective_at": policy_pending_effective_at,
|
||||
&"statistics_today": statistics_today.duplicate(true),
|
||||
&"lifetime_statistics": lifetime_statistics.duplicate(true),
|
||||
&"created_at": created_at,
|
||||
&"capacity": capacity,
|
||||
&"queue_capacity": queue_capacity,
|
||||
&"base_service_minutes": base_service_minutes,
|
||||
&"base_quality": base_quality,
|
||||
&"base_price": base_price,
|
||||
&"staff_min": staff_min,
|
||||
&"staff_max": staff_max,
|
||||
&"access_available": access_available,
|
||||
&"power_available": power_available,
|
||||
&"operator_open": operator_open,
|
||||
&"opening_intervals": _serialize_intervals(opening_intervals),
|
||||
&"degraded_reasons": _names_to_strings(degraded_reasons),
|
||||
}
|
||||
|
||||
|
||||
static func from_snapshot(snapshot: Dictionary) -> FacilityInstance:
|
||||
if not _snapshot_shapes_are_valid(snapshot):
|
||||
return null
|
||||
var instance := FacilityInstance.new().configure(
|
||||
StringName(snapshot.get(&"instance_id", "")),
|
||||
StringName(snapshot.get(&"definition_id", "")),
|
||||
{
|
||||
&"origin": snapshot.get(&"origin", []),
|
||||
&"rotation": snapshot.get(&"rotation", 0),
|
||||
&"occupied_cells": snapshot.get(&"occupied_cells", []),
|
||||
&"level": snapshot.get(&"level", 1),
|
||||
&"durability": snapshot.get(&"durability", DEFAULT_DURABILITY),
|
||||
&"local_inventory_id": snapshot.get(&"local_inventory_id", ""),
|
||||
&"capacity": snapshot.get(&"capacity", 1),
|
||||
&"queue_capacity": snapshot.get(&"queue_capacity", 0),
|
||||
&"base_service_minutes": snapshot.get(&"base_service_minutes", 1),
|
||||
&"base_quality": snapshot.get(&"base_quality", 0),
|
||||
&"base_price": snapshot.get(&"base_price", 0),
|
||||
&"staff_min": snapshot.get(&"staff_min", 0),
|
||||
&"staff_max": snapshot.get(&"staff_max", 0),
|
||||
&"policy_current": snapshot.get(&"policy_current", {}),
|
||||
&"policy_revision": snapshot.get(&"policy_revision", 0),
|
||||
&"created_at": snapshot.get(&"created_at", 0),
|
||||
&"access_available": snapshot.get(&"access_available", true),
|
||||
&"power_available": snapshot.get(&"power_available", true),
|
||||
&"operator_open": snapshot.get(&"operator_open", true),
|
||||
&"opening_intervals": snapshot.get(&"opening_intervals", []),
|
||||
&"active_staff_ids": snapshot.get(&"active_staff_ids", []),
|
||||
&"degraded_reasons": snapshot.get(&"degraded_reasons", []),
|
||||
}
|
||||
)
|
||||
var state_index := State.keys().find(String(snapshot.get(&"state", "OPEN")))
|
||||
if state_index < 0:
|
||||
return null
|
||||
instance.state = state_index as State
|
||||
instance.state_reasons = _string_name_array(snapshot.get(&"state_reasons", []))
|
||||
instance.assigned_staff_by_shift = snapshot.get(&"assigned_staff_by_shift", {}).duplicate(true)
|
||||
instance.queue_entries = _string_name_array(snapshot.get(&"queue_entries", []))
|
||||
instance.policy_pending = _normalize_policy(snapshot.get(&"policy_pending", {}))
|
||||
instance.policy_pending_revision = maxi(int(snapshot.get(&"policy_pending_revision", 0)), 0)
|
||||
instance.policy_pending_effective_at = int(snapshot.get(&"policy_pending_effective_at", -1))
|
||||
instance.statistics_today = snapshot.get(&"statistics_today", {}).duplicate(true)
|
||||
instance.lifetime_statistics = snapshot.get(&"lifetime_statistics", {}).duplicate(true)
|
||||
var seen_services: Dictionary = {}
|
||||
for saved_service: Variant in snapshot.get(&"active_services", []):
|
||||
var service := ServiceInstanceType.from_snapshot(saved_service)
|
||||
if service == null or service.is_terminal() or service.facility_id != instance.instance_id:
|
||||
return null
|
||||
if seen_services.has(service.service_id):
|
||||
return null
|
||||
seen_services[service.service_id] = true
|
||||
instance.active_services[service.service_id] = service
|
||||
if not instance.is_valid() or instance.active_services.size() > instance.capacity:
|
||||
return null
|
||||
return instance
|
||||
|
||||
|
||||
static func _snapshot_shapes_are_valid(snapshot: Dictionary) -> bool:
|
||||
var array_fields := [
|
||||
&"origin", &"occupied_cells", &"state_reasons", &"active_staff_ids",
|
||||
&"queue_entries", &"active_services", &"opening_intervals", &"degraded_reasons",
|
||||
]
|
||||
for field: StringName in array_fields:
|
||||
if not snapshot.get(field, null) is Array:
|
||||
return false
|
||||
var dictionary_fields := [
|
||||
&"assigned_staff_by_shift", &"policy_current", &"policy_pending",
|
||||
&"statistics_today", &"lifetime_statistics",
|
||||
]
|
||||
for field: StringName in dictionary_fields:
|
||||
if not snapshot.get(field, null) is Dictionary:
|
||||
return false
|
||||
return true
|
||||
|
||||
|
||||
static func _value(source: Dictionary, key: StringName, fallback: Variant) -> Variant:
|
||||
return source.get(key, source.get(String(key), fallback))
|
||||
|
||||
|
||||
static func _has_value(source: Dictionary, key: StringName) -> bool:
|
||||
return source.has(key) or source.has(String(key))
|
||||
|
||||
|
||||
static func _copy(value: Variant) -> Variant:
|
||||
return value.duplicate(true) if value is Dictionary or value is Array else value
|
||||
|
||||
|
||||
static func _dictionary_copy(source: Variant) -> Dictionary:
|
||||
return source.duplicate(true) if source is Dictionary else {}
|
||||
|
||||
|
||||
static func _normalize_policy(source: Variant) -> Dictionary:
|
||||
var normalized: Dictionary = {}
|
||||
if not source is Dictionary:
|
||||
return normalized
|
||||
for raw_key: Variant in source:
|
||||
normalized[StringName(raw_key)] = _copy(source[raw_key])
|
||||
return normalized
|
||||
|
||||
|
||||
static func _vector3i(source: Variant) -> Vector3i:
|
||||
if source is Vector3i:
|
||||
return source
|
||||
if source is Array and source.size() == 3:
|
||||
return Vector3i(int(source[0]), int(source[1]), int(source[2]))
|
||||
return Vector3i.ZERO
|
||||
|
||||
|
||||
static func _cell_array(source: Variant) -> Array[Vector3i]:
|
||||
var cells: Array[Vector3i] = []
|
||||
if not source is Array:
|
||||
return cells
|
||||
for raw_cell: Variant in source:
|
||||
if raw_cell is Vector3i:
|
||||
cells.append(raw_cell)
|
||||
elif raw_cell is Array and raw_cell.size() == 3:
|
||||
cells.append(_vector3i(raw_cell))
|
||||
return cells
|
||||
|
||||
|
||||
static func _parse_intervals(source: Variant) -> Array[Vector2i]:
|
||||
var intervals: Array[Vector2i] = []
|
||||
if not source is Array:
|
||||
return intervals
|
||||
for raw_interval: Variant in source:
|
||||
if raw_interval is Vector2i:
|
||||
intervals.append(raw_interval)
|
||||
elif raw_interval is Array and raw_interval.size() == 2:
|
||||
intervals.append(Vector2i(int(raw_interval[0]), int(raw_interval[1])))
|
||||
intervals.sort_custom(func(a: Vector2i, b: Vector2i) -> bool:
|
||||
return a.x < b.x or (a.x == b.x and a.y < b.y)
|
||||
)
|
||||
return intervals
|
||||
|
||||
|
||||
static func _intervals_are_valid(intervals: Array[Vector2i]) -> bool:
|
||||
if intervals.is_empty():
|
||||
return false
|
||||
var previous_end := -1
|
||||
for interval: Vector2i in intervals:
|
||||
if interval.x < 0 or interval.y > FULL_DAY_MINUTES or interval.x >= interval.y:
|
||||
return false
|
||||
if interval.x < previous_end:
|
||||
return false
|
||||
previous_end = interval.y
|
||||
return true
|
||||
|
||||
|
||||
static func _sorted_unique_names(source: Variant) -> Array[StringName]:
|
||||
var values := _string_name_array(source)
|
||||
var seen: Dictionary = {}
|
||||
var unique: Array[StringName] = []
|
||||
for value: StringName in values:
|
||||
if not value.is_empty() and not seen.has(value):
|
||||
seen[value] = true
|
||||
unique.append(value)
|
||||
unique.sort_custom(func(a: StringName, b: StringName) -> bool: return String(a) < String(b))
|
||||
return unique
|
||||
|
||||
|
||||
static func _string_name_array(source: Variant) -> Array[StringName]:
|
||||
var values: Array[StringName] = []
|
||||
if source is Array:
|
||||
for value: Variant in source:
|
||||
values.append(StringName(value))
|
||||
return values
|
||||
|
||||
|
||||
static func _sorted_keys(source: Dictionary) -> Array[StringName]:
|
||||
var values: Array[StringName] = []
|
||||
for value: Variant in source.keys():
|
||||
values.append(StringName(value))
|
||||
values.sort_custom(func(a: StringName, b: StringName) -> bool: return String(a) < String(b))
|
||||
return values
|
||||
|
||||
|
||||
static func _serialize_cells(source: Array[Vector3i]) -> Array[Array]:
|
||||
var cells: Array[Array] = []
|
||||
for cell: Vector3i in source:
|
||||
cells.append([cell.x, cell.y, cell.z])
|
||||
return cells
|
||||
|
||||
|
||||
static func _serialize_intervals(source: Array[Vector2i]) -> Array[Array]:
|
||||
var intervals: Array[Array] = []
|
||||
for interval: Vector2i in source:
|
||||
intervals.append([interval.x, interval.y])
|
||||
return intervals
|
||||
|
||||
|
||||
static func _names_to_strings(source: Array[StringName]) -> Array[String]:
|
||||
var values: Array[String] = []
|
||||
for value: StringName in source:
|
||||
values.append(String(value))
|
||||
return values
|
||||
|
||||
|
||||
static func _normalize_rotation(value: int) -> int:
|
||||
var normalized := posmod(value, 360)
|
||||
return (normalized / 90) * 90
|
||||
@@ -0,0 +1 @@
|
||||
uid://dwac1ecwvokkl
|
||||
@@ -0,0 +1,945 @@
|
||||
class_name FacilityService
|
||||
extends RefCounted
|
||||
|
||||
signal instance_registered(instance: FacilityInstance)
|
||||
signal instance_unregistered(instance_id: StringName)
|
||||
signal facility_state_changed(instance_id: StringName, old_state: int, new_state: int, reasons: Array[StringName])
|
||||
signal request_enqueued(request: ServiceRequest)
|
||||
signal request_left_queue(request_id: StringName, reason: StringName)
|
||||
signal service_started(service: ServiceInstance)
|
||||
signal service_interrupted(service: ServiceInstance, reason: StringName)
|
||||
signal service_completed(service: ServiceInstance)
|
||||
signal service_failed(service: ServiceInstance, reason: StringName)
|
||||
|
||||
const FacilityInstanceType = preload("res://scripts/facility/facility_instance.gd")
|
||||
const ServiceRequestType = preload("res://scripts/facility/service_request.gd")
|
||||
const ServiceInstanceType = preload("res://scripts/facility/service_instance.gd")
|
||||
const CommandResultType = preload("res://scripts/core/command_result.gd")
|
||||
|
||||
const SNAPSHOT_VERSION := 1
|
||||
const STAFF_REPLACEMENT_GRACE_MINUTES := 5
|
||||
|
||||
const INTERRUPTION_PLAYER_CLOSED := &"PLAYER_CLOSED"
|
||||
const INTERRUPTION_NO_POWER := &"NO_POWER"
|
||||
const INTERRUPTION_MANA_STOP := &"MANA_STOP"
|
||||
const INTERRUPTION_STAFF_UNAVAILABLE := &"STAFF_UNAVAILABLE"
|
||||
const INTERRUPTION_EMERGENCY := &"EMERGENCY"
|
||||
const INTERRUPTION_FIRE := &"FIRE"
|
||||
const INTERRUPTION_RIOT := &"RIOT"
|
||||
const INTERRUPTION_GUEST_LEFT := &"GUEST_LEFT"
|
||||
const INTERRUPTION_BROKEN := &"BROKEN"
|
||||
|
||||
var current_minute := 0
|
||||
var last_error := ""
|
||||
|
||||
var _instances: Dictionary = {}
|
||||
var _requests: Dictionary = {}
|
||||
var _known_instance_ids: Dictionary = {}
|
||||
var _known_request_ids: Dictionary = {}
|
||||
var _known_service_ids: Dictionary = {}
|
||||
var _service_history: Dictionary = {}
|
||||
var _next_service_sequence := 1
|
||||
var _start_validator := Callable()
|
||||
var _completion_callback := Callable()
|
||||
|
||||
|
||||
func configure_callbacks(
|
||||
start_validator: Callable = Callable(),
|
||||
completion_callback: Callable = Callable()
|
||||
) -> FacilityService:
|
||||
_start_validator = start_validator
|
||||
_completion_callback = completion_callback
|
||||
return self
|
||||
|
||||
|
||||
func set_start_validator(callback: Callable) -> void:
|
||||
_start_validator = callback
|
||||
|
||||
|
||||
func set_completion_callback(callback: Callable) -> void:
|
||||
_completion_callback = callback
|
||||
|
||||
|
||||
func register_instance(instance: Variant) -> CommandResult:
|
||||
if instance == null or not instance is FacilityInstance:
|
||||
return _invalid("Facility instance is missing or has an invalid type.")
|
||||
var facility: FacilityInstance = instance
|
||||
if not facility.is_valid():
|
||||
return _invalid("Facility instance is invalid.", {&"instance_id": facility.instance_id})
|
||||
if _known_instance_ids.has(facility.instance_id):
|
||||
return _duplicate("Facility instance", facility.instance_id)
|
||||
if not facility.queue_entries.is_empty() or not facility.active_services.is_empty():
|
||||
return _invalid(
|
||||
"A new facility must be registered before it owns requests or services.",
|
||||
{&"instance_id": facility.instance_id}
|
||||
)
|
||||
_instances[facility.instance_id] = facility
|
||||
_known_instance_ids[facility.instance_id] = true
|
||||
facility.evaluate_state(current_minute)
|
||||
instance_registered.emit(facility)
|
||||
last_error = ""
|
||||
return _success({&"instance_id": facility.instance_id})
|
||||
|
||||
|
||||
func create_instance(configuration: Dictionary) -> CommandResult:
|
||||
var instance := FacilityInstanceType.new().configure(
|
||||
StringName(_value(configuration, &"instance_id", &"")),
|
||||
StringName(_value(configuration, &"definition_id", &"")),
|
||||
configuration
|
||||
)
|
||||
var result := register_instance(instance)
|
||||
if result.is_success():
|
||||
result.details[&"instance"] = instance
|
||||
return result
|
||||
|
||||
|
||||
func has_instance(instance_id: StringName) -> bool:
|
||||
return _instances.has(instance_id)
|
||||
|
||||
|
||||
func get_instance(instance_id: StringName) -> FacilityInstance:
|
||||
return _instances.get(instance_id) as FacilityInstance
|
||||
|
||||
|
||||
func get_facility(instance_id: StringName) -> FacilityInstance:
|
||||
return get_instance(instance_id)
|
||||
|
||||
|
||||
func get_instances() -> Array[FacilityInstance]:
|
||||
var result: Array[FacilityInstance] = []
|
||||
for instance_id: StringName in _sorted_keys(_instances):
|
||||
result.append(_instances[instance_id])
|
||||
return result
|
||||
|
||||
|
||||
func unregister_instance(instance_id: StringName) -> CommandResult:
|
||||
var instance := get_instance(instance_id)
|
||||
if instance == null:
|
||||
return _not_found("Facility instance", instance_id)
|
||||
if not instance.queue_entries.is_empty() or not instance.active_services.is_empty():
|
||||
return _invalid(
|
||||
"A facility with queued or active services cannot be unregistered.",
|
||||
{&"instance_id": instance_id}
|
||||
)
|
||||
_instances.erase(instance_id)
|
||||
instance_unregistered.emit(instance_id)
|
||||
last_error = ""
|
||||
return _success({&"instance_id": instance_id})
|
||||
|
||||
|
||||
func set_operational_status(
|
||||
instance_id: StringName,
|
||||
conditions: Dictionary,
|
||||
absolute_minute: int = -1
|
||||
) -> CommandResult:
|
||||
var instance := get_instance(instance_id)
|
||||
if instance == null:
|
||||
return _not_found("Facility instance", instance_id)
|
||||
var minute := _resolve_minute(absolute_minute)
|
||||
var old_state := instance.state
|
||||
instance.set_operational_conditions(conditions, minute)
|
||||
_emit_state_change(instance, old_state)
|
||||
last_error = ""
|
||||
return _success({
|
||||
&"instance_id": instance_id,
|
||||
&"state": FacilityInstanceType.State.keys()[instance.state],
|
||||
&"state_reasons": instance.state_reasons.duplicate(),
|
||||
})
|
||||
|
||||
|
||||
func set_opening_hours(
|
||||
instance_id: StringName,
|
||||
open_minute_of_day: int,
|
||||
close_minute_of_day: int,
|
||||
absolute_minute: int = -1
|
||||
) -> CommandResult:
|
||||
var instance := get_instance(instance_id)
|
||||
if instance == null:
|
||||
return _not_found("Facility instance", instance_id)
|
||||
if not instance.set_opening_hours(open_minute_of_day, close_minute_of_day):
|
||||
return _invalid("Opening hours must be valid minute-of-day values.")
|
||||
var old_state := instance.state
|
||||
instance.evaluate_state(_resolve_minute(absolute_minute))
|
||||
_emit_state_change(instance, old_state)
|
||||
last_error = ""
|
||||
return _success({&"instance_id": instance_id})
|
||||
|
||||
|
||||
func set_policy(
|
||||
instance_id: StringName,
|
||||
patch: Dictionary,
|
||||
effective_at: int = -1
|
||||
) -> CommandResult:
|
||||
var instance := get_instance(instance_id)
|
||||
if instance == null:
|
||||
return _not_found("Facility instance", instance_id)
|
||||
if patch.is_empty():
|
||||
return _invalid("Policy patch cannot be empty.")
|
||||
if effective_at >= 0 and effective_at > current_minute:
|
||||
instance.schedule_policy_patch(patch, effective_at)
|
||||
last_error = ""
|
||||
return _success({
|
||||
&"instance_id": instance_id,
|
||||
&"pending_revision": instance.policy_pending_revision,
|
||||
&"effective_at": effective_at,
|
||||
})
|
||||
var changed := instance.apply_policy_patch(patch)
|
||||
last_error = ""
|
||||
return _success({
|
||||
&"instance_id": instance_id,
|
||||
&"policy_revision": instance.policy_revision,
|
||||
&"changed": changed,
|
||||
})
|
||||
|
||||
|
||||
func create_request(
|
||||
request_id: StringName,
|
||||
facility_id: StringName,
|
||||
guest_ids: Array[StringName],
|
||||
service_type: StringName,
|
||||
created_at: int,
|
||||
options: Dictionary = {}
|
||||
) -> ServiceRequest:
|
||||
var instance := get_instance(facility_id)
|
||||
if instance == null:
|
||||
return null
|
||||
var quotation := options.duplicate(true)
|
||||
if not _has_value(quotation, &"quoted_price"):
|
||||
quotation[&"quoted_price"] = instance.base_price
|
||||
if not _has_value(quotation, &"expected_quality"):
|
||||
quotation[&"expected_quality"] = instance.base_quality
|
||||
if not _has_value(quotation, &"service_minutes"):
|
||||
quotation[&"service_minutes"] = instance.base_service_minutes
|
||||
quotation[&"policy_snapshot"] = instance.policy_current.duplicate(true)
|
||||
quotation[&"policy_revision"] = instance.policy_revision
|
||||
return ServiceRequestType.new().configure(
|
||||
request_id,
|
||||
facility_id,
|
||||
guest_ids,
|
||||
service_type,
|
||||
created_at,
|
||||
quotation
|
||||
)
|
||||
|
||||
|
||||
func enqueue_request(request: Variant, absolute_minute: int = -1) -> CommandResult:
|
||||
if request == null or not request is ServiceRequest:
|
||||
return _invalid("Service request is missing or has an invalid type.")
|
||||
var queued_request: ServiceRequest = request
|
||||
if not queued_request.is_valid():
|
||||
return _invalid("Service request is invalid.")
|
||||
if _known_request_ids.has(queued_request.request_id):
|
||||
return _duplicate("Service request", queued_request.request_id)
|
||||
var instance := get_instance(queued_request.facility_id)
|
||||
if instance == null:
|
||||
return _not_found("Facility instance", queued_request.facility_id)
|
||||
if not instance.has_queue_space():
|
||||
return _invalid(
|
||||
"Facility queue is full.",
|
||||
{&"instance_id": instance.instance_id, &"queue_capacity": instance.queue_capacity}
|
||||
)
|
||||
var minute := _resolve_minute(absolute_minute)
|
||||
queued_request.enqueued_at = minute
|
||||
_requests[queued_request.request_id] = queued_request
|
||||
_known_request_ids[queued_request.request_id] = true
|
||||
instance.queue_entries.append(queued_request.request_id)
|
||||
_sort_queue(instance)
|
||||
queued_request.expected_wait = _estimate_wait_before(instance, queued_request.request_id)
|
||||
_increment_stat(instance.statistics_today, &"requests_enqueued")
|
||||
_increment_stat(instance.lifetime_statistics, &"requests_enqueued")
|
||||
request_enqueued.emit(queued_request)
|
||||
last_error = ""
|
||||
return _success({
|
||||
&"request_id": queued_request.request_id,
|
||||
&"expected_wait": queued_request.expected_wait,
|
||||
&"queue_position": instance.queue_entries.find(queued_request.request_id),
|
||||
})
|
||||
|
||||
|
||||
func submit_request(request: Variant, absolute_minute: int = -1) -> CommandResult:
|
||||
return enqueue_request(request, absolute_minute)
|
||||
|
||||
|
||||
func get_request(request_id: StringName) -> ServiceRequest:
|
||||
return _requests.get(request_id) as ServiceRequest
|
||||
|
||||
|
||||
func get_queue(instance_id: StringName) -> Array[ServiceRequest]:
|
||||
var queued: Array[ServiceRequest] = []
|
||||
var instance := get_instance(instance_id)
|
||||
if instance == null:
|
||||
return queued
|
||||
for request_id: StringName in instance.queue_entries:
|
||||
var request := get_request(request_id)
|
||||
if request != null:
|
||||
queued.append(request)
|
||||
return queued
|
||||
|
||||
|
||||
func peek_next_request(instance_id: StringName) -> ServiceRequest:
|
||||
var instance := get_instance(instance_id)
|
||||
if instance == null or instance.queue_entries.is_empty():
|
||||
return null
|
||||
return get_request(instance.queue_entries[0])
|
||||
|
||||
|
||||
func leave_queue(
|
||||
instance_id: StringName,
|
||||
request_id: StringName,
|
||||
reason: StringName = &"GUEST_LEFT"
|
||||
) -> CommandResult:
|
||||
var instance := get_instance(instance_id)
|
||||
if instance == null:
|
||||
return _not_found("Facility instance", instance_id)
|
||||
var queue_index := instance.queue_entries.find(request_id)
|
||||
if queue_index < 0:
|
||||
return _not_found("Queued service request", request_id)
|
||||
instance.queue_entries.remove_at(queue_index)
|
||||
_requests.erase(request_id)
|
||||
_increment_stat(instance.statistics_today, &"queue_departures")
|
||||
_increment_stat(instance.lifetime_statistics, &"queue_departures")
|
||||
request_left_queue.emit(request_id, reason)
|
||||
last_error = ""
|
||||
return _success({&"request_id": request_id, &"reason": reason})
|
||||
|
||||
|
||||
func cancel_request(request_id: StringName, reason: StringName = &"GUEST_LEFT") -> CommandResult:
|
||||
for instance_id: StringName in _sorted_keys(_instances):
|
||||
var instance: FacilityInstance = _instances[instance_id]
|
||||
if instance.queue_entries.has(request_id):
|
||||
return leave_queue(instance_id, request_id, reason)
|
||||
return _not_found("Queued service request", request_id)
|
||||
|
||||
|
||||
func start_next_service(
|
||||
instance_id: StringName,
|
||||
absolute_minute: int = -1,
|
||||
validator: Callable = Callable()
|
||||
) -> CommandResult:
|
||||
var instance := get_instance(instance_id)
|
||||
if instance == null:
|
||||
return _not_found("Facility instance", instance_id)
|
||||
var minute := _resolve_minute(absolute_minute)
|
||||
instance.apply_pending_policy(minute)
|
||||
var old_state := instance.state
|
||||
instance.evaluate_state(minute)
|
||||
_emit_state_change(instance, old_state)
|
||||
if not instance.can_start_service(minute):
|
||||
return _invalid(
|
||||
"Facility cannot start a service in its current state.",
|
||||
{
|
||||
&"instance_id": instance_id,
|
||||
&"state": FacilityInstanceType.State.keys()[instance.state],
|
||||
&"reasons": instance.state_reasons.duplicate(),
|
||||
}
|
||||
)
|
||||
_cleanup_missing_queue_requests(instance)
|
||||
if instance.queue_entries.is_empty():
|
||||
return _not_found("Queued service request", &"")
|
||||
var request := get_request(instance.queue_entries[0])
|
||||
if request == null:
|
||||
return _invalid("The next queue entry is corrupt.")
|
||||
var effective_validator := validator if validator.is_valid() else _start_validator
|
||||
var callback_failure := _run_boundary_callback(
|
||||
effective_validator,
|
||||
[request, instance, minute],
|
||||
"Service start validation failed.",
|
||||
CommandResultType.Code.VALIDATION_FAILED
|
||||
)
|
||||
if callback_failure != null:
|
||||
callback_failure.details[&"request_id"] = request.request_id
|
||||
callback_failure.details[&"instance_id"] = instance_id
|
||||
return callback_failure
|
||||
|
||||
var service_id := _next_service_id()
|
||||
var service := ServiceInstanceType.new().configure(
|
||||
service_id,
|
||||
request.request_id,
|
||||
instance_id,
|
||||
minute,
|
||||
request.service_minutes if request.service_minutes > 0 else instance.base_service_minutes,
|
||||
request.guest_ids,
|
||||
instance.active_staff_ids,
|
||||
{
|
||||
&"quality_snapshot": request.expected_quality,
|
||||
&"price_snapshot": request.quoted_price,
|
||||
&"policy_snapshot": request.policy_snapshot,
|
||||
&"inventory_reservations": request.reserved_inventory_ids,
|
||||
}
|
||||
)
|
||||
instance.queue_entries.remove_at(0)
|
||||
instance.active_services[service_id] = service
|
||||
_known_service_ids[service_id] = true
|
||||
_increment_stat(instance.statistics_today, &"services_started")
|
||||
_increment_stat(instance.lifetime_statistics, &"services_started")
|
||||
service_started.emit(service)
|
||||
last_error = ""
|
||||
return _success({
|
||||
&"instance_id": instance_id,
|
||||
&"request_id": request.request_id,
|
||||
&"service_id": service_id,
|
||||
&"slot": instance.capacity - instance.available_slots() - 1,
|
||||
})
|
||||
|
||||
|
||||
func fill_available_slots(
|
||||
instance_id: StringName,
|
||||
absolute_minute: int = -1,
|
||||
validator: Callable = Callable()
|
||||
) -> Array[CommandResult]:
|
||||
var results: Array[CommandResult] = []
|
||||
var instance := get_instance(instance_id)
|
||||
if instance == null:
|
||||
results.append(_not_found("Facility instance", instance_id))
|
||||
return results
|
||||
var minute := _resolve_minute(absolute_minute)
|
||||
while instance.available_slots() > 0 and not instance.queue_entries.is_empty():
|
||||
var result := start_next_service(instance_id, minute, validator)
|
||||
results.append(result)
|
||||
if not result.is_success():
|
||||
break
|
||||
return results
|
||||
|
||||
|
||||
func advance_one_minute(absolute_minute: int = -1) -> Array[CommandResult]:
|
||||
if absolute_minute < 0:
|
||||
current_minute += 1
|
||||
elif absolute_minute < current_minute:
|
||||
return [_invalid("FacilityService cannot advance backwards in time.")]
|
||||
else:
|
||||
current_minute = absolute_minute
|
||||
var results: Array[CommandResult] = []
|
||||
for instance_id: StringName in _sorted_keys(_instances):
|
||||
var instance: FacilityInstance = _instances[instance_id]
|
||||
instance.apply_pending_policy(current_minute)
|
||||
var old_state := instance.state
|
||||
instance.evaluate_state(current_minute)
|
||||
_emit_state_change(instance, old_state)
|
||||
var active_ids := _sorted_keys(instance.active_services)
|
||||
for service_id: StringName in active_ids:
|
||||
if not instance.active_services.has(service_id):
|
||||
continue
|
||||
var service: ServiceInstance = instance.active_services[service_id]
|
||||
var preparation := _prepare_service_for_tick(instance, service)
|
||||
if preparation != null:
|
||||
results.append(preparation)
|
||||
if not instance.active_services.has(service_id):
|
||||
continue
|
||||
if service.state != ServiceInstanceType.State.ACTIVE:
|
||||
continue
|
||||
var ready_to_complete := service.elapsed_minutes >= service.duration_minutes
|
||||
if not ready_to_complete:
|
||||
ready_to_complete = service.advance_one_minute()
|
||||
if ready_to_complete:
|
||||
results.append(_complete_service(instance, service))
|
||||
var starts := fill_available_slots(instance_id, current_minute)
|
||||
results.append_array(starts)
|
||||
last_error = ""
|
||||
return results
|
||||
|
||||
|
||||
func interrupt_service(
|
||||
service_id: StringName,
|
||||
reason: StringName,
|
||||
absolute_minute: int = -1
|
||||
) -> CommandResult:
|
||||
var located := _find_active_service(service_id)
|
||||
if located.is_empty():
|
||||
return _not_found("Active service", service_id)
|
||||
var instance: FacilityInstance = located[&"instance"]
|
||||
var service: ServiceInstance = located[&"service"]
|
||||
var minute := _resolve_minute(absolute_minute)
|
||||
match reason:
|
||||
INTERRUPTION_PLAYER_CLOSED:
|
||||
return _success({&"service_id": service_id, &"continues": true})
|
||||
INTERRUPTION_NO_POWER, INTERRUPTION_MANA_STOP:
|
||||
service.pause(INTERRUPTION_NO_POWER, minute)
|
||||
INTERRUPTION_STAFF_UNAVAILABLE:
|
||||
service.wait_for_staff(reason, minute, STAFF_REPLACEMENT_GRACE_MINUTES)
|
||||
INTERRUPTION_BROKEN, INTERRUPTION_EMERGENCY, INTERRUPTION_FIRE, INTERRUPTION_RIOT, INTERRUPTION_GUEST_LEFT:
|
||||
_fail_service(instance, service, reason, minute)
|
||||
return _success({&"service_id": service_id, &"failed": true, &"reason": reason})
|
||||
_:
|
||||
return _invalid("Unknown service interruption reason.", {&"reason": reason})
|
||||
service_interrupted.emit(service, reason)
|
||||
last_error = ""
|
||||
return _success({
|
||||
&"service_id": service_id,
|
||||
&"state": ServiceInstanceType.State.keys()[service.state],
|
||||
&"reason": reason,
|
||||
})
|
||||
|
||||
|
||||
func resume_service(service_id: StringName) -> CommandResult:
|
||||
var located := _find_active_service(service_id)
|
||||
if located.is_empty():
|
||||
return _not_found("Active service", service_id)
|
||||
var service: ServiceInstance = located[&"service"]
|
||||
if not service.resume():
|
||||
return _invalid("Only an interrupted service can resume.")
|
||||
last_error = ""
|
||||
return _success({&"service_id": service_id})
|
||||
|
||||
|
||||
func replace_service_staff(service_id: StringName, staff_ids: Array[StringName]) -> CommandResult:
|
||||
var located := _find_active_service(service_id)
|
||||
if located.is_empty():
|
||||
return _not_found("Active service", service_id)
|
||||
if staff_ids.is_empty():
|
||||
return _invalid("Replacement staff list cannot be empty.")
|
||||
var service: ServiceInstance = located[&"service"]
|
||||
service.staff_ids.assign(staff_ids)
|
||||
if service.state == ServiceInstanceType.State.WAITING_FOR_STAFF:
|
||||
service.resume()
|
||||
last_error = ""
|
||||
return _success({&"service_id": service_id})
|
||||
|
||||
|
||||
func get_service(service_id: StringName) -> ServiceInstance:
|
||||
var located := _find_active_service(service_id)
|
||||
if not located.is_empty():
|
||||
return located[&"service"] as ServiceInstance
|
||||
return _service_history.get(service_id) as ServiceInstance
|
||||
|
||||
|
||||
func get_active_services(instance_id: StringName) -> Array[ServiceInstance]:
|
||||
var services: Array[ServiceInstance] = []
|
||||
var instance := get_instance(instance_id)
|
||||
if instance == null:
|
||||
return services
|
||||
for service_id: StringName in _sorted_keys(instance.active_services):
|
||||
services.append(instance.active_services[service_id])
|
||||
return services
|
||||
|
||||
|
||||
func create_snapshot() -> Dictionary:
|
||||
var saved_instances: Array[Dictionary] = []
|
||||
for instance_id: StringName in _sorted_keys(_instances):
|
||||
var instance: FacilityInstance = _instances[instance_id]
|
||||
saved_instances.append(instance.create_snapshot())
|
||||
var saved_requests: Array[Dictionary] = []
|
||||
for request_id: StringName in _sorted_keys(_requests):
|
||||
var request: ServiceRequest = _requests[request_id]
|
||||
saved_requests.append(request.create_snapshot())
|
||||
var saved_history: Array[Dictionary] = []
|
||||
for service_id: StringName in _sorted_keys(_service_history):
|
||||
var service: ServiceInstance = _service_history[service_id]
|
||||
saved_history.append(service.create_snapshot())
|
||||
return {
|
||||
&"schema_version": SNAPSHOT_VERSION,
|
||||
&"current_minute": current_minute,
|
||||
&"next_service_sequence": _next_service_sequence,
|
||||
&"known_instance_ids": _keys_to_strings(_known_instance_ids),
|
||||
&"known_request_ids": _keys_to_strings(_known_request_ids),
|
||||
&"known_service_ids": _keys_to_strings(_known_service_ids),
|
||||
&"instances": saved_instances,
|
||||
&"requests": saved_requests,
|
||||
&"service_history": saved_history,
|
||||
}
|
||||
|
||||
|
||||
func restore_snapshot(snapshot: Dictionary) -> bool:
|
||||
if int(_value(snapshot, &"schema_version", 0)) != SNAPSHOT_VERSION:
|
||||
last_error = "Unsupported FacilityService snapshot version."
|
||||
return false
|
||||
var array_fields := [
|
||||
&"known_instance_ids", &"known_request_ids", &"known_service_ids",
|
||||
&"instances", &"requests", &"service_history",
|
||||
]
|
||||
for field: StringName in array_fields:
|
||||
if not _value(snapshot, field, null) is Array:
|
||||
last_error = "FacilityService snapshot field '%s' must be an Array." % field
|
||||
return false
|
||||
|
||||
var restored_instances: Dictionary = {}
|
||||
for saved_instance: Variant in _value(snapshot, &"instances", []):
|
||||
if not saved_instance is Dictionary:
|
||||
last_error = "Facility snapshot entry must be a Dictionary."
|
||||
return false
|
||||
var instance := FacilityInstanceType.from_snapshot(saved_instance)
|
||||
if instance == null or restored_instances.has(instance.instance_id):
|
||||
last_error = "Facility snapshot contains an invalid or duplicate instance."
|
||||
return false
|
||||
restored_instances[instance.instance_id] = instance
|
||||
|
||||
var restored_requests: Dictionary = {}
|
||||
for saved_request: Variant in _value(snapshot, &"requests", []):
|
||||
if not saved_request is Dictionary:
|
||||
last_error = "Request snapshot entry must be a Dictionary."
|
||||
return false
|
||||
var request := ServiceRequestType.from_snapshot(saved_request)
|
||||
if request == null or restored_requests.has(request.request_id):
|
||||
last_error = "Facility snapshot contains an invalid or duplicate request."
|
||||
return false
|
||||
restored_requests[request.request_id] = request
|
||||
|
||||
var restored_history: Dictionary = {}
|
||||
for saved_service: Variant in _value(snapshot, &"service_history", []):
|
||||
if not saved_service is Dictionary:
|
||||
last_error = "Service history entry must be a Dictionary."
|
||||
return false
|
||||
var service := ServiceInstanceType.from_snapshot(saved_service)
|
||||
if service == null or not service.is_terminal() or restored_history.has(service.service_id):
|
||||
last_error = "Facility snapshot contains invalid service history."
|
||||
return false
|
||||
restored_history[service.service_id] = service
|
||||
|
||||
var restored_known_instances: Variant = _id_set(_value(snapshot, &"known_instance_ids", []))
|
||||
var restored_known_requests: Variant = _id_set(_value(snapshot, &"known_request_ids", []))
|
||||
var restored_known_services: Variant = _id_set(_value(snapshot, &"known_service_ids", []))
|
||||
if restored_known_instances == null or restored_known_requests == null or restored_known_services == null:
|
||||
last_error = "Known ID lists contain an empty or duplicate ID."
|
||||
return false
|
||||
if not _validate_restored_graph(
|
||||
restored_instances,
|
||||
restored_requests,
|
||||
restored_history,
|
||||
restored_known_instances,
|
||||
restored_known_requests,
|
||||
restored_known_services
|
||||
):
|
||||
return false
|
||||
|
||||
var restored_next_sequence := int(_value(snapshot, &"next_service_sequence", 0))
|
||||
var restored_minute := int(_value(snapshot, &"current_minute", -1))
|
||||
if restored_next_sequence < 1 or restored_minute < 0:
|
||||
last_error = "FacilityService counters are invalid."
|
||||
return false
|
||||
|
||||
# Commit only after every object and cross-reference has validated.
|
||||
_instances = restored_instances
|
||||
_requests = restored_requests
|
||||
_service_history = restored_history
|
||||
_known_instance_ids = restored_known_instances
|
||||
_known_request_ids = restored_known_requests
|
||||
_known_service_ids = restored_known_services
|
||||
_next_service_sequence = restored_next_sequence
|
||||
current_minute = restored_minute
|
||||
last_error = ""
|
||||
return true
|
||||
|
||||
|
||||
func _validate_restored_graph(
|
||||
instances: Dictionary,
|
||||
requests: Dictionary,
|
||||
history: Dictionary,
|
||||
known_instances: Dictionary,
|
||||
known_requests: Dictionary,
|
||||
known_services: Dictionary
|
||||
) -> bool:
|
||||
var referenced_requests: Dictionary = {}
|
||||
var active_service_ids: Dictionary = {}
|
||||
for instance_id: StringName in _sorted_keys(instances):
|
||||
var instance: FacilityInstance = instances[instance_id]
|
||||
if not known_instances.has(instance_id):
|
||||
last_error = "Active facility ID is absent from known IDs."
|
||||
return false
|
||||
for request_id: StringName in instance.queue_entries:
|
||||
if referenced_requests.has(request_id) or not requests.has(request_id):
|
||||
last_error = "Queue contains a duplicate or missing request."
|
||||
return false
|
||||
var request: ServiceRequest = requests[request_id]
|
||||
if request.facility_id != instance_id:
|
||||
last_error = "Queued request targets another facility."
|
||||
return false
|
||||
referenced_requests[request_id] = true
|
||||
for service_id: StringName in _sorted_keys(instance.active_services):
|
||||
var service: ServiceInstance = instance.active_services[service_id]
|
||||
if active_service_ids.has(service_id) or history.has(service_id):
|
||||
last_error = "Service ID occurs more than once."
|
||||
return false
|
||||
if not known_services.has(service_id) or not requests.has(service.request_id):
|
||||
last_error = "Active service has a missing known ID or request."
|
||||
return false
|
||||
if referenced_requests.has(service.request_id):
|
||||
last_error = "Request is referenced by multiple queue/service entries."
|
||||
return false
|
||||
var request: ServiceRequest = requests[service.request_id]
|
||||
if request.facility_id != instance_id:
|
||||
last_error = "Active service request targets another facility."
|
||||
return false
|
||||
referenced_requests[service.request_id] = true
|
||||
active_service_ids[service_id] = true
|
||||
for request_id: StringName in _sorted_keys(requests):
|
||||
if not known_requests.has(request_id) or not referenced_requests.has(request_id):
|
||||
last_error = "Live request is orphaned or absent from known IDs."
|
||||
return false
|
||||
for service_id: StringName in _sorted_keys(history):
|
||||
if not known_services.has(service_id):
|
||||
last_error = "Historical service is absent from known IDs."
|
||||
return false
|
||||
return true
|
||||
|
||||
|
||||
func _prepare_service_for_tick(
|
||||
instance: FacilityInstance,
|
||||
service: ServiceInstance
|
||||
) -> CommandResult:
|
||||
if instance.state == FacilityInstanceType.State.BROKEN:
|
||||
_fail_service(instance, service, INTERRUPTION_BROKEN, current_minute)
|
||||
return _success({&"service_id": service.service_id, &"failed": true})
|
||||
if not instance.power_available:
|
||||
if service.state == ServiceInstanceType.State.ACTIVE:
|
||||
service.pause(INTERRUPTION_NO_POWER, current_minute)
|
||||
service_interrupted.emit(service, INTERRUPTION_NO_POWER)
|
||||
return null
|
||||
if service.state == ServiceInstanceType.State.PAUSED \
|
||||
and service.interruption_reason == INTERRUPTION_NO_POWER:
|
||||
service.resume()
|
||||
var has_required_staff := instance.active_staff_ids.size() >= instance.staff_min
|
||||
if not has_required_staff:
|
||||
if service.state in [ServiceInstanceType.State.ACTIVE, ServiceInstanceType.State.PAUSED]:
|
||||
service.wait_for_staff(
|
||||
INTERRUPTION_STAFF_UNAVAILABLE,
|
||||
current_minute,
|
||||
STAFF_REPLACEMENT_GRACE_MINUTES
|
||||
)
|
||||
service_interrupted.emit(service, INTERRUPTION_STAFF_UNAVAILABLE)
|
||||
elif instance.staff_min > 0 \
|
||||
and service.state == ServiceInstanceType.State.WAITING_FOR_STAFF \
|
||||
and service.interruption_reason == INTERRUPTION_STAFF_UNAVAILABLE:
|
||||
service.resume()
|
||||
if service.state == ServiceInstanceType.State.WAITING_FOR_STAFF:
|
||||
if service.staff_grace_deadline >= 0 and current_minute >= service.staff_grace_deadline:
|
||||
_fail_service(instance, service, INTERRUPTION_STAFF_UNAVAILABLE, current_minute)
|
||||
return _success({&"service_id": service.service_id, &"failed": true})
|
||||
return null
|
||||
|
||||
|
||||
func _complete_service(
|
||||
instance: FacilityInstance,
|
||||
service: ServiceInstance,
|
||||
callback: Callable = Callable()
|
||||
) -> CommandResult:
|
||||
var request := get_request(service.request_id)
|
||||
if request == null:
|
||||
_fail_service(instance, service, &"MISSING_REQUEST", current_minute)
|
||||
return _invalid(
|
||||
"Active service lost its request and was failed safely.",
|
||||
{&"service_id": service.service_id}
|
||||
)
|
||||
var effective_callback := callback if callback.is_valid() else _completion_callback
|
||||
var callback_failure := _run_boundary_callback(
|
||||
effective_callback,
|
||||
[service, request, instance, current_minute],
|
||||
"Service completion transaction failed.",
|
||||
CommandResultType.Code.TRANSACTION_APPLY_FAILED
|
||||
)
|
||||
if callback_failure != null:
|
||||
callback_failure.details[&"service_id"] = service.service_id
|
||||
callback_failure.details[&"request_id"] = request.request_id
|
||||
return callback_failure
|
||||
service.mark_completed(current_minute)
|
||||
instance.active_services.erase(service.service_id)
|
||||
_requests.erase(request.request_id)
|
||||
_service_history[service.service_id] = service
|
||||
_increment_stat(instance.statistics_today, &"services_completed")
|
||||
_increment_stat(instance.lifetime_statistics, &"services_completed")
|
||||
service_completed.emit(service)
|
||||
return _success({
|
||||
&"instance_id": instance.instance_id,
|
||||
&"request_id": request.request_id,
|
||||
&"service_id": service.service_id,
|
||||
&"completed": true,
|
||||
})
|
||||
|
||||
|
||||
func _fail_service(
|
||||
instance: FacilityInstance,
|
||||
service: ServiceInstance,
|
||||
reason: StringName,
|
||||
absolute_minute: int
|
||||
) -> void:
|
||||
service.mark_failed(reason, absolute_minute)
|
||||
instance.active_services.erase(service.service_id)
|
||||
_requests.erase(service.request_id)
|
||||
_service_history[service.service_id] = service
|
||||
_increment_stat(instance.statistics_today, &"services_failed")
|
||||
_increment_stat(instance.lifetime_statistics, &"services_failed")
|
||||
service_failed.emit(service, reason)
|
||||
|
||||
|
||||
func _run_boundary_callback(
|
||||
callback: Callable,
|
||||
arguments: Array,
|
||||
fallback_message: String,
|
||||
failure_code: CommandResult.Code
|
||||
) -> CommandResult:
|
||||
if not callback.is_valid():
|
||||
return null
|
||||
var argument_count := callback.get_argument_count()
|
||||
var effective_arguments := arguments
|
||||
if argument_count >= 0 and argument_count < arguments.size():
|
||||
effective_arguments = arguments.slice(0, argument_count)
|
||||
var returned: Variant = callback.callv(effective_arguments)
|
||||
if returned == null or (returned is bool and returned):
|
||||
return null
|
||||
if returned is CommandResult:
|
||||
var command_result: CommandResult = returned
|
||||
return null if command_result.is_success() else command_result.copy()
|
||||
if returned is Dictionary:
|
||||
if bool(returned.get(&"ok", returned.get("ok", false))):
|
||||
return null
|
||||
var dictionary_message := String(
|
||||
returned.get(&"message", returned.get("message", fallback_message))
|
||||
)
|
||||
return CommandResultType.failed(failure_code, 0, dictionary_message, returned)
|
||||
var message := String(returned) if returned is String and not returned.is_empty() else fallback_message
|
||||
return CommandResultType.failed(failure_code, 0, message)
|
||||
|
||||
|
||||
func _estimate_wait_before(instance: FacilityInstance, target_request_id: StringName) -> int:
|
||||
var outstanding_minutes := 0
|
||||
for service_id: StringName in _sorted_keys(instance.active_services):
|
||||
var service: ServiceInstance = instance.active_services[service_id]
|
||||
outstanding_minutes += service.remaining_minutes()
|
||||
for request_id: StringName in instance.queue_entries:
|
||||
if request_id == target_request_id:
|
||||
break
|
||||
var request := get_request(request_id)
|
||||
if request != null:
|
||||
outstanding_minutes += request.service_minutes
|
||||
return ceili(float(outstanding_minutes) / float(maxi(instance.capacity, 1)))
|
||||
|
||||
|
||||
func _cleanup_missing_queue_requests(instance: FacilityInstance) -> void:
|
||||
var valid_ids: Array[StringName] = []
|
||||
for request_id: StringName in instance.queue_entries:
|
||||
if _requests.has(request_id):
|
||||
valid_ids.append(request_id)
|
||||
else:
|
||||
push_warning(
|
||||
"Removed corrupt FacilityService queue entry '%s' from '%s'." \
|
||||
% [request_id, instance.instance_id]
|
||||
)
|
||||
instance.queue_entries = valid_ids
|
||||
_sort_queue(instance)
|
||||
|
||||
|
||||
func _sort_queue(instance: FacilityInstance) -> void:
|
||||
instance.queue_entries.sort_custom(Callable(self, "_queue_entry_less"))
|
||||
|
||||
|
||||
func _queue_entry_less(left_id: StringName, right_id: StringName) -> bool:
|
||||
var left := get_request(left_id)
|
||||
var right := get_request(right_id)
|
||||
if left == null:
|
||||
return false
|
||||
if right == null:
|
||||
return true
|
||||
if left.priority != right.priority:
|
||||
return left.priority > right.priority
|
||||
if left.enqueued_at != right.enqueued_at:
|
||||
return left.enqueued_at < right.enqueued_at
|
||||
return String(left.request_id) < String(right.request_id)
|
||||
|
||||
|
||||
func _find_active_service(service_id: StringName) -> Dictionary:
|
||||
for instance_id: StringName in _sorted_keys(_instances):
|
||||
var instance: FacilityInstance = _instances[instance_id]
|
||||
if instance.active_services.has(service_id):
|
||||
return {
|
||||
&"instance": instance,
|
||||
&"service": instance.active_services[service_id],
|
||||
}
|
||||
return {}
|
||||
|
||||
|
||||
func _next_service_id() -> StringName:
|
||||
while true:
|
||||
var candidate := StringName("service_%08d" % _next_service_sequence)
|
||||
_next_service_sequence += 1
|
||||
if not _known_service_ids.has(candidate):
|
||||
return candidate
|
||||
return &""
|
||||
|
||||
|
||||
func _resolve_minute(absolute_minute: int) -> int:
|
||||
if absolute_minute >= 0:
|
||||
current_minute = maxi(current_minute, absolute_minute)
|
||||
return current_minute
|
||||
|
||||
|
||||
func _emit_state_change(instance: FacilityInstance, old_state: int) -> void:
|
||||
if old_state != instance.state:
|
||||
facility_state_changed.emit(
|
||||
instance.instance_id,
|
||||
old_state,
|
||||
instance.state,
|
||||
instance.state_reasons.duplicate()
|
||||
)
|
||||
|
||||
|
||||
func _success(details: Dictionary = {}) -> CommandResult:
|
||||
return CommandResultType.succeeded(0, details)
|
||||
|
||||
|
||||
func _invalid(message: String, details: Dictionary = {}) -> CommandResult:
|
||||
last_error = message
|
||||
return CommandResultType.failed(
|
||||
CommandResultType.Code.VALIDATION_FAILED,
|
||||
0,
|
||||
message,
|
||||
details
|
||||
)
|
||||
|
||||
|
||||
func _not_found(entity_name: String, entity_id: StringName) -> CommandResult:
|
||||
var message := "%s '%s' was not found." % [entity_name, entity_id]
|
||||
last_error = message
|
||||
return CommandResultType.failed(
|
||||
CommandResultType.Code.NOT_FOUND,
|
||||
0,
|
||||
message,
|
||||
{&"id": entity_id}
|
||||
)
|
||||
|
||||
|
||||
func _duplicate(entity_name: String, entity_id: StringName) -> CommandResult:
|
||||
var message := "%s '%s' already exists." % [entity_name, entity_id]
|
||||
last_error = message
|
||||
return CommandResultType.failed(
|
||||
CommandResultType.Code.DUPLICATE_COMMAND_ID,
|
||||
0,
|
||||
message,
|
||||
{&"id": entity_id}
|
||||
)
|
||||
|
||||
|
||||
static func _increment_stat(statistics: Dictionary, key: StringName) -> void:
|
||||
statistics[key] = int(statistics.get(key, 0)) + 1
|
||||
|
||||
|
||||
static func _value(source: Dictionary, key: StringName, fallback: Variant) -> Variant:
|
||||
return source.get(key, source.get(String(key), fallback))
|
||||
|
||||
|
||||
static func _has_value(source: Dictionary, key: StringName) -> bool:
|
||||
return source.has(key) or source.has(String(key))
|
||||
|
||||
|
||||
static func _sorted_keys(source: Dictionary) -> Array[StringName]:
|
||||
var values: Array[StringName] = []
|
||||
for value: Variant in source.keys():
|
||||
values.append(StringName(value))
|
||||
values.sort_custom(func(a: StringName, b: StringName) -> bool: return String(a) < String(b))
|
||||
return values
|
||||
|
||||
|
||||
static func _keys_to_strings(source: Dictionary) -> Array[String]:
|
||||
var values: Array[String] = []
|
||||
for key: StringName in _sorted_keys(source):
|
||||
values.append(String(key))
|
||||
return values
|
||||
|
||||
|
||||
static func _id_set(source: Variant) -> Variant:
|
||||
if not source is Array:
|
||||
return null
|
||||
var ids: Dictionary = {}
|
||||
for raw_id: Variant in source:
|
||||
var id := StringName(raw_id)
|
||||
if id.is_empty() or ids.has(id):
|
||||
return null
|
||||
ids[id] = true
|
||||
return ids
|
||||
@@ -0,0 +1 @@
|
||||
uid://ln0q7wmvm8kx
|
||||
@@ -0,0 +1,232 @@
|
||||
class_name ServiceInstance
|
||||
extends RefCounted
|
||||
|
||||
enum State {
|
||||
ACTIVE,
|
||||
PAUSED,
|
||||
WAITING_FOR_STAFF,
|
||||
COMPLETED,
|
||||
FAILED,
|
||||
CANCELLED,
|
||||
}
|
||||
|
||||
const NO_DEADLINE := -1
|
||||
|
||||
var service_id: StringName = &""
|
||||
var request_id: StringName = &""
|
||||
var facility_id: StringName = &""
|
||||
var started_at := 0
|
||||
var finish_at := 1
|
||||
var guest_ids: Array[StringName] = []
|
||||
var staff_ids: Array[StringName] = []
|
||||
var quality_snapshot := 0
|
||||
var price_snapshot := 0
|
||||
var policy_snapshot: Dictionary = {}
|
||||
var inventory_reservations: Array[StringName] = []
|
||||
var interruption_policy: Dictionary = {}
|
||||
var duration_minutes := 1
|
||||
var elapsed_minutes := 0
|
||||
var state: State = State.ACTIVE
|
||||
var interruption_reason: StringName = &""
|
||||
var interrupted_at := -1
|
||||
var staff_grace_deadline := NO_DEADLINE
|
||||
var completed_at := -1
|
||||
var failure_reason: StringName = &""
|
||||
|
||||
|
||||
func configure(
|
||||
p_service_id: StringName,
|
||||
p_request_id: StringName,
|
||||
p_facility_id: StringName,
|
||||
p_started_at: int,
|
||||
p_duration_minutes: int,
|
||||
p_guest_ids: Array[StringName],
|
||||
p_staff_ids: Array[StringName],
|
||||
options: Dictionary = {}
|
||||
) -> ServiceInstance:
|
||||
service_id = p_service_id
|
||||
request_id = p_request_id
|
||||
facility_id = p_facility_id
|
||||
started_at = maxi(p_started_at, 0)
|
||||
duration_minutes = maxi(p_duration_minutes, 1)
|
||||
finish_at = started_at + duration_minutes
|
||||
guest_ids.assign(p_guest_ids)
|
||||
staff_ids.assign(p_staff_ids)
|
||||
quality_snapshot = int(_value(options, &"quality_snapshot", 0))
|
||||
price_snapshot = maxi(int(_value(options, &"price_snapshot", 0)), 0)
|
||||
policy_snapshot = _dictionary_copy(_value(options, &"policy_snapshot", {}))
|
||||
inventory_reservations = _string_name_array(
|
||||
_value(options, &"inventory_reservations", [])
|
||||
)
|
||||
interruption_policy = _dictionary_copy(_value(options, &"interruption_policy", {}))
|
||||
return self
|
||||
|
||||
|
||||
func is_valid() -> bool:
|
||||
return not service_id.is_empty() \
|
||||
and not request_id.is_empty() \
|
||||
and not facility_id.is_empty() \
|
||||
and not guest_ids.is_empty() \
|
||||
and duration_minutes > 0 \
|
||||
and elapsed_minutes >= 0 \
|
||||
and elapsed_minutes <= duration_minutes
|
||||
|
||||
|
||||
func is_terminal() -> bool:
|
||||
return state in [State.COMPLETED, State.FAILED, State.CANCELLED]
|
||||
|
||||
|
||||
func can_progress() -> bool:
|
||||
return state == State.ACTIVE and elapsed_minutes < duration_minutes
|
||||
|
||||
|
||||
func advance_one_minute() -> bool:
|
||||
if not can_progress():
|
||||
return false
|
||||
elapsed_minutes = mini(elapsed_minutes + 1, duration_minutes)
|
||||
return elapsed_minutes >= duration_minutes
|
||||
|
||||
|
||||
func pause(reason: StringName, absolute_minute: int) -> bool:
|
||||
if state != State.ACTIVE:
|
||||
return false
|
||||
state = State.PAUSED
|
||||
interruption_reason = reason
|
||||
interrupted_at = absolute_minute
|
||||
return true
|
||||
|
||||
|
||||
func wait_for_staff(reason: StringName, absolute_minute: int, grace_minutes := 5) -> bool:
|
||||
if state not in [State.ACTIVE, State.PAUSED]:
|
||||
return false
|
||||
state = State.WAITING_FOR_STAFF
|
||||
interruption_reason = reason
|
||||
interrupted_at = absolute_minute
|
||||
staff_grace_deadline = absolute_minute + maxi(grace_minutes, 0)
|
||||
return true
|
||||
|
||||
|
||||
func resume() -> bool:
|
||||
if state not in [State.PAUSED, State.WAITING_FOR_STAFF]:
|
||||
return false
|
||||
state = State.ACTIVE
|
||||
interruption_reason = &""
|
||||
interrupted_at = -1
|
||||
staff_grace_deadline = NO_DEADLINE
|
||||
return true
|
||||
|
||||
|
||||
func mark_completed(absolute_minute: int) -> void:
|
||||
elapsed_minutes = duration_minutes
|
||||
state = State.COMPLETED
|
||||
completed_at = absolute_minute
|
||||
interruption_reason = &""
|
||||
failure_reason = &""
|
||||
staff_grace_deadline = NO_DEADLINE
|
||||
|
||||
|
||||
func mark_failed(reason: StringName, absolute_minute: int) -> void:
|
||||
state = State.FAILED
|
||||
failure_reason = reason
|
||||
completed_at = absolute_minute
|
||||
staff_grace_deadline = NO_DEADLINE
|
||||
|
||||
|
||||
func mark_cancelled(reason: StringName, absolute_minute: int) -> void:
|
||||
state = State.CANCELLED
|
||||
failure_reason = reason
|
||||
completed_at = absolute_minute
|
||||
staff_grace_deadline = NO_DEADLINE
|
||||
|
||||
|
||||
func remaining_minutes() -> int:
|
||||
return maxi(duration_minutes - elapsed_minutes, 0)
|
||||
|
||||
|
||||
func create_snapshot() -> Dictionary:
|
||||
return {
|
||||
&"service_id": String(service_id),
|
||||
&"request_id": String(request_id),
|
||||
&"facility_id": String(facility_id),
|
||||
&"started_at": started_at,
|
||||
&"finish_at": finish_at,
|
||||
&"guest_ids": _names_to_strings(guest_ids),
|
||||
&"staff_ids": _names_to_strings(staff_ids),
|
||||
&"quality_snapshot": quality_snapshot,
|
||||
&"price_snapshot": price_snapshot,
|
||||
&"policy_snapshot": policy_snapshot.duplicate(true),
|
||||
&"inventory_reservations": _names_to_strings(inventory_reservations),
|
||||
&"interruption_policy": interruption_policy.duplicate(true),
|
||||
&"duration_minutes": duration_minutes,
|
||||
&"elapsed_minutes": elapsed_minutes,
|
||||
&"state": State.keys()[state],
|
||||
&"interruption_reason": String(interruption_reason),
|
||||
&"interrupted_at": interrupted_at,
|
||||
&"staff_grace_deadline": staff_grace_deadline,
|
||||
&"completed_at": completed_at,
|
||||
&"failure_reason": String(failure_reason),
|
||||
}
|
||||
|
||||
|
||||
static func from_snapshot(snapshot: Dictionary) -> ServiceInstance:
|
||||
if not snapshot.get(&"guest_ids", null) is Array:
|
||||
return null
|
||||
if not snapshot.get(&"staff_ids", null) is Array:
|
||||
return null
|
||||
if not snapshot.get(&"policy_snapshot", null) is Dictionary:
|
||||
return null
|
||||
if not snapshot.get(&"inventory_reservations", null) is Array:
|
||||
return null
|
||||
if not snapshot.get(&"interruption_policy", null) is Dictionary:
|
||||
return null
|
||||
var service := ServiceInstance.new().configure(
|
||||
StringName(snapshot.get(&"service_id", "")),
|
||||
StringName(snapshot.get(&"request_id", "")),
|
||||
StringName(snapshot.get(&"facility_id", "")),
|
||||
int(snapshot.get(&"started_at", 0)),
|
||||
int(snapshot.get(&"duration_minutes", 1)),
|
||||
_string_name_array(snapshot.get(&"guest_ids", [])),
|
||||
_string_name_array(snapshot.get(&"staff_ids", [])),
|
||||
{
|
||||
&"quality_snapshot": snapshot.get(&"quality_snapshot", 0),
|
||||
&"price_snapshot": snapshot.get(&"price_snapshot", 0),
|
||||
&"policy_snapshot": snapshot.get(&"policy_snapshot", {}),
|
||||
&"inventory_reservations": snapshot.get(&"inventory_reservations", []),
|
||||
&"interruption_policy": snapshot.get(&"interruption_policy", {}),
|
||||
}
|
||||
)
|
||||
var state_index := State.keys().find(String(snapshot.get(&"state", "ACTIVE")))
|
||||
if state_index < 0:
|
||||
return null
|
||||
service.state = state_index as State
|
||||
service.finish_at = int(snapshot.get(&"finish_at", service.started_at + service.duration_minutes))
|
||||
service.elapsed_minutes = int(snapshot.get(&"elapsed_minutes", 0))
|
||||
service.interruption_reason = StringName(snapshot.get(&"interruption_reason", ""))
|
||||
service.interrupted_at = int(snapshot.get(&"interrupted_at", -1))
|
||||
service.staff_grace_deadline = int(snapshot.get(&"staff_grace_deadline", NO_DEADLINE))
|
||||
service.completed_at = int(snapshot.get(&"completed_at", -1))
|
||||
service.failure_reason = StringName(snapshot.get(&"failure_reason", ""))
|
||||
return service if service.is_valid() else null
|
||||
|
||||
|
||||
static func _value(source: Dictionary, key: StringName, fallback: Variant) -> Variant:
|
||||
return source.get(key, source.get(String(key), fallback))
|
||||
|
||||
|
||||
static func _dictionary_copy(source: Variant) -> Dictionary:
|
||||
return source.duplicate(true) if source is Dictionary else {}
|
||||
|
||||
|
||||
static func _string_name_array(source: Variant) -> Array[StringName]:
|
||||
var values: Array[StringName] = []
|
||||
if source is Array:
|
||||
for value: Variant in source:
|
||||
values.append(StringName(value))
|
||||
return values
|
||||
|
||||
|
||||
static func _names_to_strings(source: Array[StringName]) -> Array[String]:
|
||||
var values: Array[String] = []
|
||||
for value: StringName in source:
|
||||
values.append(String(value))
|
||||
return values
|
||||
@@ -0,0 +1 @@
|
||||
uid://cj7rmhd4xylof
|
||||
@@ -0,0 +1,138 @@
|
||||
class_name ServiceRequest
|
||||
extends RefCounted
|
||||
|
||||
## Immutable-at-enqueue quotation for one facility visit.
|
||||
##
|
||||
## FacilityService owns the lifecycle. Price and policy values are copied when
|
||||
## this object is configured so later facility policy changes cannot alter an
|
||||
## already queued request.
|
||||
|
||||
var request_id: StringName = &""
|
||||
var guest_ids: Array[StringName] = []
|
||||
var facility_id: StringName = &""
|
||||
var service_type: StringName = &"default"
|
||||
var created_at := 0
|
||||
var quoted_price := 0
|
||||
var policy_snapshot: Dictionary = {}
|
||||
var policy_revision := 0
|
||||
var reserved_inventory_ids: Array[StringName] = []
|
||||
var preference_match := 0
|
||||
var expected_quality := 0
|
||||
var expected_wait := 0
|
||||
var priority := 0
|
||||
var enqueued_at := -1
|
||||
var service_minutes := 1
|
||||
|
||||
|
||||
func configure(
|
||||
p_request_id: StringName,
|
||||
p_facility_id: StringName,
|
||||
p_guest_ids: Array[StringName],
|
||||
p_service_type: StringName,
|
||||
p_created_at: int,
|
||||
options: Dictionary = {}
|
||||
) -> ServiceRequest:
|
||||
request_id = p_request_id
|
||||
facility_id = p_facility_id
|
||||
guest_ids.assign(p_guest_ids)
|
||||
service_type = p_service_type
|
||||
created_at = maxi(p_created_at, 0)
|
||||
quoted_price = maxi(int(_value(options, &"quoted_price", 0)), 0)
|
||||
policy_snapshot = _dictionary_copy(_value(options, &"policy_snapshot", {}))
|
||||
policy_revision = maxi(int(_value(options, &"policy_revision", 0)), 0)
|
||||
reserved_inventory_ids = _string_name_array(
|
||||
_value(options, &"reserved_inventory_ids", [])
|
||||
)
|
||||
preference_match = int(_value(options, &"preference_match", 0))
|
||||
expected_quality = int(_value(options, &"expected_quality", 0))
|
||||
expected_wait = maxi(int(_value(options, &"expected_wait", 0)), 0)
|
||||
priority = int(_value(options, &"priority", 0))
|
||||
enqueued_at = int(_value(options, &"enqueued_at", -1))
|
||||
service_minutes = maxi(int(_value(options, &"service_minutes", 1)), 1)
|
||||
return self
|
||||
|
||||
|
||||
func is_valid() -> bool:
|
||||
if request_id.is_empty() or facility_id.is_empty() or service_type.is_empty():
|
||||
return false
|
||||
if guest_ids.is_empty() or service_minutes <= 0 or quoted_price < 0:
|
||||
return false
|
||||
var seen: Dictionary = {}
|
||||
for guest_id: StringName in guest_ids:
|
||||
if guest_id.is_empty() or seen.has(guest_id):
|
||||
return false
|
||||
seen[guest_id] = true
|
||||
return true
|
||||
|
||||
|
||||
func create_snapshot() -> Dictionary:
|
||||
return {
|
||||
&"request_id": String(request_id),
|
||||
&"guest_ids": _names_to_strings(guest_ids),
|
||||
&"facility_id": String(facility_id),
|
||||
&"service_type": String(service_type),
|
||||
&"created_at": created_at,
|
||||
&"quoted_price": quoted_price,
|
||||
&"policy_snapshot": policy_snapshot.duplicate(true),
|
||||
&"policy_revision": policy_revision,
|
||||
&"reserved_inventory_ids": _names_to_strings(reserved_inventory_ids),
|
||||
&"preference_match": preference_match,
|
||||
&"expected_quality": expected_quality,
|
||||
&"expected_wait": expected_wait,
|
||||
&"priority": priority,
|
||||
&"enqueued_at": enqueued_at,
|
||||
&"service_minutes": service_minutes,
|
||||
}
|
||||
|
||||
|
||||
static func from_snapshot(snapshot: Dictionary) -> ServiceRequest:
|
||||
if not snapshot.get(&"guest_ids", null) is Array:
|
||||
return null
|
||||
if not snapshot.get(&"policy_snapshot", null) is Dictionary:
|
||||
return null
|
||||
if not snapshot.get(&"reserved_inventory_ids", null) is Array:
|
||||
return null
|
||||
var request := ServiceRequest.new().configure(
|
||||
StringName(snapshot.get(&"request_id", "")),
|
||||
StringName(snapshot.get(&"facility_id", "")),
|
||||
_string_name_array(snapshot.get(&"guest_ids", [])),
|
||||
StringName(snapshot.get(&"service_type", "default")),
|
||||
int(snapshot.get(&"created_at", 0)),
|
||||
{
|
||||
&"quoted_price": snapshot.get(&"quoted_price", 0),
|
||||
&"policy_snapshot": snapshot.get(&"policy_snapshot", {}),
|
||||
&"policy_revision": snapshot.get(&"policy_revision", 0),
|
||||
&"reserved_inventory_ids": snapshot.get(&"reserved_inventory_ids", []),
|
||||
&"preference_match": snapshot.get(&"preference_match", 0),
|
||||
&"expected_quality": snapshot.get(&"expected_quality", 0),
|
||||
&"expected_wait": snapshot.get(&"expected_wait", 0),
|
||||
&"priority": snapshot.get(&"priority", 0),
|
||||
&"enqueued_at": snapshot.get(&"enqueued_at", -1),
|
||||
&"service_minutes": snapshot.get(&"service_minutes", 1),
|
||||
}
|
||||
)
|
||||
return request if request.is_valid() else null
|
||||
|
||||
|
||||
static func _value(source: Dictionary, key: StringName, fallback: Variant) -> Variant:
|
||||
return source.get(key, source.get(String(key), fallback))
|
||||
|
||||
|
||||
static func _dictionary_copy(source: Variant) -> Dictionary:
|
||||
return source.duplicate(true) if source is Dictionary else {}
|
||||
|
||||
|
||||
static func _string_name_array(source: Variant) -> Array[StringName]:
|
||||
var values: Array[StringName] = []
|
||||
if not source is Array:
|
||||
return values
|
||||
for value: Variant in source:
|
||||
values.append(StringName(value))
|
||||
return values
|
||||
|
||||
|
||||
static func _names_to_strings(source: Array[StringName]) -> Array[String]:
|
||||
var values: Array[String] = []
|
||||
for value: StringName in source:
|
||||
values.append(String(value))
|
||||
return values
|
||||
@@ -0,0 +1 @@
|
||||
uid://bqhf3q6264fc3
|
||||
@@ -0,0 +1,269 @@
|
||||
class_name GuestDefinition
|
||||
extends RefCounted
|
||||
|
||||
## Immutable guest-archetype data used by GuestService.
|
||||
##
|
||||
## Runtime systems may construct definitions from Resources later. Keeping the
|
||||
## simulation representation independent from Resource loading makes headless
|
||||
## tests, migrations and deterministic snapshots straightforward.
|
||||
|
||||
const NEED_IDS: Array[StringName] = [
|
||||
&"hunger",
|
||||
&"sleep",
|
||||
&"hygiene",
|
||||
&"medical",
|
||||
&"leisure",
|
||||
&"intimacy",
|
||||
]
|
||||
|
||||
var guest_type_id: StringName = &""
|
||||
var tags: Array[StringName] = []
|
||||
var wealth_min := 0
|
||||
var wealth_max := 0
|
||||
var base_patience := 30
|
||||
var risk_tolerance := 50
|
||||
var preference_weights: Dictionary = {}
|
||||
var schedule_profile: StringName = &"default"
|
||||
var group_size_weights: Dictionary = {1: 100}
|
||||
var legal_sensitivity := 50
|
||||
var quality_sensitivity := 50
|
||||
var adult_verified := false
|
||||
var need_rates: Dictionary = {}
|
||||
var credit_limit_gold := 0
|
||||
var minimum_quality := 0
|
||||
var _configuration_valid := true
|
||||
|
||||
|
||||
func configure(p_guest_type_id: StringName, options: Dictionary = {}) -> GuestDefinition:
|
||||
_configuration_valid = true
|
||||
guest_type_id = p_guest_type_id
|
||||
tags = _sorted_unique_names(_value(options, &"tags", []))
|
||||
wealth_min = maxi(int(_value(options, &"wealth_min", 0)), 0)
|
||||
wealth_max = maxi(int(_value(options, &"wealth_max", wealth_min)), wealth_min)
|
||||
base_patience = maxi(int(_value(options, &"base_patience", 30)), 0)
|
||||
risk_tolerance = clampi(int(_value(options, &"risk_tolerance", 50)), 0, 100)
|
||||
preference_weights = _normalized_name_int_dictionary(
|
||||
_value(options, &"preference_weights", {})
|
||||
)
|
||||
schedule_profile = StringName(_value(options, &"schedule_profile", &"default"))
|
||||
group_size_weights = _normalized_group_weights(
|
||||
_value(options, &"group_size_weights", {1: 100})
|
||||
)
|
||||
legal_sensitivity = clampi(int(_value(options, &"legal_sensitivity", 50)), 0, 100)
|
||||
quality_sensitivity = clampi(int(_value(options, &"quality_sensitivity", 50)), 0, 100)
|
||||
var raw_adult_verified: Variant = _value(options, &"adult_verified", false)
|
||||
if not raw_adult_verified is bool:
|
||||
_configuration_valid = false
|
||||
adult_verified = false
|
||||
else:
|
||||
adult_verified = raw_adult_verified
|
||||
var normalized_need_rates: Variant = _normalized_need_dictionary(
|
||||
_value(options, &"need_rates", {}),
|
||||
false
|
||||
)
|
||||
need_rates = normalized_need_rates if normalized_need_rates is Dictionary else {}
|
||||
if not normalized_need_rates is Dictionary:
|
||||
_configuration_valid = false
|
||||
credit_limit_gold = maxi(int(_value(options, &"credit_limit_gold", 0)), 0)
|
||||
minimum_quality = clampi(int(_value(options, &"minimum_quality", 0)), 0, 100)
|
||||
return self
|
||||
|
||||
|
||||
func is_valid() -> bool:
|
||||
if not _configuration_valid:
|
||||
return false
|
||||
if guest_type_id.is_empty() or schedule_profile.is_empty():
|
||||
return false
|
||||
if wealth_min < 0 or wealth_max < wealth_min or base_patience < 0:
|
||||
return false
|
||||
if risk_tolerance < 0 or risk_tolerance > 100:
|
||||
return false
|
||||
if legal_sensitivity < 0 or legal_sensitivity > 100:
|
||||
return false
|
||||
if quality_sensitivity < 0 or quality_sensitivity > 100:
|
||||
return false
|
||||
if minimum_quality < 0 or minimum_quality > 100 or credit_limit_gold < 0:
|
||||
return false
|
||||
if not _name_int_dictionary_is_valid(preference_weights):
|
||||
return false
|
||||
if group_size_weights.is_empty():
|
||||
return false
|
||||
for raw_size: Variant in group_size_weights:
|
||||
var group_size := int(raw_size)
|
||||
if group_size < 1 or group_size > 4 or int(group_size_weights[raw_size]) <= 0:
|
||||
return false
|
||||
if need_rates.size() != NEED_IDS.size():
|
||||
return false
|
||||
for need_id: StringName in NEED_IDS:
|
||||
if not need_rates.has(need_id) or not need_rates[need_id] is int:
|
||||
return false
|
||||
return true
|
||||
|
||||
|
||||
func get_need_rate(need_id: StringName) -> int:
|
||||
return int(need_rates.get(need_id, 0))
|
||||
|
||||
|
||||
func preference_for(facility_tags: Variant) -> int:
|
||||
var total := 0
|
||||
for tag: StringName in _sorted_unique_names(facility_tags):
|
||||
total += int(preference_weights.get(tag, 0))
|
||||
return total
|
||||
|
||||
|
||||
func create_snapshot() -> Dictionary:
|
||||
return {
|
||||
&"guest_type_id": String(guest_type_id),
|
||||
&"tags": _names_to_strings(tags),
|
||||
&"wealth_min": wealth_min,
|
||||
&"wealth_max": wealth_max,
|
||||
&"base_patience": base_patience,
|
||||
&"risk_tolerance": risk_tolerance,
|
||||
&"preference_weights": _string_key_dictionary(preference_weights),
|
||||
&"schedule_profile": String(schedule_profile),
|
||||
&"group_size_weights": group_size_weights.duplicate(true),
|
||||
&"legal_sensitivity": legal_sensitivity,
|
||||
&"quality_sensitivity": quality_sensitivity,
|
||||
&"adult_verified": adult_verified,
|
||||
&"need_rates": _string_key_dictionary(need_rates),
|
||||
&"credit_limit_gold": credit_limit_gold,
|
||||
&"minimum_quality": minimum_quality,
|
||||
}
|
||||
|
||||
|
||||
static func from_snapshot(snapshot: Dictionary) -> GuestDefinition:
|
||||
if not snapshot.get(&"tags", null) is Array:
|
||||
return null
|
||||
if not snapshot.get(&"preference_weights", null) is Dictionary:
|
||||
return null
|
||||
if not snapshot.get(&"group_size_weights", null) is Dictionary:
|
||||
return null
|
||||
if not snapshot.get(&"need_rates", null) is Dictionary:
|
||||
return null
|
||||
var definition := GuestDefinition.new()
|
||||
definition.guest_type_id = StringName(snapshot.get(&"guest_type_id", ""))
|
||||
definition.tags = _sorted_unique_names(snapshot.get(&"tags", []))
|
||||
definition.wealth_min = int(snapshot.get(&"wealth_min", -1))
|
||||
definition.wealth_max = int(snapshot.get(&"wealth_max", -1))
|
||||
definition.base_patience = int(snapshot.get(&"base_patience", -1))
|
||||
definition.risk_tolerance = int(snapshot.get(&"risk_tolerance", -1))
|
||||
definition.preference_weights = _normalized_name_int_dictionary(
|
||||
snapshot.get(&"preference_weights", {})
|
||||
)
|
||||
definition.schedule_profile = StringName(snapshot.get(&"schedule_profile", ""))
|
||||
definition.group_size_weights = _normalized_group_weights(
|
||||
snapshot.get(&"group_size_weights", {})
|
||||
)
|
||||
definition.legal_sensitivity = int(snapshot.get(&"legal_sensitivity", -1))
|
||||
definition.quality_sensitivity = int(snapshot.get(&"quality_sensitivity", -1))
|
||||
var raw_adult_verified: Variant = snapshot.get(&"adult_verified", null)
|
||||
if not raw_adult_verified is bool:
|
||||
return null
|
||||
definition.adult_verified = raw_adult_verified
|
||||
var normalized_need_rates: Variant = _normalized_need_dictionary(
|
||||
snapshot.get(&"need_rates", {}),
|
||||
true
|
||||
)
|
||||
if not normalized_need_rates is Dictionary:
|
||||
return null
|
||||
definition.need_rates = normalized_need_rates
|
||||
definition.credit_limit_gold = int(snapshot.get(&"credit_limit_gold", -1))
|
||||
definition.minimum_quality = int(snapshot.get(&"minimum_quality", -1))
|
||||
return definition if definition.is_valid() else null
|
||||
|
||||
|
||||
static func _value(source: Dictionary, key: StringName, fallback: Variant) -> Variant:
|
||||
return source.get(key, source.get(String(key), fallback))
|
||||
|
||||
|
||||
static func _normalized_need_dictionary(source: Variant, require_all: bool) -> Variant:
|
||||
var result: Dictionary = {}
|
||||
if not source is Dictionary:
|
||||
return null
|
||||
var seen: Dictionary = {}
|
||||
for raw_key: Variant in source:
|
||||
if not raw_key is String and not raw_key is StringName:
|
||||
return null
|
||||
var need_id := StringName(raw_key)
|
||||
if not NEED_IDS.has(need_id) or seen.has(need_id):
|
||||
return null
|
||||
var raw_value: Variant = source[raw_key]
|
||||
if not raw_value is int:
|
||||
return null
|
||||
seen[need_id] = true
|
||||
result[need_id] = raw_value
|
||||
if require_all and seen.size() != NEED_IDS.size():
|
||||
return null
|
||||
for need_id: StringName in NEED_IDS:
|
||||
if not result.has(need_id):
|
||||
result[need_id] = 0
|
||||
return result
|
||||
|
||||
|
||||
static func _zero_needs() -> Dictionary:
|
||||
var result: Dictionary = {}
|
||||
for need_id: StringName in NEED_IDS:
|
||||
result[need_id] = 0
|
||||
return result
|
||||
|
||||
|
||||
static func _normalized_name_int_dictionary(source: Variant) -> Dictionary:
|
||||
var result: Dictionary = {}
|
||||
if not source is Dictionary:
|
||||
return result
|
||||
var keys: Array = source.keys()
|
||||
keys.sort_custom(func(a: Variant, b: Variant) -> bool: return String(a) < String(b))
|
||||
for raw_key: Variant in keys:
|
||||
var key := StringName(raw_key)
|
||||
if key.is_empty():
|
||||
continue
|
||||
result[key] = int(source[raw_key])
|
||||
return result
|
||||
|
||||
|
||||
static func _normalized_group_weights(source: Variant) -> Dictionary:
|
||||
var result: Dictionary = {}
|
||||
if not source is Dictionary:
|
||||
return result
|
||||
var keys: Array = source.keys()
|
||||
keys.sort_custom(func(a: Variant, b: Variant) -> bool: return int(a) < int(b))
|
||||
for raw_key: Variant in keys:
|
||||
var group_size := int(raw_key)
|
||||
if group_size >= 1 and group_size <= 4:
|
||||
result[group_size] = int(source[raw_key])
|
||||
return result
|
||||
|
||||
|
||||
static func _name_int_dictionary_is_valid(source: Dictionary) -> bool:
|
||||
for raw_key: Variant in source:
|
||||
if StringName(raw_key).is_empty() or not source[raw_key] is int:
|
||||
return false
|
||||
return true
|
||||
|
||||
|
||||
static func _sorted_unique_names(source: Variant) -> Array[StringName]:
|
||||
var result: Array[StringName] = []
|
||||
if not source is Array:
|
||||
return result
|
||||
for raw_value: Variant in source:
|
||||
var value := StringName(raw_value)
|
||||
if not value.is_empty() and not result.has(value):
|
||||
result.append(value)
|
||||
result.sort_custom(func(a: StringName, b: StringName) -> bool: return String(a) < String(b))
|
||||
return result
|
||||
|
||||
|
||||
static func _names_to_strings(source: Array[StringName]) -> Array[String]:
|
||||
var result: Array[String] = []
|
||||
for value: StringName in source:
|
||||
result.append(String(value))
|
||||
return result
|
||||
|
||||
|
||||
static func _string_key_dictionary(source: Dictionary) -> Dictionary:
|
||||
var result: Dictionary = {}
|
||||
var keys: Array = source.keys()
|
||||
keys.sort_custom(func(a: Variant, b: Variant) -> bool: return String(a) < String(b))
|
||||
for raw_key: Variant in keys:
|
||||
result[String(raw_key)] = source[raw_key]
|
||||
return result
|
||||
@@ -0,0 +1 @@
|
||||
uid://cxu6iqddv86k5
|
||||
@@ -0,0 +1,388 @@
|
||||
class_name GuestRuntime
|
||||
extends RefCounted
|
||||
|
||||
## Authoritative per-guest state. All scalar simulation values are integers.
|
||||
|
||||
enum State {
|
||||
SPAWNING,
|
||||
ENTERING,
|
||||
DECIDING,
|
||||
MOVING,
|
||||
QUEUING,
|
||||
RECEIVING_SERVICE,
|
||||
LEAVING,
|
||||
DESPAWNING,
|
||||
REPLANNING,
|
||||
ABANDONING,
|
||||
INTERRUPTED,
|
||||
FLEEING,
|
||||
INCAPACITATED,
|
||||
}
|
||||
|
||||
const NEED_IDS: Array[StringName] = [
|
||||
&"hunger",
|
||||
&"sleep",
|
||||
&"hygiene",
|
||||
&"medical",
|
||||
&"leisure",
|
||||
&"intimacy",
|
||||
]
|
||||
|
||||
var guest_id: StringName = &""
|
||||
var guest_type_id: StringName = &""
|
||||
var group_id: StringName = &""
|
||||
var wallet_gold := 0
|
||||
var credit_limit_gold := 0
|
||||
var current_cell := Vector3i.ZERO
|
||||
var state: State = State.SPAWNING
|
||||
var state_entered_at := 0
|
||||
var target_facility_id: StringName = &""
|
||||
var current_service_id: StringName = &""
|
||||
var queue_request_id: StringName = &""
|
||||
var needs: Dictionary = {}
|
||||
var satisfaction_total := 0
|
||||
var satisfaction_samples := 0
|
||||
var patience_remaining := 0
|
||||
var debt_gold := 0
|
||||
var suspicion := 0
|
||||
var addiction_by_tag: Dictionary = {}
|
||||
var visited_facility_ids: Array[StringName] = []
|
||||
var path_handle: Variant = null
|
||||
var planned_exit_at := 0
|
||||
|
||||
## Recovery and lifecycle data used by GuestService.
|
||||
var path_failure_count := 0
|
||||
var recovery_kind: StringName = &""
|
||||
var recovery_target_cell := Vector3i.ZERO
|
||||
var last_progress_cell := Vector3i.ZERO
|
||||
var last_progress_at := 0
|
||||
var unresolved_incident_ids: Array[StringName] = []
|
||||
var outstanding_reservation_ids: Array[StringName] = []
|
||||
var interrupted_service_ids: Array[StringName] = []
|
||||
var exit_reason: StringName = &""
|
||||
var transition_tick := -1
|
||||
var transitions_this_tick := 0
|
||||
|
||||
|
||||
func configure(
|
||||
p_guest_id: StringName,
|
||||
p_guest_type_id: StringName,
|
||||
options: Dictionary = {}
|
||||
) -> GuestRuntime:
|
||||
guest_id = p_guest_id
|
||||
guest_type_id = p_guest_type_id
|
||||
group_id = StringName(_value(options, &"group_id", &""))
|
||||
wallet_gold = maxi(int(_value(options, &"wallet_gold", 0)), 0)
|
||||
credit_limit_gold = maxi(int(_value(options, &"credit_limit_gold", 0)), 0)
|
||||
current_cell = _vector3i(_value(options, &"current_cell", Vector3i.ZERO))
|
||||
state = _state_from_variant(_value(options, &"state", State.SPAWNING))
|
||||
state_entered_at = maxi(int(_value(options, &"state_entered_at", 0)), 0)
|
||||
target_facility_id = StringName(_value(options, &"target_facility_id", &""))
|
||||
current_service_id = StringName(_value(options, &"current_service_id", &""))
|
||||
queue_request_id = StringName(_value(options, &"queue_request_id", &""))
|
||||
var normalized_needs: Variant = _normalized_needs(
|
||||
_value(options, &"needs", {}),
|
||||
true,
|
||||
false
|
||||
)
|
||||
needs = normalized_needs if normalized_needs is Dictionary else {}
|
||||
satisfaction_total = maxi(int(_value(options, &"satisfaction_total", 0)), 0)
|
||||
satisfaction_samples = maxi(int(_value(options, &"satisfaction_samples", 0)), 0)
|
||||
patience_remaining = maxi(int(_value(options, &"patience_remaining", 0)), 0)
|
||||
debt_gold = maxi(int(_value(options, &"debt_gold", 0)), 0)
|
||||
suspicion = clampi(int(_value(options, &"suspicion", 0)), 0, 100)
|
||||
addiction_by_tag = _normalized_clamped_dictionary(
|
||||
_value(options, &"addiction_by_tag", {})
|
||||
)
|
||||
visited_facility_ids = _sorted_unique_names(_value(options, &"visited_facility_ids", []))
|
||||
path_handle = _copy(_value(options, &"path_handle", null))
|
||||
planned_exit_at = maxi(int(_value(options, &"planned_exit_at", state_entered_at)), state_entered_at)
|
||||
path_failure_count = maxi(int(_value(options, &"path_failure_count", 0)), 0)
|
||||
recovery_kind = StringName(_value(options, &"recovery_kind", &""))
|
||||
recovery_target_cell = _vector3i(_value(options, &"recovery_target_cell", current_cell))
|
||||
last_progress_cell = _vector3i(_value(options, &"last_progress_cell", current_cell))
|
||||
last_progress_at = maxi(int(_value(options, &"last_progress_at", state_entered_at)), 0)
|
||||
unresolved_incident_ids = _sorted_unique_names(
|
||||
_value(options, &"unresolved_incident_ids", [])
|
||||
)
|
||||
outstanding_reservation_ids = _sorted_unique_names(
|
||||
_value(options, &"outstanding_reservation_ids", [])
|
||||
)
|
||||
interrupted_service_ids = _sorted_unique_names(
|
||||
_value(options, &"interrupted_service_ids", [])
|
||||
)
|
||||
exit_reason = StringName(_value(options, &"exit_reason", &""))
|
||||
transition_tick = int(_value(options, &"transition_tick", -1))
|
||||
transitions_this_tick = maxi(int(_value(options, &"transitions_this_tick", 0)), 0)
|
||||
return self
|
||||
|
||||
|
||||
func is_valid() -> bool:
|
||||
if guest_id.is_empty() or guest_type_id.is_empty():
|
||||
return false
|
||||
if wallet_gold < 0 or credit_limit_gold < 0 or debt_gold < 0:
|
||||
return false
|
||||
if state < State.SPAWNING or state > State.INCAPACITATED:
|
||||
return false
|
||||
if state_entered_at < 0 or planned_exit_at < state_entered_at:
|
||||
return false
|
||||
if satisfaction_total < 0 or satisfaction_samples < 0 or patience_remaining < 0:
|
||||
return false
|
||||
if suspicion < 0 or suspicion > 100 or path_failure_count < 0:
|
||||
return false
|
||||
if transitions_this_tick < 0:
|
||||
return false
|
||||
if needs.size() != NEED_IDS.size():
|
||||
return false
|
||||
for need_id: StringName in NEED_IDS:
|
||||
if not needs.has(need_id) or not needs[need_id] is int:
|
||||
return false
|
||||
var value := int(needs[need_id])
|
||||
if value < 0 or value > 100:
|
||||
return false
|
||||
for raw_tag: Variant in addiction_by_tag:
|
||||
var addiction := int(addiction_by_tag[raw_tag])
|
||||
if StringName(raw_tag).is_empty() or addiction < 0 or addiction > 100:
|
||||
return false
|
||||
return _state_references_are_valid()
|
||||
|
||||
|
||||
func average_satisfaction() -> int:
|
||||
if satisfaction_samples <= 0:
|
||||
return 50
|
||||
return clampi(satisfaction_total / satisfaction_samples, 0, 100)
|
||||
|
||||
|
||||
func max_addiction() -> int:
|
||||
var result := 0
|
||||
for value: Variant in addiction_by_tag.values():
|
||||
result = maxi(result, int(value))
|
||||
return result
|
||||
|
||||
|
||||
func create_snapshot() -> Dictionary:
|
||||
return {
|
||||
&"guest_id": String(guest_id),
|
||||
&"guest_type_id": String(guest_type_id),
|
||||
&"group_id": String(group_id),
|
||||
&"wallet_gold": wallet_gold,
|
||||
&"credit_limit_gold": credit_limit_gold,
|
||||
&"current_cell": _cell_to_array(current_cell),
|
||||
&"state": State.keys()[state],
|
||||
&"state_entered_at": state_entered_at,
|
||||
&"target_facility_id": String(target_facility_id),
|
||||
&"current_service_id": String(current_service_id),
|
||||
&"queue_request_id": String(queue_request_id),
|
||||
&"needs": _string_key_dictionary(needs),
|
||||
&"satisfaction_total": satisfaction_total,
|
||||
&"satisfaction_samples": satisfaction_samples,
|
||||
&"patience_remaining": patience_remaining,
|
||||
&"debt_gold": debt_gold,
|
||||
&"suspicion": suspicion,
|
||||
&"addiction_by_tag": _string_key_dictionary(addiction_by_tag),
|
||||
&"visited_facility_ids": _names_to_strings(visited_facility_ids),
|
||||
&"path_handle": _copy(path_handle),
|
||||
&"planned_exit_at": planned_exit_at,
|
||||
&"path_failure_count": path_failure_count,
|
||||
&"recovery_kind": String(recovery_kind),
|
||||
&"recovery_target_cell": _cell_to_array(recovery_target_cell),
|
||||
&"last_progress_cell": _cell_to_array(last_progress_cell),
|
||||
&"last_progress_at": last_progress_at,
|
||||
&"unresolved_incident_ids": _names_to_strings(unresolved_incident_ids),
|
||||
&"outstanding_reservation_ids": _names_to_strings(outstanding_reservation_ids),
|
||||
&"interrupted_service_ids": _names_to_strings(interrupted_service_ids),
|
||||
&"exit_reason": String(exit_reason),
|
||||
&"transition_tick": transition_tick,
|
||||
&"transitions_this_tick": transitions_this_tick,
|
||||
}
|
||||
|
||||
|
||||
static func from_snapshot(snapshot: Dictionary) -> GuestRuntime:
|
||||
for array_key: StringName in [
|
||||
&"current_cell",
|
||||
&"recovery_target_cell",
|
||||
&"last_progress_cell",
|
||||
&"visited_facility_ids",
|
||||
&"unresolved_incident_ids",
|
||||
&"outstanding_reservation_ids",
|
||||
&"interrupted_service_ids",
|
||||
]:
|
||||
if not snapshot.get(array_key, null) is Array:
|
||||
return null
|
||||
if not snapshot.get(&"needs", null) is Dictionary:
|
||||
return null
|
||||
if not snapshot.get(&"addiction_by_tag", null) is Dictionary:
|
||||
return null
|
||||
var state_names := State.keys()
|
||||
var state_index := state_names.find(String(snapshot.get(&"state", "")))
|
||||
if state_index < 0:
|
||||
return null
|
||||
var runtime := GuestRuntime.new()
|
||||
runtime.guest_id = StringName(snapshot.get(&"guest_id", ""))
|
||||
runtime.guest_type_id = StringName(snapshot.get(&"guest_type_id", ""))
|
||||
runtime.group_id = StringName(snapshot.get(&"group_id", ""))
|
||||
runtime.wallet_gold = int(snapshot.get(&"wallet_gold", -1))
|
||||
runtime.credit_limit_gold = int(snapshot.get(&"credit_limit_gold", -1))
|
||||
runtime.current_cell = _vector3i(snapshot.get(&"current_cell", []))
|
||||
runtime.state = state_index as State
|
||||
runtime.state_entered_at = int(snapshot.get(&"state_entered_at", -1))
|
||||
runtime.target_facility_id = StringName(snapshot.get(&"target_facility_id", ""))
|
||||
runtime.current_service_id = StringName(snapshot.get(&"current_service_id", ""))
|
||||
runtime.queue_request_id = StringName(snapshot.get(&"queue_request_id", ""))
|
||||
var normalized_needs: Variant = _normalized_needs(
|
||||
snapshot.get(&"needs", {}),
|
||||
false,
|
||||
true
|
||||
)
|
||||
if not normalized_needs is Dictionary:
|
||||
return null
|
||||
runtime.needs = normalized_needs
|
||||
runtime.satisfaction_total = int(snapshot.get(&"satisfaction_total", -1))
|
||||
runtime.satisfaction_samples = int(snapshot.get(&"satisfaction_samples", -1))
|
||||
runtime.patience_remaining = int(snapshot.get(&"patience_remaining", -1))
|
||||
runtime.debt_gold = int(snapshot.get(&"debt_gold", -1))
|
||||
runtime.suspicion = int(snapshot.get(&"suspicion", -1))
|
||||
runtime.addiction_by_tag = _normalized_clamped_dictionary_strict(
|
||||
snapshot.get(&"addiction_by_tag", {})
|
||||
)
|
||||
runtime.visited_facility_ids = _sorted_unique_names(snapshot.get(&"visited_facility_ids", []))
|
||||
runtime.path_handle = _copy(snapshot.get(&"path_handle", null))
|
||||
runtime.planned_exit_at = int(snapshot.get(&"planned_exit_at", -1))
|
||||
runtime.path_failure_count = int(snapshot.get(&"path_failure_count", -1))
|
||||
runtime.recovery_kind = StringName(snapshot.get(&"recovery_kind", ""))
|
||||
runtime.recovery_target_cell = _vector3i(snapshot.get(&"recovery_target_cell", []))
|
||||
runtime.last_progress_cell = _vector3i(snapshot.get(&"last_progress_cell", []))
|
||||
runtime.last_progress_at = int(snapshot.get(&"last_progress_at", -1))
|
||||
runtime.unresolved_incident_ids = _sorted_unique_names(
|
||||
snapshot.get(&"unresolved_incident_ids", [])
|
||||
)
|
||||
runtime.outstanding_reservation_ids = _sorted_unique_names(
|
||||
snapshot.get(&"outstanding_reservation_ids", [])
|
||||
)
|
||||
runtime.interrupted_service_ids = _sorted_unique_names(
|
||||
snapshot.get(&"interrupted_service_ids", [])
|
||||
)
|
||||
runtime.exit_reason = StringName(snapshot.get(&"exit_reason", ""))
|
||||
runtime.transition_tick = int(snapshot.get(&"transition_tick", -1))
|
||||
runtime.transitions_this_tick = int(snapshot.get(&"transitions_this_tick", -1))
|
||||
return runtime if runtime.is_valid() else null
|
||||
|
||||
|
||||
func _state_references_are_valid() -> bool:
|
||||
if state == State.QUEUING:
|
||||
return not target_facility_id.is_empty() and not queue_request_id.is_empty()
|
||||
if state == State.RECEIVING_SERVICE:
|
||||
return not target_facility_id.is_empty() and not current_service_id.is_empty()
|
||||
return true
|
||||
|
||||
|
||||
static func _state_from_variant(value: Variant) -> State:
|
||||
if value is String or value is StringName:
|
||||
var index := State.keys().find(String(value))
|
||||
return index as State if index >= 0 else State.SPAWNING
|
||||
var parsed := int(value)
|
||||
return parsed as State if parsed >= State.SPAWNING and parsed <= State.INCAPACITATED else State.SPAWNING
|
||||
|
||||
|
||||
static func _normalized_needs(
|
||||
source: Variant,
|
||||
clamp_values: bool,
|
||||
require_all: bool
|
||||
) -> Variant:
|
||||
var result: Dictionary = {}
|
||||
if not source is Dictionary:
|
||||
return null
|
||||
var seen: Dictionary = {}
|
||||
for raw_key: Variant in source:
|
||||
if not raw_key is String and not raw_key is StringName:
|
||||
return null
|
||||
var need_id := StringName(raw_key)
|
||||
if not NEED_IDS.has(need_id) or seen.has(need_id):
|
||||
return null
|
||||
var raw_value: Variant = source[raw_key]
|
||||
if not raw_value is int:
|
||||
return null
|
||||
seen[need_id] = true
|
||||
result[need_id] = clampi(raw_value, 0, 100) if clamp_values else raw_value
|
||||
if require_all and seen.size() != NEED_IDS.size():
|
||||
return null
|
||||
for need_id: StringName in NEED_IDS:
|
||||
if not result.has(need_id):
|
||||
result[need_id] = 0
|
||||
return result
|
||||
|
||||
|
||||
static func _normalized_clamped_dictionary(source: Variant) -> Dictionary:
|
||||
var result: Dictionary = {}
|
||||
if not source is Dictionary:
|
||||
return result
|
||||
var keys: Array = source.keys()
|
||||
keys.sort_custom(func(a: Variant, b: Variant) -> bool: return String(a) < String(b))
|
||||
for raw_key: Variant in keys:
|
||||
var key := StringName(raw_key)
|
||||
if not key.is_empty():
|
||||
result[key] = clampi(int(source[raw_key]), 0, 100)
|
||||
return result
|
||||
|
||||
|
||||
static func _normalized_clamped_dictionary_strict(source: Variant) -> Dictionary:
|
||||
var result: Dictionary = {}
|
||||
if not source is Dictionary:
|
||||
return result
|
||||
var keys: Array = source.keys()
|
||||
keys.sort_custom(func(a: Variant, b: Variant) -> bool: return String(a) < String(b))
|
||||
for raw_key: Variant in keys:
|
||||
var key := StringName(raw_key)
|
||||
if not key.is_empty():
|
||||
result[key] = int(source[raw_key])
|
||||
return result
|
||||
|
||||
|
||||
static func _sorted_unique_names(source: Variant) -> Array[StringName]:
|
||||
var result: Array[StringName] = []
|
||||
if not source is Array:
|
||||
return result
|
||||
for raw_value: Variant in source:
|
||||
var value := StringName(raw_value)
|
||||
if not value.is_empty() and not result.has(value):
|
||||
result.append(value)
|
||||
result.sort_custom(func(a: StringName, b: StringName) -> bool: return String(a) < String(b))
|
||||
return result
|
||||
|
||||
|
||||
static func _value(source: Dictionary, key: StringName, fallback: Variant) -> Variant:
|
||||
return source.get(key, source.get(String(key), fallback))
|
||||
|
||||
|
||||
static func _vector3i(value: Variant) -> Vector3i:
|
||||
if value is Vector3i:
|
||||
return value
|
||||
if value is Array and value.size() == 3:
|
||||
return Vector3i(int(value[0]), int(value[1]), int(value[2]))
|
||||
return Vector3i.ZERO
|
||||
|
||||
|
||||
static func _cell_to_array(cell: Vector3i) -> Array[int]:
|
||||
return [cell.x, cell.y, cell.z]
|
||||
|
||||
|
||||
static func _names_to_strings(source: Array[StringName]) -> Array[String]:
|
||||
var result: Array[String] = []
|
||||
for value: StringName in source:
|
||||
result.append(String(value))
|
||||
return result
|
||||
|
||||
|
||||
static func _string_key_dictionary(source: Dictionary) -> Dictionary:
|
||||
var result: Dictionary = {}
|
||||
var keys: Array = source.keys()
|
||||
keys.sort_custom(func(a: Variant, b: Variant) -> bool: return String(a) < String(b))
|
||||
for raw_key: Variant in keys:
|
||||
result[String(raw_key)] = source[raw_key]
|
||||
return result
|
||||
|
||||
|
||||
static func _copy(value: Variant) -> Variant:
|
||||
if value is Array or value is Dictionary:
|
||||
return value.duplicate(true)
|
||||
return value
|
||||
@@ -0,0 +1 @@
|
||||
uid://cip4mv8n4qotq
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1 @@
|
||||
uid://bms66msnt4qwy
|
||||
@@ -0,0 +1,125 @@
|
||||
class_name InventoryReservation
|
||||
extends RefCounted
|
||||
|
||||
enum State {
|
||||
ACTIVE,
|
||||
CONSUMED,
|
||||
RELEASED,
|
||||
EXPIRED,
|
||||
}
|
||||
|
||||
var reservation_id: StringName = &""
|
||||
var stack_id: StringName = &""
|
||||
var owner_inventory_id: StringName = &""
|
||||
var item_id: StringName = &""
|
||||
var quantity := 0
|
||||
var requester_id: StringName = &""
|
||||
var purpose: StringName = &""
|
||||
var created_at := 0
|
||||
var expires_at := -1
|
||||
var state: State = State.ACTIVE
|
||||
var terminal_at := -1
|
||||
|
||||
|
||||
func configure(
|
||||
p_reservation_id: StringName,
|
||||
p_stack_id: StringName,
|
||||
p_owner_inventory_id: StringName,
|
||||
p_item_id: StringName,
|
||||
p_quantity: int,
|
||||
p_requester_id: StringName,
|
||||
p_purpose: StringName,
|
||||
p_created_at: int,
|
||||
p_expires_at: int = -1
|
||||
) -> InventoryReservation:
|
||||
reservation_id = p_reservation_id
|
||||
stack_id = p_stack_id
|
||||
owner_inventory_id = p_owner_inventory_id
|
||||
item_id = p_item_id
|
||||
quantity = maxi(p_quantity, 0)
|
||||
requester_id = p_requester_id
|
||||
purpose = p_purpose
|
||||
created_at = maxi(p_created_at, 0)
|
||||
expires_at = p_expires_at
|
||||
return self
|
||||
|
||||
|
||||
func is_active() -> bool:
|
||||
return state == State.ACTIVE
|
||||
|
||||
|
||||
func is_expired(absolute_minute: int) -> bool:
|
||||
return expires_at >= 0 and absolute_minute >= expires_at
|
||||
|
||||
|
||||
func mark_consumed(absolute_minute: int) -> bool:
|
||||
if state != State.ACTIVE:
|
||||
return false
|
||||
state = State.CONSUMED
|
||||
terminal_at = absolute_minute
|
||||
return true
|
||||
|
||||
|
||||
func mark_released(absolute_minute: int) -> bool:
|
||||
if state != State.ACTIVE:
|
||||
return false
|
||||
state = State.RELEASED
|
||||
terminal_at = absolute_minute
|
||||
return true
|
||||
|
||||
|
||||
func mark_expired(absolute_minute: int) -> bool:
|
||||
if state != State.ACTIVE:
|
||||
return false
|
||||
state = State.EXPIRED
|
||||
terminal_at = absolute_minute
|
||||
return true
|
||||
|
||||
|
||||
func create_snapshot() -> Dictionary:
|
||||
return {
|
||||
&"reservation_id": String(reservation_id),
|
||||
&"stack_id": String(stack_id),
|
||||
&"owner_inventory_id": String(owner_inventory_id),
|
||||
&"item_id": String(item_id),
|
||||
&"quantity": quantity,
|
||||
&"requester_id": String(requester_id),
|
||||
&"purpose": String(purpose),
|
||||
&"created_at": created_at,
|
||||
&"expires_at": expires_at,
|
||||
&"state": State.keys()[state],
|
||||
&"terminal_at": terminal_at,
|
||||
}
|
||||
|
||||
|
||||
static func from_snapshot(snapshot: Dictionary) -> InventoryReservation:
|
||||
if not _is_valid_snapshot(snapshot):
|
||||
return null
|
||||
var state_index := State.keys().find(String(snapshot.get(&"state", "ACTIVE")))
|
||||
if state_index < 0:
|
||||
return null
|
||||
var reservation := InventoryReservation.new().configure(
|
||||
StringName(snapshot.get(&"reservation_id", "")),
|
||||
StringName(snapshot.get(&"stack_id", "")),
|
||||
StringName(snapshot.get(&"owner_inventory_id", "")),
|
||||
StringName(snapshot.get(&"item_id", "")),
|
||||
int(snapshot.get(&"quantity", 0)),
|
||||
StringName(snapshot.get(&"requester_id", "")),
|
||||
StringName(snapshot.get(&"purpose", "")),
|
||||
int(snapshot.get(&"created_at", 0)),
|
||||
int(snapshot.get(&"expires_at", -1))
|
||||
)
|
||||
reservation.state = state_index as State
|
||||
reservation.terminal_at = int(snapshot.get(&"terminal_at", -1))
|
||||
return reservation
|
||||
|
||||
|
||||
static func _is_valid_snapshot(snapshot: Dictionary) -> bool:
|
||||
return (
|
||||
not String(snapshot.get(&"reservation_id", "")).is_empty()
|
||||
and not String(snapshot.get(&"stack_id", "")).is_empty()
|
||||
and not String(snapshot.get(&"owner_inventory_id", "")).is_empty()
|
||||
and not String(snapshot.get(&"item_id", "")).is_empty()
|
||||
and int(snapshot.get(&"quantity", 0)) > 0
|
||||
and int(snapshot.get(&"created_at", -1)) >= 0
|
||||
)
|
||||
@@ -0,0 +1 @@
|
||||
uid://ci4av5wp5tmid
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1 @@
|
||||
uid://kuhl1ypidibh
|
||||
@@ -0,0 +1,481 @@
|
||||
class_name LogisticsDispatcher
|
||||
extends RefCounted
|
||||
|
||||
const LogisticsRequestType = preload("res://scripts/inventory/logistics_request.gd")
|
||||
|
||||
const LEGACY_SNAPSHOT_VERSION := 1
|
||||
const SNAPSHOT_VERSION := 2
|
||||
const PRIORITY_MULTIPLIER := 10
|
||||
const PATH_MINUTE_PENALTY := 3
|
||||
const DETOUR_MINUTE_PENALTY := 2
|
||||
const DEADLINE_RISK_WINDOW := 120
|
||||
|
||||
var last_report: Dictionary = {}
|
||||
var last_restore_error := ""
|
||||
var restored_snapshot_version := SNAPSHOT_VERSION
|
||||
|
||||
var _carrier_reserved_load: Dictionary = {}
|
||||
var _assignment_ownership: Dictionary = {}
|
||||
var _assignment_history: Array[Dictionary] = []
|
||||
|
||||
|
||||
func dispatch(
|
||||
requests: Array,
|
||||
carriers: Dictionary,
|
||||
absolute_minute: int = 0
|
||||
) -> Array[Dictionary]:
|
||||
var pending: Array[LogisticsRequest] = []
|
||||
for value: Variant in requests:
|
||||
if value is LogisticsRequest and value.is_dispatchable():
|
||||
pending.append(value)
|
||||
pending.sort_custom(func(a: LogisticsRequest, b: LogisticsRequest) -> bool:
|
||||
if a.priority != b.priority:
|
||||
return a.priority > b.priority
|
||||
var a_deadline := 9223372036854775807 if a.deadline < 0 else a.deadline
|
||||
var b_deadline := 9223372036854775807 if b.deadline < 0 else b.deadline
|
||||
if a_deadline != b_deadline:
|
||||
return a_deadline < b_deadline
|
||||
if a.created_at != b.created_at:
|
||||
return a.created_at < b.created_at
|
||||
return String(a.request_id) < String(b.request_id)
|
||||
)
|
||||
|
||||
var normalized_carriers := _normalize_carriers(carriers)
|
||||
# Carrier order is invariant for this dispatch pass. Building and sorting it
|
||||
# once avoids repeating the same allocation and lexical sort for every request.
|
||||
var ordered_carrier_ids := _sorted_names(normalized_carriers.keys())
|
||||
# A carrier can disappear from one scheduler projection while its accepted
|
||||
# assignment is still in flight. Keep those reservations until the owner
|
||||
# explicitly releases them; otherwise reappearing carriers can be overbooked.
|
||||
var reserved_loads: Dictionary = _carrier_reserved_load.duplicate(true)
|
||||
var working_loads: Dictionary = {}
|
||||
for carrier_id: StringName in ordered_carrier_ids:
|
||||
var carrier: Dictionary = normalized_carriers[carrier_id]
|
||||
working_loads[carrier_id] = maxi(
|
||||
int(carrier[&"current_load"]),
|
||||
int(_carrier_reserved_load.get(carrier_id, 0))
|
||||
)
|
||||
|
||||
var assignments: Array[Dictionary] = []
|
||||
var unassigned: Array[Dictionary] = []
|
||||
for request: LogisticsRequest in pending:
|
||||
if _assignment_ownership.has(request.request_id):
|
||||
unassigned.append({
|
||||
&"request_id": request.request_id,
|
||||
&"reason": &"REQUEST_ALREADY_OWNED",
|
||||
})
|
||||
continue
|
||||
var best: Dictionary = {}
|
||||
var deadline_score := _deadline_risk(request.deadline, absolute_minute)
|
||||
for carrier_id: StringName in ordered_carrier_ids:
|
||||
var carrier: Dictionary = normalized_carriers[carrier_id]
|
||||
var current_load := int(working_loads[carrier_id])
|
||||
if current_load + request.total_weight > int(carrier[&"capacity"]):
|
||||
continue
|
||||
var path_minutes := _path_minutes_for(carrier, request)
|
||||
if path_minutes < 0:
|
||||
continue
|
||||
# Keep score_candidate as the public single-candidate API, but avoid a
|
||||
# second path lookup while evaluating the same pair in a dispatch batch.
|
||||
var score := (
|
||||
request.priority * PRIORITY_MULTIPLIER
|
||||
- path_minutes * PATH_MINUTE_PENALTY
|
||||
- int(carrier[&"detour_minutes"]) * DETOUR_MINUTE_PENALTY
|
||||
+ _role_fitness(carrier, request)
|
||||
+ deadline_score
|
||||
)
|
||||
if (
|
||||
best.is_empty()
|
||||
or score > int(best[&"score"])
|
||||
or (score == int(best[&"score"]) and String(carrier_id) < String(best[&"carrier_id"]))
|
||||
):
|
||||
best = {
|
||||
&"carrier_id": carrier_id,
|
||||
&"score": score,
|
||||
&"path_minutes": path_minutes,
|
||||
&"position": carrier[&"position"],
|
||||
}
|
||||
if best.is_empty():
|
||||
unassigned.append({
|
||||
&"request_id": request.request_id,
|
||||
&"reason": &"NO_ELIGIBLE_CARRIER",
|
||||
})
|
||||
continue
|
||||
var selected_carrier_id: StringName = best[&"carrier_id"]
|
||||
if not request.assign(selected_carrier_id, absolute_minute):
|
||||
unassigned.append({
|
||||
&"request_id": request.request_id,
|
||||
&"reason": &"STATE_CHANGED",
|
||||
})
|
||||
continue
|
||||
working_loads[selected_carrier_id] = int(working_loads[selected_carrier_id]) + request.total_weight
|
||||
reserved_loads[selected_carrier_id] = (
|
||||
int(reserved_loads.get(selected_carrier_id, 0)) + request.total_weight
|
||||
)
|
||||
var assignment := {
|
||||
&"request_id": request.request_id,
|
||||
&"carrier_id": selected_carrier_id,
|
||||
&"score": int(best[&"score"]),
|
||||
&"path_minutes": int(best[&"path_minutes"]),
|
||||
&"weight": request.total_weight,
|
||||
&"assigned_at": absolute_minute,
|
||||
}
|
||||
assignments.append(assignment)
|
||||
_assignment_history.append(assignment.duplicate(true))
|
||||
_assignment_ownership[request.request_id] = assignment.duplicate(true)
|
||||
|
||||
_carrier_reserved_load = reserved_loads
|
||||
last_report = {
|
||||
&"assigned": assignments.duplicate(true),
|
||||
&"unassigned": unassigned,
|
||||
&"carrier_loads": _carrier_reserved_load.duplicate(true),
|
||||
}
|
||||
return assignments
|
||||
|
||||
|
||||
func score_candidate(
|
||||
request: LogisticsRequest,
|
||||
carrier: Dictionary,
|
||||
absolute_minute: int
|
||||
) -> int:
|
||||
var path_minutes := _path_minutes_for(carrier, request)
|
||||
if path_minutes < 0:
|
||||
return -2_147_483_648
|
||||
return (
|
||||
request.priority * PRIORITY_MULTIPLIER
|
||||
- path_minutes * PATH_MINUTE_PENALTY
|
||||
- int(carrier.get(&"detour_minutes", 0)) * DETOUR_MINUTE_PENALTY
|
||||
+ _role_fitness(carrier, request)
|
||||
+ _deadline_risk(request.deadline, absolute_minute)
|
||||
)
|
||||
|
||||
|
||||
func release_assignment(carrier_id: StringName, weight: int) -> int:
|
||||
var candidates: Array[StringName] = []
|
||||
for request_id: StringName in _sorted_names(_assignment_ownership.keys()):
|
||||
var ownership: Dictionary = _assignment_ownership[request_id]
|
||||
if ownership[&"carrier_id"] == carrier_id and int(ownership[&"weight"]) == weight:
|
||||
candidates.append(request_id)
|
||||
if candidates.size() == 1:
|
||||
release_assignment_for_request(candidates[0], carrier_id, weight)
|
||||
return get_reserved_load(carrier_id)
|
||||
if candidates.size() > 1 or restored_snapshot_version != LEGACY_SNAPSHOT_VERSION:
|
||||
return get_reserved_load(carrier_id)
|
||||
var next_load := maxi(int(_carrier_reserved_load.get(carrier_id, 0)) - maxi(weight, 0), 0)
|
||||
if next_load == 0:
|
||||
_carrier_reserved_load.erase(carrier_id)
|
||||
else:
|
||||
_carrier_reserved_load[carrier_id] = next_load
|
||||
return next_load
|
||||
|
||||
|
||||
func release_assignment_for_request(
|
||||
request_id: StringName,
|
||||
carrier_id: StringName,
|
||||
weight: int
|
||||
) -> bool:
|
||||
if request_id.is_empty() or carrier_id.is_empty() or weight <= 0 \
|
||||
or not _assignment_ownership.has(request_id):
|
||||
return false
|
||||
var ownership: Dictionary = _assignment_ownership[request_id]
|
||||
if ownership[&"carrier_id"] != carrier_id or int(ownership[&"weight"]) != weight:
|
||||
return false
|
||||
var current_load := int(_carrier_reserved_load.get(carrier_id, 0))
|
||||
if current_load < weight:
|
||||
return false
|
||||
var next_load := current_load - weight
|
||||
if next_load == 0:
|
||||
_carrier_reserved_load.erase(carrier_id)
|
||||
else:
|
||||
_carrier_reserved_load[carrier_id] = next_load
|
||||
_assignment_ownership.erase(request_id)
|
||||
return true
|
||||
|
||||
|
||||
func get_reserved_load(carrier_id: StringName) -> int:
|
||||
return int(_carrier_reserved_load.get(carrier_id, 0))
|
||||
|
||||
|
||||
func get_assignment_ownership() -> Dictionary:
|
||||
var result: Dictionary = {}
|
||||
for request_id: StringName in _sorted_names(_assignment_ownership.keys()):
|
||||
result[request_id] = (_assignment_ownership[request_id] as Dictionary).duplicate(true)
|
||||
return result
|
||||
|
||||
|
||||
func get_assignment_history() -> Array[Dictionary]:
|
||||
return _assignment_history.duplicate(true)
|
||||
|
||||
|
||||
func clear_runtime_loads() -> void:
|
||||
_carrier_reserved_load.clear()
|
||||
_assignment_ownership.clear()
|
||||
|
||||
|
||||
func create_snapshot() -> Dictionary:
|
||||
var carrier_loads: Array[Dictionary] = []
|
||||
for carrier_id: StringName in _sorted_names(_carrier_reserved_load.keys()):
|
||||
carrier_loads.append({
|
||||
&"carrier_id": String(carrier_id),
|
||||
&"reserved_load": int(_carrier_reserved_load[carrier_id]),
|
||||
})
|
||||
var history: Array[Dictionary] = []
|
||||
for assignment: Dictionary in _assignment_history:
|
||||
var serialized := assignment.duplicate(true)
|
||||
serialized[&"request_id"] = String(assignment[&"request_id"])
|
||||
serialized[&"carrier_id"] = String(assignment[&"carrier_id"])
|
||||
history.append(serialized)
|
||||
var ownership: Array[Dictionary] = []
|
||||
for request_id: StringName in _sorted_names(_assignment_ownership.keys()):
|
||||
var entry: Dictionary = _assignment_ownership[request_id]
|
||||
ownership.append({
|
||||
&"request_id": String(request_id),
|
||||
&"carrier_id": String(entry[&"carrier_id"]),
|
||||
&"weight": int(entry[&"weight"]),
|
||||
&"assigned_at": int(entry[&"assigned_at"]),
|
||||
})
|
||||
return {
|
||||
&"version": SNAPSHOT_VERSION,
|
||||
&"carrier_loads": carrier_loads,
|
||||
&"assignment_ownership": ownership,
|
||||
&"assignment_history": history,
|
||||
}
|
||||
|
||||
|
||||
func restore_snapshot(snapshot: Dictionary) -> bool:
|
||||
last_restore_error = ""
|
||||
var version := int(snapshot.get(&"version", -1))
|
||||
if version not in [LEGACY_SNAPSHOT_VERSION, SNAPSHOT_VERSION]:
|
||||
last_restore_error = "Unsupported logistics dispatcher snapshot version."
|
||||
return false
|
||||
var raw_loads: Variant = snapshot.get(&"carrier_loads", null)
|
||||
var raw_history: Variant = snapshot.get(&"assignment_history", null)
|
||||
var raw_ownership: Variant = snapshot.get(&"assignment_ownership", null)
|
||||
if not raw_loads is Array or not raw_history is Array \
|
||||
or (version == SNAPSHOT_VERSION and not raw_ownership is Array):
|
||||
last_restore_error = "Logistics dispatcher snapshot collections are malformed."
|
||||
return false
|
||||
|
||||
var next_loads: Dictionary = {}
|
||||
for raw_load: Variant in raw_loads:
|
||||
if not raw_load is Dictionary:
|
||||
last_restore_error = "Carrier load entry must be a Dictionary."
|
||||
return false
|
||||
var carrier_id := StringName(raw_load.get(&"carrier_id", ""))
|
||||
var reserved_load := int(raw_load.get(&"reserved_load", -1))
|
||||
if carrier_id == &"" or reserved_load < 0 or next_loads.has(carrier_id) \
|
||||
or (version == SNAPSHOT_VERSION and reserved_load == 0):
|
||||
last_restore_error = "Carrier load entry is invalid or duplicated."
|
||||
return false
|
||||
next_loads[carrier_id] = reserved_load
|
||||
|
||||
var next_history: Array[Dictionary] = []
|
||||
var history_fingerprints: Dictionary = {}
|
||||
for raw_assignment: Variant in raw_history:
|
||||
if not raw_assignment is Dictionary:
|
||||
last_restore_error = "Assignment history entry must be a Dictionary."
|
||||
return false
|
||||
var request_id := StringName(raw_assignment.get(&"request_id", ""))
|
||||
var carrier_id := StringName(raw_assignment.get(&"carrier_id", ""))
|
||||
var weight_value: Variant = raw_assignment.get(&"weight", null)
|
||||
var assigned_at_value: Variant = raw_assignment.get(&"assigned_at", null)
|
||||
if request_id == &"" or carrier_id == &"" or not weight_value is int \
|
||||
or int(weight_value) < (1 if version == SNAPSHOT_VERSION else 0) \
|
||||
or (version == SNAPSHOT_VERSION and (
|
||||
not assigned_at_value is int or int(assigned_at_value) < 0
|
||||
)):
|
||||
last_restore_error = "Assignment history entry is invalid."
|
||||
return false
|
||||
var assignment: Dictionary = raw_assignment.duplicate(true)
|
||||
assignment[&"request_id"] = request_id
|
||||
assignment[&"carrier_id"] = carrier_id
|
||||
var fingerprint := "%s|%s|%d|%d" % [
|
||||
request_id,
|
||||
carrier_id,
|
||||
int(weight_value),
|
||||
int(assigned_at_value) if assigned_at_value is int else -1,
|
||||
]
|
||||
if version == SNAPSHOT_VERSION and history_fingerprints.has(fingerprint):
|
||||
last_restore_error = "Assignment history contains an exact duplicate."
|
||||
return false
|
||||
history_fingerprints[fingerprint] = true
|
||||
next_history.append(assignment)
|
||||
|
||||
var next_ownership: Dictionary = {}
|
||||
if version == SNAPSHOT_VERSION:
|
||||
for raw_entry: Variant in raw_ownership:
|
||||
if not raw_entry is Dictionary:
|
||||
last_restore_error = "Assignment ownership entry must be a Dictionary."
|
||||
return false
|
||||
var request_id := StringName(raw_entry.get(&"request_id", ""))
|
||||
var carrier_id := StringName(raw_entry.get(&"carrier_id", ""))
|
||||
var weight_value: Variant = raw_entry.get(&"weight", null)
|
||||
var assigned_at_value: Variant = raw_entry.get(&"assigned_at", null)
|
||||
if request_id.is_empty() or carrier_id.is_empty() or next_ownership.has(request_id) \
|
||||
or not weight_value is int or int(weight_value) <= 0 \
|
||||
or not assigned_at_value is int or int(assigned_at_value) < 0:
|
||||
last_restore_error = "Assignment ownership entry is invalid or duplicated."
|
||||
return false
|
||||
next_ownership[request_id] = {
|
||||
&"request_id": request_id,
|
||||
&"carrier_id": carrier_id,
|
||||
&"weight": int(weight_value),
|
||||
&"assigned_at": int(assigned_at_value),
|
||||
}
|
||||
if not _ownership_matches_loads_and_history(next_ownership, next_loads, next_history):
|
||||
last_restore_error = "Assignment ownership does not match reserved loads or history."
|
||||
return false
|
||||
|
||||
_carrier_reserved_load = next_loads
|
||||
_assignment_ownership = next_ownership
|
||||
_assignment_history = next_history
|
||||
restored_snapshot_version = version
|
||||
last_restore_error = ""
|
||||
return true
|
||||
|
||||
|
||||
func migrate_legacy_assignment_ownership(expected: Dictionary) -> bool:
|
||||
if restored_snapshot_version != LEGACY_SNAPSHOT_VERSION or not _assignment_ownership.is_empty():
|
||||
return false
|
||||
var normalized: Dictionary = {}
|
||||
for request_id: StringName in _sorted_names(expected.keys()):
|
||||
var raw_entry: Variant = expected[request_id]
|
||||
if request_id.is_empty() or not raw_entry is Dictionary:
|
||||
return false
|
||||
var carrier_id := StringName(raw_entry.get(&"carrier_id", &""))
|
||||
var weight := int(raw_entry.get(&"weight", -1))
|
||||
var assigned_at := int(raw_entry.get(&"assigned_at", -1))
|
||||
if carrier_id.is_empty() or weight <= 0 or assigned_at < 0:
|
||||
return false
|
||||
normalized[request_id] = {
|
||||
&"request_id": request_id,
|
||||
&"carrier_id": carrier_id,
|
||||
&"weight": weight,
|
||||
&"assigned_at": assigned_at,
|
||||
}
|
||||
var rebuilt_loads: Dictionary = {}
|
||||
for request_id: StringName in _sorted_names(normalized.keys()):
|
||||
var entry: Dictionary = normalized[request_id]
|
||||
var carrier_id: StringName = entry[&"carrier_id"]
|
||||
rebuilt_loads[carrier_id] = int(rebuilt_loads.get(carrier_id, 0)) + int(entry[&"weight"])
|
||||
if int(_carrier_reserved_load.get(carrier_id, -1)) < int(rebuilt_loads[carrier_id]):
|
||||
return false
|
||||
# v1 mixed external carrier.current_load into this table. Active Requests plus
|
||||
# assignment history are the only durable ownership authority during migration;
|
||||
# any excess legacy load is therefore discarded deterministically.
|
||||
if not _ownership_matches_loads_and_history(
|
||||
normalized,
|
||||
rebuilt_loads,
|
||||
_assignment_history
|
||||
):
|
||||
return false
|
||||
_carrier_reserved_load = rebuilt_loads
|
||||
_assignment_ownership = normalized
|
||||
restored_snapshot_version = SNAPSHOT_VERSION
|
||||
return true
|
||||
|
||||
|
||||
static func _ownership_matches_loads_and_history(
|
||||
ownership: Dictionary,
|
||||
loads: Dictionary,
|
||||
history: Array[Dictionary]
|
||||
) -> bool:
|
||||
var expected_loads: Dictionary = {}
|
||||
var latest_by_request: Dictionary = {}
|
||||
for assignment: Dictionary in history:
|
||||
latest_by_request[StringName(assignment[&"request_id"])] = assignment
|
||||
for request_id: StringName in _sorted_names(ownership.keys()):
|
||||
var entry: Dictionary = ownership[request_id]
|
||||
var carrier_id: StringName = entry[&"carrier_id"]
|
||||
var weight := int(entry[&"weight"])
|
||||
expected_loads[carrier_id] = int(expected_loads.get(carrier_id, 0)) + weight
|
||||
if not latest_by_request.has(request_id):
|
||||
return false
|
||||
var latest: Dictionary = latest_by_request[request_id]
|
||||
if StringName(latest[&"carrier_id"]) != carrier_id \
|
||||
or int(latest[&"weight"]) != weight \
|
||||
or int(latest.get(&"assigned_at", -1)) != int(entry[&"assigned_at"]):
|
||||
return false
|
||||
if expected_loads.size() != loads.size():
|
||||
return false
|
||||
for carrier_id: StringName in _sorted_names(expected_loads.keys()):
|
||||
if int(loads.get(carrier_id, -1)) != int(expected_loads[carrier_id]):
|
||||
return false
|
||||
return true
|
||||
|
||||
|
||||
static func _normalize_carriers(carriers: Dictionary) -> Dictionary:
|
||||
var result: Dictionary = {}
|
||||
for raw_carrier_id: Variant in carriers:
|
||||
if not carriers[raw_carrier_id] is Dictionary:
|
||||
continue
|
||||
var carrier_id := StringName(raw_carrier_id)
|
||||
var source: Dictionary = carriers[raw_carrier_id]
|
||||
var capacity := int(source.get(&"capacity", source.get(&"capacity_weight", 0)))
|
||||
var current_load := maxi(int(source.get(&"current_load", 0)), 0)
|
||||
if carrier_id == &"" or capacity <= 0 or current_load > capacity:
|
||||
continue
|
||||
result[carrier_id] = {
|
||||
&"carrier_id": carrier_id,
|
||||
&"position": source.get(&"position", Vector3i.ZERO),
|
||||
&"capacity": capacity,
|
||||
&"current_load": current_load,
|
||||
&"role": StringName(source.get(&"role", &"")),
|
||||
&"role_fit": source.get(&"role_fit", 0),
|
||||
&"path_minutes": source.get(&"path_minutes", -1),
|
||||
&"detour_minutes": maxi(int(source.get(&"detour_minutes", 0)), 0),
|
||||
}
|
||||
return result
|
||||
|
||||
|
||||
static func _path_minutes_for(carrier: Dictionary, request: LogisticsRequest) -> int:
|
||||
var path_source: Variant = carrier.get(&"path_minutes", -1)
|
||||
if path_source is int or path_source is float:
|
||||
return int(path_source)
|
||||
if not path_source is Dictionary:
|
||||
return -1
|
||||
var paths: Dictionary = path_source
|
||||
for key: Variant in [request.request_id, request.source_inventory_id, &"default", "default"]:
|
||||
if paths.has(key):
|
||||
return int(paths[key])
|
||||
return -1
|
||||
|
||||
|
||||
static func _role_fitness(carrier: Dictionary, request: LogisticsRequest) -> int:
|
||||
var explicit_fit: Variant = carrier.get(&"role_fit", 0)
|
||||
if explicit_fit is Dictionary:
|
||||
var fits: Dictionary = explicit_fit
|
||||
if fits.has(request.request_id):
|
||||
return int(fits[request.request_id])
|
||||
if fits.has(&"default"):
|
||||
return int(fits[&"default"])
|
||||
elif explicit_fit is int or explicit_fit is float:
|
||||
var fit := int(explicit_fit)
|
||||
if fit != 0:
|
||||
return fit
|
||||
match String(carrier.get(&"role", &"")).to_lower():
|
||||
"logistics":
|
||||
return 100
|
||||
"porter":
|
||||
return 80
|
||||
"carrier":
|
||||
return 60
|
||||
_:
|
||||
return 0
|
||||
|
||||
|
||||
static func _deadline_risk(deadline: int, absolute_minute: int) -> int:
|
||||
if deadline < 0:
|
||||
return 0
|
||||
var remaining := deadline - absolute_minute
|
||||
if remaining <= 0:
|
||||
return 200 + mini(-remaining, 100)
|
||||
return maxi(DEADLINE_RISK_WINDOW - remaining, 0)
|
||||
|
||||
|
||||
static func _sorted_names(values: Variant) -> Array[StringName]:
|
||||
var result: Array[StringName] = []
|
||||
for value: Variant in values:
|
||||
result.append(StringName(value))
|
||||
result.sort_custom(func(a: StringName, b: StringName) -> bool: return String(a) < String(b))
|
||||
return result
|
||||
@@ -0,0 +1 @@
|
||||
uid://bakay5aor6q03
|
||||
@@ -0,0 +1,222 @@
|
||||
class_name LogisticsRequest
|
||||
extends RefCounted
|
||||
|
||||
enum State {
|
||||
OPEN,
|
||||
RESERVED,
|
||||
ASSIGNED,
|
||||
PICKING_UP,
|
||||
DELIVERING,
|
||||
COMPLETED,
|
||||
BLOCKED,
|
||||
CANCELLED,
|
||||
}
|
||||
|
||||
const MAX_PRIORITY := 100
|
||||
|
||||
var request_id: StringName = &""
|
||||
var source_inventory_id: StringName = &""
|
||||
var destination_inventory_id: StringName = &""
|
||||
var item_requirements: Dictionary = {}
|
||||
var priority := 0
|
||||
var created_at := 0
|
||||
var deadline := -1
|
||||
var total_weight := 0
|
||||
var reserved_stack_ids: Array[StringName] = []
|
||||
var reservation_ids: Array[StringName] = []
|
||||
var assigned_carrier_id: StringName = &""
|
||||
var state: State = State.OPEN
|
||||
var retry_count := 0
|
||||
var state_reason := ""
|
||||
var state_changed_at := 0
|
||||
|
||||
|
||||
func configure(
|
||||
p_request_id: StringName,
|
||||
p_source_inventory_id: StringName,
|
||||
p_destination_inventory_id: StringName,
|
||||
p_item_requirements: Dictionary,
|
||||
p_priority: int,
|
||||
p_created_at: int,
|
||||
p_deadline: int,
|
||||
p_total_weight: int
|
||||
) -> LogisticsRequest:
|
||||
request_id = p_request_id
|
||||
source_inventory_id = p_source_inventory_id
|
||||
destination_inventory_id = p_destination_inventory_id
|
||||
item_requirements = _normalize_requirements(p_item_requirements)
|
||||
priority = clampi(p_priority, 0, MAX_PRIORITY)
|
||||
created_at = maxi(p_created_at, 0)
|
||||
deadline = p_deadline
|
||||
total_weight = maxi(p_total_weight, 0)
|
||||
state_changed_at = created_at
|
||||
return self
|
||||
|
||||
|
||||
func mark_reserved(
|
||||
p_reservation_ids: Array[StringName],
|
||||
p_reserved_stack_ids: Array[StringName] = [],
|
||||
absolute_minute: int = -1
|
||||
) -> bool:
|
||||
if state != State.OPEN and state != State.BLOCKED:
|
||||
return false
|
||||
if p_reservation_ids.is_empty():
|
||||
return false
|
||||
reservation_ids.assign(p_reservation_ids)
|
||||
reserved_stack_ids.assign(p_reserved_stack_ids)
|
||||
assigned_carrier_id = &""
|
||||
return transition_to(State.RESERVED, "", absolute_minute)
|
||||
|
||||
|
||||
func assign(carrier_id: StringName, absolute_minute: int = -1) -> bool:
|
||||
if carrier_id == &"" or state != State.RESERVED:
|
||||
return false
|
||||
assigned_carrier_id = carrier_id
|
||||
if transition_to(State.ASSIGNED, "", absolute_minute):
|
||||
return true
|
||||
assigned_carrier_id = &""
|
||||
return false
|
||||
|
||||
|
||||
func unassign(absolute_minute: int = -1) -> bool:
|
||||
if state != State.ASSIGNED:
|
||||
return false
|
||||
assigned_carrier_id = &""
|
||||
return transition_to(State.RESERVED, "", absolute_minute)
|
||||
|
||||
|
||||
func mark_blocked(reason: String, absolute_minute: int = -1) -> bool:
|
||||
if state == State.COMPLETED or state == State.CANCELLED:
|
||||
return false
|
||||
retry_count += 1
|
||||
assigned_carrier_id = &""
|
||||
return transition_to(State.BLOCKED, reason, absolute_minute)
|
||||
|
||||
|
||||
func cancel(reason: String = "", absolute_minute: int = -1) -> bool:
|
||||
return transition_to(State.CANCELLED, reason, absolute_minute)
|
||||
|
||||
|
||||
func transition_to(next_state: State, reason: String = "", absolute_minute: int = -1) -> bool:
|
||||
if state == next_state:
|
||||
return true
|
||||
if not _can_transition(state, next_state):
|
||||
return false
|
||||
state = next_state
|
||||
state_reason = reason
|
||||
state_changed_at = created_at if absolute_minute < 0 else absolute_minute
|
||||
if state == State.COMPLETED or state == State.CANCELLED:
|
||||
assigned_carrier_id = &""
|
||||
return true
|
||||
|
||||
|
||||
func is_terminal() -> bool:
|
||||
return state == State.COMPLETED or state == State.CANCELLED
|
||||
|
||||
|
||||
func is_dispatchable() -> bool:
|
||||
return state == State.RESERVED and assigned_carrier_id == &"" and total_weight > 0
|
||||
|
||||
|
||||
func create_snapshot() -> Dictionary:
|
||||
return {
|
||||
&"request_id": String(request_id),
|
||||
&"source_inventory_id": String(source_inventory_id),
|
||||
&"destination_inventory_id": String(destination_inventory_id),
|
||||
&"item_requirements": item_requirements.duplicate(true),
|
||||
&"priority": priority,
|
||||
&"created_at": created_at,
|
||||
&"deadline": deadline,
|
||||
&"total_weight": total_weight,
|
||||
&"reserved_stack_ids": _names_to_strings(reserved_stack_ids),
|
||||
&"reservation_ids": _names_to_strings(reservation_ids),
|
||||
&"assigned_carrier_id": String(assigned_carrier_id),
|
||||
&"state": State.keys()[state],
|
||||
&"retry_count": retry_count,
|
||||
&"state_reason": state_reason,
|
||||
&"state_changed_at": state_changed_at,
|
||||
}
|
||||
|
||||
|
||||
static func from_snapshot(snapshot: Dictionary) -> LogisticsRequest:
|
||||
if not _is_valid_snapshot(snapshot):
|
||||
return null
|
||||
var state_index := State.keys().find(String(snapshot.get(&"state", "OPEN")))
|
||||
if state_index < 0:
|
||||
return null
|
||||
var request := LogisticsRequest.new().configure(
|
||||
StringName(snapshot.get(&"request_id", "")),
|
||||
StringName(snapshot.get(&"source_inventory_id", "")),
|
||||
StringName(snapshot.get(&"destination_inventory_id", "")),
|
||||
snapshot.get(&"item_requirements", {}),
|
||||
int(snapshot.get(&"priority", 0)),
|
||||
int(snapshot.get(&"created_at", 0)),
|
||||
int(snapshot.get(&"deadline", -1)),
|
||||
int(snapshot.get(&"total_weight", 0))
|
||||
)
|
||||
request.reserved_stack_ids = _parse_names(snapshot.get(&"reserved_stack_ids", []))
|
||||
request.reservation_ids = _parse_names(snapshot.get(&"reservation_ids", []))
|
||||
request.assigned_carrier_id = StringName(snapshot.get(&"assigned_carrier_id", ""))
|
||||
request.state = state_index as State
|
||||
request.retry_count = maxi(int(snapshot.get(&"retry_count", 0)), 0)
|
||||
request.state_reason = String(snapshot.get(&"state_reason", ""))
|
||||
request.state_changed_at = int(snapshot.get(&"state_changed_at", request.created_at))
|
||||
if request.state == State.ASSIGNED and request.assigned_carrier_id == &"":
|
||||
return null
|
||||
return request
|
||||
|
||||
|
||||
static func _can_transition(from_state: State, to_state: State) -> bool:
|
||||
match from_state:
|
||||
State.OPEN:
|
||||
return to_state in [State.RESERVED, State.BLOCKED, State.CANCELLED]
|
||||
State.RESERVED:
|
||||
return to_state in [State.OPEN, State.ASSIGNED, State.BLOCKED, State.CANCELLED]
|
||||
State.ASSIGNED:
|
||||
return to_state in [State.RESERVED, State.PICKING_UP, State.BLOCKED, State.CANCELLED]
|
||||
State.PICKING_UP:
|
||||
return to_state in [State.DELIVERING, State.BLOCKED, State.CANCELLED]
|
||||
State.DELIVERING:
|
||||
return to_state in [State.COMPLETED, State.BLOCKED, State.CANCELLED]
|
||||
State.BLOCKED:
|
||||
return to_state in [State.OPEN, State.RESERVED, State.CANCELLED]
|
||||
_:
|
||||
return false
|
||||
|
||||
|
||||
static func _normalize_requirements(source: Variant) -> Dictionary:
|
||||
var normalized: Dictionary = {}
|
||||
if not source is Dictionary:
|
||||
return normalized
|
||||
for raw_item_id: Variant in source:
|
||||
var item_id := StringName(raw_item_id)
|
||||
var quantity := int(source[raw_item_id])
|
||||
if item_id != &"" and quantity > 0:
|
||||
normalized[item_id] = quantity
|
||||
return normalized
|
||||
|
||||
|
||||
static func _is_valid_snapshot(snapshot: Dictionary) -> bool:
|
||||
return (
|
||||
not String(snapshot.get(&"request_id", "")).is_empty()
|
||||
and not String(snapshot.get(&"source_inventory_id", "")).is_empty()
|
||||
and not String(snapshot.get(&"destination_inventory_id", "")).is_empty()
|
||||
and snapshot.get(&"item_requirements", null) is Dictionary
|
||||
and int(snapshot.get(&"total_weight", -1)) >= 0
|
||||
)
|
||||
|
||||
|
||||
static func _parse_names(source: Variant) -> Array[StringName]:
|
||||
var result: Array[StringName] = []
|
||||
if not source is Array:
|
||||
return result
|
||||
for value: Variant in source:
|
||||
result.append(StringName(value))
|
||||
return result
|
||||
|
||||
|
||||
static func _names_to_strings(source: Array[StringName]) -> Array[String]:
|
||||
var result: Array[String] = []
|
||||
for value: StringName in source:
|
||||
result.append(String(value))
|
||||
return result
|
||||
@@ -0,0 +1 @@
|
||||
uid://bny06mspvcg8u
|
||||
@@ -0,0 +1,574 @@
|
||||
class_name ManaNetworkService
|
||||
extends RefCounted
|
||||
|
||||
## Deterministic Mana graph, allocation, storage and brownout simulation.
|
||||
##
|
||||
## The graph is intentionally separate from the walking graph. Authoritative
|
||||
## values are integers so save/load and repeated simulations produce the same
|
||||
## result on every platform.
|
||||
|
||||
signal consumer_state_changed(consumer_id: StringName, previous_state: int, next_state: int, reasons: Array[StringName])
|
||||
|
||||
enum NodeKind {
|
||||
SOURCE,
|
||||
CONSUMER,
|
||||
CONNECTOR,
|
||||
STORAGE,
|
||||
}
|
||||
|
||||
enum ConsumerPriority {
|
||||
LUXURY = 0,
|
||||
NORMAL = 50,
|
||||
ESSENTIAL = 100,
|
||||
}
|
||||
|
||||
enum ConsumerState {
|
||||
ACTIVE,
|
||||
DEGRADED,
|
||||
NO_POWER,
|
||||
DISABLED,
|
||||
}
|
||||
|
||||
const SNAPSHOT_VERSION := 1
|
||||
const EVALUATION_INTERVAL_MINUTES := 10
|
||||
const MINIMUM_OFF_HOLD_MINUTES := 10
|
||||
const DEGRADED_THRESHOLD_PERCENT := 75
|
||||
|
||||
var revision := 0
|
||||
var last_evaluation_minute := -1
|
||||
var last_error := ""
|
||||
|
||||
var _nodes: Dictionary = {}
|
||||
var _adjacency: Dictionary = {}
|
||||
var _last_report: Dictionary = {}
|
||||
|
||||
|
||||
func register_source(node_id: StringName, output: int, enabled := true) -> bool:
|
||||
if output < 0:
|
||||
return _fail("Source output cannot be negative.")
|
||||
return _register_node(node_id, NodeKind.SOURCE, {
|
||||
&"output": output,
|
||||
&"enabled": enabled,
|
||||
})
|
||||
|
||||
|
||||
func register_consumer(
|
||||
node_id: StringName,
|
||||
demand: int,
|
||||
priority := ConsumerPriority.NORMAL,
|
||||
enabled := true
|
||||
) -> bool:
|
||||
if demand < 0:
|
||||
return _fail("Consumer demand cannot be negative.")
|
||||
return _register_node(node_id, NodeKind.CONSUMER, {
|
||||
&"demand": demand,
|
||||
&"priority": priority,
|
||||
&"enabled": enabled,
|
||||
&"allocated": 0,
|
||||
&"state": ConsumerState.NO_POWER if enabled else ConsumerState.DISABLED,
|
||||
&"state_reasons": [&"awaiting_evaluation"] if enabled else [&"disabled"],
|
||||
&"hold_until": 0,
|
||||
})
|
||||
|
||||
|
||||
func register_connector(node_id: StringName, enabled := true) -> bool:
|
||||
return _register_node(node_id, NodeKind.CONNECTOR, {&"enabled": enabled})
|
||||
|
||||
|
||||
func register_storage(
|
||||
node_id: StringName,
|
||||
capacity: int,
|
||||
current: int,
|
||||
max_flow: int,
|
||||
enabled := true
|
||||
) -> bool:
|
||||
if capacity < 0 or current < 0 or current > capacity or max_flow < 0:
|
||||
return _fail("Storage values must satisfy 0 <= current <= capacity and max_flow >= 0.")
|
||||
return _register_node(node_id, NodeKind.STORAGE, {
|
||||
&"capacity": capacity,
|
||||
&"current": current,
|
||||
&"max_flow": max_flow,
|
||||
&"enabled": enabled,
|
||||
})
|
||||
|
||||
|
||||
func unregister_node(node_id: StringName) -> bool:
|
||||
if not _nodes.has(node_id):
|
||||
return _fail("Unknown Mana node: %s" % node_id)
|
||||
var neighbors: Array = _adjacency.get(node_id, {}).keys()
|
||||
for neighbor: Variant in neighbors:
|
||||
_adjacency[neighbor].erase(node_id)
|
||||
_adjacency.erase(node_id)
|
||||
_nodes.erase(node_id)
|
||||
revision += 1
|
||||
last_error = ""
|
||||
return true
|
||||
|
||||
|
||||
func connect_nodes(first_id: StringName, second_id: StringName) -> bool:
|
||||
if first_id == second_id:
|
||||
return _fail("A Mana node cannot connect to itself.")
|
||||
if not _nodes.has(first_id) or not _nodes.has(second_id):
|
||||
return _fail("Both Mana nodes must exist before they are connected.")
|
||||
if _adjacency[first_id].has(second_id):
|
||||
last_error = ""
|
||||
return true
|
||||
_adjacency[first_id][second_id] = true
|
||||
_adjacency[second_id][first_id] = true
|
||||
revision += 1
|
||||
last_error = ""
|
||||
return true
|
||||
|
||||
|
||||
func disconnect_nodes(first_id: StringName, second_id: StringName) -> bool:
|
||||
if not _adjacency.has(first_id) or not _adjacency[first_id].has(second_id):
|
||||
return _fail("Mana edge does not exist.")
|
||||
_adjacency[first_id].erase(second_id)
|
||||
_adjacency[second_id].erase(first_id)
|
||||
revision += 1
|
||||
last_error = ""
|
||||
return true
|
||||
|
||||
|
||||
func set_node_enabled(node_id: StringName, enabled: bool) -> bool:
|
||||
if not _nodes.has(node_id):
|
||||
return _fail("Unknown Mana node: %s" % node_id)
|
||||
var node: Dictionary = _nodes[node_id]
|
||||
if bool(node[&"enabled"]) == enabled:
|
||||
last_error = ""
|
||||
return true
|
||||
node[&"enabled"] = enabled
|
||||
if int(node[&"kind"]) == NodeKind.CONSUMER and not enabled:
|
||||
node[&"allocated"] = 0
|
||||
_set_consumer_state(node_id, node, ConsumerState.DISABLED, [&"disabled"])
|
||||
_nodes[node_id] = node
|
||||
revision += 1
|
||||
last_error = ""
|
||||
return true
|
||||
|
||||
|
||||
func set_source_output(node_id: StringName, output: int) -> bool:
|
||||
if output < 0 or not _is_kind(node_id, NodeKind.SOURCE):
|
||||
return _fail("A valid source and non-negative output are required.")
|
||||
var node: Dictionary = _nodes[node_id]
|
||||
node[&"output"] = output
|
||||
_nodes[node_id] = node
|
||||
revision += 1
|
||||
last_error = ""
|
||||
return true
|
||||
|
||||
|
||||
func set_consumer_demand(node_id: StringName, demand: int) -> bool:
|
||||
if demand < 0 or not _is_kind(node_id, NodeKind.CONSUMER):
|
||||
return _fail("A valid consumer and non-negative demand are required.")
|
||||
var node: Dictionary = _nodes[node_id]
|
||||
node[&"demand"] = demand
|
||||
_nodes[node_id] = node
|
||||
revision += 1
|
||||
last_error = ""
|
||||
return true
|
||||
|
||||
|
||||
func set_consumer_priority(node_id: StringName, priority: int) -> bool:
|
||||
if not _is_kind(node_id, NodeKind.CONSUMER):
|
||||
return _fail("A valid consumer is required.")
|
||||
var node: Dictionary = _nodes[node_id]
|
||||
node[&"priority"] = priority
|
||||
_nodes[node_id] = node
|
||||
revision += 1
|
||||
last_error = ""
|
||||
return true
|
||||
|
||||
|
||||
func advance_one_minute(absolute_minute: int) -> Dictionary:
|
||||
if absolute_minute < 0:
|
||||
return {&"evaluated": false, &"error": "Minute cannot be negative."}
|
||||
if absolute_minute % EVALUATION_INTERVAL_MINUTES != 0 \
|
||||
or absolute_minute == last_evaluation_minute:
|
||||
return {&"evaluated": false, &"minute": absolute_minute}
|
||||
return evaluate_now(absolute_minute)
|
||||
|
||||
|
||||
func evaluate_now(absolute_minute: int) -> Dictionary:
|
||||
if absolute_minute < 0:
|
||||
return {&"evaluated": false, &"error": "Minute cannot be negative."}
|
||||
var changes: Array = []
|
||||
var component_reports: Array = []
|
||||
for component: Array[StringName] in _connected_components():
|
||||
component_reports.append(_evaluate_component(component, absolute_minute, changes))
|
||||
last_evaluation_minute = absolute_minute
|
||||
_last_report = {
|
||||
&"evaluated": true,
|
||||
&"minute": absolute_minute,
|
||||
&"components": component_reports,
|
||||
&"state_changes": changes,
|
||||
}
|
||||
last_error = ""
|
||||
return _last_report.duplicate(true)
|
||||
|
||||
|
||||
func get_node(node_id: StringName) -> Dictionary:
|
||||
if not _nodes.has(node_id):
|
||||
return {}
|
||||
return (_nodes[node_id] as Dictionary).duplicate(true)
|
||||
|
||||
|
||||
func get_consumer_state(node_id: StringName) -> int:
|
||||
if not _is_kind(node_id, NodeKind.CONSUMER):
|
||||
return -1
|
||||
return int(_nodes[node_id][&"state"])
|
||||
|
||||
|
||||
func get_allocated_mana(node_id: StringName) -> int:
|
||||
if not _is_kind(node_id, NodeKind.CONSUMER):
|
||||
return 0
|
||||
return int(_nodes[node_id][&"allocated"])
|
||||
|
||||
|
||||
func get_storage_current(node_id: StringName) -> int:
|
||||
if not _is_kind(node_id, NodeKind.STORAGE):
|
||||
return 0
|
||||
return int(_nodes[node_id][&"current"])
|
||||
|
||||
|
||||
func get_last_report() -> Dictionary:
|
||||
return _last_report.duplicate(true)
|
||||
|
||||
|
||||
func node_count() -> int:
|
||||
return _nodes.size()
|
||||
|
||||
|
||||
func edge_count() -> int:
|
||||
var doubled := 0
|
||||
for neighbors: Variant in _adjacency.values():
|
||||
doubled += (neighbors as Dictionary).size()
|
||||
return doubled / 2
|
||||
|
||||
|
||||
func validate_invariants() -> Dictionary:
|
||||
var errors: Array[String] = []
|
||||
for node_id: Variant in _sorted_ids(_nodes.keys()):
|
||||
var node: Dictionary = _nodes[node_id]
|
||||
if not _adjacency.has(node_id):
|
||||
errors.append("Node %s has no adjacency entry." % node_id)
|
||||
if int(node[&"kind"]) == NodeKind.STORAGE:
|
||||
var current := int(node[&"current"])
|
||||
var capacity := int(node[&"capacity"])
|
||||
if current < 0 or current > capacity:
|
||||
errors.append("Storage %s is outside its capacity." % node_id)
|
||||
if int(node[&"kind"]) == NodeKind.CONSUMER:
|
||||
var allocation := int(node[&"allocated"])
|
||||
if allocation < 0 or allocation > int(node[&"demand"]):
|
||||
errors.append("Consumer %s has an invalid allocation." % node_id)
|
||||
for first_id: Variant in _adjacency:
|
||||
for second_id: Variant in _adjacency[first_id]:
|
||||
if not _adjacency.has(second_id) or not _adjacency[second_id].has(first_id):
|
||||
errors.append("Edge %s-%s is not symmetric." % [first_id, second_id])
|
||||
return {&"valid": errors.is_empty(), &"errors": errors}
|
||||
|
||||
|
||||
func create_snapshot() -> Dictionary:
|
||||
var serialized_nodes: Array = []
|
||||
for node_id: StringName in _sorted_ids(_nodes.keys()):
|
||||
var node: Dictionary = (_nodes[node_id] as Dictionary).duplicate(true)
|
||||
node[&"id"] = String(node_id)
|
||||
var serialized_reasons: Array[String] = []
|
||||
for reason: Variant in node.get(&"state_reasons", []):
|
||||
serialized_reasons.append(String(reason))
|
||||
if node.has(&"state_reasons"):
|
||||
node[&"state_reasons"] = serialized_reasons
|
||||
serialized_nodes.append(node)
|
||||
|
||||
var edges: Array = []
|
||||
for first_id: StringName in _sorted_ids(_adjacency.keys()):
|
||||
for second_id: StringName in _sorted_ids(_adjacency[first_id].keys()):
|
||||
if String(first_id) < String(second_id):
|
||||
edges.append([String(first_id), String(second_id)])
|
||||
return {
|
||||
&"version": SNAPSHOT_VERSION,
|
||||
&"revision": revision,
|
||||
&"last_evaluation_minute": last_evaluation_minute,
|
||||
&"nodes": serialized_nodes,
|
||||
&"edges": edges,
|
||||
}
|
||||
|
||||
|
||||
func restore_snapshot(snapshot: Dictionary) -> bool:
|
||||
if int(snapshot.get(&"version", 0)) != SNAPSHOT_VERSION:
|
||||
return _fail("Unsupported Mana snapshot version.")
|
||||
var raw_nodes: Variant = snapshot.get(&"nodes", null)
|
||||
var raw_edges: Variant = snapshot.get(&"edges", null)
|
||||
if not raw_nodes is Array or not raw_edges is Array:
|
||||
return _fail("Mana snapshot nodes and edges must be Arrays.")
|
||||
|
||||
var next_nodes: Dictionary = {}
|
||||
var next_adjacency: Dictionary = {}
|
||||
for raw_node: Variant in raw_nodes:
|
||||
var normalization := _normalize_saved_node(raw_node)
|
||||
if not normalization[&"ok"]:
|
||||
return _fail(normalization[&"error"])
|
||||
var node: Dictionary = normalization[&"node"]
|
||||
var node_id: StringName = node[&"id"]
|
||||
if next_nodes.has(node_id):
|
||||
return _fail("Duplicate Mana node in snapshot: %s" % node_id)
|
||||
next_nodes[node_id] = node
|
||||
next_adjacency[node_id] = {}
|
||||
|
||||
for raw_edge: Variant in raw_edges:
|
||||
if not raw_edge is Array or raw_edge.size() != 2:
|
||||
return _fail("Mana snapshot edge must contain two node ids.")
|
||||
var first_id := StringName(raw_edge[0])
|
||||
var second_id := StringName(raw_edge[1])
|
||||
if first_id == second_id or not next_nodes.has(first_id) or not next_nodes.has(second_id):
|
||||
return _fail("Mana snapshot contains an invalid edge.")
|
||||
next_adjacency[first_id][second_id] = true
|
||||
next_adjacency[second_id][first_id] = true
|
||||
|
||||
_nodes = next_nodes
|
||||
_adjacency = next_adjacency
|
||||
revision = maxi(int(snapshot.get(&"revision", 0)), 0)
|
||||
last_evaluation_minute = int(snapshot.get(&"last_evaluation_minute", -1))
|
||||
_last_report = {}
|
||||
last_error = ""
|
||||
var validation := validate_invariants()
|
||||
if not validation[&"valid"]:
|
||||
return _fail("Restored Mana state violates its invariants.")
|
||||
return true
|
||||
|
||||
|
||||
func _register_node(node_id: StringName, kind: int, fields: Dictionary) -> bool:
|
||||
if node_id.is_empty():
|
||||
return _fail("Mana node id cannot be empty.")
|
||||
if _nodes.has(node_id):
|
||||
return _fail("Duplicate Mana node: %s" % node_id)
|
||||
var node := {
|
||||
&"id": node_id,
|
||||
&"kind": kind,
|
||||
&"enabled": true,
|
||||
}
|
||||
for key: Variant in fields:
|
||||
node[key] = fields[key]
|
||||
_nodes[node_id] = node
|
||||
_adjacency[node_id] = {}
|
||||
revision += 1
|
||||
last_error = ""
|
||||
return true
|
||||
|
||||
|
||||
func _evaluate_component(
|
||||
component: Array[StringName],
|
||||
absolute_minute: int,
|
||||
changes: Array
|
||||
) -> Dictionary:
|
||||
var generation := 0
|
||||
var consumers: Array[StringName] = []
|
||||
var storages: Array[StringName] = []
|
||||
for node_id: StringName in component:
|
||||
var node: Dictionary = _nodes[node_id]
|
||||
if not bool(node[&"enabled"]):
|
||||
if int(node[&"kind"]) == NodeKind.CONSUMER:
|
||||
node[&"allocated"] = 0
|
||||
_record_state_change(node_id, node, ConsumerState.DISABLED, [&"disabled"], changes)
|
||||
_nodes[node_id] = node
|
||||
continue
|
||||
match int(node[&"kind"]):
|
||||
NodeKind.SOURCE:
|
||||
generation += int(node[&"output"])
|
||||
NodeKind.CONSUMER:
|
||||
consumers.append(node_id)
|
||||
NodeKind.STORAGE:
|
||||
storages.append(node_id)
|
||||
consumers.sort_custom(Callable(self, "_consumer_precedes"))
|
||||
storages = _sorted_ids(storages)
|
||||
|
||||
var eligible_demand := 0
|
||||
for consumer_id: StringName in consumers:
|
||||
var consumer: Dictionary = _nodes[consumer_id]
|
||||
if absolute_minute >= int(consumer[&"hold_until"]):
|
||||
eligible_demand += int(consumer[&"demand"])
|
||||
|
||||
var discharged := 0
|
||||
var shortage := maxi(eligible_demand - generation, 0)
|
||||
for storage_id: StringName in storages:
|
||||
if shortage <= 0:
|
||||
break
|
||||
var storage: Dictionary = _nodes[storage_id]
|
||||
var amount := mini(shortage, mini(int(storage[&"current"]), int(storage[&"max_flow"])))
|
||||
storage[&"current"] = int(storage[&"current"]) - amount
|
||||
_nodes[storage_id] = storage
|
||||
shortage -= amount
|
||||
discharged += amount
|
||||
|
||||
var remaining := generation + discharged
|
||||
var allocated_total := 0
|
||||
for consumer_id: StringName in consumers:
|
||||
var consumer: Dictionary = _nodes[consumer_id]
|
||||
var demand := int(consumer[&"demand"])
|
||||
if absolute_minute < int(consumer[&"hold_until"]):
|
||||
consumer[&"allocated"] = 0
|
||||
_record_state_change(consumer_id, consumer, ConsumerState.NO_POWER, [&"minimum_off_hold"], changes)
|
||||
_nodes[consumer_id] = consumer
|
||||
continue
|
||||
var allocation := mini(demand, remaining)
|
||||
remaining -= allocation
|
||||
allocated_total += allocation
|
||||
consumer[&"allocated"] = allocation
|
||||
var next_state := ConsumerState.ACTIVE
|
||||
var reasons: Array[StringName] = []
|
||||
if demand > 0 and allocation < demand:
|
||||
if allocation * 100 >= demand * DEGRADED_THRESHOLD_PERCENT:
|
||||
next_state = ConsumerState.DEGRADED
|
||||
reasons = [&"brownout"]
|
||||
else:
|
||||
next_state = ConsumerState.NO_POWER
|
||||
reasons = [&"insufficient_supply"]
|
||||
consumer[&"hold_until"] = absolute_minute + MINIMUM_OFF_HOLD_MINUTES
|
||||
_record_state_change(consumer_id, consumer, next_state, reasons, changes)
|
||||
_nodes[consumer_id] = consumer
|
||||
|
||||
var charged := 0
|
||||
for storage_id: StringName in storages:
|
||||
if remaining <= 0:
|
||||
break
|
||||
var storage: Dictionary = _nodes[storage_id]
|
||||
var headroom := int(storage[&"capacity"]) - int(storage[&"current"])
|
||||
var amount := mini(remaining, mini(headroom, int(storage[&"max_flow"])))
|
||||
storage[&"current"] = int(storage[&"current"]) + amount
|
||||
_nodes[storage_id] = storage
|
||||
remaining -= amount
|
||||
charged += amount
|
||||
|
||||
return {
|
||||
&"component_id": String(component[0]) if not component.is_empty() else "",
|
||||
&"generation": generation,
|
||||
&"discharged": discharged,
|
||||
&"charged": charged,
|
||||
&"allocated": allocated_total,
|
||||
&"unused": remaining,
|
||||
&"nodes": _string_ids(component),
|
||||
}
|
||||
|
||||
|
||||
func _record_state_change(
|
||||
consumer_id: StringName,
|
||||
consumer: Dictionary,
|
||||
next_state: int,
|
||||
reasons: Array[StringName],
|
||||
changes: Array
|
||||
) -> void:
|
||||
var previous_state := int(consumer[&"state"])
|
||||
var previous_reasons: Array = consumer.get(&"state_reasons", [])
|
||||
consumer[&"state"] = next_state
|
||||
consumer[&"state_reasons"] = reasons.duplicate()
|
||||
if previous_state == next_state and previous_reasons == reasons:
|
||||
return
|
||||
changes.append({
|
||||
&"consumer_id": String(consumer_id),
|
||||
&"previous_state": previous_state,
|
||||
&"state": next_state,
|
||||
&"reasons": _string_ids(reasons),
|
||||
})
|
||||
consumer_state_changed.emit(consumer_id, previous_state, next_state, reasons)
|
||||
|
||||
|
||||
func _set_consumer_state(
|
||||
consumer_id: StringName,
|
||||
consumer: Dictionary,
|
||||
next_state: int,
|
||||
reasons: Array[StringName]
|
||||
) -> void:
|
||||
var ignored_changes: Array = []
|
||||
_record_state_change(consumer_id, consumer, next_state, reasons, ignored_changes)
|
||||
|
||||
|
||||
func _connected_components() -> Array[Array]:
|
||||
var components: Array[Array] = []
|
||||
var visited: Dictionary = {}
|
||||
for start_id: StringName in _sorted_ids(_nodes.keys()):
|
||||
if visited.has(start_id) or not bool(_nodes[start_id][&"enabled"]):
|
||||
continue
|
||||
var component: Array[StringName] = []
|
||||
var frontier: Array[StringName] = [start_id]
|
||||
visited[start_id] = true
|
||||
while not frontier.is_empty():
|
||||
var current: StringName = frontier.pop_front()
|
||||
component.append(current)
|
||||
for neighbor: StringName in _sorted_ids(_adjacency[current].keys()):
|
||||
if visited.has(neighbor) or not bool(_nodes[neighbor][&"enabled"]):
|
||||
continue
|
||||
visited[neighbor] = true
|
||||
frontier.append(neighbor)
|
||||
component = _sorted_ids(component)
|
||||
components.append(component)
|
||||
return components
|
||||
|
||||
|
||||
func _consumer_precedes(first_id: StringName, second_id: StringName) -> bool:
|
||||
var first_priority := int(_nodes[first_id][&"priority"])
|
||||
var second_priority := int(_nodes[second_id][&"priority"])
|
||||
if first_priority != second_priority:
|
||||
return first_priority > second_priority
|
||||
return String(first_id) < String(second_id)
|
||||
|
||||
|
||||
func _normalize_saved_node(raw_node: Variant) -> Dictionary:
|
||||
if not raw_node is Dictionary:
|
||||
return {&"ok": false, &"error": "Mana snapshot node must be a Dictionary."}
|
||||
var node_id := StringName(raw_node.get(&"id", ""))
|
||||
var kind := int(raw_node.get(&"kind", -1))
|
||||
if node_id.is_empty() or kind < NodeKind.SOURCE or kind > NodeKind.STORAGE:
|
||||
return {&"ok": false, &"error": "Mana snapshot node id or kind is invalid."}
|
||||
var node: Dictionary = raw_node.duplicate(true)
|
||||
node[&"id"] = node_id
|
||||
node[&"kind"] = kind
|
||||
node[&"enabled"] = bool(node.get(&"enabled", true))
|
||||
match kind:
|
||||
NodeKind.SOURCE:
|
||||
if int(node.get(&"output", -1)) < 0:
|
||||
return {&"ok": false, &"error": "Mana source output is invalid."}
|
||||
NodeKind.CONSUMER:
|
||||
var demand := int(node.get(&"demand", -1))
|
||||
var allocated := int(node.get(&"allocated", -1))
|
||||
var state := int(node.get(&"state", -1))
|
||||
if demand < 0 or allocated < 0 or allocated > demand \
|
||||
or state < ConsumerState.ACTIVE or state > ConsumerState.DISABLED:
|
||||
return {&"ok": false, &"error": "Mana consumer values are invalid."}
|
||||
var reasons: Array[StringName] = []
|
||||
for raw_reason: Variant in node.get(&"state_reasons", []):
|
||||
reasons.append(StringName(raw_reason))
|
||||
node[&"state_reasons"] = reasons
|
||||
node[&"priority"] = int(node.get(&"priority", ConsumerPriority.NORMAL))
|
||||
node[&"hold_until"] = maxi(int(node.get(&"hold_until", 0)), 0)
|
||||
NodeKind.STORAGE:
|
||||
var capacity := int(node.get(&"capacity", -1))
|
||||
var current := int(node.get(&"current", -1))
|
||||
if capacity < 0 or current < 0 or current > capacity or int(node.get(&"max_flow", -1)) < 0:
|
||||
return {&"ok": false, &"error": "Mana storage values are invalid."}
|
||||
return {&"ok": true, &"node": node}
|
||||
|
||||
|
||||
func _is_kind(node_id: StringName, kind: int) -> bool:
|
||||
return _nodes.has(node_id) and int(_nodes[node_id][&"kind"]) == kind
|
||||
|
||||
|
||||
func _sorted_ids(raw_ids: Array) -> Array[StringName]:
|
||||
var ids: Array[StringName] = []
|
||||
for raw_id: Variant in raw_ids:
|
||||
ids.append(StringName(raw_id))
|
||||
ids.sort_custom(func(first: StringName, second: StringName) -> bool:
|
||||
return String(first) < String(second)
|
||||
)
|
||||
return ids
|
||||
|
||||
|
||||
func _string_ids(raw_ids: Array) -> Array[String]:
|
||||
var result: Array[String] = []
|
||||
for raw_id: Variant in raw_ids:
|
||||
result.append(String(raw_id))
|
||||
return result
|
||||
|
||||
|
||||
func _fail(message: String) -> bool:
|
||||
last_error = message
|
||||
return false
|
||||
@@ -0,0 +1 @@
|
||||
uid://c11a4j1u7gogb
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1 @@
|
||||
uid://i34u80f4q4ot
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user