diff --git a/tests/context_scoped_world_targets_test.gd b/tests/context_scoped_world_targets_test.gd new file mode 100644 index 0000000..f0c333d --- /dev/null +++ b/tests/context_scoped_world_targets_test.gd @@ -0,0 +1,321 @@ +extends SceneTree + +const SHARED_RESOURCE_ID := &"shared_berry" +const SHARED_STORAGE_ID := &"shared_pantry" +const SHARED_ACTIVITY_ID := &"shared_bench" +const SHARED_ANIMAL_ID := &"shared_goat" + +var failures: Array[String] = [] + + +func _initialize() -> void: + call_deferred("_run") + + +func _run() -> void: + var first := _create_context(&"context_one", &"world_one", &"location_one", Vector3.ZERO) + var second := _create_context( + &"context_two", &"world_two", &"location_two", Vector3(1000.0, 0.0, 0.0) + ) + root.add_child(first["root"]) + root.add_child(second["root"]) + await process_frame + + _test_context_identity_and_duplicate_local_ids(first, second) + _test_context_scoped_queries(first, second) + _test_activity_interaction_range(first) + _test_cross_kind_uniqueness(first) + await _test_unregister_rebind_and_generation(first) + + (first["root"] as Node).free() + (second["root"] as Node).free() + _check( + ResourceNode.get_all().is_empty(), "Context cleanup should release legacy resource views" + ) + _check(StorageNode.get_all().is_empty(), "Context cleanup should release legacy storage views") + _check( + ActivitySite.get_all().is_empty(), "Context cleanup should release legacy activity views" + ) + _check(AnimalNode.get_all().is_empty(), "Context cleanup should release legacy animal views") + _finish() + + +func _test_context_identity_and_duplicate_local_ids(first: Dictionary, second: Dictionary) -> void: + var first_adapter := first["adapter"] as ActiveWorldAdapter + var second_adapter := second["adapter"] as ActiveWorldAdapter + _check( + ( + first_adapter.get_context_identity() + == { + "context_id": &"context_one", + "world_id": &"world_one", + "location_id": &"location_one", + } + ), + "Each adapter should expose its explicit context/world/location identity", + ) + _check( + first_adapter.get_target_registry().size() == 4, + "The first context should register all four canonical target kinds", + ) + _check( + second_adapter.get_target_registry().size() == 4, + "A second context may reuse every local target ID without a collision", + ) + for target_id in [SHARED_RESOURCE_ID, SHARED_STORAGE_ID, SHARED_ACTIVITY_ID, SHARED_ANIMAL_ID]: + var first_provider := first_adapter.get_target_provider(target_id) + var second_provider := second_adapter.get_target_provider(target_id) + _check( + ( + first_provider != null + and second_provider != null + and first_provider != second_provider + ), + "Canonical lookup for '%s' should resolve only the owning context" % target_id, + ) + _check( + ( + first_adapter.resolve_target_handle( + second_adapter.get_target_handle(SHARED_RESOURCE_ID) + ) + == null + ), + "Transient handles from another adapter must not resolve", + ) + + +func _test_context_scoped_queries(first: Dictionary, second: Dictionary) -> void: + var first_adapter := first["adapter"] as ActiveWorldAdapter + var second_adapter := second["adapter"] as ActiveWorldAdapter + var first_resource := first["resource"] as ResourceNode + var second_resource := second["resource"] as ResourceNode + var first_candidates := first_adapter.get_resource_candidates_in_radius( + SimulationIds.ACTION_GATHER_FOOD, first_resource.global_position, 2.0 + ) + var second_candidates := second_adapter.get_resource_candidates_in_radius( + SimulationIds.ACTION_GATHER_FOOD, second_resource.global_position, 2.0 + ) + _check( + first_candidates.size() == 1 and second_candidates.size() == 1, + "Each spatial index should return its own same-ID resource", + ) + _check( + ( + first_adapter + . get_resource_candidates_in_radius( + SimulationIds.ACTION_GATHER_FOOD, second_resource.global_position, 2.0 + ) + . is_empty() + ), + "Equal local target IDs must not leak through another context's spatial query", + ) + _check( + first_adapter.get_activity_candidates(SimulationIds.ACTION_REST).size() == 1, + "Activity queries should read the context registry", + ) + _check( + ( + first_adapter.get_storage_target(SHARED_STORAGE_ID)["position"] + != second_adapter.get_storage_target(SHARED_STORAGE_ID)["position"] + ), + "Storage lookup should return the owning context's position", + ) + _check( + first_adapter.get_animal_candidates(SimulationIds.ACTION_FEED_ANIMAL).size() == 1, + "Animal queries should read the context registry", + ) + _check( + ( + first_adapter + . get_animal_nodes_in_radius((second["animal"] as AnimalNode).global_position, 2.0) + . is_empty() + ), + "Animal radius queries must not return another context's same-ID provider", + ) + + +func _test_cross_kind_uniqueness(fixture: Dictionary) -> void: + var adapter := fixture["adapter"] as ActiveWorldAdapter + var conflicting := ActivitySite.new() + conflicting.site_id = SHARED_RESOURCE_ID + conflicting.action_id = SimulationIds.ACTION_REST + conflicting.display_name = "Conflicting bench" + conflicting.debug_label_enabled = false + var interaction_point := Marker3D.new() + interaction_point.name = "InteractionPoint" + conflicting.add_child(interaction_point) + (fixture["root"] as Node).add_child(conflicting) + _check( + adapter.get_target_registry().size() == 4, + "A same-context cross-kind collision must not replace or add a canonical target", + ) + _check( + ( + adapter.get_target_registry().get_descriptor(SHARED_RESOURCE_ID).get_target_kind() + == &"resource" + ), + "The original target kind should survive a cross-kind registration attempt", + ) + conflicting.free() + + +func _test_activity_interaction_range(fixture: Dictionary) -> void: + var adapter := fixture["adapter"] as ActiveWorldAdapter + var descriptor := adapter.get_target_registry().get_descriptor(SHARED_ACTIVITY_ID) + var capability := descriptor.get_capability(SimulationIds.ACTION_REST) + var interaction_range := float(capability.get_attribute(&"interaction_range", -1.0)) + _check( + is_finite(interaction_range) and interaction_range > 0.0, + "Activity capabilities should expose a positive finite generic interaction range", + ) + var invalid := ActivitySite.new() + invalid.interaction_range = 0.0 + _check( + not invalid.is_interaction_range_valid(), + "ActivitySite should reject a non-positive interaction range", + ) + invalid.interaction_range = NAN + _check( + not invalid.is_interaction_range_valid(), + "ActivitySite should reject a non-finite interaction range", + ) + invalid.free() + + +func _test_unregister_rebind_and_generation(fixture: Dictionary) -> void: + var adapter := fixture["adapter"] as ActiveWorldAdapter + var first_resource := fixture["resource"] as ResourceNode + var first_handle := adapter.get_target_handle(SHARED_RESOURCE_ID) + var previous_position := first_resource.global_position + first_resource.free() + _check( + not adapter.get_target_registry().is_handle_valid(first_handle), + "Unloading the provider should invalidate its transient handle", + ) + _check( + int(adapter.get_resource_index_stats()["candidate_count"]) == 0, + "Unloading the provider should remove it from the context spatial index", + ) + + var replacement := _create_resource( + SHARED_RESOURCE_ID, previous_position + Vector3(8.0, 0.0, 0.0) + ) + (fixture["root"] as Node).add_child(replacement) + await process_frame + var rebound_handle := adapter.get_target_handle(SHARED_RESOURCE_ID) + _check( + rebound_handle != null and rebound_handle.get_generation() > first_handle.get_generation(), + "Rebinding the same local ID should advance its generation", + ) + _check( + adapter.get_target_provider(SHARED_RESOURCE_ID, &"resource") == replacement, + "Rebinding should point canonical lookup at the replacement provider", + ) + _check( + ( + ( + adapter + . get_resource_candidates_in_radius( + SimulationIds.ACTION_GATHER_FOOD, replacement.global_position, 0.1 + ) + . size() + ) + == 1 + ), + "A rebound resource should be indexed at its new position", + ) + + +func _create_context( + context_id: StringName, world_id: StringName, location_id: StringName, offset: Vector3 +) -> Dictionary: + var context_root := Node3D.new() + context_root.name = String(context_id) + context_root.position = offset + var adapter := ActiveWorldAdapter.new() + adapter.name = "ActiveWorldAdapter" + adapter.context_id = context_id + adapter.world_id = world_id + adapter.location_id = location_id + context_root.add_child(adapter) + + var resource := _create_resource(SHARED_RESOURCE_ID, Vector3(1.0, 0.0, 0.0)) + context_root.add_child(resource) + var storage := _create_storage(SHARED_STORAGE_ID, Vector3(2.0, 0.0, 0.0)) + context_root.add_child(storage) + var activity := _create_activity(SHARED_ACTIVITY_ID, Vector3(3.0, 0.0, 0.0)) + context_root.add_child(activity) + var animal := _create_animal(SHARED_ANIMAL_ID, Vector3(4.0, 0.0, 0.0)) + context_root.add_child(animal) + return { + "root": context_root, + "adapter": adapter, + "resource": resource, + "storage": storage, + "activity": activity, + "animal": animal, + } + + +func _create_resource(target_id: StringName, position: Vector3) -> ResourceNode: + var node := ResourceNode.new() + node.node_id = target_id + node.action_id = SimulationIds.ACTION_GATHER_FOOD + node.resource_id = SimulationIds.RESOURCE_FOOD + node.debug_label_enabled = false + node.position = position + var interaction_point := Marker3D.new() + interaction_point.name = "InteractionPoint" + node.add_child(interaction_point) + return node + + +func _create_storage(target_id: StringName, position: Vector3) -> StorageNode: + var node := StorageNode.new() + node.storage_id = target_id + node.accepted_items = [SimulationIds.RESOURCE_FOOD] + node.debug_label_enabled = false + node.position = position + var interaction_point := Marker3D.new() + interaction_point.name = "InteractionPoint" + node.add_child(interaction_point) + return node + + +func _create_activity(target_id: StringName, position: Vector3) -> ActivitySite: + var node := ActivitySite.new() + node.site_id = target_id + node.action_id = SimulationIds.ACTION_REST + node.debug_label_enabled = false + node.position = position + var interaction_point := Marker3D.new() + interaction_point.name = "InteractionPoint" + node.add_child(interaction_point) + return node + + +func _create_animal(target_id: StringName, position: Vector3) -> AnimalNode: + var node := AnimalNode.new() + node.animal_id = target_id + node.animal_definition_id = &"domestic_goat" + node.debug_label_enabled = false + node.position = position + var interaction_point := Marker3D.new() + interaction_point.name = "InteractionPoint" + node.add_child(interaction_point) + return node + + +func _check(condition: bool, message: String) -> void: + if not condition: + failures.append(message) + + +func _finish() -> void: + if failures.is_empty(): + print("[TEST] Context-scoped world targets passed: four kinds, isolation, lifecycle") + quit(0) + return + for failure in failures: + push_error("[TEST] " + failure) + quit(1) diff --git a/tests/context_scoped_world_targets_test.gd.uid b/tests/context_scoped_world_targets_test.gd.uid new file mode 100644 index 0000000..51cfbf6 --- /dev/null +++ b/tests/context_scoped_world_targets_test.gd.uid @@ -0,0 +1 @@ +uid://c0nt3xtw0rld7arg diff --git a/world/active_world_adapter.gd b/world/active_world_adapter.gd index dbdad21..b0871c3 100644 --- a/world/active_world_adapter.gd +++ b/world/active_world_adapter.gd @@ -5,31 +5,208 @@ const LoadedResourceSpatialIndexScript := preload( "res://world/resource_nodes/LoadedResourceSpatialIndex.gd" ) +const DEFAULT_CONTEXT_ID := &"jajce_interior" +const DEFAULT_WORLD_ID := &"regional_bosnia" +const DEFAULT_LOCATION_ID := &"location_jajce" +const TARGET_KIND_RESOURCE := &"resource" +const TARGET_KIND_STORAGE := &"storage" +const TARGET_KIND_ACTIVITY := &"activity" +const TARGET_KIND_ANIMAL := &"animal" +const TARGET_GROUPS: Array[StringName] = [ + &"resource_nodes", &"storage_nodes", &"activity_sites", &"animal_nodes" +] + +@export var context_id: StringName = DEFAULT_CONTEXT_ID +@export var world_id: StringName = DEFAULT_WORLD_ID +@export var location_id: StringName = DEFAULT_LOCATION_ID @export var pantry_storage: StorageNode @export var woodpile_storage: StorageNode @export_range(4.0, 128.0, 1.0) var resource_cell_size := 24.0 var _resource_index := LoadedResourceSpatialIndexScript.new() +var _target_registry: WorldTargetRegistry +var _target_handles_by_provider_id: Dictionary = {} func _ready() -> void: _resource_index.configure(resource_cell_size) add_to_group("active_world_adapter") - rebuild_resource_index() + _ensure_target_registry() + rebuild_target_registry() + + +func _exit_tree() -> void: + _resource_index.clear() + _target_handles_by_provider_id.clear() + if _target_registry != null: + _target_registry.clear() + + +func configure_context_identity( + next_context_id: StringName, next_world_id: StringName, next_location_id: StringName +) -> bool: + if next_context_id.is_empty() or next_world_id.is_empty() or next_location_id.is_empty(): + return false + context_id = next_context_id + world_id = next_world_id + location_id = next_location_id + _target_registry = WorldTargetRegistry.new(context_id, world_id, location_id) + _target_handles_by_provider_id.clear() + _resource_index.clear() + if is_node_ready(): + rebuild_target_registry() + return true + + +func get_context_identity() -> Dictionary: + _ensure_target_registry() + return _target_registry.get_identity() + + +func get_target_registry() -> WorldTargetRegistry: + _ensure_target_registry() + return _target_registry + + +func get_target_handle(target_id: StringName) -> WorldTargetHandle: + _ensure_target_registry() + return _target_registry.get_handle(target_id) + + +func resolve_target_handle(handle: WorldTargetHandle) -> WorldTargetDescriptor: + _ensure_target_registry() + return _target_registry.resolve_handle(handle) + + +func get_target_provider(target_id: StringName, target_kind: StringName = &"") -> Object: + _ensure_target_registry() + var handle := _target_registry.get_handle(target_id) + if handle == null or (not target_kind.is_empty() and handle.get_target_kind() != target_kind): + return null + return _target_registry.get_provider(handle) + + +func owns_target_node(target: Node) -> bool: + return find_for_node(target) == self + + +static func find_for_node(target: Node) -> ActiveWorldAdapter: + if target == null: + return null + var current: Node = target + while current != null: + if current is ActiveWorldAdapter: + return current as ActiveWorldAdapter + for child in current.get_children(): + if child is ActiveWorldAdapter: + return child as ActiveWorldAdapter + current = current.get_parent() + return null + + +static func nodes_share_context(first: Node, second: Node) -> bool: + if first == null or second == null: + return false + return find_for_node(first) == find_for_node(second) + + +func rebuild_target_registry() -> void: + _ensure_target_registry() + _target_registry.clear() + _target_handles_by_provider_id.clear() + _resource_index.clear() + if not is_inside_tree(): + return + for group_id in TARGET_GROUPS: + for candidate in get_tree().get_nodes_in_group(group_id): + var provider := candidate as Node + if provider != null and owns_target_node(provider): + register_world_target(provider) func rebuild_resource_index() -> void: _resource_index.clear() - for node in ResourceNode.get_all(): - _resource_index.register_node(node, node.state) + _ensure_target_registry() + for target_id in _target_registry.get_target_ids(TARGET_KIND_RESOURCE): + var node := get_target_provider(target_id, TARGET_KIND_RESOURCE) as ResourceNode + if node != null: + _resource_index.register_node(node, node.state) + + +func register_world_target(provider: Node) -> WorldTargetHandle: + if provider == null or not owns_target_node(provider): + return null + _bind_provider(provider) + var descriptor := _descriptor_for_provider(provider) + if descriptor == null: + return null + var handle := _register_descriptor(provider, descriptor) + if handle != null and provider is ResourceNode: + var resource := provider as ResourceNode + if not _resource_index.register_node(resource, resource.state): + unregister_world_target(provider) + return null + return handle + + +func unregister_world_target(provider: Node) -> bool: + if provider == null: + return false + _ensure_target_registry() + var provider_id := provider.get_instance_id() + var handle := _target_handles_by_provider_id.get(provider_id) as WorldTargetHandle + if handle == null or _target_registry.get_provider(handle) != provider: + _target_handles_by_provider_id.erase(provider_id) + return false + var target_id := handle.get_target_id() + var target_kind := handle.get_target_kind() + var removed := _target_registry.unregister_target(handle) + _target_handles_by_provider_id.erase(provider_id) + if removed and target_kind == TARGET_KIND_RESOURCE: + _resource_index.unregister_node(target_id) + return removed + + +func refresh_world_target_position(provider: Node) -> bool: + if provider == null or not owns_target_node(provider): + return false + _ensure_target_registry() + var handle := ( + _target_handles_by_provider_id.get(provider.get_instance_id()) as WorldTargetHandle + ) + if handle == null or not _target_registry.is_handle_valid(handle, true): + return register_world_target(provider) != null + var position := _interaction_position_for(provider) + if not _target_registry.update_target_position(handle, position): + return false + if provider is ResourceNode: + var resource := provider as ResourceNode + return _resource_index.register_node(resource, resource.state) + return true func register_resource_node(node: ResourceNode, state: ResourceStateRecord = null) -> bool: - return _resource_index.register_node(node, state) + if node == null or not owns_target_node(node): + return false + _bind_provider(node) + var descriptor := _resource_descriptor(node, state) + var handle := _register_descriptor(node, descriptor) + return handle != null and _resource_index.register_node(node, state) -func unregister_resource_node(node_id: StringName) -> void: - _resource_index.unregister_node(node_id) +func unregister_resource_node(node_id: StringName, provider: ResourceNode = null) -> void: + if provider != null: + unregister_world_target(provider) + return + _ensure_target_registry() + var handle := _target_registry.get_handle(node_id) + if handle == null or handle.get_target_kind() != TARGET_KIND_RESOURCE: + return + var registered_provider := _target_registry.get_provider(handle) + if registered_provider != null: + _target_handles_by_provider_id.erase(registered_provider.get_instance_id()) + if _target_registry.unregister_target(handle): + _resource_index.unregister_node(node_id) func get_resource_candidates(action_id: StringName) -> Array[Dictionary]: @@ -60,31 +237,29 @@ func get_resource_index_stats() -> Dictionary: func get_animal_candidates(action_id: StringName) -> Array[Dictionary]: + _ensure_target_registry() var candidates: Array[Dictionary] = [] - for node in AnimalNode.get_all(): - if not node.supports_action(action_id): + for target_id in _target_registry.get_target_ids(TARGET_KIND_ANIMAL, action_id): + var descriptor := _target_registry.get_descriptor(target_id) + if descriptor == null: continue ( candidates . append( { - "target_id": String(node.animal_id), - "position": node.get_interaction_position(), - "display_name": node.display_name, + "target_id": String(target_id), + "position": descriptor.get_local_position(), + "display_name": descriptor.get_display_name(), } ) ) - candidates.sort_custom( - func(first: Dictionary, second: Dictionary) -> bool: - return String(first["target_id"]) < String(second["target_id"]) - ) return candidates func get_animal_routine_sites(species_id: StringName, animal_id: StringName) -> Array[Dictionary]: var candidates: Array[Dictionary] = [] for site in AnimalRoutineSite.get_all(): - if not site.supports_animal(animal_id, species_id): + if not owns_target_node(site) or not site.supports_animal(animal_id, species_id): continue ( candidates @@ -104,10 +279,21 @@ func get_animal_routine_sites(species_id: StringName, animal_id: StringName) -> func get_animal_nodes_in_radius(origin: Vector3, max_distance: float) -> Array[AnimalNode]: + _ensure_target_registry() var nodes: Array[AnimalNode] = [] var maximum_distance_squared := maxf(max_distance, 0.0) * maxf(max_distance, 0.0) - for node in AnimalNode.get_all(): - if origin.distance_squared_to(node.get_interaction_position()) <= maximum_distance_squared: + for target_id in _target_registry.get_target_ids(TARGET_KIND_ANIMAL): + var descriptor := _target_registry.get_descriptor(target_id) + if ( + descriptor == null + or ( + origin.distance_squared_to(descriptor.get_local_position()) + > maximum_distance_squared + ) + ): + continue + var node := get_target_provider(target_id, TARGET_KIND_ANIMAL) as AnimalNode + if node != null: nodes.append(node) nodes.sort_custom( func(first: AnimalNode, second: AnimalNode) -> bool: @@ -142,33 +328,214 @@ func get_activity_target(action_id: StringName, origin: Vector3 = Vector3.ZERO) func get_activity_candidates(action_id: StringName) -> Array[Dictionary]: + _ensure_target_registry() var candidates: Array[Dictionary] = [] - for candidate in ActivitySite.get_all(): - var site := candidate as ActivitySite - if site == null: + for target_id in _target_registry.get_target_ids(TARGET_KIND_ACTIVITY, action_id): + var descriptor := _target_registry.get_descriptor(target_id) + if descriptor == null: continue - if not site.supports_action(action_id): - continue - candidates.append( - { - "target_id": String(site.site_id), - "position": site.get_interaction_position(), - "capacity": site.capacity - } + ( + candidates + . append( + { + "target_id": String(target_id), + "position": descriptor.get_local_position(), + "capacity": int(descriptor.get_metadata_value(&"capacity", 1)), + } + ) ) return candidates func get_storage_target(storage_id: StringName) -> Dictionary: - var storage_node: StorageNode - match storage_id: - SimulationIds.STORAGE_VILLAGE_PANTRY: - storage_node = pantry_storage - SimulationIds.STORAGE_VILLAGE_WOODPILE: - storage_node = woodpile_storage - if storage_node == null: + _ensure_target_registry() + var descriptor := _target_registry.get_descriptor(storage_id) + if descriptor == null or descriptor.get_target_kind() != TARGET_KIND_STORAGE: return {} return { - "target_id": String(storage_node.storage_id), - "position": storage_node.get_interaction_position() + "target_id": String(storage_id), + "position": descriptor.get_local_position(), } + + +func _ensure_target_registry() -> void: + if _target_registry == null: + _target_registry = WorldTargetRegistry.new(context_id, world_id, location_id) + + +func _register_descriptor(provider: Node, descriptor: WorldTargetDescriptor) -> WorldTargetHandle: + _ensure_target_registry() + if provider == null or descriptor == null or not descriptor.is_valid(): + return null + var provider_id := provider.get_instance_id() + var handle := _target_handles_by_provider_id.get(provider_id) as WorldTargetHandle + if handle != null and _target_registry.is_handle_valid(handle, true): + if ( + handle.get_target_id() == descriptor.get_target_id() + and handle.get_target_kind() == descriptor.get_target_kind() + ): + return handle if _target_registry.update_target(handle, descriptor) else null + unregister_world_target(provider) + + var existing_handle := _target_registry.get_handle(descriptor.get_target_id()) + if existing_handle != null: + if _target_registry.get_provider(existing_handle) != provider: + _target_registry.register_target(descriptor, provider) + return null + handle = existing_handle + if not _target_registry.update_target(handle, descriptor): + return null + else: + handle = _target_registry.register_target(descriptor, provider) + if handle != null: + _target_handles_by_provider_id[provider_id] = handle + return handle + + +func _bind_provider(provider: Node) -> void: + if provider.has_method("_bind_active_world_adapter"): + provider.call("_bind_active_world_adapter", self) + + +func _descriptor_for_provider(provider: Node) -> WorldTargetDescriptor: + if provider is ResourceNode: + var resource := provider as ResourceNode + return _resource_descriptor(resource, resource.state) + if provider is StorageNode: + return _storage_descriptor(provider as StorageNode) + if provider is ActivitySite: + return _activity_descriptor(provider as ActivitySite) + if provider is AnimalNode: + return _animal_descriptor(provider as AnimalNode) + return null + + +func _resource_descriptor( + node: ResourceNode, state: ResourceStateRecord = null +) -> WorldTargetDescriptor: + if node == null or node.node_id.is_empty(): + return null + var action_id := state.get_action_id() if state != null else node.action_id + var resource_id := state.get_resource_id() if state != null else node.resource_id + var capability := ( + WorldTargetCapability + . new( + action_id, + { + "resource_id": String(resource_id), + "can_npcs_use": node.can_npcs_use, + "can_player_use": node.can_player_use, + } + ) + ) + return ( + WorldTargetDescriptor + . new( + node.node_id, + TARGET_KIND_RESOURCE, + [capability], + _interaction_position_for(node), + String(node.name), + { + "resource_id": String(resource_id), + "safety_risk": state.get_safety_risk() if state != null else node.safety_risk, + "comfort_distance": + state.get_comfort_distance() if state != null else node.comfort_distance, + "discovery_priority": + state.get_discovery_priority() if state != null else node.discovery_priority, + } + ) + ) + + +func _storage_descriptor(node: StorageNode) -> WorldTargetDescriptor: + if node == null or node.storage_id.is_empty(): + return null + var capability_ids: Array[StringName] = [] + if node.accepts_item(SimulationIds.RESOURCE_FOOD): + ( + capability_ids + . append_array( + [ + SimulationIds.ACTION_EAT, + SimulationIds.ACTION_DEPOSIT_FOOD, + SimulationIds.ACTION_WITHDRAW_FOOD, + ] + ) + ) + if node.accepts_item(SimulationIds.RESOURCE_WOOD): + capability_ids.append(SimulationIds.ACTION_DEPOSIT_WOOD) + var capabilities: Array[WorldTargetCapability] = [] + for capability_id in capability_ids: + capabilities.append(WorldTargetCapability.new(capability_id)) + var accepted_item_ids: Array[String] = [] + for item_id in node.accepted_items: + accepted_item_ids.append(String(item_id)) + return WorldTargetDescriptor.new( + node.storage_id, + TARGET_KIND_STORAGE, + capabilities, + _interaction_position_for(node), + node.display_name, + {"accepted_item_ids": accepted_item_ids} + ) + + +func _activity_descriptor(node: ActivitySite) -> WorldTargetDescriptor: + if node == null or node.site_id.is_empty() or not node.is_interaction_range_valid(): + return null + var capability_ids: Array[StringName] = [node.action_id] + if node.action_id == SimulationIds.ACTION_PATROL: + capability_ids.append(SimulationIds.ACTION_DEFEND) + var capabilities: Array[WorldTargetCapability] = [] + for capability_id in capability_ids: + capabilities.append( + WorldTargetCapability.new(capability_id, {"interaction_range": node.interaction_range}) + ) + return WorldTargetDescriptor.new( + node.site_id, + TARGET_KIND_ACTIVITY, + capabilities, + _interaction_position_for(node), + node.display_name, + {"capacity": node.capacity} + ) + + +func _animal_descriptor(node: AnimalNode) -> WorldTargetDescriptor: + if node == null or node.animal_id.is_empty(): + return null + var capabilities: Array[WorldTargetCapability] = [] + for capability_id in node.supported_action_ids: + capabilities.append(WorldTargetCapability.new(capability_id)) + return ( + WorldTargetDescriptor + . new( + node.animal_id, + TARGET_KIND_ANIMAL, + capabilities, + _interaction_position_for(node), + node.display_name, + { + "animal_definition_id": String(node.animal_definition_id), + "species_id": String(node.species_id), + } + ) + ) + + +func _interaction_position_for(provider: Node) -> Vector3: + if provider is ResourceNode: + var resource := provider as ResourceNode + return ( + resource.interaction_point.global_position + if resource.interaction_point != null + else resource.global_position + ) + if provider is StorageNode: + return (provider as StorageNode).get_interaction_position() + if provider is ActivitySite: + return (provider as ActivitySite).get_interaction_position() + if provider is AnimalNode: + return (provider as AnimalNode).get_interaction_position() + return Vector3.ZERO diff --git a/world/activity/ActivitySite.gd b/world/activity/ActivitySite.gd index a2148d4..1aba788 100644 --- a/world/activity/ActivitySite.gd +++ b/world/activity/ActivitySite.gd @@ -7,11 +7,13 @@ static var _all: Array = [] @export var action_id: StringName = SimulationIds.ACTION_REST @export var display_name := "Activity Site" @export_range(1, 12, 1) var capacity := 1 +@export_range(0.1, 100.0, 0.1, "or_greater") var interaction_range := 3.0 @export var debug_label_enabled := true @onready var interaction_point: Marker3D = $InteractionPoint var _last_label_text := "" +var _active_world_adapter: ActiveWorldAdapter func _ready() -> void: @@ -19,7 +21,12 @@ func _ready() -> void: push_error("ActivitySite at %s has empty site_id" % get_path()) _update_presentation() return - var existing: ActivitySite = get_by_id(site_id) as ActivitySite + if not is_interaction_range_valid(): + push_error("ActivitySite '%s' must have a positive finite interaction_range" % site_id) + _update_presentation() + return + _active_world_adapter = ActiveWorldAdapter.find_for_node(self) + var existing: ActivitySite = get_by_id_in_context(site_id, _active_world_adapter) if existing != null: push_error( ( @@ -31,11 +38,14 @@ func _ready() -> void: return _all.append(self) add_to_group("activity_sites") + set_notify_transform(true) + _notify_world_adapter() _update_presentation() set_process(false) func _exit_tree() -> void: + _unregister_from_world_adapter() _all.erase(self) @@ -52,6 +62,41 @@ func supports_action(candidate_action_id: StringName) -> bool: ) +func is_interaction_range_valid() -> bool: + return is_finite(interaction_range) and interaction_range > 0.0 + + +func _notification(what: int) -> void: + if what == NOTIFICATION_TRANSFORM_CHANGED and is_node_ready(): + if is_instance_valid(_active_world_adapter): + _active_world_adapter.refresh_world_target_position(self) + + +func _notify_world_adapter() -> void: + if not is_inside_tree(): + return + if ( + not is_instance_valid(_active_world_adapter) + or not _active_world_adapter.owns_target_node(self) + ): + _active_world_adapter = ActiveWorldAdapter.find_for_node(self) + if _active_world_adapter != null: + _active_world_adapter.register_world_target(self) + + +func _bind_active_world_adapter(adapter: ActiveWorldAdapter) -> void: + if adapter != null and adapter.owns_target_node(self): + _active_world_adapter = adapter + + +func _unregister_from_world_adapter() -> void: + if not is_instance_valid(_active_world_adapter): + _active_world_adapter = ActiveWorldAdapter.find_for_node(self) + if is_instance_valid(_active_world_adapter): + _active_world_adapter.unregister_world_target(self) + _active_world_adapter = null + + func _update_presentation() -> void: if not has_node("DebugLabel"): return @@ -78,5 +123,14 @@ static func get_by_id(search_id: StringName): return null +static func get_by_id_in_context( + search_id: StringName, adapter: ActiveWorldAdapter +) -> ActivitySite: + for node in _all: + if node.site_id == search_id and ActiveWorldAdapter.find_for_node(node) == adapter: + return node as ActivitySite + return null + + static func get_all() -> Array: return _all.duplicate() diff --git a/world/animals/animal_node.gd b/world/animals/animal_node.gd index c2a329e..939d271 100644 --- a/world/animals/animal_node.gd +++ b/world/animals/animal_node.gd @@ -41,6 +41,7 @@ var path_pending := false var has_reported_navigation_result := true var _animal_definition: AnimalDefinition var _presentation_parameters: Dictionary = {} +var _active_world_adapter: ActiveWorldAdapter func _ready() -> void: @@ -59,7 +60,8 @@ func _ready() -> void: initial_enabled = false _update_presentation() return - var existing := get_by_id(animal_id) + _active_world_adapter = ActiveWorldAdapter.find_for_node(self) + var existing := get_by_id_in_context(animal_id, _active_world_adapter) if existing != null: push_error( ( @@ -73,12 +75,15 @@ func _ready() -> void: _all.append(self) add_to_group("animal_nodes") add_to_group("grass_interactors") + set_notify_transform(true) _try_register_with_simulation() + _notify_world_adapter() _update_presentation() func _exit_tree() -> void: navigation_request_id += 1 + _unregister_from_world_adapter() _all.erase(self) _disconnect_state() state = null @@ -105,6 +110,7 @@ func bind_state(animal_state: AnimalStateRecord) -> bool: call_deferred("_sync_navigation_from_state") _update_presentation() hunger_changed.emit(animal_id, state.get_hunger()) + _notify_world_adapter() return true @@ -126,6 +132,37 @@ func _try_register_with_simulation() -> void: manager.animal_care.register_node(self) +func _notification(what: int) -> void: + if what == NOTIFICATION_TRANSFORM_CHANGED and is_node_ready(): + if is_instance_valid(_active_world_adapter): + _active_world_adapter.refresh_world_target_position(self) + + +func _notify_world_adapter() -> void: + if not is_inside_tree(): + return + if ( + not is_instance_valid(_active_world_adapter) + or not _active_world_adapter.owns_target_node(self) + ): + _active_world_adapter = ActiveWorldAdapter.find_for_node(self) + if _active_world_adapter != null: + _active_world_adapter.register_world_target(self) + + +func _bind_active_world_adapter(adapter: ActiveWorldAdapter) -> void: + if adapter != null and adapter.owns_target_node(self): + _active_world_adapter = adapter + + +func _unregister_from_world_adapter() -> void: + if not is_instance_valid(_active_world_adapter): + _active_world_adapter = ActiveWorldAdapter.find_for_node(self) + if is_instance_valid(_active_world_adapter): + _active_world_adapter.unregister_world_target(self) + _active_world_adapter = null + + func get_interaction_position() -> Vector3: return interaction_point.global_position if interaction_point != null else global_position @@ -381,5 +418,12 @@ static func get_by_id(search_id: StringName) -> AnimalNode: return null +static func get_by_id_in_context(search_id: StringName, adapter: ActiveWorldAdapter) -> AnimalNode: + for node in _all: + if node.animal_id == search_id and ActiveWorldAdapter.find_for_node(node) == adapter: + return node + return null + + static func get_all() -> Array[AnimalNode]: return _all.duplicate() diff --git a/world/resource_nodes/ResourceNode.gd b/world/resource_nodes/ResourceNode.gd index 22f2848..a17093c 100644 --- a/world/resource_nodes/ResourceNode.gd +++ b/world/resource_nodes/ResourceNode.gd @@ -26,6 +26,7 @@ static var _all: Array[ResourceNode] = [] @onready var interaction_point: Marker3D = $InteractionPoint var state: ResourceStateRecord +var _active_world_adapter: ActiveWorldAdapter func _ready() -> void: @@ -34,7 +35,8 @@ func _ready() -> void: initial_enabled = false _update_presentation() return - var existing := get_by_id(node_id) + _active_world_adapter = ActiveWorldAdapter.find_for_node(self) + var existing := get_by_id_in_context(node_id, _active_world_adapter) if existing != null: push_error( ( @@ -49,12 +51,12 @@ func _ready() -> void: add_to_group("resource_nodes") set_notify_transform(true) _try_register_with_simulation() - _notify_world_adapters() + _notify_world_adapter() _update_presentation() func _exit_tree() -> void: - _unregister_from_world_adapters() + _unregister_from_world_adapter() _all.erase(self) if state != null and state.changed.is_connected(_on_state_changed): state.changed.disconnect(_on_state_changed) @@ -77,7 +79,7 @@ func bind_state(resource_state: ResourceStateRecord) -> bool: state.depleted.connect(_on_state_depleted) _update_presentation() amount_changed.emit(node_id, state.get_amount_remaining()) - _notify_world_adapters() + _notify_world_adapter() return true @@ -92,23 +94,37 @@ func _try_register_with_simulation() -> void: func _notification(what: int) -> void: if what == NOTIFICATION_TRANSFORM_CHANGED and is_node_ready(): - _notify_world_adapters() + _refresh_world_target_position() -func _notify_world_adapters() -> void: +func _notify_world_adapter() -> void: if not is_inside_tree(): return - for adapter in get_tree().get_nodes_in_group("active_world_adapter"): - if adapter.has_method("register_resource_node"): - adapter.register_resource_node(self, state) + if ( + not is_instance_valid(_active_world_adapter) + or not _active_world_adapter.owns_target_node(self) + ): + _active_world_adapter = ActiveWorldAdapter.find_for_node(self) + if _active_world_adapter != null: + _active_world_adapter.register_resource_node(self, state) -func _unregister_from_world_adapters() -> void: - if not is_inside_tree(): - return - for adapter in get_tree().get_nodes_in_group("active_world_adapter"): - if adapter.has_method("unregister_resource_node"): - adapter.unregister_resource_node(node_id) +func _bind_active_world_adapter(adapter: ActiveWorldAdapter) -> void: + if adapter != null and adapter.owns_target_node(self): + _active_world_adapter = adapter + + +func _refresh_world_target_position() -> void: + if is_instance_valid(_active_world_adapter): + _active_world_adapter.refresh_world_target_position(self) + + +func _unregister_from_world_adapter() -> void: + if not is_instance_valid(_active_world_adapter): + _active_world_adapter = ActiveWorldAdapter.find_for_node(self) + if is_instance_valid(_active_world_adapter): + _active_world_adapter.unregister_resource_node(node_id, self) + _active_world_adapter = null func get_amount_remaining() -> float: @@ -177,5 +193,14 @@ static func get_by_id(search_id: StringName) -> ResourceNode: return null +static func get_by_id_in_context( + search_id: StringName, adapter: ActiveWorldAdapter +) -> ResourceNode: + for node in _all: + if node.node_id == search_id and ActiveWorldAdapter.find_for_node(node) == adapter: + return node + return null + + static func get_all() -> Array[ResourceNode]: return _all.duplicate() diff --git a/world/storage/StorageNode.gd b/world/storage/StorageNode.gd index 73659b9..98f8800 100644 --- a/world/storage/StorageNode.gd +++ b/world/storage/StorageNode.gd @@ -15,6 +15,7 @@ const PRESENTATION_UPDATE_INTERVAL_SECONDS := 0.25 var state: StorageStateRecord var _last_label_text := "" var _presentation_elapsed := 0.0 +var _active_world_adapter: ActiveWorldAdapter func _ready() -> void: @@ -24,7 +25,8 @@ func _ready() -> void: push_error("StorageNode at %s has empty storage_id" % get_path()) _update_presentation() return - var existing: StorageNode = get_by_id(storage_id) as StorageNode + _active_world_adapter = ActiveWorldAdapter.find_for_node(self) + var existing: StorageNode = get_by_id_in_context(storage_id, _active_world_adapter) if existing != null: push_error( ( @@ -36,7 +38,9 @@ func _ready() -> void: return _all.append(self) add_to_group("storage_nodes") + set_notify_transform(true) _try_register_with_simulation() + _notify_world_adapter() _update_presentation() set_process(debug_label_enabled) @@ -50,6 +54,7 @@ func _process(delta: float) -> void: func _exit_tree() -> void: + _unregister_from_world_adapter() _all.erase(self) state = null @@ -59,6 +64,7 @@ func bind_state(storage_state: StorageStateRecord) -> bool: return false state = storage_state _update_presentation() + _notify_world_adapter() return true @@ -79,6 +85,37 @@ func _try_register_with_simulation() -> void: manager.register_storage_node(self) +func _notification(what: int) -> void: + if what == NOTIFICATION_TRANSFORM_CHANGED and is_node_ready(): + if is_instance_valid(_active_world_adapter): + _active_world_adapter.refresh_world_target_position(self) + + +func _notify_world_adapter() -> void: + if not is_inside_tree(): + return + if ( + not is_instance_valid(_active_world_adapter) + or not _active_world_adapter.owns_target_node(self) + ): + _active_world_adapter = ActiveWorldAdapter.find_for_node(self) + if _active_world_adapter != null: + _active_world_adapter.register_world_target(self) + + +func _bind_active_world_adapter(adapter: ActiveWorldAdapter) -> void: + if adapter != null and adapter.owns_target_node(self): + _active_world_adapter = adapter + + +func _unregister_from_world_adapter() -> void: + if not is_instance_valid(_active_world_adapter): + _active_world_adapter = ActiveWorldAdapter.find_for_node(self) + if is_instance_valid(_active_world_adapter): + _active_world_adapter.unregister_world_target(self) + _active_world_adapter = null + + func _update_presentation() -> void: if not has_node("DebugLabel"): return @@ -112,5 +149,12 @@ static func get_by_id(search_id: StringName): return null +static func get_by_id_in_context(search_id: StringName, adapter: ActiveWorldAdapter) -> StorageNode: + for node in _all: + if node.storage_id == search_id and ActiveWorldAdapter.find_for_node(node) == adapter: + return node as StorageNode + return null + + static func get_all() -> Array: return _all.duplicate() diff --git a/world/targets/WorldTargetRegistry.gd b/world/targets/WorldTargetRegistry.gd index 5199090..1861c21 100644 --- a/world/targets/WorldTargetRegistry.gd +++ b/world/targets/WorldTargetRegistry.gd @@ -4,6 +4,8 @@ extends RefCounted var last_error := "" var _context_id: StringName +var _world_id: StringName +var _location_id: StringName var _entries_by_id: Dictionary = {} var _generation_by_id: Dictionary = {} var _sorted_target_ids: Array[StringName] = [] @@ -11,8 +13,14 @@ var _target_ids_by_kind: Dictionary = {} var _target_ids_by_capability: Dictionary = {} -func _init(owned_context_id: StringName = &"") -> void: +func _init( + owned_context_id: StringName = &"", + owned_world_id: StringName = &"", + owned_location_id: StringName = &"" +) -> void: _context_id = owned_context_id + _world_id = owned_world_id + _location_id = owned_location_id func register_target( @@ -75,6 +83,19 @@ func update_target(handle: WorldTargetHandle, descriptor: WorldTargetDescriptor) return true +func update_target_position(handle: WorldTargetHandle, local_position: Vector3) -> bool: + last_error = "" + var entry := _get_handle_entry(handle) + if entry.is_empty(): + return _fail_bool("Target handle is stale or belongs to another registry") + if not local_position.is_finite(): + return _fail_bool("Target position must be finite") + var previous := entry["descriptor"] as WorldTargetDescriptor + entry["descriptor"] = previous.with_local_position(local_position) + _entries_by_id[previous.get_target_id()] = entry + return true + + func unregister_target(handle: WorldTargetHandle) -> bool: last_error = "" var entry := _get_handle_entry(handle) @@ -112,6 +133,22 @@ func get_context_id() -> StringName: return _context_id +func get_world_id() -> StringName: + return _world_id + + +func get_location_id() -> StringName: + return _location_id + + +func get_identity() -> Dictionary: + return { + "context_id": _context_id, + "world_id": _world_id, + "location_id": _location_id, + } + + func size() -> int: return _entries_by_id.size()