Files
TheTree_mock/scripts/mana/mana_network_service.gd

575 lines
18 KiB
GDScript

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