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"]) ## Upkeep demand of every registered consumer, including disabled ones, ## except the given consumer ids (for example destroyed facilities). func get_total_consumer_demand(excluded_ids: Array = []) -> int: var total := 0 for node_id: StringName in _nodes: if int(_nodes[node_id][&"kind"]) == NodeKind.CONSUMER and not node_id in excluded_ids: total += int(_nodes[node_id][&"demand"]) return total 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