Initial 100, capacity 300, world tree +20 per 10 game minutes. Each boundary pays registered upkeep demand before storing surplus; overflow is discarded and recorded, 0 stops the whole tower. Furnace offers are judged after generation and demand payment. New saves carry mana_reserve and Operations rollback includes it; legacy saves keep a 0..300 amount and reject others. Outage tests that relied on Mana 0 persisting now hold the outage with a disabled demand sink, since the world tree recovers at the next boundary. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
584 lines
18 KiB
GDScript
584 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"])
|
|
|
|
|
|
## Upkeep demand of every registered consumer, including disabled ones.
|
|
func get_total_consumer_demand() -> int:
|
|
var total := 0
|
|
for node_id: StringName in _nodes:
|
|
if int(_nodes[node_id][&"kind"]) == NodeKind.CONSUMER:
|
|
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
|